Fixes a render bug where a federated user (e.g. axel@nova) appeared with the federation globe icon and a broken avatar when viewed on his own home instance. Root cause: `populateFromReady` is first-wins by federatedId and discards the entire skipped DM payload — including its `members` array — so when a sibling instance's ready arrived first, the home instance's view of every shared user was dropped on the floor. Adds a render-only `userViews` cache that mirrors the `dmAlternatives` philosophy: information from skipped ready payloads is preserved for rendering. Every wire surface that delivers a User upserts into the cache regardless of dedup outcome; render sites read through a Zustand selector hook to surface the home view when one is loaded. The DM channel ingestion race is left untouched — the existing no-flapping invariant on origin reconnect is intentional and load-bearing for failover. Layered changes: - `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`, `isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers for origin/host normalization and the home/stub tier decision. - `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView` action with the home-wins preference rule, prune by `deliveredBy` in `removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset` clears. - `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for React) + `getCanonicalUserView` (sync getter for non-React paths). Render reactivity is structural via the selector, not coincidence on legacy update paths. - `populateFromReady` upsert pass runs BEFORE the federatedId dedup so members of skipped DMs still reach the cache. - WS handlers (dm_message_*, message_*, user_updated, member_joined, friend_request_*, dm_channel_created, dm_member_added) and REST hydrators (socialStore, discoverStore, mutuals) feed the cache with their delivering origin. - Render-site routing through `useCanonicalUserView` at every audited user-rendering site (sidebar, header, search, message bubble, reply chips, profile popout/modal, group settings, voice tiles, mention chips, member lists, friends, invites). Self-rendering sites compose alongside via existing `isSelf`/`resolveDisplayIdentity`. - Globe predicate hoisted to `isFederationGlobeApplicable` and applied at three sites, gating on `domain !== window.location.host` so we never show the globe for users whose home IS our own. Tests: 31 new unit tests across `identity`, `userViews` store, and `userViewLookup`. Full suite 276/276. Docs: `client-federation.md` §3 gains a "User View Cache" section parallel to "DM Origin Failover"; `dm-system.md` notes the new store action and WS handler upserts. Bug 3 (federation profile-sync gap — orbit's stale profile data on nova-Axel after a clear/color-change on nova never propagated) remains open. The user-view cache routes around it for the common case (home instance is connected), but the underlying S2S relay gap is its own diagnosis and follows in a separate branch.
747 lines
33 KiB
TypeScript
747 lines
33 KiB
TypeScript
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 { 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 { 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 <div className="w-[140px] h-[96px] rounded-lg bg-surface-channel" />;
|
|
}
|
|
return (
|
|
<div className="relative w-[140px] h-[96px] rounded-lg overflow-hidden bg-surface-channel">
|
|
<AttachmentProgress
|
|
loaded={transfer.progress.loaded}
|
|
total={transfer.progress.total}
|
|
state={transfer.state}
|
|
filename={transfer.file.name}
|
|
error={transfer.error?.message}
|
|
onPause={transfer.state === 'active' ? () => pauseUpload(transfer.id) : undefined}
|
|
onResume={transfer.state === 'paused' ? () => resumeUpload(transfer.id) : undefined}
|
|
onAbort={() => abortUpload(transfer.id)}
|
|
size="tile"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <MentionBadge key={i} userId={match[1]!} />;
|
|
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<ReturnType<typeof setTimeout>>();
|
|
const reactionPickerBtnRef = useRef<HTMLButtonElement>(null);
|
|
const reactionPickerRef = useRef<HTMLDivElement>(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 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);
|
|
|
|
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) => {
|
|
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<string, { count: number; me: boolean }>);
|
|
|
|
// 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();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
openUserProfile(message.user, {
|
|
top: Math.min(rect.top, window.innerHeight - 450),
|
|
left: rect.right + 16,
|
|
});
|
|
};
|
|
|
|
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 = (
|
|
<div
|
|
id={`msg-${message.id}`}
|
|
className={`group relative flex gap-4 px-5 py-[3px] transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''} ${
|
|
isMentioned
|
|
? 'bg-accent-amber/10 border-l-2 border-l-accent-amber hover:bg-accent-amber/15'
|
|
: 'hover:bg-[rgba(255,255,255,0.025)]'
|
|
}`}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => {
|
|
setIsHovered(false);
|
|
if (confirmingDelete) {
|
|
clearTimeout(confirmDeleteTimeout.current);
|
|
confirmDeleteTimeout.current = setTimeout(() => setConfirmingDelete(false), 2000);
|
|
}
|
|
}}
|
|
>
|
|
{/* Reply Line */}
|
|
{message.replyTo && (
|
|
<div className="absolute left-[40px] top-[-14px] w-[30px] h-[22px] border-l-2 border-t-2 border-interactive-muted rounded-tl-[6px] opacity-60" />
|
|
)}
|
|
|
|
{/* Avatar or timestamp column */}
|
|
<div className="w-10 flex-shrink-0 flex items-start justify-start">
|
|
{isFirstInGroup || message.replyTo ? (
|
|
<div className="mt-0.5">
|
|
<Avatar
|
|
src={displayIdentity.avatar}
|
|
name={displayName}
|
|
size={40}
|
|
user={displayIdentity}
|
|
className="hover:drop-shadow-md transition-all active:translate-y-[1px]"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<span className={`text-[11px] text-txt-tertiary opacity-0 group-hover:opacity-100 select-none w-full text-right whitespace-nowrap leading-[1.375rem]`}>
|
|
{formatHoverTime(message.createdAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
{message.replyTo && (() => {
|
|
const _rawReply = resolveDisplayIdentity(message.replyTo.user, currentUser);
|
|
const replyIdentity = (!isSelf(_rawReply, currentUser) && _rawReplyUser)
|
|
? _canonicalReplyUser
|
|
: _rawReply;
|
|
const replyDisplayName = replyIdentity.displayName ?? replyIdentity.username;
|
|
return (
|
|
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
|
|
<Avatar src={replyIdentity.avatar} name={replyDisplayName} size={16} user={replyIdentity} />
|
|
<Username
|
|
username={replyDisplayName}
|
|
className="text-[14px] font-bold text-txt-primary hover:underline"
|
|
style={replyRoleColor(message.replyTo)}
|
|
/>
|
|
<span className="text-[14px] text-txt-message truncate max-w-[400px] hover:text-txt-primary">
|
|
{message.replyTo.content ? renderInlineWithMentions(message.replyTo.content) : ''}
|
|
</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{(isFirstInGroup || message.replyTo) && (
|
|
<div className="flex items-baseline gap-2 mb-0.5">
|
|
<span onClick={handleUsernameClick}>
|
|
<Username
|
|
username={displayName}
|
|
className="font-semibold cursor-pointer hover:underline text-[15px] leading-tight"
|
|
style={roleColor}
|
|
/>
|
|
</span>
|
|
<span className="text-[11px] text-txt-tertiary leading-tight hover:cursor-default">
|
|
{formatTime(message.createdAt)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{isEditing ? (
|
|
<div className="mt-1 w-full">
|
|
<textarea
|
|
value={editContent}
|
|
onChange={(e) => setEditContent(e.target.value)}
|
|
onKeyDown={handleEditSubmit}
|
|
className="input-standard w-full p-3 rounded-lg resize-none text-[15px] leading-[1.5] shadow-inner"
|
|
rows={2}
|
|
autoFocus
|
|
/>
|
|
<p className="text-[12px] text-txt-tertiary mt-1.5 ml-1">
|
|
escape to <button onClick={() => setIsEditing(false)} className="text-txt-link hover:underline">cancel</button>
|
|
{' '}• enter to <button onClick={() => {
|
|
if (editContent.trim()) {
|
|
editMessage(message.id, editContent.trim(), channelKey);
|
|
setIsEditing(false);
|
|
}
|
|
}} className="text-txt-link hover:underline">save</button>
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{isGifOnly ? (
|
|
<div className="mt-1 max-w-[400px]">
|
|
<img
|
|
src={message.content!.trim()}
|
|
alt="GIF"
|
|
className="max-w-full max-h-[300px] rounded-lg"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
) : imageEmbedSourceUrl ? (
|
|
<>
|
|
{/* URL text suppressed — embeds render the image */}
|
|
{message.embeds && message.embeds.length > 0 && (
|
|
<div className="flex flex-col gap-2 mt-1">
|
|
{message.embeds.map((embed) => (
|
|
<EmbedRenderer key={embed.id} embed={embed} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{message.editedAt && (
|
|
<span className="text-[10px] text-txt-tertiary select-none font-medium">(edited)</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{message.content && (
|
|
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
|
<MarkdownRenderer content={message.content} />
|
|
{message.editedAt && (
|
|
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Embeds */}
|
|
{!isEditing && message.embeds && message.embeds.length > 0 && (
|
|
<div className="flex flex-col gap-2 mt-1">
|
|
{message.embeds.map((embed) => (
|
|
<EmbedRenderer key={embed.id} embed={embed} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Attachments */}
|
|
{message.attachments && message.attachments.length > 0 && (
|
|
<div className="mt-1 grid gap-2">
|
|
{message.attachments.map((att) => {
|
|
const pendingAtt = att as PendingAttachmentView;
|
|
if (pending && pendingAtt.__transferId) {
|
|
return <PendingAttachmentTile key={att.id} transferId={pendingAtt.__transferId} />;
|
|
}
|
|
return <AttachmentRenderer key={att.id} attachment={att} />;
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Failed-state retry/discard row */}
|
|
{showRetryDiscardRow && pending && (
|
|
<div className="mt-2 inline-flex items-center self-start gap-2 px-2.5 py-1.5 rounded-lg bg-accent-rose/10 border border-accent-rose/25 shadow-sm">
|
|
<span className="flex items-center gap-1.5 text-[11.5px] font-medium text-accent-rose">
|
|
<svg width="13" height="13" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path fillRule="evenodd" d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 4a.875.875 0 01.875.875v4a.875.875 0 11-1.75 0v-4A.875.875 0 0110 6zm0 8.25a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
|
</svg>
|
|
Upload failed
|
|
</span>
|
|
<span className="w-px h-3.5 bg-accent-rose/25" aria-hidden="true" />
|
|
{canRetry && (
|
|
<button
|
|
onClick={async () => {
|
|
const transfers = useTransferStore.getState().transfers;
|
|
const retryIds = pending.transferIds.filter((tid) => {
|
|
const s = transfers.get(tid)?.state;
|
|
return s === 'failed' || s === 'aborted';
|
|
});
|
|
// Flip the bubble back to 'sending' first so the orchestrator
|
|
// re-evaluates after the resumed transfers complete.
|
|
usePendingMessageStore.getState().markSending(pending.clientId);
|
|
for (const tid of retryIds) {
|
|
await useTransferStore.getState().resumeUpload(tid);
|
|
}
|
|
}}
|
|
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-accent-mint bg-accent-mint/10 hover:bg-accent-mint/20 transition-colors"
|
|
>
|
|
Retry
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
const transfers = useTransferStore.getState().transfers;
|
|
for (const tid of pending.transferIds) {
|
|
const t = transfers.get(tid);
|
|
if (!t) continue;
|
|
// Route anything with potential server-side state (active/paused/
|
|
// queued/failed) through abortUpload — it sends DELETE so the
|
|
// .tus session doesn't sit on disk for the janitor to sweep.
|
|
// 'aborted' already cleaned itself; 'completed' has a finalized
|
|
// attachment row that the unlinked-attachment janitor handles (1h grace).
|
|
if (t.state !== 'aborted' && t.state !== 'completed') {
|
|
useTransferStore.getState().abortUpload(tid);
|
|
}
|
|
useTransferStore.getState().remove(tid);
|
|
}
|
|
if (channelKey) {
|
|
usePendingMessageStore.getState().removeByClientId(channelKey, pending.clientId);
|
|
}
|
|
}}
|
|
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-txt-secondary bg-surface-channel/50 hover:bg-accent-rose/15 hover:text-accent-rose transition-colors"
|
|
>
|
|
Discard
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reactions */}
|
|
{showInteractions && Object.keys(reactionGroups).length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.entries(reactionGroups).map(([emoji, { count, me }]) => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => toggleReaction(emoji)}
|
|
className={`glass-pill flex items-center gap-1 rounded-[6px] cursor-pointer transition-all duration-[120ms] ease-out ${
|
|
me ? 'glass-pill-mine' : ''
|
|
}`}
|
|
style={{ padding: '2px 8px', fontSize: '13px', lineHeight: 1 }}
|
|
>
|
|
<span style={{ fontSize: '14px', lineHeight: 1 }}>{emoji}</span>
|
|
<span className={`font-semibold ${me ? 'text-accent-mint' : 'text-txt-secondary'}`} style={{ fontSize: '12px' }}>{count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reaction emoji picker */}
|
|
{showInteractions && showReactionPicker && canAddReactions && reactionPickerBtnRef.current && (() => {
|
|
const PICKER_HEIGHT = 400;
|
|
const PICKER_WIDTH = 360;
|
|
const MARGIN = 8;
|
|
const btnRect = reactionPickerBtnRef.current!.getBoundingClientRect();
|
|
const spaceBelow = window.innerHeight - btnRect.bottom;
|
|
const spaceAbove = btnRect.top;
|
|
const flipAbove = spaceBelow < (PICKER_HEIGHT + MARGIN) && spaceAbove > spaceBelow;
|
|
const top = flipAbove
|
|
? Math.max(MARGIN, btnRect.top - PICKER_HEIGHT - MARGIN)
|
|
: btnRect.bottom + MARGIN;
|
|
const left = Math.min(
|
|
Math.max(MARGIN, btnRect.left),
|
|
window.innerWidth - PICKER_WIDTH - MARGIN,
|
|
);
|
|
return createPortal(
|
|
<div
|
|
ref={reactionPickerRef}
|
|
className={`fixed z-[300] ${flipAbove ? 'animate-slide-down' : 'animate-slide-up'}`}
|
|
style={{ top, left }}
|
|
>
|
|
<div className="glass rounded-xl overflow-hidden">
|
|
<EmojiPicker onEmojiSelect={handleReactionEmojiSelect} />
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
})()}
|
|
|
|
{/* Action buttons on hover */}
|
|
{showInteractions && (isHovered || showReactionPicker || confirmingDelete) && !isEditing && (
|
|
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8">
|
|
{canAddReactions && (
|
|
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
|
|
{['👍', '❤️', '😂', '😮'].map(emoji => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => toggleReaction(emoji)}
|
|
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
<button
|
|
ref={reactionPickerBtnRef}
|
|
onClick={() => setShowReactionPicker((v) => !v)}
|
|
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
|
|
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
|
}`}
|
|
title="Add reaction"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => setReplyTo(message)}
|
|
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
|
title="Reply"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
|
</svg>
|
|
</button>
|
|
{isAuthor && (
|
|
<button
|
|
onClick={() => {
|
|
setEditContent(message.content ?? '');
|
|
setIsEditing(true);
|
|
}}
|
|
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
|
title="Edit"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{canDelete && (
|
|
<button
|
|
onClick={() => {
|
|
if (confirmingDelete) {
|
|
cancelDeleteConfirm();
|
|
deleteMessage(message.id, channelKey);
|
|
} else {
|
|
startDeleteConfirm();
|
|
}
|
|
}}
|
|
className={`px-2 h-full transition-all duration-150 flex items-center justify-center relative w-9 ${
|
|
confirmingDelete
|
|
? 'bg-green-500/20 text-green-400'
|
|
: 'text-txt-tertiary hover:text-txt-danger hover:bg-interactive-hover'
|
|
}`}
|
|
title={confirmingDelete ? 'Confirm delete' : 'Delete'}
|
|
>
|
|
{/* Trash icon */}
|
|
<svg
|
|
width="20" height="20" viewBox="0 0 24 24" fill="currentColor"
|
|
className={`absolute transition-all duration-150 ${
|
|
confirmingDelete ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
|
|
}`}
|
|
>
|
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
|
</svg>
|
|
{/* Checkmark icon */}
|
|
<svg
|
|
width="20" height="20" viewBox="0 0 24 24" fill="currentColor"
|
|
className={`absolute transition-all duration-150 ${
|
|
confirmingDelete ? 'scale-100 opacity-100' : 'scale-0 opacity-0'
|
|
}`}
|
|
>
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div data-context-menu onContextMenu={handleContextMenu}>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|