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 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(); 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( null, ); const [mobileSheetForMsg, setMobileSheetForMsg] = useState(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(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 (
{messages.map((msg, i) => { const isFirst = i === 0; const isOwn = msg.senderId === myUserId; const menuOpenForThis = contextMenu?.messageId === msg.id; return (
{ if (isMobile) return; e.preventDefault(); setContextMenu({ messageId: msg.id, x: e.clientX, y: e.clientY }); }} onTouchStart={() => startLongPress(msg)} onTouchEnd={cancelLongPress} onTouchMove={cancelLongPress} onTouchCancel={cancelLongPress} > 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 && (
{ 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' }} >
{msg.replyToAuthorName ?? 'Unknown'} {msg.replyToContent ?? (missing context)}
)} {isFirst && ( <>
{ 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' }} >
{ 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} {formatTime(first.timestamp)}
)} {!isFirst && ( {new Date(msg.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true, })} )} {msg.content && !isGifOnlyContent(msg.content) && (
{msg.editedTimestamp && ( (edited) )}
)} {msg.content && extractUrls(msg.content) .slice(0, 3) .map((url, idx) => ( setLightboxItem({ src: gifUrl, filename: gifUrl.split('/').pop() || 'gif', mimeType: 'image/gif', }) } /> ))} {msg.attachments.length > 0 && (
{msg.attachments.map((att, j) => { const kind = att.mimeType.split('/')[0]; const cls = kind === 'image' || kind === 'video' ? styles.imageAttachment : styles.fileAttachment; return ( { setLightboxItem({ src: url, attachment }); }} /> ); })}
)} {msg.reactions.length > 0 && (
{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 = (
{customUrl ? ( {emojiLabel} ) : ( )}
{emojiLabel} reacted by {namesText} Click to view all reactions
); return ( setReactionsModalMsgId(msg.id)} > ); })}
)}
); })}
{reactPicker && createPortal(
e.stopPropagation()} > setReactPicker(null)} />
, document.body, )} setLightboxItem(null)} /> setPinTarget(null)} channelId={channelId} messageId={pinTarget?.message.id ?? null} message={pinTarget?.message ?? null} variant={pinTarget?.variant ?? 'pin'} /> {authorPopout && ( 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 ( 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 && ( 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 } /> )}
); }