Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 200 additions & 45 deletions app/desktop/src/components/IM/IMContactList.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { useState, useMemo, useCallback, memo } from 'react';
import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { MessageCircle, Plus, UserPlus, Users } from 'lucide-react';
import type { ContactInfo } from '@/api/hubClient';
import type { IMContact } from './types';
import styles from './IMContactList.module.css';

type ComposeMode = 'contact' | 'private' | 'group';

interface IMContactListProps {
contacts: IMContact[];
hubContacts?: ContactInfo[];
onSelect?: (contact: IMContact) => void;
onAdd?: (name: string) => void;
onAddContact?: (userId: string) => boolean | void | Promise<boolean | void>;
onCreatePrivateSession?: (userId: string) => boolean | void | Promise<boolean | void>;
onCreateGroupSession?: (name: string, memberIds: string[]) => boolean | void | Promise<boolean | void>;
selectedId?: string;
}

Expand All @@ -23,63 +30,211 @@ function avatarClass(type: string): string {

const IMContactList = memo(function IMContactList({
contacts,
hubContacts = [],
onSelect,
onAdd,
onAddContact,
onCreatePrivateSession,
onCreateGroupSession,
selectedId,
}: IMContactListProps) {
const [search, setSearch] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [addName, setAddName] = useState('');
const [showCompose, setShowCompose] = useState(false);
const [composeMode, setComposeMode] = useState<ComposeMode>('contact');
const [targetUserId, setTargetUserId] = useState('');
const [groupName, setGroupName] = useState('');
const [groupMembers, setGroupMembers] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const { t } = useTranslation();
const label = useCallback(
(key: string, fallback: string) => {
const translated = t(key);
return translated === key ? fallback : translated;
},
[t],
);
const statusHintLabel = useCallback(
(contact: IMContact) => {
if (!contact.statusHint) return contact.lastSeen;
const translated = t(contact.statusHint, contact.statusHintParams);
if (translated !== contact.statusHint) return translated;
if (contact.statusHintParams?.seq) return `${contact.statusHint} ${contact.statusHintParams.seq}`;
return contact.statusHint;
},
[t],
);
const canCompose = Boolean(onAddContact || onCreatePrivateSession || onCreateGroupSession);

const filtered = useMemo(() => {
if (!search.trim()) return contacts;
const lower = search.toLowerCase();
return contacts.filter((c) => c.name.toLowerCase().includes(lower));
}, [contacts, search]);

const handleAdd = useCallback(() => {
const trimmed = addName.trim();
if (!trimmed) return;
onAdd?.(trimmed);
setAddName('');
setShowAdd(false);
}, [addName, onAdd]);
const handleSubmit = useCallback(async () => {
if (submitting) return;
setSubmitting(true);
try {
let accepted: boolean | void = false;
if (composeMode === 'group') {
const memberIds = groupMembers.map((id) => id.trim()).filter(Boolean);
if (groupName.trim() && memberIds.length > 0) {
accepted = await onCreateGroupSession?.(groupName.trim(), memberIds);
}
} else if (targetUserId.trim()) {
accepted = composeMode === 'private'
? await onCreatePrivateSession?.(targetUserId.trim())
: await onAddContact?.(targetUserId.trim());
}

if (accepted === false) return;
setTargetUserId('');
setGroupName('');
setGroupMembers([]);
setShowCompose(false);
} finally {
setSubmitting(false);
}
}, [
composeMode,
groupMembers,
groupName,
onAddContact,
onCreateGroupSession,
onCreatePrivateSession,
submitting,
targetUserId,
]);

const handleAddKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleAdd();
if (e.key === 'Escape') setShowAdd(false);
if (e.key === 'Enter') void handleSubmit();
if (e.key === 'Escape') setShowCompose(false);
},
[handleAdd],
[handleSubmit],
);

const toggleGroupMember = useCallback((userId: string) => {
setGroupMembers((prev) =>
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId],
);
}, []);

return (
<div className={styles.root}>
<div className={styles.header}>
<span className={styles.title}>Contacts</span>
<button
className={styles.addBtn}
onClick={() => setShowAdd((v) => !v)}
aria-label={showAdd ? 'Cancel add contact' : 'Add contact'}
title={showAdd ? 'Cancel' : 'Add contact'}
>
<Plus size={14} />
</button>
<span className={styles.title}>{label('im.contact.title', 'Contacts')}</span>
{canCompose && (
<button
className={styles.addBtn}
onClick={() => setShowCompose((v) => !v)}
aria-label={showCompose ? label('im.contact.cancelCompose', 'Cancel Hub compose') : label('im.contact.openCompose', 'Open Hub compose')}
title={showCompose ? label('common.cancel', 'Cancel') : label('im.contact.hubActions', 'Hub actions')}
>
<Plus size={14} />
</button>
)}
</div>

{showAdd && (
<div className={styles.addForm}>
<input
className={styles.addInput}
value={addName}
onChange={(e) => setAddName(e.target.value)}
onKeyDown={handleAddKeyDown}
placeholder="Contact name..."
autoFocus
aria-label="Contact name"
/>
<button className={styles.addConfirm} onClick={handleAdd}>
Add
{showCompose && canCompose && (
<div className={styles.composeForm}>
<div className={styles.composeModes} aria-label={label('im.contact.composeMode', 'Hub compose mode')}>
{onAddContact && (
<button
type="button"
className={`${styles.modeBtn} ${composeMode === 'contact' ? styles.modeBtnActive : ''}`}
onClick={() => setComposeMode('contact')}
aria-pressed={composeMode === 'contact'}
aria-label={label('im.contact.addContact', 'Add contact')}
title={label('im.contact.addContact', 'Add contact')}
>
<UserPlus size={14} />
</button>
)}
{onCreatePrivateSession && (
<button
type="button"
className={`${styles.modeBtn} ${composeMode === 'private' ? styles.modeBtnActive : ''}`}
onClick={() => setComposeMode('private')}
aria-pressed={composeMode === 'private'}
aria-label={label('im.contact.createDirectChat', 'Create direct chat')}
title={label('im.contact.createDirectChat', 'Create direct chat')}
>
<MessageCircle size={14} />
</button>
)}
{onCreateGroupSession && (
<button
type="button"
className={`${styles.modeBtn} ${composeMode === 'group' ? styles.modeBtnActive : ''}`}
onClick={() => setComposeMode('group')}
aria-pressed={composeMode === 'group'}
aria-label={label('im.contact.createGroupChat', 'Create group chat')}
title={label('im.contact.createGroupChat', 'Create group chat')}
>
<Users size={14} />
</button>
)}
</div>

{composeMode === 'group' ? (
<>
<input
className={styles.addInput}
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={handleAddKeyDown}
placeholder={label('im.contact.groupNamePlaceholder', 'Group name...')}
autoFocus
aria-label={label('im.contact.groupName', 'Group name')}
/>
<div className={styles.memberPicker} aria-label={label('im.contact.groupMembers', 'Group members')}>
{hubContacts.length === 0 ? (
<span className={styles.memberEmpty}>{label('im.contact.noHubContacts', 'No Hub contacts available')}</span>
) : (
hubContacts.map((contact) => (
<label className={styles.memberOption} key={contact.user_id}>
<input
type="checkbox"
checked={groupMembers.includes(contact.user_id)}
onChange={() => toggleGroupMember(contact.user_id)}
/>
<span>{contact.remark ?? contact.nickname ?? contact.username}</span>
</label>
))
)}
</div>
</>
) : (
<>
{composeMode === 'private' && hubContacts.length > 0 && (
<select
className={styles.addInput}
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
aria-label={label('im.contact.hubContact', 'Hub contact')}
>
<option value="">{label('im.contact.chooseHubContact', 'Choose a Hub contact...')}</option>
{hubContacts.map((contact) => (
<option key={contact.user_id} value={contact.user_id}>
{contact.remark ?? contact.nickname ?? contact.username}
</option>
))}
</select>
)}
<input
className={styles.addInput}
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
onKeyDown={handleAddKeyDown}
placeholder={label('im.contact.hubUserIdPlaceholder', 'Hub user ID...')}
autoFocus
aria-label={label('im.contact.hubUserId', 'Hub user ID')}
/>
</>
)}

<button className={styles.addConfirm} onClick={() => void handleSubmit()} disabled={submitting}>
{composeMode === 'contact' ? label('common.add', 'Add') : label('common.create', 'Create')}
</button>
</div>
)}
Expand All @@ -89,15 +244,15 @@ const IMContactList = memo(function IMContactList({
className={styles.searchInput}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search contacts..."
aria-label="Search contacts"
placeholder={label('im.contact.search', 'Search contacts...')}
aria-label={label('im.contact.searchLabel', 'Search contacts')}
/>
</div>

<div className={styles.list} role="listbox" aria-label="Contacts">
<div className={styles.list} role="listbox" aria-label={label('im.contact.title', 'Contacts')}>
{filtered.length === 0 ? (
<div className={styles.empty}>
{search ? 'No contacts match your search' : 'No contacts yet'}
{search ? label('im.contact.noSearchResults', 'No contacts match your search') : label('im.contact.noContacts', 'No contacts yet')}
</div>
) : (
filtered.map((contact) => (
Expand All @@ -115,16 +270,16 @@ const IMContactList = memo(function IMContactList({
<div className={styles.itemName}>{contact.name}</div>
<div className={styles.itemMeta}>
{contact.type}
{contact.authority ? ` · ${contact.authority}` : ''}
{contact.lastSeen ? ` · ${contact.lastSeen}` : ''}
{contact.authority ? ` | ${contact.authority}` : ''}
{statusHintLabel(contact) ? ` | ${statusHintLabel(contact)}` : ''}
</div>
</div>
<div
className={`${styles.onlineDot} ${
contact.online ? styles.onlineDotOn : styles.onlineDotOff
}`}
aria-label={contact.online ? 'Online' : 'Offline'}
title={contact.online ? 'Online' : 'Offline'}
aria-label={contact.online ? label('im.contact.online', 'Online') : label('im.contact.offline', 'Offline')}
title={contact.online ? label('im.contact.online', 'Online') : label('im.contact.offline', 'Offline')}
/>
</div>
))
Expand Down
7 changes: 4 additions & 3 deletions app/desktop/src/components/IM/IMMessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import styles from './IMMessageInput.module.css';
const MAX_CHARS = 2000;

interface IMMessageInputProps {
onSend: (content: string) => void;
onSend: (content: string) => boolean | void | Promise<boolean | void>;
disabled?: boolean;
placeholder?: string;
}
Expand All @@ -18,10 +18,11 @@ const IMMessageInput = memo(function IMMessageInput({
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);

const handleSend = useCallback(() => {
const handleSend = useCallback(async () => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
const accepted = await onSend(trimmed);
if (accepted === false) return;
setValue('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
Expand Down
Loading
Loading