Skip to content

Commit cc5fbe0

Browse files
feat(desktop): IM 系统增强——好友/通知/撤回/已读/群聊 (#220)
feat(desktop): IM 系统增强——好友/通知/撤回/已读/群聊
2 parents 397836d + 22df172 commit cc5fbe0

6 files changed

Lines changed: 1444 additions & 123 deletions

File tree

app/desktop/src/components/IM/IMContactList.tsx

Lines changed: 200 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { useState, useMemo, useCallback, memo } from 'react';
2-
import { Plus } from 'lucide-react';
2+
import { useTranslation } from 'react-i18next';
3+
import { MessageCircle, Plus, UserPlus, Users } from 'lucide-react';
4+
import type { ContactInfo } from '@/api/hubClient';
35
import type { IMContact } from './types';
46
import styles from './IMContactList.module.css';
57

8+
type ComposeMode = 'contact' | 'private' | 'group';
9+
610
interface IMContactListProps {
711
contacts: IMContact[];
12+
hubContacts?: ContactInfo[];
813
onSelect?: (contact: IMContact) => void;
9-
onAdd?: (name: string) => void;
14+
onAddContact?: (userId: string) => boolean | void | Promise<boolean | void>;
15+
onCreatePrivateSession?: (userId: string) => boolean | void | Promise<boolean | void>;
16+
onCreateGroupSession?: (name: string, memberIds: string[]) => boolean | void | Promise<boolean | void>;
1017
selectedId?: string;
1118
}
1219

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

2431
const IMContactList = memo(function IMContactList({
2532
contacts,
33+
hubContacts = [],
2634
onSelect,
27-
onAdd,
35+
onAddContact,
36+
onCreatePrivateSession,
37+
onCreateGroupSession,
2838
selectedId,
2939
}: IMContactListProps) {
3040
const [search, setSearch] = useState('');
31-
const [showAdd, setShowAdd] = useState(false);
32-
const [addName, setAddName] = useState('');
41+
const [showCompose, setShowCompose] = useState(false);
42+
const [composeMode, setComposeMode] = useState<ComposeMode>('contact');
43+
const [targetUserId, setTargetUserId] = useState('');
44+
const [groupName, setGroupName] = useState('');
45+
const [groupMembers, setGroupMembers] = useState<string[]>([]);
46+
const [submitting, setSubmitting] = useState(false);
47+
const { t } = useTranslation();
48+
const label = useCallback(
49+
(key: string, fallback: string) => {
50+
const translated = t(key);
51+
return translated === key ? fallback : translated;
52+
},
53+
[t],
54+
);
55+
const statusHintLabel = useCallback(
56+
(contact: IMContact) => {
57+
if (!contact.statusHint) return contact.lastSeen;
58+
const translated = t(contact.statusHint, contact.statusHintParams);
59+
if (translated !== contact.statusHint) return translated;
60+
if (contact.statusHintParams?.seq) return `${contact.statusHint} ${contact.statusHintParams.seq}`;
61+
return contact.statusHint;
62+
},
63+
[t],
64+
);
65+
const canCompose = Boolean(onAddContact || onCreatePrivateSession || onCreateGroupSession);
3366

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

40-
const handleAdd = useCallback(() => {
41-
const trimmed = addName.trim();
42-
if (!trimmed) return;
43-
onAdd?.(trimmed);
44-
setAddName('');
45-
setShowAdd(false);
46-
}, [addName, onAdd]);
73+
const handleSubmit = useCallback(async () => {
74+
if (submitting) return;
75+
setSubmitting(true);
76+
try {
77+
let accepted: boolean | void = false;
78+
if (composeMode === 'group') {
79+
const memberIds = groupMembers.map((id) => id.trim()).filter(Boolean);
80+
if (groupName.trim() && memberIds.length > 0) {
81+
accepted = await onCreateGroupSession?.(groupName.trim(), memberIds);
82+
}
83+
} else if (targetUserId.trim()) {
84+
accepted = composeMode === 'private'
85+
? await onCreatePrivateSession?.(targetUserId.trim())
86+
: await onAddContact?.(targetUserId.trim());
87+
}
88+
89+
if (accepted === false) return;
90+
setTargetUserId('');
91+
setGroupName('');
92+
setGroupMembers([]);
93+
setShowCompose(false);
94+
} finally {
95+
setSubmitting(false);
96+
}
97+
}, [
98+
composeMode,
99+
groupMembers,
100+
groupName,
101+
onAddContact,
102+
onCreateGroupSession,
103+
onCreatePrivateSession,
104+
submitting,
105+
targetUserId,
106+
]);
47107

48108
const handleAddKeyDown = useCallback(
49109
(e: React.KeyboardEvent<HTMLInputElement>) => {
50-
if (e.key === 'Enter') handleAdd();
51-
if (e.key === 'Escape') setShowAdd(false);
110+
if (e.key === 'Enter') void handleSubmit();
111+
if (e.key === 'Escape') setShowCompose(false);
52112
},
53-
[handleAdd],
113+
[handleSubmit],
54114
);
55115

116+
const toggleGroupMember = useCallback((userId: string) => {
117+
setGroupMembers((prev) =>
118+
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId],
119+
);
120+
}, []);
121+
56122
return (
57123
<div className={styles.root}>
58124
<div className={styles.header}>
59-
<span className={styles.title}>Contacts</span>
60-
<button
61-
className={styles.addBtn}
62-
onClick={() => setShowAdd((v) => !v)}
63-
aria-label={showAdd ? 'Cancel add contact' : 'Add contact'}
64-
title={showAdd ? 'Cancel' : 'Add contact'}
65-
>
66-
<Plus size={14} />
67-
</button>
125+
<span className={styles.title}>{label('im.contact.title', 'Contacts')}</span>
126+
{canCompose && (
127+
<button
128+
className={styles.addBtn}
129+
onClick={() => setShowCompose((v) => !v)}
130+
aria-label={showCompose ? label('im.contact.cancelCompose', 'Cancel Hub compose') : label('im.contact.openCompose', 'Open Hub compose')}
131+
title={showCompose ? label('common.cancel', 'Cancel') : label('im.contact.hubActions', 'Hub actions')}
132+
>
133+
<Plus size={14} />
134+
</button>
135+
)}
68136
</div>
69137

70-
{showAdd && (
71-
<div className={styles.addForm}>
72-
<input
73-
className={styles.addInput}
74-
value={addName}
75-
onChange={(e) => setAddName(e.target.value)}
76-
onKeyDown={handleAddKeyDown}
77-
placeholder="Contact name..."
78-
autoFocus
79-
aria-label="Contact name"
80-
/>
81-
<button className={styles.addConfirm} onClick={handleAdd}>
82-
Add
138+
{showCompose && canCompose && (
139+
<div className={styles.composeForm}>
140+
<div className={styles.composeModes} aria-label={label('im.contact.composeMode', 'Hub compose mode')}>
141+
{onAddContact && (
142+
<button
143+
type="button"
144+
className={`${styles.modeBtn} ${composeMode === 'contact' ? styles.modeBtnActive : ''}`}
145+
onClick={() => setComposeMode('contact')}
146+
aria-pressed={composeMode === 'contact'}
147+
aria-label={label('im.contact.addContact', 'Add contact')}
148+
title={label('im.contact.addContact', 'Add contact')}
149+
>
150+
<UserPlus size={14} />
151+
</button>
152+
)}
153+
{onCreatePrivateSession && (
154+
<button
155+
type="button"
156+
className={`${styles.modeBtn} ${composeMode === 'private' ? styles.modeBtnActive : ''}`}
157+
onClick={() => setComposeMode('private')}
158+
aria-pressed={composeMode === 'private'}
159+
aria-label={label('im.contact.createDirectChat', 'Create direct chat')}
160+
title={label('im.contact.createDirectChat', 'Create direct chat')}
161+
>
162+
<MessageCircle size={14} />
163+
</button>
164+
)}
165+
{onCreateGroupSession && (
166+
<button
167+
type="button"
168+
className={`${styles.modeBtn} ${composeMode === 'group' ? styles.modeBtnActive : ''}`}
169+
onClick={() => setComposeMode('group')}
170+
aria-pressed={composeMode === 'group'}
171+
aria-label={label('im.contact.createGroupChat', 'Create group chat')}
172+
title={label('im.contact.createGroupChat', 'Create group chat')}
173+
>
174+
<Users size={14} />
175+
</button>
176+
)}
177+
</div>
178+
179+
{composeMode === 'group' ? (
180+
<>
181+
<input
182+
className={styles.addInput}
183+
value={groupName}
184+
onChange={(e) => setGroupName(e.target.value)}
185+
onKeyDown={handleAddKeyDown}
186+
placeholder={label('im.contact.groupNamePlaceholder', 'Group name...')}
187+
autoFocus
188+
aria-label={label('im.contact.groupName', 'Group name')}
189+
/>
190+
<div className={styles.memberPicker} aria-label={label('im.contact.groupMembers', 'Group members')}>
191+
{hubContacts.length === 0 ? (
192+
<span className={styles.memberEmpty}>{label('im.contact.noHubContacts', 'No Hub contacts available')}</span>
193+
) : (
194+
hubContacts.map((contact) => (
195+
<label className={styles.memberOption} key={contact.user_id}>
196+
<input
197+
type="checkbox"
198+
checked={groupMembers.includes(contact.user_id)}
199+
onChange={() => toggleGroupMember(contact.user_id)}
200+
/>
201+
<span>{contact.remark ?? contact.nickname ?? contact.username}</span>
202+
</label>
203+
))
204+
)}
205+
</div>
206+
</>
207+
) : (
208+
<>
209+
{composeMode === 'private' && hubContacts.length > 0 && (
210+
<select
211+
className={styles.addInput}
212+
value={targetUserId}
213+
onChange={(e) => setTargetUserId(e.target.value)}
214+
aria-label={label('im.contact.hubContact', 'Hub contact')}
215+
>
216+
<option value="">{label('im.contact.chooseHubContact', 'Choose a Hub contact...')}</option>
217+
{hubContacts.map((contact) => (
218+
<option key={contact.user_id} value={contact.user_id}>
219+
{contact.remark ?? contact.nickname ?? contact.username}
220+
</option>
221+
))}
222+
</select>
223+
)}
224+
<input
225+
className={styles.addInput}
226+
value={targetUserId}
227+
onChange={(e) => setTargetUserId(e.target.value)}
228+
onKeyDown={handleAddKeyDown}
229+
placeholder={label('im.contact.hubUserIdPlaceholder', 'Hub user ID...')}
230+
autoFocus
231+
aria-label={label('im.contact.hubUserId', 'Hub user ID')}
232+
/>
233+
</>
234+
)}
235+
236+
<button className={styles.addConfirm} onClick={() => void handleSubmit()} disabled={submitting}>
237+
{composeMode === 'contact' ? label('common.add', 'Add') : label('common.create', 'Create')}
83238
</button>
84239
</div>
85240
)}
@@ -89,15 +244,15 @@ const IMContactList = memo(function IMContactList({
89244
className={styles.searchInput}
90245
value={search}
91246
onChange={(e) => setSearch(e.target.value)}
92-
placeholder="Search contacts..."
93-
aria-label="Search contacts"
247+
placeholder={label('im.contact.search', 'Search contacts...')}
248+
aria-label={label('im.contact.searchLabel', 'Search contacts')}
94249
/>
95250
</div>
96251

97-
<div className={styles.list} role="listbox" aria-label="Contacts">
252+
<div className={styles.list} role="listbox" aria-label={label('im.contact.title', 'Contacts')}>
98253
{filtered.length === 0 ? (
99254
<div className={styles.empty}>
100-
{search ? 'No contacts match your search' : 'No contacts yet'}
255+
{search ? label('im.contact.noSearchResults', 'No contacts match your search') : label('im.contact.noContacts', 'No contacts yet')}
101256
</div>
102257
) : (
103258
filtered.map((contact) => (
@@ -115,16 +270,16 @@ const IMContactList = memo(function IMContactList({
115270
<div className={styles.itemName}>{contact.name}</div>
116271
<div className={styles.itemMeta}>
117272
{contact.type}
118-
{contact.authority ? ` · ${contact.authority}` : ''}
119-
{contact.lastSeen ? ` · ${contact.lastSeen}` : ''}
273+
{contact.authority ? ` | ${contact.authority}` : ''}
274+
{statusHintLabel(contact) ? ` | ${statusHintLabel(contact)}` : ''}
120275
</div>
121276
</div>
122277
<div
123278
className={`${styles.onlineDot} ${
124279
contact.online ? styles.onlineDotOn : styles.onlineDotOff
125280
}`}
126-
aria-label={contact.online ? 'Online' : 'Offline'}
127-
title={contact.online ? 'Online' : 'Offline'}
281+
aria-label={contact.online ? label('im.contact.online', 'Online') : label('im.contact.offline', 'Offline')}
282+
title={contact.online ? label('im.contact.online', 'Online') : label('im.contact.offline', 'Offline')}
128283
/>
129284
</div>
130285
))

app/desktop/src/components/IM/IMMessageInput.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import styles from './IMMessageInput.module.css';
55
const MAX_CHARS = 2000;
66

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

21-
const handleSend = useCallback(() => {
21+
const handleSend = useCallback(async () => {
2222
const trimmed = value.trim();
2323
if (!trimmed || disabled) return;
24-
onSend(trimmed);
24+
const accepted = await onSend(trimmed);
25+
if (accepted === false) return;
2526
setValue('');
2627
if (textareaRef.current) {
2728
textareaRef.current.style.height = 'auto';

0 commit comments

Comments
 (0)