feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
735
packages/shared/src/components/channel/MessageGroup.tsx
Normal file
735
packages/shared/src/components/channel/MessageGroup.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Avatar } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
|
||||
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
import { LinkEmbed } from './LinkEmbed';
|
||||
import type { DecryptedMessage } from './Messages';
|
||||
import { MessageActionBar } from './MessageActionBar';
|
||||
import { MessageContent } from './MessageContent';
|
||||
import { MobileMessageActionsSheet } from './MobileMessageActionsSheet';
|
||||
import {
|
||||
MemberProfilePopout,
|
||||
type MemberProfilePopoutMember,
|
||||
} from '../member/MemberProfilePopout';
|
||||
import { PinConfirmationModal } from './PinConfirmationModal';
|
||||
import { ReactionsModal } from './ReactionsModal';
|
||||
import { Tooltip } from '@discord-clone/ui';
|
||||
import { reactionKeyToName } from '../../utils/emojiLookup';
|
||||
import type { PinnedMessage } from './PinnedMessageRow';
|
||||
import { TwemojiImg } from './TwemojiImg';
|
||||
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
|
||||
import styles from './MessageGroup.module.css';
|
||||
|
||||
interface MessageGroupProps {
|
||||
messages: DecryptedMessage[];
|
||||
channelId: string;
|
||||
onReply?: (eventId: string, username: string) => void;
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
|
||||
|
||||
function extractUrls(text: string): string[] {
|
||||
const matches = text.match(URL_REGEX) ?? [];
|
||||
// Strip trailing punctuation that's almost never part of the URL but
|
||||
// commonly butts up against one in prose ("see https://foo.com.").
|
||||
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
|
||||
return Array.from(new Set(cleaned));
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatFullTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps) {
|
||||
const first = messages[0];
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const channelMembers = useQuery(api.members.getChannelMembers, {
|
||||
channelId: channelId as any,
|
||||
});
|
||||
const mentionMembers = Array.isArray(channelMembers)
|
||||
? channelMembers.map((m: any) => ({
|
||||
displayName: m.displayName || m.username || '',
|
||||
username: m.username || '',
|
||||
userId: m.id,
|
||||
}))
|
||||
: [];
|
||||
const removeMessage = useMutation(api.messages.remove);
|
||||
const addReaction = useMutation(api.reactions.add);
|
||||
const removeReaction = useMutation(api.reactions.remove);
|
||||
|
||||
// Custom emoji catalog — powers `:shortcode:` rendering inside
|
||||
// message bodies and custom-emoji reaction chips. One query per
|
||||
// group keeps the subscription count reasonable.
|
||||
const customEmojiDocs = (useQuery(api.customEmojis.list, {}) ?? []) as Array<{
|
||||
_id: string;
|
||||
name: string;
|
||||
src: string;
|
||||
}>;
|
||||
const customEmojiList = customEmojiDocs.map((e) => ({ name: e.name, url: e.src }));
|
||||
const customEmojiByName = new Map<string, string>();
|
||||
for (const e of customEmojiDocs) customEmojiByName.set(e.name.toLowerCase(), e.src);
|
||||
|
||||
// Reaction picker state — a single picker shared across the group.
|
||||
// Records which message was clicked so the chosen emoji is bound
|
||||
// to the right target.
|
||||
const [reactPicker, setReactPicker] = useState<{
|
||||
messageId: string;
|
||||
pos: { top: number; left: number };
|
||||
} | null>(null);
|
||||
|
||||
// Image lightbox state — tracks the decrypted blob URL + the
|
||||
// full attachment metadata of the image that was clicked so the
|
||||
// lightbox info card can render filename / size / dimensions.
|
||||
// Null means the lightbox is closed.
|
||||
const [lightboxItem, setLightboxItem] = useState<{
|
||||
src: string;
|
||||
attachment: AttachmentMetadata;
|
||||
} | null>(null);
|
||||
|
||||
// Right-click context menu state. When set, the MessageActionBar for
|
||||
// the target message opens its More dropdown at the click coordinates
|
||||
// and the parent row gets `actionBarForceVisible` so the hover bar
|
||||
// stays pinned regardless of cursor position.
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
messageId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
// Tracks which message currently has its local More dropdown open
|
||||
// so we can pin that row's hover bar visible even when the pointer
|
||||
// leaves the row. Flips back to null when the dropdown closes.
|
||||
const [localMenuOpenFor, setLocalMenuOpenFor] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [mobileSheetForMsg, setMobileSheetForMsg] =
|
||||
useState<DecryptedMessage | null>(null);
|
||||
|
||||
// Author profile popout — opens when the user clicks the avatar
|
||||
// or username in the message header, same component MemberListContainer
|
||||
// uses so profile cards look identical regardless of entry point.
|
||||
const [authorPopout, setAuthorPopout] = useState<{
|
||||
anchorRect: DOMRect;
|
||||
member: MemberProfilePopoutMember;
|
||||
} | null>(null);
|
||||
|
||||
// Full reactions breakdown modal — opens when the user clicks a
|
||||
// reaction chip's tooltip or the chip itself. Tracks the target
|
||||
// message id so the modal can read its latest reaction rows.
|
||||
const [reactionsModalMsgId, setReactionsModalMsgId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const longPressTimerRef = useRef<number | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const openAuthorPopout = (msg: DecryptedMessage, rect: DOMRect) => {
|
||||
setAuthorPopout({
|
||||
anchorRect: rect,
|
||||
member: {
|
||||
userId: msg.senderId,
|
||||
displayName: msg.authorName,
|
||||
avatarUrl: msg.authorAvatarUrl,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const startLongPress = (msg: DecryptedMessage) => {
|
||||
if (!isMobile) return;
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
}
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
setMobileSheetForMsg(msg);
|
||||
}, 450);
|
||||
};
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = (content: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = (messageId: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
const url = `${window.location.origin}${window.location.pathname}#msg-${messageId}`;
|
||||
void navigator.clipboard.writeText(url);
|
||||
}
|
||||
};
|
||||
|
||||
// Pin / unpin both flow through the PinConfirmationModal so the
|
||||
// user gets the same confirmation step the new UI uses. The
|
||||
// variant flips based on whether the target message is already
|
||||
// pinned.
|
||||
const [pinTarget, setPinTarget] = useState<{
|
||||
message: PinnedMessage;
|
||||
variant: 'pin' | 'unpin';
|
||||
} | null>(null);
|
||||
const handlePin = (messageId: string) => {
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return;
|
||||
setPinTarget({
|
||||
message: {
|
||||
id: msg.id,
|
||||
authorName: msg.authorName,
|
||||
authorAvatarUrl: msg.authorAvatarUrl,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
attachments: msg.attachments,
|
||||
},
|
||||
variant: msg.pinned ? 'unpin' : 'pin',
|
||||
});
|
||||
};
|
||||
|
||||
const openReactPicker = (messageId: string, anchorEl: Element | null) => {
|
||||
const rect = anchorEl?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setReactPicker({
|
||||
messageId,
|
||||
pos: {
|
||||
top: rect.bottom + 8,
|
||||
left: Math.max(8, rect.right - 480),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePickReaction = async (value: EmojiPickerValue) => {
|
||||
if (!reactPicker || !myUserId) return;
|
||||
try {
|
||||
// For unicode we store the raw surrogates as the reaction key.
|
||||
// For custom server emojis we store the shortcode — the render
|
||||
// path below rehydrates it via `customEmojiByName` and falls
|
||||
// back through `resolveReactionKeyToUnicode` for legacy /
|
||||
// unknown keys.
|
||||
const emojiKey =
|
||||
value.kind === 'custom' ? value.shortcode : value.surrogates;
|
||||
await addReaction({
|
||||
messageId: reactPicker.messageId as any,
|
||||
userId: myUserId as any,
|
||||
emoji: emojiKey,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to add reaction:', err);
|
||||
}
|
||||
setReactPicker(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (messageId: string) => {
|
||||
if (!myUserId) return;
|
||||
try {
|
||||
await removeMessage({ id: messageId as any, userId: myUserId as any });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReaction = async (messageId: string, emoji: string, me: boolean) => {
|
||||
if (!myUserId) return;
|
||||
try {
|
||||
if (me) {
|
||||
await removeReaction({
|
||||
messageId: messageId as any,
|
||||
userId: myUserId as any,
|
||||
emoji,
|
||||
});
|
||||
} else {
|
||||
await addReaction({
|
||||
messageId: messageId as any,
|
||||
userId: myUserId as any,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle reaction:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.group}>
|
||||
<div className={styles.contentColumn}>
|
||||
{messages.map((msg, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isOwn = msg.senderId === myUserId;
|
||||
const menuOpenForThis = contextMenu?.messageId === msg.id;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
data-message-id={msg.id}
|
||||
className={`${styles.messageContent} ${isFirst ? styles.firstMessage : ''} messageHoverable ${menuOpenForThis || localMenuOpenFor === msg.id ? 'actionBarForceVisible' : ''}`}
|
||||
onContextMenu={(e) => {
|
||||
if (isMobile) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ messageId: msg.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onTouchStart={() => startLongPress(msg)}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchMove={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
>
|
||||
<MessageActionBar
|
||||
isOwnMessage={isOwn}
|
||||
onMenuOpenChange={(open) =>
|
||||
setLocalMenuOpenFor(open ? msg.id : null)
|
||||
}
|
||||
onReply={() => onReply?.(msg.id, first.authorName)}
|
||||
onDelete={() => handleDelete(msg.id)}
|
||||
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
|
||||
onQuickReact={async (emoji) => {
|
||||
if (!myUserId) return;
|
||||
try {
|
||||
await addReaction({
|
||||
messageId: msg.id as any,
|
||||
userId: myUserId as any,
|
||||
emoji,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to add quick reaction:', err);
|
||||
}
|
||||
}}
|
||||
onPin={() => handlePin(msg.id)}
|
||||
onCopyText={() => handleCopyText(msg.content)}
|
||||
onCopyLink={() => handleCopyLink(msg.id)}
|
||||
externalMenuAt={
|
||||
menuOpenForThis ? { x: contextMenu.x, y: contextMenu.y } : null
|
||||
}
|
||||
onExternalMenuClose={() => setContextMenu(null)}
|
||||
/>
|
||||
{isFirst && msg.replyToId && (
|
||||
<div
|
||||
className={styles.replyContext}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (!msg.replyToId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('brycord:scroll-to-message', {
|
||||
detail: {
|
||||
channelId,
|
||||
messageId: msg.replyToId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!msg.replyToId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('brycord:scroll-to-message', {
|
||||
detail: {
|
||||
channelId,
|
||||
messageId: msg.replyToId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className={styles.replySpine} />
|
||||
<span className={styles.replyAuthor}>
|
||||
{msg.replyToAuthorName ?? 'Unknown'}
|
||||
</span>
|
||||
<span className={styles.replyText}>
|
||||
{msg.replyToContent ?? <em>(missing context)</em>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isFirst && (
|
||||
<>
|
||||
<div
|
||||
className={styles.avatarSlot}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openAuthorPopout(
|
||||
first,
|
||||
(e.currentTarget as HTMLElement).getBoundingClientRect(),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openAuthorPopout(
|
||||
first,
|
||||
(e.currentTarget as HTMLElement).getBoundingClientRect(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Avatar
|
||||
src={first.authorAvatarUrl}
|
||||
size={40}
|
||||
fallback={first.authorName}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.header}>
|
||||
<span
|
||||
className={styles.username}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openAuthorPopout(
|
||||
first,
|
||||
(e.currentTarget as HTMLElement).getBoundingClientRect(),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openAuthorPopout(
|
||||
first,
|
||||
(e.currentTarget as HTMLElement).getBoundingClientRect(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: first.authorRoleColor ?? undefined,
|
||||
}}
|
||||
>
|
||||
{first.authorName}
|
||||
</span>
|
||||
<span
|
||||
className={styles.timestamp}
|
||||
title={formatFullTime(first.timestamp)}
|
||||
>
|
||||
{formatTime(first.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isFirst && (
|
||||
<span
|
||||
className={styles.gutterTimestamp}
|
||||
title={formatFullTime(msg.timestamp)}
|
||||
>
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{msg.content && (
|
||||
<div className={styles.text}>
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
members={mentionMembers}
|
||||
customEmojis={customEmojiList}
|
||||
/>
|
||||
{msg.editedTimestamp && (
|
||||
<span className={styles.editedTag}> (edited)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.content &&
|
||||
extractUrls(msg.content)
|
||||
.slice(0, 3)
|
||||
.map((url, idx) => (
|
||||
<LinkEmbed key={`embed-${idx}-${url}`} url={url} />
|
||||
))}
|
||||
{msg.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{msg.attachments.map((att, j) => {
|
||||
const kind = att.mimeType.split('/')[0];
|
||||
const cls =
|
||||
kind === 'image' || kind === 'video'
|
||||
? styles.imageAttachment
|
||||
: styles.fileAttachment;
|
||||
return (
|
||||
<EncryptedAttachment
|
||||
key={`${msg.id}-${j}`}
|
||||
metadata={att}
|
||||
className={cls}
|
||||
onImageClick={(url, attachment) => {
|
||||
setLightboxItem({ src: url, attachment });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{msg.reactions.length > 0 && (
|
||||
<div className={styles.reactions}>
|
||||
{msg.reactions.map((r) => {
|
||||
// Custom emoji wins over unicode lookup. The reaction
|
||||
// key is a plain shortcode, so check the custom map
|
||||
// first; if no match, fall through to the existing
|
||||
// shortcode→unicode path.
|
||||
const customUrl = /^[a-zA-Z0-9_]+$/.test(r.emoji)
|
||||
? customEmojiByName.get(r.emoji.toLowerCase())
|
||||
: undefined;
|
||||
// Tooltip body: emoji glyph + ":name: reacted by
|
||||
// A, B, C, and N others" + hint line. Names come
|
||||
// straight from the enriched payload so we don't
|
||||
// refetch on hover. Marked interactive so the user
|
||||
// can move onto the panel and click it to open the
|
||||
// full reactions modal.
|
||||
const emojiLabel = `:${reactionKeyToName(r.emoji)}:`;
|
||||
const displayNames = r.users
|
||||
.slice(0, 3)
|
||||
.map((u) => u.displayName || u.username);
|
||||
const extras = Math.max(0, r.count - displayNames.length);
|
||||
const namesText =
|
||||
displayNames.length === 0
|
||||
? ''
|
||||
: extras === 0
|
||||
? displayNames.length === 1
|
||||
? displayNames[0]
|
||||
: displayNames.length === 2
|
||||
? `${displayNames[0]} and ${displayNames[1]}`
|
||||
: `${displayNames.slice(0, -1).join(', ')}, and ${displayNames[displayNames.length - 1]}`
|
||||
: `${displayNames.join(', ')}, and ${extras} ${extras === 1 ? 'other' : 'others'}`;
|
||||
const tooltipContent = (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 240,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{customUrl ? (
|
||||
<img
|
||||
src={customUrl}
|
||||
alt={emojiLabel}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TwemojiImg
|
||||
emoji={resolveReactionKeyToUnicode(r.emoji)}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{emojiLabel} reacted by
|
||||
</span>
|
||||
<span style={{ fontWeight: 500 }}>{namesText}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6875rem',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Click to view all reactions
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={r.emoji}
|
||||
content={tooltipContent}
|
||||
placement="top"
|
||||
delay={200}
|
||||
interactive
|
||||
onContentClick={() => setReactionsModalMsgId(msg.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.reactionChip} ${r.me ? styles.reactionMe : ''}`}
|
||||
onClick={() => setReactionsModalMsgId(msg.id)}
|
||||
>
|
||||
{customUrl ? (
|
||||
<img
|
||||
src={customUrl}
|
||||
alt={`:${r.emoji}:`}
|
||||
title={`:${r.emoji}:`}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TwemojiImg
|
||||
emoji={resolveReactionKeyToUnicode(r.emoji)}
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.reactionCount}>{r.count}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{reactPicker &&
|
||||
createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: reactPicker.pos.top,
|
||||
left: reactPicker.pos.left,
|
||||
zIndex: 15000,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EmojiPicker
|
||||
onSelect={handlePickReaction}
|
||||
onClose={() => setReactPicker(null)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<ImageLightbox
|
||||
isOpen={!!lightboxItem}
|
||||
src={lightboxItem?.src ?? ''}
|
||||
filename={lightboxItem?.attachment.filename}
|
||||
mimeType={lightboxItem?.attachment.mimeType}
|
||||
size={lightboxItem?.attachment.size}
|
||||
width={lightboxItem?.attachment.width}
|
||||
height={lightboxItem?.attachment.height}
|
||||
attachment={lightboxItem?.attachment}
|
||||
onClose={() => setLightboxItem(null)}
|
||||
/>
|
||||
|
||||
<PinConfirmationModal
|
||||
isOpen={!!pinTarget}
|
||||
onClose={() => setPinTarget(null)}
|
||||
channelId={channelId}
|
||||
messageId={pinTarget?.message.id ?? null}
|
||||
message={pinTarget?.message ?? null}
|
||||
variant={pinTarget?.variant ?? 'pin'}
|
||||
/>
|
||||
|
||||
{authorPopout && (
|
||||
<MemberProfilePopout
|
||||
anchorRect={authorPopout.anchorRect}
|
||||
member={authorPopout.member}
|
||||
onClose={() => setAuthorPopout(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
// Look up the target message by id from the current group
|
||||
// so the modal always renders against the latest reaction
|
||||
// rows (the group array is re-created on every parent
|
||||
// re-render by Messages.tsx's memoized decrypt step).
|
||||
const targetMsg = reactionsModalMsgId
|
||||
? messages.find((m) => m.id === reactionsModalMsgId) ?? null
|
||||
: null;
|
||||
return (
|
||||
<ReactionsModal
|
||||
isOpen={!!targetMsg}
|
||||
onClose={() => setReactionsModalMsgId(null)}
|
||||
reactions={targetMsg?.reactions ?? []}
|
||||
myUserId={myUserId}
|
||||
customEmojiByName={customEmojiByName}
|
||||
onRemoveOwnReaction={async (emoji) => {
|
||||
if (!targetMsg || !myUserId) return;
|
||||
try {
|
||||
await removeReaction({
|
||||
messageId: targetMsg.id as any,
|
||||
userId: myUserId as any,
|
||||
emoji,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to remove reaction:', err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{mobileSheetForMsg && (
|
||||
<MobileMessageActionsSheet
|
||||
isOpen={!!mobileSheetForMsg}
|
||||
onClose={() => setMobileSheetForMsg(null)}
|
||||
isOwnMessage={mobileSheetForMsg.senderId === myUserId}
|
||||
canDelete={mobileSheetForMsg.senderId === myUserId}
|
||||
hasContent={!!mobileSheetForMsg.content}
|
||||
onQuickReact={async (emoji) => {
|
||||
if (!myUserId) return;
|
||||
try {
|
||||
await addReaction({
|
||||
messageId: mobileSheetForMsg.id as any,
|
||||
userId: myUserId as any,
|
||||
emoji,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to add quick reaction:', err);
|
||||
}
|
||||
}}
|
||||
onAddReaction={() => {
|
||||
// No anchor on mobile — just open a centered picker
|
||||
// by passing `null` as the anchor.
|
||||
setReactPicker({
|
||||
messageId: mobileSheetForMsg.id,
|
||||
anchor: null as any,
|
||||
});
|
||||
}}
|
||||
onReply={() => {
|
||||
onReply?.(mobileSheetForMsg.id, first.authorName);
|
||||
}}
|
||||
onForward={() => {
|
||||
/* forward not implemented yet */
|
||||
}}
|
||||
onEdit={
|
||||
mobileSheetForMsg.senderId === myUserId
|
||||
? () => {
|
||||
/* edit not implemented via sheet yet */
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPin={() => handlePin(mobileSheetForMsg.id)}
|
||||
isPinned={mobileSheetForMsg.pinned}
|
||||
onCopyText={() => handleCopyText(mobileSheetForMsg.content)}
|
||||
onDelete={
|
||||
mobileSheetForMsg.senderId === myUserId
|
||||
? () => handleDelete(mobileSheetForMsg.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user