@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.
- package/README.md +144 -0
- package/dist/index.cjs +5087 -11279
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +632 -152
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +273 -9
- package/dist/index.d.ts +273 -9
- package/dist/index.mjs +5085 -11295
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel.tsx +0 -3
- package/src/components/ChannelActions.tsx +6 -1
- package/src/components/ChannelHeader.tsx +8 -32
- package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
- package/src/components/ChannelList.tsx +72 -13
- package/src/components/CreateChannelModal.tsx +131 -12
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +27 -16
- package/src/components/ForwardMessageModal.tsx +11 -3
- package/src/components/MediaLightbox.tsx +444 -304
- package/src/components/MessageActionsBox.tsx +2 -0
- package/src/components/MessageInput.tsx +41 -12
- package/src/components/MessageItem.tsx +70 -25
- package/src/components/MessageQuickReactions.tsx +131 -128
- package/src/components/MessageReactions.tsx +47 -2
- package/src/components/MessageRenderers.tsx +1030 -433
- package/src/components/PinnedMessages.tsx +40 -12
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +20 -5
- package/src/components/TypingIndicator.tsx +3 -3
- package/src/components/UserPicker.tsx +26 -25
- package/src/components/VirtualMessageList.tsx +345 -125
- package/src/context/ChatProvider.tsx +27 -1
- package/src/hooks/useChannelListUpdates.ts +22 -1
- package/src/hooks/useChannelMessages.ts +338 -51
- package/src/hooks/useChannelRowUpdates.ts +18 -6
- package/src/hooks/useChatUser.ts +9 -1
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +210 -13
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -6
- package/src/hooks/useMessageActions.ts +14 -8
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useTopicGroupUpdates.ts +49 -11
- package/src/index.ts +23 -0
- package/src/messageTypeUtils.ts +14 -0
- package/src/styles/_channel-info.css +9 -0
- package/src/styles/_channel-list.css +37 -14
- package/src/styles/_media-lightbox.css +36 -3
- package/src/styles/_message-bubble.css +381 -41
- package/src/styles/_message-input.css +8 -0
- package/src/styles/_message-list.css +67 -10
- package/src/styles/_message-quick-reactions.css +101 -59
- package/src/styles/_message-reactions.css +18 -32
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +5 -5
- package/src/styles/_typing-indicator.css +23 -13
- package/src/styles/index.css +1 -0
- package/src/types.ts +115 -1
- package/src/utils/avatarColors.ts +1 -1
- 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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
49
|
-
if (
|
|
50
|
-
else if (
|
|
51
|
-
else if (type ===
|
|
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={
|
|
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 ||
|
|
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(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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">{
|
|
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
|
|
123
|
-
const
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
);
|