import React, { useState, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import type { MessageWithUser, Embed, User } from '@backspace/shared'; import { MarkdownRenderer } from './MarkdownRenderer'; import { MentionBadge } from './MentionBadge'; import { Avatar } from '../ui/Avatar'; import { ProfileAvatar } from '../ui/ProfileAvatar'; import { useContextMenuStore } from '../../stores/contextMenuStore'; import { buildMessageMenuItems } from './messageMenuItems'; import { useAuthStore } from '../../stores/authStore'; import { useChatStore } from '../../stores/chatStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { AttachmentRenderer, attUrlOf } from './AttachmentRenderer'; import { AttachmentProgress } from './AttachmentProgress'; import { EmbedRenderer } from './EmbedRenderer'; import { Username } from '../ui/Username'; import { EmojiPicker } from './EmojiPicker'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { isDeletedPartnerDm } from '../../utils/dmFormatters'; import { isSelf, resolveDisplayIdentity } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { isPendingMessage, usePendingMessageStore, type PendingMessageView, type PendingAttachmentView, } from '../../stores/pendingMessageStore'; import { useTransferStore } from '../../stores/transferStore'; interface MessageProps { message: MessageWithUser | PendingMessageView; isCompact: boolean; isFirstInGroup: boolean; previousMessageId: string | null; } interface PendingAttachmentTileProps { transferId: string; } function PendingAttachmentTile({ transferId }: PendingAttachmentTileProps) { const transfer = useTransferStore((s) => s.transfers.get(transferId)); const pauseUpload = useTransferStore((s) => s.pauseUpload); const resumeUpload = useTransferStore((s) => s.resumeUpload); const abortUpload = useTransferStore((s) => s.abortUpload); if (!transfer) { return
; } return (
pauseUpload(transfer.id) : undefined} onResume={transfer.state === 'paused' ? () => resumeUpload(transfer.id) : undefined} onAbort={() => abortUpload(transfer.id)} size="tile" />
); } function formatTime(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = date.toDateString() === yesterday.toDateString(); const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (isToday) return `Today at ${time}`; if (isYesterday) return `Yesterday at ${time}`; return `${date.toLocaleDateString()} ${time}`; } function formatHoverTime(timestamp: number): string { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } /** Lightweight inline renderer that resolves <@userId> mentions to MentionBadge components. */ function renderInlineWithMentions(content: string): React.ReactNode { const parts = content.split(/(<@[a-zA-Z0-9_-]+>)/g); return parts.map((part, i) => { const match = part.match(/^<@([a-zA-Z0-9_-]+)>$/); if (match) return ; return part; }); } const GIF_URL_REGEX = /^https:\/\/(?:media\.tenor\.com|static\.klipy\.com)\/.+$/; function isGifOnlyMessage(content: string | null): boolean { if (!content) return false; const trimmed = content.trim(); return GIF_URL_REGEX.test(trimmed); } /** * Returns the original posted URL when a message is a single URL that resolved * to an image embed, or null if the message should render normally. */ function getImageEmbedSourceUrl(content: string | null, embeds: Embed[]): string | null { if (!content) return null; const trimmed = content.trim(); // Must be a single URL with no surrounding text if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return null; if (/\s/.test(trimmed)) return null; // Must have at least one image embed whose URL matches the posted content const hasMatchingImageEmbed = embeds.some( (e) => e.embedType === 'image' && e.url === trimmed ); return hasMatchingImageEmbed ? trimmed : null; } export function Message({ message, isCompact, isFirstInGroup, previousMessageId }: MessageProps) { const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content ?? ''); const [isHovered, setIsHovered] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const [showReactionPicker, setShowReactionPicker] = useState(false); const confirmDeleteTimeout = useRef>(); const reactionPickerBtnRef = useRef(null); const reactionPickerRef = useRef(null); const currentUser = useAuthStore((s) => s.user); const editMessage = useChatStore((s) => s.editMessage); const deleteMessage = useChatStore((s) => s.deleteMessage); const members = useSpaceStore((s) => s.members); const openUserProfile = useUIStore((s) => s.openUserProfile); const pending = isPendingMessage(message) ? message.__pending : null; const showInteractions = !pending; const transfersForRow = useTransferStore((s) => s.transfers); const inMemoryFiles = useTransferStore((s) => s.hasInMemoryFile); const anyTransferTerminallyBad = !!pending && pending.transferIds.some((tid) => { const t = transfersForRow.get(tid); return t && (t.state === 'failed' || t.state === 'aborted'); }); const showRetryDiscardRow = pending?.state === 'failed' || anyTransferTerminallyBad; // Retry is only feasible when we can re-source the bytes for every transfer in the // pending row — either the in-memory File ref still exists (same-session retry) // or a persisted FileSystemFileHandle can reacquire the bytes (Chrome/Edge drag-drop). // After a reload (or post-redeploy refresh) without a handle, both are gone, and // showing a Retry button that silently no-ops would strand the user. Hide it instead. const canRetry = !!pending && pending.transferIds.every((tid) => { const t = transfersForRow.get(tid); if (!t || t.type !== 'upload') return false; return inMemoryFiles.has(tid) || !!t.fileHandleId; }); const channelKey: string = isPendingMessage(message) ? message.channelId || message.dmChannelId || '' : message.channelId || (message as MessageWithUser & { dmChannelId?: string }).dmChannelId || ''; const isAuthor = isSelf(message.user, currentUser); const channelPermissions = useSpaceStore((s) => s.channelPermissions); const myChPerms = channelPermissions.get(message.channelId); const isDmMessage = isPendingMessage(message) ? !!message.dmChannelId || !message.channelId : !!(message as MessageWithUser & { dmChannelId?: string }).dmChannelId || !message.channelId; const dmChannelId = isPendingMessage(message) ? message.dmChannelId : (message as MessageWithUser & { dmChannelId?: string }).dmChannelId; const dmChannels = useSpaceStore((s) => s.dmChannels); // Read-only enforcement (client mirror of the server guard): a dead 1-on-1 DM // (partner tombstoned) accepts no reaction mutations. Existing reactions still // DISPLAY, but the add/toggle affordances are withdrawn since the server drops them. const isDeadDmThread = !!dmChannelId && (() => { const dm = dmChannels.find(d => d.id === dmChannelId); return dm ? isDeletedPartnerDm(dm, currentUser) : false; })(); const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES); const canSendMessages = isDmMessage || hasPermissionBit(myChPerms, PermissionBits.SEND_MESSAGES); const canDelete = isAuthor || canManageMessages; const canAddReactions = (isDmMessage || hasPermissionBit(myChPerms, PermissionBits.ADD_REACTIONS)) && !isDeadDmThread; const addReaction = useChatStore((s) => s.addReaction); const removeReaction = useChatStore((s) => s.removeReaction); const setReplyTo = useChatStore((s) => s.setReplyTo); const markUnread = useChatStore((s) => s.markUnread); const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; const _rawMsgUser = message.user ?? null; const _canonicalMsgUser = useCanonicalUserView(_rawMsgUser ?? _FALLBACK_USER); const _rawReplyUser = (!isPendingMessage(message) && message.replyTo?.user) ? message.replyTo.user : null; const _canonicalReplyUser = useCanonicalUserView(_rawReplyUser ?? _FALLBACK_USER); const isOwnReaction = (r: { userId: string; user?: { id: string; username: string; homeInstance?: string | null } | null }) => r.user ? isSelf(r.user, currentUser) : r.userId === currentUser?.id; const toggleReaction = (emoji: string) => { // Read-only: a dead 1-on-1 DM accepts no reaction mutations (add OR remove). if (isDeadDmThread) return; const hasReacted = message.reactions?.some(r => isOwnReaction(r) && r.emoji === emoji); if (hasReacted) { removeReaction(message.id, emoji); } else if (canAddReactions) { addReaction(message.id, emoji); } }; const reactionGroups = (message.reactions || []).reduce((acc, r) => { const group = acc[r.emoji] || { count: 0, me: false }; group.count++; if (isOwnReaction(r)) { group.me = true; } acc[r.emoji] = group; return acc; }, {} as Record); // Auto-cancel delete confirmation after timeout const startDeleteConfirm = useCallback(() => { setConfirmingDelete(true); clearTimeout(confirmDeleteTimeout.current); confirmDeleteTimeout.current = setTimeout(() => setConfirmingDelete(false), 3000); }, []); const cancelDeleteConfirm = useCallback(() => { setConfirmingDelete(false); clearTimeout(confirmDeleteTimeout.current); }, []); useEffect(() => { return () => clearTimeout(confirmDeleteTimeout.current); }, []); const isGifOnly = isGifOnlyMessage(message.content); const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []); // sourceUrl: the original URL for context menu Copy/Open Link actions const sourceUrl = isGifOnly ? (message.content?.trim() ?? null) : imageEmbedSourceUrl; // Close reaction picker on outside click useEffect(() => { if (!showReactionPicker) return; const handler = (e: MouseEvent) => { if (reactionPickerRef.current?.contains(e.target as Node)) return; if (reactionPickerBtnRef.current?.contains(e.target as Node)) return; setShowReactionPicker(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showReactionPicker]); // Close reaction picker on Escape useEffect(() => { if (!showReactionPicker) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowReactionPicker(false); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [showReactionPicker]); const handleReactionEmojiSelect = useCallback((emoji: { native: string }) => { addReaction(message.id, emoji.native); setShowReactionPicker(false); }, [addReaction, message.id]); const handleUsernameClick = (e: React.MouseEvent) => { if (!message.user) return; e.stopPropagation(); openUserProfile(message.user, e.currentTarget.getBoundingClientRect()); }; const handleContextMenu = (e: React.MouseEvent) => { if (pending) { e.preventDefault(); e.stopPropagation(); return; } e.preventDefault(); e.stopPropagation(); const selectedText = window.getSelection()?.toString() ?? ''; // Detect if the right-click target is a content image (not an avatar or embed thumbnail) const imgEl = (e.target as HTMLElement).closest('img') as HTMLImageElement | null; const isContentImage = imgEl && !imgEl.closest('[data-avatar]') && !imgEl.closest('[data-embed-thumbnail]'); const imageUrl = isContentImage ? imgEl.src : null; // Detect right-click on a video/audio element and resolve its underlying attachment. // `message` is the persisted form here — pending messages return early above. const persistedAttachments = (message as MessageWithUser).attachments ?? []; const videoEl = (e.target as HTMLElement).closest('video') as HTMLVideoElement | null; const audioEl = (e.target as HTMLElement).closest('audio') as HTMLAudioElement | null; const matchAttByUrl = (mediaSrc: string, kind: 'video' | 'audio') => { // currentSrc is absolute; attUrlOf may be relative — compare via URL parse. let mediaPath: string; try { mediaPath = new URL(mediaSrc, window.location.origin).pathname; } catch { mediaPath = mediaSrc; } return persistedAttachments.find((att) => { if (!att.mimetype.startsWith(`${kind}/`)) return false; const attRaw = attUrlOf(att.filename); let attPath: string; try { attPath = new URL(attRaw, window.location.origin).pathname; } catch { attPath = attRaw; } return attPath === mediaPath; }) ?? null; }; const videoAtt = videoEl?.currentSrc ? matchAttByUrl(videoEl.currentSrc, 'video') : null; const audioAtt = audioEl?.currentSrc ? matchAttByUrl(audioEl.currentSrc, 'audio') : null; const items = buildMessageMenuItems({ message, selectedText, previousMessageId, imageUrl, sourceUrl, videoUrl: videoAtt ? attUrlOf(videoAtt.filename) : null, videoFilename: videoAtt ? videoAtt.originalName : null, videoSize: videoAtt ? videoAtt.size : null, audioUrl: audioAtt ? attUrlOf(audioAtt.filename) : null, audioFilename: audioAtt ? audioAtt.originalName : null, audioSize: audioAtt ? audioAtt.size : null, isAuthor, isDm: isDmMessage, canAddReactions, canSendMessages, canManageMessages, onReply: () => setReplyTo(message), onEdit: () => { setEditContent(message.content ?? ''); setIsEditing(true); }, onDelete: () => deleteMessage(message.id, channelKey), onReaction: (emoji: string) => toggleReaction(emoji), onOpenEmojiPicker: () => { // Close the context menu, then show the reaction picker useContextMenuStore.getState().close(); setShowReactionPicker(true); }, onMarkUnread: (msgId: string) => markUnread(channelKey, msgId), }); if (items.length === 0) return; useContextMenuStore.getState().open({ x: e.clientX, y: e.clientY }, items); }; const handleEditSubmit = async (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (editContent.trim()) { await editMessage(message.id, editContent.trim(), channelKey); setIsEditing(false); } } if (e.key === 'Escape') { setIsEditing(false); setEditContent(message.content ?? ''); } }; // Resolve display identity: replicated-self messages show home user's avatar/name. // For non-self messages, further route through canonical user view cache so stale // federated stubs are replaced with the best-known profile data. const _resolvedIdentity = resolveDisplayIdentity(message.user, currentUser); const displayIdentity = (!isSelf(_resolvedIdentity, currentUser) && _rawMsgUser) ? _canonicalMsgUser : _resolvedIdentity; const displayName = displayIdentity.displayName ?? displayIdentity.username; const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const ownerId = spaces.find(s => s.id === currentSpaceId)?.ownerId; const getMemberDisplayColor = (userId: string) => { if (isDmMessage) return { color: '#d8d8de' }; const member = members.find(m => m.userId === userId); if (member?.roles && member.roles.length > 0) { const sorted = [...member.roles].sort((a, b) => b.position - a.position); return { color: sorted[0]!.color }; } if (ownerId && userId === ownerId) return { color: '#fda4af' }; return { color: '#d8d8de' }; }; const roleColor = getMemberDisplayColor(message.userId); const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId); // Self-mention highlighting const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>'); const content = (
setIsHovered(true)} onMouseLeave={() => { setIsHovered(false); if (confirmingDelete) { clearTimeout(confirmDeleteTimeout.current); confirmDeleteTimeout.current = setTimeout(() => setConfirmingDelete(false), 2000); } }} > {/* Reply Line */} {message.replyTo && (
)} {/* Avatar or timestamp column */}
{isFirstInGroup || message.replyTo ? (
) : ( {formatHoverTime(message.createdAt)} )}
{/* Content */}
{message.replyTo && (() => { const _rawReply = resolveDisplayIdentity(message.replyTo.user, currentUser); const replyIdentity = (!isSelf(_rawReply, currentUser) && _rawReplyUser) ? _canonicalReplyUser : _rawReply; const replyDisplayName = replyIdentity.displayName ?? replyIdentity.username; return (
{message.replyTo.content ? renderInlineWithMentions(message.replyTo.content) : ''}
); })()} {(isFirstInGroup || message.replyTo) && (
{formatTime(message.createdAt)}
)} {isEditing ? (