@ermis-network/ermis-chat-react 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2411 -1309
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +471 -16
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +145 -1
- package/dist/index.d.ts +145 -1
- package/dist/index.mjs +2340 -1242
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +231 -0
- package/src/components/ChannelHeader.tsx +38 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +118 -20
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +10 -2
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +88 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -4
- package/src/components/ChannelList.tsx +467 -45
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- package/src/components/MessageInput.tsx +19 -2
- package/src/components/MessageItem.tsx +8 -11
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +7 -9
- package/src/components/PendingOverlay.tsx +41 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/VirtualMessageList.tsx +74 -43
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useChannelCapabilities.ts +7 -3
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +24 -3
- package/src/hooks/useChannelRowUpdates.ts +6 -0
- package/src/hooks/useMessageActions.ts +1 -1
- package/src/index.ts +6 -1
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +217 -6
- package/src/styles/_message-bubble.css +75 -9
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +51 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +1 -0
- package/src/types.ts +157 -3
|
@@ -11,6 +11,12 @@ import { Avatar } from './Avatar';
|
|
|
11
11
|
import type { ChannelItemProps, ChannelListProps } from '../types';
|
|
12
12
|
|
|
13
13
|
export type { ChannelListProps, ChannelItemProps } from '../types';
|
|
14
|
+
import type { ChannelActionsProps } from '../types';
|
|
15
|
+
import { TopicModal } from './TopicModal';
|
|
16
|
+
import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
|
|
17
|
+
|
|
18
|
+
export { DefaultChannelActions } from './ChannelActions';
|
|
19
|
+
export type { ChannelAction, ChannelActionsProps } from '../types';
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* Get a human-readable preview string for the last message,
|
|
@@ -19,26 +25,28 @@ export type { ChannelListProps, ChannelItemProps } from '../types';
|
|
|
19
25
|
function getLastMessagePreview(
|
|
20
26
|
channel: Channel,
|
|
21
27
|
myUserId?: string,
|
|
22
|
-
): { text: string; user: string } {
|
|
28
|
+
): { text: string; user: string; timestamp?: string | Date } {
|
|
23
29
|
const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
|
|
24
30
|
if (!lastMsg) return { text: '', user: '' };
|
|
25
31
|
|
|
32
|
+
const timestamp = lastMsg.created_at;
|
|
33
|
+
|
|
26
34
|
const msgType = lastMsg.type || 'regular';
|
|
27
35
|
const rawText = lastMsg.text ?? '';
|
|
28
36
|
|
|
29
37
|
if (msgType === 'system') {
|
|
30
38
|
const userMap = buildUserMap(channel.state);
|
|
31
|
-
return { text: parseSystemMessage(rawText, userMap), user: '' };
|
|
39
|
+
return { text: parseSystemMessage(rawText, userMap), user: '', timestamp };
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
if (msgType === 'signal') {
|
|
35
43
|
const result = parseSignalMessage(rawText, myUserId || '');
|
|
36
|
-
return { text: result?.text || rawText, user: '' };
|
|
44
|
+
return { text: result?.text || rawText, user: '', timestamp };
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
// Display 'Sticker' if message is a sticker
|
|
40
48
|
if (msgType === 'sticker' || (lastMsg as Record<string, unknown>).sticker_url) {
|
|
41
|
-
return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '' };
|
|
49
|
+
return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '', timestamp };
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
// Regular / other
|
|
@@ -78,6 +86,7 @@ function getLastMessagePreview(
|
|
|
78
86
|
return {
|
|
79
87
|
text: displayText,
|
|
80
88
|
user: lastMsg.user?.name || lastMsg.user_id || '',
|
|
89
|
+
timestamp,
|
|
81
90
|
};
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -91,25 +100,68 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
|
|
|
91
100
|
unreadCount,
|
|
92
101
|
lastMessageText,
|
|
93
102
|
lastMessageUser,
|
|
103
|
+
lastMessageTimestamp,
|
|
94
104
|
onSelect,
|
|
95
105
|
AvatarComponent,
|
|
96
106
|
isBlocked,
|
|
97
107
|
isPending,
|
|
98
108
|
pendingBadgeLabel,
|
|
99
109
|
blockedBadgeLabel,
|
|
110
|
+
isClosedTopic,
|
|
111
|
+
closedTopicIcon,
|
|
112
|
+
PinnedIconComponent,
|
|
113
|
+
ChannelActionsComponent,
|
|
114
|
+
onAddTopic,
|
|
115
|
+
onEditTopic,
|
|
116
|
+
onToggleCloseTopic,
|
|
117
|
+
hiddenActions,
|
|
118
|
+
actionLabels,
|
|
119
|
+
actionIcons,
|
|
100
120
|
}) => {
|
|
121
|
+
const { client } = useChatClient();
|
|
122
|
+
const currentUserId = client.userID;
|
|
123
|
+
|
|
101
124
|
// Subscribe to channel.updated so that when name/image/description change,
|
|
102
125
|
// we re-render from within (bypasses React.memo which only blocks parent-driven re-renders)
|
|
103
|
-
const [, forceUpdate] = useState(0);
|
|
126
|
+
const [updateCount, forceUpdate] = useState(0);
|
|
104
127
|
useEffect(() => {
|
|
105
|
-
const
|
|
106
|
-
|
|
128
|
+
const handleUpdate = () => forceUpdate((c) => c + 1);
|
|
129
|
+
const sub1 = channel.on('channel.updated', handleUpdate);
|
|
130
|
+
const sub2 = channel.on('channel.pinned', handleUpdate);
|
|
131
|
+
const sub3 = channel.on('channel.unpinned', handleUpdate);
|
|
132
|
+
return () => {
|
|
133
|
+
sub1.unsubscribe();
|
|
134
|
+
sub2.unsubscribe();
|
|
135
|
+
sub3.unsubscribe();
|
|
136
|
+
};
|
|
107
137
|
}, [channel]);
|
|
108
138
|
|
|
139
|
+
const defaultActions = useMemo(
|
|
140
|
+
() => computeDefaultActions(channel, currentUserId, { onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons }),
|
|
141
|
+
[channel, currentUserId, updateCount, onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const filteredActions = useMemo(() => {
|
|
145
|
+
if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
|
|
146
|
+
return defaultActions.filter(a => !hiddenActions.includes(a.id));
|
|
147
|
+
}, [defaultActions, hiddenActions]);
|
|
148
|
+
const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
|
|
149
|
+
|
|
109
150
|
const name = channel.data?.name || channel.cid;
|
|
110
151
|
const image = channel.data?.image as string | undefined;
|
|
111
152
|
const showUnread = hasUnread && !isActive;
|
|
112
153
|
|
|
154
|
+
const timestampText = useMemo(() => {
|
|
155
|
+
if (!lastMessageTimestamp) return null;
|
|
156
|
+
const d = new Date(lastMessageTimestamp);
|
|
157
|
+
if (isNaN(d.getTime())) return null;
|
|
158
|
+
const today = new Date();
|
|
159
|
+
const isToday = d.toDateString() === today.toDateString();
|
|
160
|
+
return isToday
|
|
161
|
+
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
162
|
+
: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
163
|
+
}, [lastMessageTimestamp]);
|
|
164
|
+
|
|
113
165
|
const handleClick = useCallback(() => {
|
|
114
166
|
onSelect(channel);
|
|
115
167
|
}, [channel, onSelect]);
|
|
@@ -118,7 +170,6 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
|
|
|
118
170
|
'ermis-channel-list__item',
|
|
119
171
|
isActive ? 'ermis-channel-list__item--active' : '',
|
|
120
172
|
showUnread ? 'ermis-channel-list__item--unread' : '',
|
|
121
|
-
isBlocked ? 'ermis-channel-list__item--blocked' : '',
|
|
122
173
|
isPending ? 'ermis-channel-list__item--pending' : '',
|
|
123
174
|
].filter(Boolean).join(' ');
|
|
124
175
|
|
|
@@ -126,39 +177,78 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
|
|
|
126
177
|
<div className={itemClass} onClick={handleClick}>
|
|
127
178
|
<AvatarComponent image={image} name={name} size={40} />
|
|
128
179
|
<div className="ermis-channel-list__item-content">
|
|
129
|
-
<div className="ermis-channel-list__item-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<span>
|
|
138
|
-
|
|
139
|
-
|
|
180
|
+
<div className="ermis-channel-list__item-top-row">
|
|
181
|
+
<div className="ermis-channel-list__item-name">{name}</div>
|
|
182
|
+
{channel.data?.is_pinned === true && !isClosedTopic && PinnedIconComponent && (
|
|
183
|
+
<span className="ermis-channel-list__pinned-icon" title="Pinned">
|
|
184
|
+
<PinnedIconComponent />
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
{isClosedTopic && (
|
|
188
|
+
<span className="ermis-channel-list__closed-icon">
|
|
189
|
+
{closedTopicIcon || (
|
|
190
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
191
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
192
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
193
|
+
</svg>
|
|
194
|
+
)}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
{!isClosedTopic && timestampText && <div className="ermis-channel-list__item-timestamp">{timestampText}</div>}
|
|
198
|
+
|
|
199
|
+
{isPending && (
|
|
200
|
+
<span className="ermis-channel-list__pending-badge">{pendingBadgeLabel || 'Invited'}</span>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{isBlocked && (
|
|
204
|
+
<span className="ermis-channel-list__blocked-icon" title={blockedBadgeLabel || "Blocked"}>
|
|
205
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
206
|
+
<circle cx="12" cy="12" r="10" />
|
|
207
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
208
|
+
</svg>
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
<div className="ermis-channel-list__item-bottom-row">
|
|
213
|
+
{!isClosedTopic && lastMessageText && (
|
|
214
|
+
<div className="ermis-channel-list__item-last-message">
|
|
215
|
+
{lastMessageUser && (
|
|
216
|
+
<span className="ermis-channel-list__item-last-message-user">
|
|
217
|
+
{lastMessageUser}:{' '}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
<span>{lastMessageText}</span>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{!isClosedTopic && (
|
|
225
|
+
<div className="ermis-channel-list__item-badges">
|
|
226
|
+
{showUnread && unreadCount > 0 && (
|
|
227
|
+
<span className="ermis-channel-list__unread-badge">
|
|
228
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
140
234
|
</div>
|
|
141
|
-
{
|
|
142
|
-
<
|
|
143
|
-
{
|
|
144
|
-
</
|
|
145
|
-
)}
|
|
146
|
-
{isBlocked && (
|
|
147
|
-
<span className="ermis-channel-list__blocked-icon" title={blockedBadgeLabel || "Blocked"}>
|
|
148
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
149
|
-
<circle cx="12" cy="12" r="10" />
|
|
150
|
-
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
151
|
-
</svg>
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
154
|
-
{isPending && (
|
|
155
|
-
<span className="ermis-channel-list__pending-badge">{pendingBadgeLabel || 'Invited'}</span>
|
|
235
|
+
{!isPending && (
|
|
236
|
+
<div className="ermis-channel-list__item-actions-wrapper">
|
|
237
|
+
<ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
|
|
238
|
+
</div>
|
|
156
239
|
)}
|
|
157
240
|
</div>
|
|
158
241
|
);
|
|
159
242
|
});
|
|
160
243
|
ChannelItem.displayName = 'ChannelItem';
|
|
161
244
|
|
|
245
|
+
export const DefaultPinnedIcon = React.memo(() => (
|
|
246
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
247
|
+
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
|
248
|
+
</svg>
|
|
249
|
+
));
|
|
250
|
+
DefaultPinnedIcon.displayName = 'DefaultPinnedIcon';
|
|
251
|
+
|
|
162
252
|
const DefaultLoading = React.memo(({ text }: { text?: string }) => (
|
|
163
253
|
<div className="ermis-channel-list__loading">{text || 'Loading channels...'}</div>
|
|
164
254
|
));
|
|
@@ -182,6 +272,15 @@ type ChannelRowProps = {
|
|
|
182
272
|
currentUserId?: string;
|
|
183
273
|
pendingBadgeLabel?: string;
|
|
184
274
|
blockedBadgeLabel?: string;
|
|
275
|
+
closedTopicIcon?: React.ReactNode;
|
|
276
|
+
PinnedIconComponent?: React.ComponentType;
|
|
277
|
+
ChannelActionsComponent?: React.ComponentType<ChannelActionsProps>;
|
|
278
|
+
onAddTopic?: (channel: Channel) => void;
|
|
279
|
+
onEditTopic?: (channel: Channel) => void;
|
|
280
|
+
onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
|
|
281
|
+
hiddenActions?: string[];
|
|
282
|
+
actionLabels?: import('../types').ChannelActionLabels;
|
|
283
|
+
actionIcons?: import('../types').ChannelActionIcons;
|
|
185
284
|
};
|
|
186
285
|
|
|
187
286
|
const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
@@ -194,6 +293,15 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
194
293
|
currentUserId,
|
|
195
294
|
pendingBadgeLabel,
|
|
196
295
|
blockedBadgeLabel,
|
|
296
|
+
closedTopicIcon,
|
|
297
|
+
PinnedIconComponent,
|
|
298
|
+
ChannelActionsComponent,
|
|
299
|
+
onAddTopic,
|
|
300
|
+
onEditTopic,
|
|
301
|
+
onToggleCloseTopic,
|
|
302
|
+
hiddenActions,
|
|
303
|
+
actionLabels,
|
|
304
|
+
actionIcons,
|
|
197
305
|
}) => {
|
|
198
306
|
// Use the new custom hook to handle all row-level realtime updates
|
|
199
307
|
const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
|
|
@@ -201,12 +309,15 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
201
309
|
|
|
202
310
|
const channelState = channel.state as unknown as Record<string, unknown> | undefined;
|
|
203
311
|
const rawUnreadCount = (channelState?.unreadCount as number) ?? 0;
|
|
312
|
+
|
|
313
|
+
const isClosedTopic = channel.data?.is_closed_topic === true;
|
|
314
|
+
|
|
315
|
+
// Render logic continues...
|
|
204
316
|
const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending) ? 0 : rawUnreadCount;
|
|
205
317
|
const hasUnread = unreadCount > 0;
|
|
206
318
|
|
|
207
|
-
// Derive last message preview computation
|
|
208
|
-
|
|
209
|
-
const { text: rawLastMessageText, user: rawLastMessageUser } = useMemo(
|
|
319
|
+
// Derive last message preview computation
|
|
320
|
+
const { text: rawLastMessageText, user: rawLastMessageUser, timestamp: rawLastMessageTimestamp } = useMemo(
|
|
210
321
|
() => getLastMessagePreview(channel, currentUserId),
|
|
211
322
|
// Recompute if latestMessage changes or we get a force update
|
|
212
323
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -216,6 +327,7 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
216
327
|
// Hide last message preview when banned, blocked, or pending
|
|
217
328
|
const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageText;
|
|
218
329
|
const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageUser;
|
|
330
|
+
const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending) ? null : rawLastMessageTimestamp;
|
|
219
331
|
|
|
220
332
|
if (renderChannel) {
|
|
221
333
|
return (
|
|
@@ -233,19 +345,209 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
233
345
|
unreadCount={unreadCount}
|
|
234
346
|
lastMessageText={lastMessageText}
|
|
235
347
|
lastMessageUser={lastMessageUser}
|
|
348
|
+
lastMessageTimestamp={lastMessageTimestamp}
|
|
236
349
|
onSelect={handleSelect}
|
|
237
350
|
AvatarComponent={AvatarComponent}
|
|
238
351
|
isBlocked={isBlockedInChannel}
|
|
239
352
|
isPending={isPending}
|
|
240
353
|
pendingBadgeLabel={pendingBadgeLabel}
|
|
241
354
|
blockedBadgeLabel={blockedBadgeLabel}
|
|
355
|
+
isClosedTopic={isClosedTopic}
|
|
356
|
+
closedTopicIcon={closedTopicIcon}
|
|
357
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
358
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
359
|
+
onAddTopic={onAddTopic}
|
|
360
|
+
onEditTopic={onEditTopic}
|
|
361
|
+
onToggleCloseTopic={onToggleCloseTopic}
|
|
362
|
+
hiddenActions={hiddenActions}
|
|
363
|
+
actionLabels={actionLabels}
|
|
364
|
+
actionIcons={actionIcons}
|
|
242
365
|
/>
|
|
243
366
|
);
|
|
244
367
|
});
|
|
245
368
|
ChannelRow.displayName = 'ChannelRow';
|
|
246
369
|
|
|
370
|
+
export const ChannelTopicGroup = React.memo(({
|
|
371
|
+
channel,
|
|
372
|
+
activeChannel,
|
|
373
|
+
handleSelect,
|
|
374
|
+
renderChannel,
|
|
375
|
+
ChannelItemComponent,
|
|
376
|
+
AvatarComponent,
|
|
377
|
+
GeneralTopicAvatarComponent,
|
|
378
|
+
TopicAvatarComponent,
|
|
379
|
+
currentUserId,
|
|
380
|
+
pendingBadgeLabel,
|
|
381
|
+
blockedBadgeLabel,
|
|
382
|
+
generalTopicLabel,
|
|
383
|
+
closedTopicIcon,
|
|
384
|
+
PinnedIconComponent,
|
|
385
|
+
ChannelActionsComponent,
|
|
386
|
+
onAddTopic,
|
|
387
|
+
onEditTopic,
|
|
388
|
+
onToggleCloseTopic,
|
|
389
|
+
hiddenActions,
|
|
390
|
+
actionLabels,
|
|
391
|
+
actionIcons,
|
|
392
|
+
}: any) => {
|
|
393
|
+
const { updateCount } = useChannelRowUpdates(channel, currentUserId);
|
|
394
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
395
|
+
const [topicUpdateCount, setTopicUpdateCount] = useState(0);
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const subs: { unsubscribe: () => void }[] = [];
|
|
399
|
+
const handleUpdate = () => setTopicUpdateCount((c) => c + 1);
|
|
400
|
+
const currentTopics = channel.state?.topics || [];
|
|
401
|
+
currentTopics.forEach((t: Channel) => {
|
|
402
|
+
subs.push(t.on('channel.pinned', handleUpdate));
|
|
403
|
+
subs.push(t.on('channel.unpinned', handleUpdate));
|
|
404
|
+
subs.push(t.on('message.new', handleUpdate));
|
|
405
|
+
subs.push(t.on('message.deleted', handleUpdate));
|
|
406
|
+
});
|
|
407
|
+
return () => {
|
|
408
|
+
subs.forEach((s) => s.unsubscribe());
|
|
409
|
+
};
|
|
410
|
+
}, [channel.state?.topics]);
|
|
411
|
+
|
|
412
|
+
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
|
|
413
|
+
|
|
414
|
+
const userRole = channel.state?.members?.[currentUserId]?.channel_role;
|
|
415
|
+
const hasTopicAddPermission = Boolean(userRole === 'owner' || userRole === 'moder');
|
|
416
|
+
|
|
417
|
+
const getTopicTime = (t: Channel) => {
|
|
418
|
+
const lastMsg = t.state?.latestMessages?.slice(-1)[0];
|
|
419
|
+
if (lastMsg?.created_at) return new Date(lastMsg.created_at).getTime();
|
|
420
|
+
if (t.data?.last_message_at) return new Date(t.data.last_message_at as string | Date).getTime();
|
|
421
|
+
if (t.data?.created_at) return new Date(t.data.created_at as string | Date).getTime();
|
|
422
|
+
return 0;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const topics = useMemo(() => {
|
|
426
|
+
const allTopics = channel.state?.topics || [];
|
|
427
|
+
return [...allTopics].sort((a: any, b: any) => {
|
|
428
|
+
const aPinned = a.data?.is_pinned === true;
|
|
429
|
+
const bPinned = b.data?.is_pinned === true;
|
|
430
|
+
if (aPinned && !bPinned) return -1;
|
|
431
|
+
if (!aPinned && bPinned) return 1;
|
|
432
|
+
|
|
433
|
+
return getTopicTime(b) - getTopicTime(a);
|
|
434
|
+
});
|
|
435
|
+
}, [channel.state?.topics, topicUpdateCount]);
|
|
436
|
+
const name = channel.data?.name || channel.cid;
|
|
437
|
+
const image = channel.data?.image as string | undefined;
|
|
438
|
+
|
|
439
|
+
const GeneralAvatar = useCallback(() => (
|
|
440
|
+
<div className="ermis-channel-list__topic-hashtag">#</div>
|
|
441
|
+
), []);
|
|
442
|
+
|
|
443
|
+
const TopicEmojiAvatar = useCallback(({ image }: any) => {
|
|
444
|
+
let emoji = '💬';
|
|
445
|
+
if (image && typeof image === 'string' && image.startsWith('emoji://')) {
|
|
446
|
+
emoji = image.replace('emoji://', '');
|
|
447
|
+
}
|
|
448
|
+
return <div className="ermis-channel-list__topic-hashtag">{emoji}</div>;
|
|
449
|
+
}, []);
|
|
450
|
+
|
|
451
|
+
const generalChannelProxy = useMemo(() => {
|
|
452
|
+
return new Proxy(channel, {
|
|
453
|
+
get(target, prop, receiver) {
|
|
454
|
+
if (prop === 'data') {
|
|
455
|
+
return { ...target.data, name: generalTopicLabel || 'general', is_pinned: false };
|
|
456
|
+
}
|
|
457
|
+
const value = Reflect.get(target, prop, receiver);
|
|
458
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}, [channel, generalTopicLabel]);
|
|
462
|
+
|
|
463
|
+
const defaultActions = useMemo(
|
|
464
|
+
() => computeDefaultActions(channel, currentUserId, { onAddTopic, actionLabels, actionIcons }),
|
|
465
|
+
[channel, currentUserId, updateCount, onAddTopic, actionLabels, actionIcons],
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const filteredActions = useMemo(() => {
|
|
469
|
+
if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
|
|
470
|
+
return defaultActions.filter((a: any) => !hiddenActions.includes(a.id));
|
|
471
|
+
}, [defaultActions, hiddenActions]);
|
|
472
|
+
const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
<div className="ermis-channel-list__topic-group">
|
|
476
|
+
<div
|
|
477
|
+
className={`ermis-channel-list__topic-header ${isExpanded ? 'ermis-channel-list__topic-header--expanded' : ''}`}
|
|
478
|
+
onClick={handleToggle}
|
|
479
|
+
>
|
|
480
|
+
<AvatarComponent image={image} name={name} size={40} />
|
|
481
|
+
<div className="ermis-channel-list__topic-header-name">{name}</div>
|
|
482
|
+
|
|
483
|
+
{channel.data?.is_pinned === true && PinnedIconComponent && (
|
|
484
|
+
<span className="ermis-channel-list__pinned-icon" title="Pinned">
|
|
485
|
+
<PinnedIconComponent />
|
|
486
|
+
</span>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
<div className="ermis-channel-list__topic-actions-wrapper">
|
|
490
|
+
<ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<svg
|
|
494
|
+
className="ermis-channel-list__accordion-icon"
|
|
495
|
+
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
496
|
+
>
|
|
497
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
498
|
+
</svg>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
{isExpanded && (
|
|
502
|
+
<div className="ermis-channel-list__topic-sublist">
|
|
503
|
+
<ChannelRow
|
|
504
|
+
channel={generalChannelProxy as any}
|
|
505
|
+
isActive={activeChannel?.cid === channel.cid}
|
|
506
|
+
handleSelect={handleSelect}
|
|
507
|
+
renderChannel={renderChannel}
|
|
508
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
509
|
+
AvatarComponent={GeneralTopicAvatarComponent || GeneralAvatar}
|
|
510
|
+
currentUserId={currentUserId}
|
|
511
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
512
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
513
|
+
closedTopicIcon={closedTopicIcon}
|
|
514
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
515
|
+
ChannelActionsComponent={() => null}
|
|
516
|
+
hiddenActions={hiddenActions}
|
|
517
|
+
actionLabels={actionLabels}
|
|
518
|
+
actionIcons={actionIcons}
|
|
519
|
+
/>
|
|
520
|
+
{topics.map((topicChannel: any) => (
|
|
521
|
+
<ChannelRow
|
|
522
|
+
key={topicChannel.cid}
|
|
523
|
+
channel={topicChannel}
|
|
524
|
+
isActive={activeChannel?.cid === topicChannel.cid}
|
|
525
|
+
handleSelect={handleSelect}
|
|
526
|
+
renderChannel={renderChannel}
|
|
527
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
528
|
+
AvatarComponent={TopicAvatarComponent || TopicEmojiAvatar}
|
|
529
|
+
currentUserId={currentUserId}
|
|
530
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
531
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
532
|
+
closedTopicIcon={closedTopicIcon}
|
|
533
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
534
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
535
|
+
onEditTopic={onEditTopic}
|
|
536
|
+
onToggleCloseTopic={onToggleCloseTopic}
|
|
537
|
+
hiddenActions={hiddenActions}
|
|
538
|
+
actionLabels={actionLabels}
|
|
539
|
+
actionIcons={actionIcons}
|
|
540
|
+
/>
|
|
541
|
+
))}
|
|
542
|
+
</div>
|
|
543
|
+
)}
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
ChannelTopicGroup.displayName = 'ChannelTopicGroup';
|
|
548
|
+
|
|
247
549
|
export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
248
|
-
filters = { type: ['messaging', 'team'], include_pinned_messages: true } as unknown as ChannelFilters,
|
|
550
|
+
filters = { type: ['messaging', 'team', 'meeting'], include_pinned_messages: true } as unknown as ChannelFilters,
|
|
249
551
|
sort = [],
|
|
250
552
|
options = { message_limit: 25 } as unknown as ChannelListProps['options'],
|
|
251
553
|
renderChannel,
|
|
@@ -261,28 +563,86 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
261
563
|
loadingLabel,
|
|
262
564
|
emptyStateLabel = 'No channels found',
|
|
263
565
|
blockedBadgeLabel = 'Blocked',
|
|
566
|
+
ChannelTopicGroupComponent,
|
|
567
|
+
GeneralTopicAvatarComponent,
|
|
568
|
+
TopicAvatarComponent,
|
|
569
|
+
generalTopicLabel = 'general',
|
|
570
|
+
onAddTopic,
|
|
571
|
+
TopicEmojiPickerComponent,
|
|
572
|
+
closedTopicIcon,
|
|
573
|
+
PinnedIconComponent = DefaultPinnedIcon,
|
|
574
|
+
ChannelActionsComponent,
|
|
575
|
+
onEditTopic,
|
|
576
|
+
onToggleCloseTopic,
|
|
577
|
+
hiddenActions,
|
|
578
|
+
actionLabels,
|
|
579
|
+
actionIcons,
|
|
264
580
|
}) => {
|
|
265
581
|
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
266
582
|
const [channels, setChannels] = useState<Channel[]>([]);
|
|
267
583
|
const [loading, setLoading] = useState(true);
|
|
268
584
|
const [isPendingExpanded, setIsPendingExpanded] = useState(true);
|
|
585
|
+
const [addingTopicForChannel, setAddingTopicForChannel] = useState<Channel | null>(null);
|
|
586
|
+
const [editingTopicForChannel, setEditingTopicForChannel] = useState<Channel | null>(null);
|
|
587
|
+
|
|
588
|
+
const handleAddTopicClick = useCallback((channel: Channel) => {
|
|
589
|
+
if (onAddTopic) {
|
|
590
|
+
onAddTopic(channel);
|
|
591
|
+
} else {
|
|
592
|
+
setAddingTopicForChannel(channel);
|
|
593
|
+
}
|
|
594
|
+
}, [onAddTopic]);
|
|
595
|
+
|
|
596
|
+
const handleEditTopicClick = useCallback((channel: Channel) => {
|
|
597
|
+
if (onEditTopic) {
|
|
598
|
+
onEditTopic(channel);
|
|
599
|
+
} else {
|
|
600
|
+
setEditingTopicForChannel(channel);
|
|
601
|
+
}
|
|
602
|
+
}, [onEditTopic]);
|
|
603
|
+
|
|
604
|
+
const handleToggleCloseTopicClick = useCallback(async (channel: Channel, isClosed: boolean) => {
|
|
605
|
+
if (onToggleCloseTopic) {
|
|
606
|
+
onToggleCloseTopic(channel, isClosed);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const parentCid = channel.data?.parent_cid as string | undefined;
|
|
611
|
+
if (!parentCid) return;
|
|
612
|
+
|
|
613
|
+
const parentChannel = client.activeChannels[parentCid];
|
|
614
|
+
if (!parentChannel) return;
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
if (isClosed) {
|
|
618
|
+
await parentChannel.reopenTopic(channel.cid);
|
|
619
|
+
} else {
|
|
620
|
+
await parentChannel.closeTopic(channel.cid);
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
console.error('Failed to toggle topic close state', err);
|
|
624
|
+
}
|
|
625
|
+
}, [client.activeChannels, onToggleCloseTopic]);
|
|
269
626
|
|
|
270
627
|
// Group channels into pending and regular
|
|
271
628
|
const { pendingChannels, regularChannels } = useMemo<{ pendingChannels: Channel[], regularChannels: Channel[] }>(() => {
|
|
272
629
|
const pending: Channel[] = [];
|
|
630
|
+
const pinned: Channel[] = [];
|
|
273
631
|
const regular: Channel[] = [];
|
|
274
|
-
|
|
632
|
+
|
|
275
633
|
channels.forEach(ch => {
|
|
276
634
|
const ms = ch.state?.membership as Record<string, unknown> | undefined;
|
|
277
635
|
const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
|
|
278
636
|
if (isPending) {
|
|
279
637
|
pending.push(ch);
|
|
638
|
+
} else if (ch.data?.is_pinned) {
|
|
639
|
+
pinned.push(ch);
|
|
280
640
|
} else {
|
|
281
641
|
regular.push(ch);
|
|
282
642
|
}
|
|
283
643
|
});
|
|
284
644
|
|
|
285
|
-
return { pendingChannels: pending, regularChannels: regular };
|
|
645
|
+
return { pendingChannels: pending, regularChannels: [...pinned, ...regular] };
|
|
286
646
|
}, [channels]);
|
|
287
647
|
|
|
288
648
|
const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
|
|
@@ -317,7 +677,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
317
677
|
const isBannedInChannel = Boolean(ms?.banned);
|
|
318
678
|
const isBlockedInChannel = channel.type === 'messaging' && Boolean(ms?.blocked);
|
|
319
679
|
const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
|
|
320
|
-
|
|
680
|
+
|
|
321
681
|
if (!isBannedInChannel && !isBlockedInChannel && !isPending && (chState?.unreadCount as number) > 0) {
|
|
322
682
|
channel.markRead().catch(() => { });
|
|
323
683
|
// Optimistically reset unread to update UI immediately
|
|
@@ -336,8 +696,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
336
696
|
{/* VList requires its container to have a height to work. */}
|
|
337
697
|
<VList style={{ height: '100%' }}>
|
|
338
698
|
{pendingChannels.length > 0 && (
|
|
339
|
-
<div
|
|
340
|
-
className="ermis-channel-list__accordion-header"
|
|
699
|
+
<div
|
|
700
|
+
className="ermis-channel-list__accordion-header"
|
|
341
701
|
onClick={() => setIsPendingExpanded(prev => !prev)}
|
|
342
702
|
>
|
|
343
703
|
<span>
|
|
@@ -345,9 +705,9 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
345
705
|
? pendingInvitesLabel(pendingChannels.length)
|
|
346
706
|
: pendingInvitesLabel || `Invites (${pendingChannels.length})`}
|
|
347
707
|
</span>
|
|
348
|
-
<svg
|
|
708
|
+
<svg
|
|
349
709
|
className={`ermis-channel-list__accordion-icon ${isPendingExpanded ? 'ermis-channel-list__accordion-icon--expanded' : ''}`}
|
|
350
|
-
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
710
|
+
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
351
711
|
>
|
|
352
712
|
<polyline points="6 9 12 15 18 9"></polyline>
|
|
353
713
|
</svg>
|
|
@@ -367,6 +727,12 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
367
727
|
currentUserId={client.userID}
|
|
368
728
|
pendingBadgeLabel={pendingBadgeLabel}
|
|
369
729
|
blockedBadgeLabel={blockedBadgeLabel}
|
|
730
|
+
closedTopicIcon={closedTopicIcon}
|
|
731
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
732
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
733
|
+
hiddenActions={hiddenActions}
|
|
734
|
+
actionLabels={actionLabels}
|
|
735
|
+
actionIcons={actionIcons}
|
|
370
736
|
/>
|
|
371
737
|
);
|
|
372
738
|
})}
|
|
@@ -377,6 +743,37 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
377
743
|
)}
|
|
378
744
|
{regularChannels.map((channel: Channel) => {
|
|
379
745
|
const isActive = activeChannel?.cid === channel.cid;
|
|
746
|
+
const isTeamWithTopics = (channel.type === 'team' || channel.type === 'meeting') && channel.data?.topics_enabled;
|
|
747
|
+
|
|
748
|
+
if (isTeamWithTopics) {
|
|
749
|
+
const GroupComponent = ChannelTopicGroupComponent || ChannelTopicGroup;
|
|
750
|
+
return (
|
|
751
|
+
<GroupComponent
|
|
752
|
+
key={channel.cid}
|
|
753
|
+
channel={channel}
|
|
754
|
+
activeChannel={activeChannel}
|
|
755
|
+
handleSelect={handleSelect}
|
|
756
|
+
renderChannel={renderChannel}
|
|
757
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
758
|
+
AvatarComponent={AvatarComponent}
|
|
759
|
+
GeneralTopicAvatarComponent={GeneralTopicAvatarComponent}
|
|
760
|
+
TopicAvatarComponent={TopicAvatarComponent}
|
|
761
|
+
currentUserId={client.userID}
|
|
762
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
763
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
764
|
+
generalTopicLabel={generalTopicLabel}
|
|
765
|
+
onAddTopic={handleAddTopicClick}
|
|
766
|
+
closedTopicIcon={closedTopicIcon}
|
|
767
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
768
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
769
|
+
onEditTopic={handleEditTopicClick}
|
|
770
|
+
onToggleCloseTopic={handleToggleCloseTopicClick}
|
|
771
|
+
hiddenActions={hiddenActions}
|
|
772
|
+
actionLabels={actionLabels}
|
|
773
|
+
actionIcons={actionIcons}
|
|
774
|
+
/>
|
|
775
|
+
);
|
|
776
|
+
}
|
|
380
777
|
|
|
381
778
|
return (
|
|
382
779
|
<ChannelRow
|
|
@@ -390,10 +787,35 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
390
787
|
currentUserId={client.userID}
|
|
391
788
|
pendingBadgeLabel={pendingBadgeLabel}
|
|
392
789
|
blockedBadgeLabel={blockedBadgeLabel}
|
|
790
|
+
closedTopicIcon={closedTopicIcon}
|
|
791
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
792
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
793
|
+
onAddTopic={handleAddTopicClick}
|
|
794
|
+
onEditTopic={handleEditTopicClick}
|
|
795
|
+
onToggleCloseTopic={handleToggleCloseTopicClick}
|
|
796
|
+
hiddenActions={hiddenActions}
|
|
797
|
+
actionLabels={actionLabels}
|
|
798
|
+
actionIcons={actionIcons}
|
|
393
799
|
/>
|
|
394
800
|
);
|
|
395
801
|
})}
|
|
396
802
|
</VList>
|
|
803
|
+
{addingTopicForChannel && (
|
|
804
|
+
<TopicModal
|
|
805
|
+
isOpen={true}
|
|
806
|
+
onClose={() => setAddingTopicForChannel(null)}
|
|
807
|
+
parentChannel={addingTopicForChannel}
|
|
808
|
+
EmojiPickerComponent={TopicEmojiPickerComponent}
|
|
809
|
+
/>
|
|
810
|
+
)}
|
|
811
|
+
{editingTopicForChannel && (
|
|
812
|
+
<TopicModal
|
|
813
|
+
isOpen={true}
|
|
814
|
+
onClose={() => setEditingTopicForChannel(null)}
|
|
815
|
+
topic={editingTopicForChannel}
|
|
816
|
+
EmojiPickerComponent={TopicEmojiPickerComponent}
|
|
817
|
+
/>
|
|
818
|
+
)}
|
|
397
819
|
</div>
|
|
398
820
|
);
|
|
399
821
|
});
|