@ermis-network/ermis-chat-react 1.0.9 → 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 +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- 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 +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
package/src/components/Panel.tsx
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
/** Whether the panel is visible */
|
|
5
|
-
isOpen: boolean;
|
|
6
|
-
/** Called when user clicks the back button */
|
|
7
|
-
onClose: () => void;
|
|
8
|
-
/** Panel title shown in the header */
|
|
9
|
-
title?: string;
|
|
10
|
-
/** Panel body content */
|
|
11
|
-
children: React.ReactNode;
|
|
12
|
-
/** Optional header content (replaces default title + back button) */
|
|
13
|
-
headerContent?: React.ReactNode;
|
|
14
|
-
/** Additional CSS class name */
|
|
15
|
-
className?: string;
|
|
16
|
-
};
|
|
3
|
+
import type { PanelProps } from '../types';
|
|
17
4
|
|
|
18
5
|
/**
|
|
19
6
|
* Reusable sliding panel component.
|
|
@@ -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';
|
|
@@ -15,6 +22,10 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
15
22
|
onClickMessage,
|
|
16
23
|
onUnpin,
|
|
17
24
|
AvatarComponent,
|
|
25
|
+
unpinLabel = 'Unpin message',
|
|
26
|
+
stickerLabel = 'Sticker',
|
|
27
|
+
attachmentLabel = 'Attachment',
|
|
28
|
+
unavailableMessageLabel = 'Message unavailable',
|
|
18
29
|
}) => {
|
|
19
30
|
const { activeChannel } = useChatClient();
|
|
20
31
|
const userName = message.user?.name || message.user_id || 'Unknown';
|
|
@@ -28,11 +39,24 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
28
39
|
let previewText = message.text || '';
|
|
29
40
|
const isSticker = isStickerMessage(message);
|
|
30
41
|
|
|
31
|
-
|
|
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) {
|
|
32
52
|
const firstAttach = message.attachments![0];
|
|
33
|
-
previewText =
|
|
53
|
+
previewText =
|
|
54
|
+
(isLinkPreviewAttachment(firstAttach) && firstAttach.title) ||
|
|
55
|
+
firstAttach.title ||
|
|
56
|
+
firstAttach.file_name ||
|
|
57
|
+
attachmentLabel;
|
|
34
58
|
} else if (isSticker) {
|
|
35
|
-
previewText =
|
|
59
|
+
previewText = stickerLabel;
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
// Convert @userId → @UserName in preview text
|
|
@@ -42,11 +66,13 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
42
66
|
|
|
43
67
|
// Attachment icon prefix
|
|
44
68
|
let attachIcon = '';
|
|
45
|
-
if (hasAttachments) {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
else if (
|
|
49
|
-
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
|
+
}
|
|
50
76
|
else attachIcon = '📄 ';
|
|
51
77
|
} else if (isSticker) {
|
|
52
78
|
attachIcon = '😀 ';
|
|
@@ -59,16 +85,16 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
59
85
|
role="button"
|
|
60
86
|
tabIndex={0}
|
|
61
87
|
>
|
|
62
|
-
<AvatarComponent image={userAvatar} name={userName} size={
|
|
88
|
+
<AvatarComponent image={userAvatar} name={userName} size={38} />
|
|
63
89
|
<div className="ermis-pinned-messages__item-content">
|
|
64
90
|
<span className="ermis-pinned-messages__item-user">{userName}</span>
|
|
65
|
-
<span className="ermis-pinned-messages__item-text">{attachIcon}{previewText ||
|
|
91
|
+
<span className="ermis-pinned-messages__item-text">{attachIcon}{previewText || unavailableMessageLabel}</span>
|
|
66
92
|
</div>
|
|
67
93
|
<button
|
|
68
94
|
className="ermis-pinned-messages__unpin-btn"
|
|
69
95
|
onClick={(e) => { e.stopPropagation(); onUnpin?.(message.id); }}
|
|
70
|
-
title=
|
|
71
|
-
aria-label=
|
|
96
|
+
title={unpinLabel}
|
|
97
|
+
aria-label={unpinLabel}
|
|
72
98
|
>
|
|
73
99
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
74
100
|
<line x1="2" y1="2" x2="22" y2="22" />
|
|
@@ -89,6 +115,13 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
|
|
|
89
115
|
PinnedMessageItemComponent = DefaultPinnedMessageItem,
|
|
90
116
|
onClickMessage,
|
|
91
117
|
maxCollapsed = 1,
|
|
118
|
+
pinnedMessagesLabel,
|
|
119
|
+
seeAllLabel = 'See all',
|
|
120
|
+
collapseLabel = 'Collapse',
|
|
121
|
+
unpinLabel = 'Unpin message',
|
|
122
|
+
stickerLabel = 'Sticker',
|
|
123
|
+
attachmentLabel = 'Attachment',
|
|
124
|
+
unavailableMessageLabel = 'Message unavailable',
|
|
92
125
|
}) => {
|
|
93
126
|
const { activeChannel, client, messages } = useChatClient();
|
|
94
127
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -134,14 +167,17 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
|
|
|
134
167
|
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
|
135
168
|
</svg>
|
|
136
169
|
<span className="ermis-pinned-messages__label">
|
|
137
|
-
{
|
|
170
|
+
{typeof pinnedMessagesLabel === 'function'
|
|
171
|
+
? pinnedMessagesLabel(pinnedMessages.length)
|
|
172
|
+
: pinnedMessagesLabel || `${pinnedMessages.length} pinned message${pinnedMessages.length > 1 ? 's' : ''}`
|
|
173
|
+
}
|
|
138
174
|
</span>
|
|
139
175
|
{hasMore && (
|
|
140
176
|
<button
|
|
141
177
|
className="ermis-pinned-messages__toggle"
|
|
142
178
|
onClick={(e) => { e.stopPropagation(); toggleExpanded(); }}
|
|
143
179
|
>
|
|
144
|
-
{expanded ?
|
|
180
|
+
{expanded ? collapseLabel : seeAllLabel}
|
|
145
181
|
</button>
|
|
146
182
|
)}
|
|
147
183
|
</div>
|
|
@@ -156,6 +192,10 @@ export const PinnedMessages: React.FC<PinnedMessagesProps> = React.memo(({
|
|
|
156
192
|
onClickMessage={onClickMessage}
|
|
157
193
|
onUnpin={handleUnpin}
|
|
158
194
|
AvatarComponent={AvatarComponent}
|
|
195
|
+
unpinLabel={unpinLabel}
|
|
196
|
+
stickerLabel={stickerLabel}
|
|
197
|
+
attachmentLabel={attachmentLabel}
|
|
198
|
+
unavailableMessageLabel={unavailableMessageLabel}
|
|
159
199
|
/>
|
|
160
200
|
))}
|
|
161
201
|
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { PreviewOverlayProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export const PreviewOverlay: React.FC<PreviewOverlayProps> = ({
|
|
5
|
+
title = 'You are viewing a public channel.',
|
|
6
|
+
buttonLabel = 'Join Channel',
|
|
7
|
+
onJoin,
|
|
8
|
+
className = '',
|
|
9
|
+
}) => {
|
|
10
|
+
return (
|
|
11
|
+
<div className={`ermis-preview-overlay ${className}`}>
|
|
12
|
+
<span className="ermis-preview-overlay__text">{title}</span>
|
|
13
|
+
<button
|
|
14
|
+
className="ermis-preview-overlay__button"
|
|
15
|
+
onClick={onJoin}
|
|
16
|
+
type="button"
|
|
17
|
+
>
|
|
18
|
+
{buttonLabel}
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
PreviewOverlay.displayName = 'PreviewOverlay';
|
|
@@ -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
|
});
|
|
@@ -41,6 +41,7 @@ export const ReadReceipts: React.FC<ReadReceiptsProps> = React.memo(({
|
|
|
41
41
|
AvatarComponent = Avatar,
|
|
42
42
|
TooltipComponent = DefaultReadReceiptsTooltip,
|
|
43
43
|
showTooltip = true,
|
|
44
|
+
isOwnMessage,
|
|
44
45
|
}) => {
|
|
45
46
|
// Only render when there are actual readers (avatar-based display)
|
|
46
47
|
// Sent/Sending/Error status icons are now rendered inline inside the message bubble
|
|
@@ -52,7 +53,7 @@ export const ReadReceipts: React.FC<ReadReceiptsProps> = React.memo(({
|
|
|
52
53
|
const overflow = readers.length - maxAvatars;
|
|
53
54
|
|
|
54
55
|
return (
|
|
55
|
-
<div className=
|
|
56
|
+
<div className={`ermis-read-receipts${isOwnMessage ? ' ermis-read-receipts--own' : ' ermis-read-receipts--other'}`}>
|
|
56
57
|
<div className="ermis-read-receipts__avatars">
|
|
57
58
|
{visible.map((reader) => (
|
|
58
59
|
<AvatarComponent
|
|
@@ -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';
|