11import { 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' ;
35import type { IMContact } from './types' ;
46import styles from './IMContactList.module.css' ;
57
8+ type ComposeMode = 'contact' | 'private' | 'group' ;
9+
610interface 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
2431const 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 ) )
0 commit comments