All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s
785 lines
24 KiB
TypeScript
785 lines
24 KiB
TypeScript
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));
|
|
}
|
|
|
|
/** True when a message body is entirely made up of one or more GIF
|
|
* URLs plus whitespace — i.e. the user posted a GIF from the
|
|
* picker and there's nothing worth showing as text. The render
|
|
* path hides the <MessageContent> block in that case so only the
|
|
* embedded preview appears. */
|
|
function isGifOnlyContent(text: string): boolean {
|
|
const urls = extractUrls(text);
|
|
if (urls.length === 0) return false;
|
|
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
|
|
let remainder = text;
|
|
for (const u of urls) {
|
|
remainder = remainder.split(u).join('');
|
|
}
|
|
return remainder.trim().length === 0;
|
|
}
|
|
|
|
/**
|
|
* Discord-style relative timestamp:
|
|
* - Same calendar day → `Today at 7:08 PM`
|
|
* - Previous calendar day → `Yesterday at 7:08 PM`
|
|
* - Anything else → `4/11/2026, 7:08 PM`
|
|
*/
|
|
function formatTime(ts: number): string {
|
|
const date = new Date(ts);
|
|
const now = new Date();
|
|
const time = date.toLocaleTimeString([], {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
const isToday = date.toDateString() === now.toDateString();
|
|
if (isToday) return `Today at ${time}`;
|
|
const yesterday = new Date(now);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
if (date.toDateString() === yesterday.toDateString()) {
|
|
return `Yesterday at ${time}`;
|
|
}
|
|
return `${date.toLocaleDateString()}, ${time}`;
|
|
}
|
|
|
|
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
|
|
// optional attachment metadata of the image that was clicked so
|
|
// the lightbox info card can render filename / size / dimensions.
|
|
// Null means the lightbox is closed. For inline GIFs posted via
|
|
// a URL (no encrypted attachment), the metadata is absent — the
|
|
// lightbox gracefully hides the star / detail chrome in that
|
|
// case.
|
|
const [lightboxItem, setLightboxItem] = useState<{
|
|
src: string;
|
|
attachment?: AttachmentMetadata;
|
|
filename?: string;
|
|
mimeType?: string;
|
|
} | 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 && !isGifOnlyContent(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}
|
|
onOpenGif={(gifUrl) =>
|
|
setLightboxItem({
|
|
src: gifUrl,
|
|
filename: gifUrl.split('/').pop() || 'gif',
|
|
mimeType: 'image/gif',
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
{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}
|
|
className={styles.reactionEmoji}
|
|
/>
|
|
) : (
|
|
<TwemojiImg
|
|
emoji={resolveReactionKeyToUnicode(r.emoji)}
|
|
size={16}
|
|
className={styles.reactionEmoji}
|
|
/>
|
|
)}
|
|
<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 ?? lightboxItem?.filename
|
|
}
|
|
mimeType={
|
|
lightboxItem?.attachment?.mimeType ?? lightboxItem?.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>
|
|
);
|
|
}
|