@ermis-network/ermis-chat-react 2.0.0 → 2.0.1

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.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -1,7 +1,14 @@
1
1
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { Avatar } from './Avatar';
4
- import { isStickerMessage } from '../messageTypeUtils';
4
+ import {
5
+ ATTACHMENT_TYPES,
6
+ isImageAttachment,
7
+ isLinkPreviewAttachment,
8
+ isStickerMessage,
9
+ isVideoAttachment,
10
+ isVoiceRecordingAttachment,
11
+ } from '../messageTypeUtils';
5
12
  import { replaceMentionsForPreview, buildUserMap } from '../utils';
6
13
  import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
7
14
  import type { PinnedMessageItemProps, PinnedMessagesProps } from '../types';
@@ -17,6 +24,8 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
17
24
  AvatarComponent,
18
25
  unpinLabel = 'Unpin message',
19
26
  stickerLabel = 'Sticker',
27
+ attachmentLabel = 'Attachment',
28
+ unavailableMessageLabel = 'Message unavailable',
20
29
  }) => {
21
30
  const { activeChannel } = useChatClient();
22
31
  const userName = message.user?.name || message.user_id || 'Unknown';
@@ -30,9 +39,22 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
30
39
  let previewText = message.text || '';
31
40
  const isSticker = isStickerMessage(message);
32
41
 
33
- if (!previewText && hasAttachments) {
42
+ const isUnavailable =
43
+ !previewText &&
44
+ ((message as any).content_type === 'mls' ||
45
+ Boolean((message as any).mls_ciphertext) ||
46
+ (message as any).e2ee_status === 'failed' ||
47
+ (message as any).e2ee_status === 'decrypting');
48
+
49
+ if (isUnavailable) {
50
+ previewText = unavailableMessageLabel;
51
+ } else if (!previewText && hasAttachments) {
34
52
  const firstAttach = message.attachments![0];
35
- previewText = firstAttach.title || `${firstAttach.type || 'file'}`;
53
+ previewText =
54
+ (isLinkPreviewAttachment(firstAttach) && firstAttach.title) ||
55
+ firstAttach.title ||
56
+ firstAttach.file_name ||
57
+ attachmentLabel;
36
58
  } else if (isSticker) {
37
59
  previewText = stickerLabel;
38
60
  }
@@ -44,11 +66,13 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
44
66
 
45
67
  // Attachment icon prefix
46
68
  let attachIcon = '';
47
- if (hasAttachments) {
48
- const type = message.attachments![0].type;
49
- if (type === 'image') attachIcon = '📷 ';
50
- else if (type === 'video') attachIcon = '🎥 ';
51
- else if (type === 'audio') attachIcon = '🎵 ';
69
+ if (!isUnavailable && hasAttachments) {
70
+ const firstAttach = message.attachments![0];
71
+ if (isImageAttachment(firstAttach)) attachIcon = '📷 ';
72
+ else if (isVideoAttachment(firstAttach)) attachIcon = '🎥 ';
73
+ else if (isVoiceRecordingAttachment(firstAttach) || firstAttach.type === ATTACHMENT_TYPES.AUDIO) {
74
+ attachIcon = '🎵 ';
75
+ }
52
76
  else attachIcon = '📄 ';
53
77
  } else if (isSticker) {
54
78
  attachIcon = '😀 ';
@@ -61,10 +85,10 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
61
85
  role="button"
62
86
  tabIndex={0}
63
87
  >
64
- <AvatarComponent image={userAvatar} name={userName} size={28} />
88
+ <AvatarComponent image={userAvatar} name={userName} size={38} />
65
89
  <div className="ermis-pinned-messages__item-content">
66
90
  <span className="ermis-pinned-messages__item-user">{userName}</span>
67
- <span className="ermis-pinned-messages__item-text">{attachIcon}{previewText || 'Attachment'}</span>
91
+ <span className="ermis-pinned-messages__item-text">{attachIcon}{previewText || unavailableMessageLabel}</span>
68
92
  </div>
69
93
  <button
70
94
  className="ermis-pinned-messages__unpin-btn"
@@ -96,6 +120,8 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
96
120
  collapseLabel = 'Collapse',
97
121
  unpinLabel = 'Unpin message',
98
122
  stickerLabel = 'Sticker',
123
+ attachmentLabel = 'Attachment',
124
+ unavailableMessageLabel = 'Message unavailable',
99
125
  }) => {
100
126
  const { activeChannel, client, messages } = useChatClient();
101
127
  const [expanded, setExpanded] = useState(false);
@@ -141,8 +167,8 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
141
167
  <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
142
168
  </svg>
143
169
  <span className="ermis-pinned-messages__label">
144
- {typeof pinnedMessagesLabel === 'function'
145
- ? pinnedMessagesLabel(pinnedMessages.length)
170
+ {typeof pinnedMessagesLabel === 'function'
171
+ ? pinnedMessagesLabel(pinnedMessages.length)
146
172
  : pinnedMessagesLabel || `${pinnedMessages.length} pinned message${pinnedMessages.length > 1 ? 's' : ''}`
147
173
  }
148
174
  </span>
@@ -168,6 +194,8 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
168
194
  AvatarComponent={AvatarComponent}
169
195
  unpinLabel={unpinLabel}
170
196
  stickerLabel={stickerLabel}
197
+ attachmentLabel={attachmentLabel}
198
+ unavailableMessageLabel={unavailableMessageLabel}
171
199
  />
172
200
  ))}
173
201
  </div>
@@ -2,6 +2,14 @@ import React, { useMemo } from 'react';
2
2
  import { useChatClient } from '../hooks/useChatClient';
3
3
  import { replaceMentionsForPreview, buildUserMap } from '../utils';
4
4
  import type { QuotedMessagePreviewProps } from '../types';
5
+ import {
6
+ isImageAttachment,
7
+ isLinkPreviewAttachment,
8
+ isStickerMessage,
9
+ isVideoAttachment,
10
+ isVoiceRecordingAttachment,
11
+ } from '../messageTypeUtils';
12
+ import { isDeletedDisplayMessage } from '../messageTypeUtils';
5
13
 
6
14
  export type { QuotedMessagePreviewProps } from '../types';
7
15
 
@@ -12,10 +20,50 @@ function truncateText(text: string, maxLength: number): string {
12
20
  return text.slice(0, maxLength).trimEnd() + '…';
13
21
  }
14
22
 
23
+ function getAttachmentPreview(
24
+ attachments: NonNullable<QuotedMessagePreviewProps['quotedMessage']['attachments']>,
25
+ attachmentLabel: string,
26
+ ): string {
27
+ const firstAttachment = attachments[0];
28
+ if (!firstAttachment) return attachmentLabel;
29
+
30
+ if (isLinkPreviewAttachment(firstAttachment) && firstAttachment.title) {
31
+ return firstAttachment.title;
32
+ }
33
+
34
+ if (firstAttachment.title || firstAttachment.file_name) {
35
+ return firstAttachment.title || firstAttachment.file_name || attachmentLabel;
36
+ }
37
+
38
+ if (isImageAttachment(firstAttachment)) return attachmentLabel;
39
+ if (isVideoAttachment(firstAttachment)) return attachmentLabel;
40
+ if (isVoiceRecordingAttachment(firstAttachment)) return attachmentLabel;
41
+
42
+ return attachmentLabel;
43
+ }
44
+
45
+ function hasUnavailableContent(quotedMessage: QuotedMessagePreviewProps['quotedMessage']): boolean {
46
+ const hasText = Boolean(quotedMessage.text?.trim());
47
+ if (hasText) return false;
48
+ if (isStickerMessage(quotedMessage)) return false;
49
+ if (quotedMessage.attachments?.length) return false;
50
+
51
+ return (
52
+ quotedMessage.content_type === 'mls' ||
53
+ Boolean(quotedMessage.mls_ciphertext) ||
54
+ quotedMessage.e2ee_status === 'failed' ||
55
+ quotedMessage.e2ee_status === 'decrypting'
56
+ );
57
+ }
58
+
15
59
  export const QuotedMessagePreview: React.FC<QuotedMessagePreviewProps> = React.memo(({
16
60
  quotedMessage,
17
61
  isOwnMessage,
18
62
  onClick,
63
+ attachmentLabel = 'Attachment',
64
+ unavailableMessageLabel = 'Message unavailable',
65
+ stickerLabel = 'Sticker',
66
+ deletedMessageLabel = 'This message was deleted',
19
67
  }) => {
20
68
  const { activeChannel } = useChatClient();
21
69
 
@@ -25,12 +73,53 @@ export const QuotedMessagePreview: React.FC<QuotedMessagePreviewProps> = React.m
25
73
 
26
74
  const authorName = quotedMessage.user?.name || quotedMessage.user?.id || 'Unknown';
27
75
 
28
- const rawText = quotedMessage.text || '';
29
- const formattedText = useMemo(() => replaceMentionsForPreview(rawText, quotedMessage as any, userMap), [rawText, quotedMessage, userMap]);
30
-
31
- const previewText = formattedText
32
- ? truncateText(formattedText, MAX_PREVIEW_LENGTH)
33
- : 'Attachment';
76
+ const rawText = quotedMessage.text?.trim() || '';
77
+ const formattedText = useMemo(
78
+ () => replaceMentionsForPreview(rawText, quotedMessage, userMap),
79
+ [rawText, quotedMessage, userMap],
80
+ );
81
+
82
+ const preview = useMemo(() => {
83
+ if (formattedText) {
84
+ return {
85
+ text: truncateText(formattedText, MAX_PREVIEW_LENGTH),
86
+ unavailable: false,
87
+ };
88
+ }
89
+
90
+ if (isStickerMessage(quotedMessage)) {
91
+ return {
92
+ text: stickerLabel,
93
+ unavailable: false,
94
+ };
95
+ }
96
+
97
+ if (quotedMessage.attachments?.length) {
98
+ return {
99
+ text: getAttachmentPreview(quotedMessage.attachments, attachmentLabel),
100
+ unavailable: false,
101
+ };
102
+ }
103
+
104
+ if (isDeletedDisplayMessage(quotedMessage)) {
105
+ return {
106
+ text: deletedMessageLabel,
107
+ unavailable: true,
108
+ };
109
+ }
110
+
111
+ if (hasUnavailableContent(quotedMessage)) {
112
+ return {
113
+ text: unavailableMessageLabel,
114
+ unavailable: true,
115
+ };
116
+ }
117
+
118
+ return {
119
+ text: unavailableMessageLabel,
120
+ unavailable: true,
121
+ };
122
+ }, [attachmentLabel, formattedText, quotedMessage, stickerLabel, unavailableMessageLabel, deletedMessageLabel]);
34
123
 
35
124
  const handleClick = () => {
36
125
  onClick(quotedMessage.id);
@@ -38,7 +127,9 @@ export const QuotedMessagePreview: React.FC<QuotedMessagePreviewProps> = React.m
38
127
 
39
128
  return (
40
129
  <div
41
- className={`ermis-quoted-message ${isOwnMessage ? 'ermis-quoted-message--own' : ''}`}
130
+ className={`ermis-quoted-message ${isOwnMessage ? 'ermis-quoted-message--own' : ''}${
131
+ preview.unavailable ? ' ermis-quoted-message--unavailable' : ''
132
+ }`}
42
133
  onClick={handleClick}
43
134
  role="button"
44
135
  tabIndex={0}
@@ -47,7 +138,7 @@ export const QuotedMessagePreview: React.FC<QuotedMessagePreviewProps> = React.m
47
138
  }}
48
139
  >
49
140
  <span className="ermis-quoted-message__author">{authorName}</span>
50
- <span className="ermis-quoted-message__text">{previewText}</span>
141
+ <span className="ermis-quoted-message__text">{preview.text}</span>
51
142
  </div>
52
143
  );
53
144
  });
@@ -0,0 +1,279 @@
1
+ import React, { useMemo, useState } from 'react';
2
+
3
+ import { useRecoveryPin } from '../../hooks/useRecoveryPin';
4
+ import type { RecoveryPinStatus } from '../../hooks/useRecoveryPin';
5
+
6
+ export type RecoveryPinSetupProps = {
7
+ onComplete?: () => void;
8
+ minDigits?: number;
9
+ };
10
+
11
+ export type RecoveryPinRestoreProps = {
12
+ onComplete?: () => void;
13
+ };
14
+
15
+ export type RecoveryPinChangeProps = {
16
+ onComplete?: () => void;
17
+ minDigits?: number;
18
+ };
19
+
20
+ export type RecoveryStatusProps = {
21
+ status?: RecoveryPinStatus;
22
+ hasRecoveryKey?: boolean;
23
+ className?: string;
24
+ };
25
+
26
+ export type RecoveryGapProps = {
27
+ epoch?: number;
28
+ reason: string;
29
+ className?: string;
30
+ };
31
+
32
+ export type RecoveryGateProps = {
33
+ onSkip?: () => void;
34
+ className?: string;
35
+ };
36
+
37
+ export type RecoveryRestoreProgressProps = {
38
+ restoredEpochs: number;
39
+ totalEpochs: number;
40
+ gaps?: Array<{ epoch?: number; reason: string }>;
41
+ className?: string;
42
+ };
43
+
44
+ const MIN_PIN_DIGITS = 8;
45
+
46
+ const pinError = (pin: string, minDigits = MIN_PIN_DIGITS): string | null => {
47
+ if (!/^\d+$/.test(pin)) return 'PIN must contain digits only.';
48
+ if (pin.length < minDigits) return `PIN must be at least ${minDigits} digits.`;
49
+ return null;
50
+ };
51
+
52
+ const bruteForceLabel = (digits: number): string => {
53
+ if (digits >= 10) return 'Strong';
54
+ if (digits >= 8) return 'Medium';
55
+ return 'Weak';
56
+ };
57
+
58
+ export const RecoveryPinSetup: React.FC<RecoveryPinSetupProps> = ({
59
+ onComplete,
60
+ minDigits = MIN_PIN_DIGITS,
61
+ }) => {
62
+ const recovery = useRecoveryPin();
63
+ const [pin, setPin] = useState('');
64
+ const [confirmPin, setConfirmPin] = useState('');
65
+ const validation = pin ? pinError(pin, minDigits) : null;
66
+ const mismatch = confirmPin && pin !== confirmPin ? 'PIN confirmation does not match.' : null;
67
+ const canSubmit = !!pin && !!confirmPin && !validation && !mismatch && recovery.status !== 'working';
68
+
69
+ const submit = async (event: React.FormEvent) => {
70
+ event.preventDefault();
71
+ if (!canSubmit) return;
72
+ await recovery.setupRecoveryPin(pin);
73
+ onComplete?.();
74
+ };
75
+
76
+ return (
77
+ <form className="ermis-recovery-pin" onSubmit={submit}>
78
+ <div className="ermis-recovery-pin__header">
79
+ <h3>Recovery PIN</h3>
80
+ <span className="ermis-recovery-pin__badge">{bruteForceLabel(pin.length)}</span>
81
+ </div>
82
+ <input
83
+ className="ermis-recovery-pin__input"
84
+ inputMode="numeric"
85
+ autoComplete="new-password"
86
+ type="password"
87
+ value={pin}
88
+ onChange={(event) => setPin(event.target.value)}
89
+ placeholder="Enter PIN"
90
+ />
91
+ <input
92
+ className="ermis-recovery-pin__input"
93
+ inputMode="numeric"
94
+ autoComplete="new-password"
95
+ type="password"
96
+ value={confirmPin}
97
+ onChange={(event) => setConfirmPin(event.target.value)}
98
+ placeholder="Confirm PIN"
99
+ />
100
+ {(validation || mismatch || recovery.error) && (
101
+ <div className="ermis-recovery-pin__error">
102
+ {validation || mismatch || recovery.error?.message}
103
+ </div>
104
+ )}
105
+ <button className="ermis-recovery-pin__button" type="submit" disabled={!canSubmit}>
106
+ {recovery.status === 'working' ? 'Saving...' : 'Save PIN'}
107
+ </button>
108
+ </form>
109
+ );
110
+ };
111
+
112
+ export const RecoveryPinRestore: React.FC<RecoveryPinRestoreProps> = ({ onComplete }) => {
113
+ const recovery = useRecoveryPin();
114
+ const [pin, setPin] = useState('');
115
+ const validation = pin ? pinError(pin) : null;
116
+ const canSubmit = !!pin && !validation && recovery.status !== 'working';
117
+
118
+ const submit = async (event: React.FormEvent) => {
119
+ event.preventDefault();
120
+ if (!canSubmit) return;
121
+ await recovery.unlockRecoveryVault(pin);
122
+ onComplete?.();
123
+ };
124
+
125
+ return (
126
+ <form className="ermis-recovery-pin" onSubmit={submit}>
127
+ <div className="ermis-recovery-pin__header">
128
+ <h3>Unlock History</h3>
129
+ </div>
130
+ <input
131
+ className="ermis-recovery-pin__input"
132
+ inputMode="numeric"
133
+ autoComplete="current-password"
134
+ type="password"
135
+ value={pin}
136
+ onChange={(event) => setPin(event.target.value)}
137
+ placeholder="Enter recovery PIN"
138
+ />
139
+ {(validation || recovery.error) && (
140
+ <div className="ermis-recovery-pin__error">{validation || recovery.error?.message}</div>
141
+ )}
142
+ <button className="ermis-recovery-pin__button" type="submit" disabled={!canSubmit}>
143
+ {recovery.status === 'working' ? 'Unlocking...' : 'Unlock'}
144
+ </button>
145
+ </form>
146
+ );
147
+ };
148
+
149
+ export const RecoveryPinChange: React.FC<RecoveryPinChangeProps> = ({
150
+ onComplete,
151
+ minDigits = MIN_PIN_DIGITS,
152
+ }) => {
153
+ const recovery = useRecoveryPin();
154
+ const [oldPin, setOldPin] = useState('');
155
+ const [newPin, setNewPin] = useState('');
156
+ const validation = newPin ? pinError(newPin, minDigits) : null;
157
+ const canSubmit = !!oldPin && !!newPin && !validation && recovery.status !== 'working';
158
+
159
+ const submit = async (event: React.FormEvent) => {
160
+ event.preventDefault();
161
+ if (!canSubmit) return;
162
+ await recovery.changeRecoveryPin(oldPin, newPin);
163
+ onComplete?.();
164
+ };
165
+
166
+ return (
167
+ <form className="ermis-recovery-pin" onSubmit={submit}>
168
+ <div className="ermis-recovery-pin__header">
169
+ <h3>Change Recovery PIN</h3>
170
+ <span className="ermis-recovery-pin__badge">{bruteForceLabel(newPin.length)}</span>
171
+ </div>
172
+ <input
173
+ className="ermis-recovery-pin__input"
174
+ inputMode="numeric"
175
+ autoComplete="current-password"
176
+ type="password"
177
+ value={oldPin}
178
+ onChange={(event) => setOldPin(event.target.value)}
179
+ placeholder="Current PIN"
180
+ />
181
+ <input
182
+ className="ermis-recovery-pin__input"
183
+ inputMode="numeric"
184
+ autoComplete="new-password"
185
+ type="password"
186
+ value={newPin}
187
+ onChange={(event) => setNewPin(event.target.value)}
188
+ placeholder="New PIN"
189
+ />
190
+ {(validation || recovery.error) && (
191
+ <div className="ermis-recovery-pin__error">{validation || recovery.error?.message}</div>
192
+ )}
193
+ <button className="ermis-recovery-pin__button" type="submit" disabled={!canSubmit}>
194
+ {recovery.status === 'working' ? 'Changing...' : 'Change PIN'}
195
+ </button>
196
+ </form>
197
+ );
198
+ };
199
+
200
+ export const RecoveryStatus: React.FC<RecoveryStatusProps> = ({
201
+ status,
202
+ hasRecoveryKey,
203
+ className,
204
+ }) => {
205
+ const recovery = useRecoveryPin();
206
+ const resolvedStatus = status || recovery.status;
207
+ const resolvedHasKey = hasRecoveryKey ?? recovery.hasRecoveryKey;
208
+ const label = useMemo(() => {
209
+ if (resolvedStatus === 'working') return 'Recovery syncing';
210
+ if (resolvedStatus === 'error') return 'Recovery error';
211
+ if (recovery.recoveryStatus?.hasIncompleteRestore) return 'Recovery pending';
212
+ if ((recovery.recoveryStatus?.channelsWithPermanentGaps.length || 0) > 0) return 'Recovery has gaps';
213
+ return resolvedHasKey ? 'Recovery ready' : 'Recovery locked';
214
+ }, [recovery.recoveryStatus, resolvedHasKey, resolvedStatus]);
215
+
216
+ return (
217
+ <span className={`ermis-recovery-status ermis-recovery-status--${resolvedStatus}${className ? ` ${className}` : ''}`}>
218
+ {label}
219
+ </span>
220
+ );
221
+ };
222
+
223
+ export const RecoveryGap: React.FC<RecoveryGapProps> = ({ epoch, reason, className }) => (
224
+ <div className={`ermis-recovery-gap${className ? ` ${className}` : ''}`}>
225
+ {epoch !== undefined ? `Epoch ${epoch}: ` : ''}{reason}
226
+ </div>
227
+ );
228
+
229
+ export const RecoveryGate: React.FC<RecoveryGateProps> = ({ onSkip, className }) => {
230
+ const recovery = useRecoveryPin();
231
+ const [skipped, setSkipped] = useState(false);
232
+ const status = recovery.recoveryStatus;
233
+ if (skipped || !status) return null;
234
+
235
+ const skip = () => {
236
+ setSkipped(true);
237
+ onSkip?.();
238
+ };
239
+
240
+ if (!status.hasVault) {
241
+ return (
242
+ <div className={`ermis-recovery-gate${className ? ` ${className}` : ''}`} role="dialog" aria-modal="true">
243
+ <RecoveryPinSetup onComplete={recovery.refresh} />
244
+ <button className="ermis-recovery-pin__button ermis-recovery-pin__button--secondary" type="button" onClick={skip}>
245
+ Skip
246
+ </button>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ if (!status.unlocked && status.hasIncompleteRestore) {
252
+ return (
253
+ <div className={`ermis-recovery-gate${className ? ` ${className}` : ''}`} role="dialog" aria-modal="true">
254
+ <RecoveryPinRestore onComplete={recovery.refresh} />
255
+ <button className="ermis-recovery-pin__button ermis-recovery-pin__button--secondary" type="button" onClick={skip}>
256
+ Skip
257
+ </button>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ return null;
263
+ };
264
+
265
+ export const RecoveryRestoreProgress: React.FC<RecoveryRestoreProgressProps> = ({
266
+ restoredEpochs,
267
+ totalEpochs,
268
+ gaps = [],
269
+ className,
270
+ }) => (
271
+ <div className={`ermis-recovery-progress${className ? ` ${className}` : ''}`}>
272
+ <div className="ermis-recovery-progress__summary">
273
+ {restoredEpochs}/{totalEpochs} epochs restored
274
+ </div>
275
+ {gaps.map((gap, index) => (
276
+ <RecoveryGap key={`${gap.epoch ?? 'gap'}-${index}`} epoch={gap.epoch} reason={gap.reason} />
277
+ ))}
278
+ </div>
279
+ );
@@ -0,0 +1,19 @@
1
+ export {
2
+ RecoveryPinSetup,
3
+ RecoveryPinRestore,
4
+ RecoveryPinChange,
5
+ RecoveryStatus,
6
+ RecoveryGap,
7
+ RecoveryGate,
8
+ RecoveryRestoreProgress,
9
+ } from './RecoveryPin';
10
+
11
+ export type {
12
+ RecoveryPinSetupProps,
13
+ RecoveryPinRestoreProps,
14
+ RecoveryPinChangeProps,
15
+ RecoveryStatusProps,
16
+ RecoveryGapProps,
17
+ RecoveryGateProps,
18
+ RecoveryRestoreProgressProps,
19
+ } from './RecoveryPin';
@@ -58,6 +58,8 @@ export const TopicList: React.FC<TopicListProps> = React.memo(({
58
58
  videoMessageLabel,
59
59
  voiceRecordingMessageLabel,
60
60
  fileMessageLabel,
61
+ encryptedMessageLabel,
62
+ encryptedMessageUnavailableLabel,
61
63
  systemMessageTranslations,
62
64
  signalMessageTranslations,
63
65
  }) => {
@@ -119,15 +121,24 @@ export const TopicList: React.FC<TopicListProps> = React.memo(({
119
121
  }, [channel, generalTopicLabel]);
120
122
 
121
123
  const markChannelRead = useCallback((ch: Channel) => {
122
- const ms = ch.state?.membership as Record<string, unknown> | undefined;
123
- const chState = ch.state as unknown as Record<string, unknown> | undefined;
124
+ const client = ch.getClient();
125
+ const activeCh = client.activeChannels[ch.cid] || ch;
126
+ const ms = activeCh.state?.membership as Record<string, unknown> | undefined;
127
+ const chState = activeCh.state as unknown as Record<string, unknown> | undefined;
124
128
  const isBannedInChannel = Boolean(ms?.banned);
125
129
  const isPending = isPendingMember(ms?.channel_role as string);
126
130
  const isSkipped = isSkippedMember(ms?.channel_role as string);
127
131
 
128
- if (!isBannedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
129
- ch.markRead().catch(() => { });
130
- if (chState) chState.unreadCount = 0;
132
+ if (!isBannedInChannel && !isPending && !isSkipped) {
133
+ if ((chState?.unreadCount as number) > 0) {
134
+ activeCh.markRead().catch(() => { });
135
+ if (chState) chState.unreadCount = 0;
136
+ }
137
+
138
+ // Always clear the stale channel just in case to fix UI ghost badges
139
+ if (ch.state && (ch.state as any).unreadCount > 0) {
140
+ (ch.state as any).unreadCount = 0;
141
+ }
131
142
  }
132
143
  }, []);
133
144
 
@@ -173,6 +184,8 @@ export const TopicList: React.FC<TopicListProps> = React.memo(({
173
184
  videoMessageLabel={videoMessageLabel}
174
185
  voiceRecordingMessageLabel={voiceRecordingMessageLabel}
175
186
  fileMessageLabel={fileMessageLabel}
187
+ encryptedMessageLabel={encryptedMessageLabel}
188
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
176
189
  systemMessageTranslations={systemMessageTranslations}
177
190
  signalMessageTranslations={signalMessageTranslations}
178
191
  />
@@ -203,6 +216,8 @@ export const TopicList: React.FC<TopicListProps> = React.memo(({
203
216
  videoMessageLabel={videoMessageLabel}
204
217
  voiceRecordingMessageLabel={voiceRecordingMessageLabel}
205
218
  fileMessageLabel={fileMessageLabel}
219
+ encryptedMessageLabel={encryptedMessageLabel}
220
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
206
221
  systemMessageTranslations={systemMessageTranslations}
207
222
  signalMessageTranslations={signalMessageTranslations}
208
223
  />
@@ -29,16 +29,16 @@ export const TypingIndicator: React.FC<TypingIndicatorProps> = React.memo(({ typ
29
29
  }
30
30
 
31
31
  return (
32
- <div className={`ermis-typing-indicator${isActive ? ' ermis-typing-indicator--active' : ''}`}>
32
+ <div className={`ermis-typing-indicator-wrapper`}>
33
33
  {isActive && (
34
- <>
34
+ <div className="ermis-typing-indicator ermis-typing-indicator--active">
35
35
  <div className="ermis-typing-indicator__dots">
36
36
  <span className="ermis-typing-indicator__dot" />
37
37
  <span className="ermis-typing-indicator__dot" />
38
38
  <span className="ermis-typing-indicator__dot" />
39
39
  </div>
40
40
  <span className="ermis-typing-indicator__text">{text}</span>
41
- </>
41
+ </div>
42
42
  )}
43
43
  </div>
44
44
  );