feat(web): MessageList interleaves pending bubbles by createdAtLocal

This commit is contained in:
Jannis Braun
2026-05-02 16:28:21 +02:00
parent b97326ef18
commit 7b6586110d
2 changed files with 113 additions and 3 deletions
@@ -6,6 +6,13 @@ import { useChatStore } from '../../stores/chatStore';
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useSocialStore } from '../../stores/socialStore';
import {
usePendingMessageStore,
isPendingMessage,
type PendingMessageView,
type PendingAttachmentView,
type PendingBubble,
} from '../../stores/pendingMessageStore';
import { Avatar } from '../ui/Avatar';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { isSelf, parseFederatedUsername } from '../../utils/identity';
@@ -14,6 +21,7 @@ import type { MessageWithUser, SpaceInviteSystemPayload } from '@backspace/share
import { SpaceInviteCard } from './SpaceInviteCard';
const EMPTY_MESSAGES: MessageWithUser[] = [];
const EMPTY_PENDING_BUBBLES: PendingBubble[] = [];
interface MessageListProps {
channelId: string;
@@ -171,6 +179,65 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const isDm = isDmChannel(channelId);
const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY);
// Pending bubble interleaving — synthetic MessageWithUser-shaped objects
// representing optimistic sends. `Message.tsx` (Task 19) branches on the
// `__pending` sentinel to render upload progress instead of confirmed state.
// Note: per-byte transfer progress is intentionally NOT subscribed here.
// Each attachment carries only `__transferId`; Message.tsx subscribes to a
// single transfer in isolation so progress ticks don't re-render the list.
const pendingBubbles = usePendingMessageStore((s) => s.bubbles.get(channelId)) ?? EMPTY_PENDING_BUBBLES;
const currentUser = useAuthStore((s) => s.user);
// Map for O(1) replyTo lookup when synthesizing pending bubbles. Built once
// per `messages` change; per-bubble lookup is then constant-time.
const messagesById = useMemo(() => {
const m = new Map<string, MessageWithUser>();
for (const msg of messages) m.set(msg.id, msg);
return m;
}, [messages]);
const interleavedMessages: (MessageWithUser | PendingMessageView)[] = useMemo(() => {
if (!currentUser || pendingBubbles.length === 0) return messages;
// Synthesized DM messages keep the chatStore convention of channelId === ''
// (real DM messages have empty channelId — DM identity lives on dmChannelId).
const isDm = isDmChannel(channelId);
const synthChannelId = isDm ? '' : channelId;
const synthesized: PendingMessageView[] = pendingBubbles.map((b) => {
const synth: PendingMessageView = {
id: `pending-${b.clientId}`,
channelId: synthChannelId,
userId: currentUser.id,
content: b.content,
replyToId: b.replyToId,
type: 'user',
editedAt: null,
createdAt: b.createdAtLocal,
user: currentUser,
attachments: b.transferIds.map((tid): PendingAttachmentView => ({
id: `tx-${tid}`, // synthetic — no real attachmentId yet
messageId: '',
filename: '', // unknown until Message.tsx looks up the transfer
originalName: '',
mimetype: 'application/octet-stream',
size: 0,
thumbnailFilename: null,
width: null,
height: null,
duration: null,
createdAt: b.createdAtLocal,
__transferId: tid,
})),
embeds: [],
reactions: [],
replyTo: b.replyToId ? messagesById.get(b.replyToId) ?? null : null,
__pending: b,
...(isDm ? { dmChannelId: channelId } : {}),
};
return synth;
});
return [...messages, ...synthesized].sort((a, b) => a.createdAt - b.createdAt);
}, [messages, pendingBubbles, messagesById, channelId, currentUser]);
useEffect(() => {
if (canReadHistory) {
loadMessages(channelId);
@@ -545,11 +612,23 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
{!hasMore && <WelcomeHeader channelId={channelId} />}
<div ref={contentRef} className="pt-4 pb-6 md:pb-20">
{messages.map((msg, i) => {
const prevMsg = messages[i - 1];
{interleavedMessages.map((msg, i) => {
const prevMsg = interleavedMessages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
// Walk back to find the nearest non-pending neighbor for "Mark Unread".
// A `pending-${clientId}` ID would be rejected by the server, so we skip
// any pending entries when computing the previous-message reference.
let realPrevId: string | null = null;
for (let j = i - 1; j >= 0; j--) {
const candidate = interleavedMessages[j];
if (candidate && !isPendingMessage(candidate)) {
realPrevId = candidate.id;
break;
}
}
return (
<React.Fragment key={msg.id}>
{showDate && (
@@ -568,7 +647,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
message={msg}
isCompact={!isFirstInGroup}
isFirstInGroup={isFirstInGroup}
previousMessageId={messages[i - 1]?.id ?? null}
previousMessageId={realPrevId}
/>
)}
</React.Fragment>
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
import type { MessageWithUser, Attachment } from '@backspace/shared';
import { useTransferStore } from './transferStore';
export type PendingBubbleState = 'sending' | 'failed';
@@ -227,3 +228,33 @@ export const usePendingMessageStore = create<PendingMessageStore>()(
},
),
);
// ─── Synthesized view types for MessageList rendering ───────────────────────
// Pending bubbles are interleaved into the chat message list as synthetic
// MessageWithUser-shaped objects. The sentinel `__pending` field lets renderers
// branch on pending vs. server-confirmed state, and `__transferId` on each
// attachment lets renderers subscribe to a single transfer for live progress
// (per-byte updates don't re-render the whole list).
/** A synthesized "MessageWithUser" representing a pending optimistic bubble. */
export interface PendingMessageView extends Omit<MessageWithUser, 'attachments'> {
attachments: PendingAttachmentView[];
/** Set when the bubble is for a DM; mirrors chatStore optimistic-temp convention. */
dmChannelId?: string;
__pending: PendingBubble;
}
/** A synthesized Attachment for a PendingMessageView. The optional `__transferId`
* field references the in-flight upload; consumers (Message.tsx) subscribe to
* the transfer individually so per-byte progress doesn't re-render the list. */
export interface PendingAttachmentView extends Attachment {
__transferId?: string;
}
/** Type guard for PendingMessageView. */
export function isPendingMessage(
m: MessageWithUser | PendingMessageView,
): m is PendingMessageView {
const p = (m as PendingMessageView).__pending;
return typeof p === 'object' && p !== null && 'clientId' in p;
}