feat(web): MessageList interleaves pending bubbles by createdAtLocal
This commit is contained in:
@@ -6,6 +6,13 @@ import { useChatStore } from '../../stores/chatStore';
|
|||||||
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
|
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useSocialStore } from '../../stores/socialStore';
|
import { useSocialStore } from '../../stores/socialStore';
|
||||||
|
import {
|
||||||
|
usePendingMessageStore,
|
||||||
|
isPendingMessage,
|
||||||
|
type PendingMessageView,
|
||||||
|
type PendingAttachmentView,
|
||||||
|
type PendingBubble,
|
||||||
|
} from '../../stores/pendingMessageStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||||
@@ -14,6 +21,7 @@ import type { MessageWithUser, SpaceInviteSystemPayload } from '@backspace/share
|
|||||||
import { SpaceInviteCard } from './SpaceInviteCard';
|
import { SpaceInviteCard } from './SpaceInviteCard';
|
||||||
|
|
||||||
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
||||||
|
const EMPTY_PENDING_BUBBLES: PendingBubble[] = [];
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
@@ -171,6 +179,65 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY);
|
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(() => {
|
useEffect(() => {
|
||||||
if (canReadHistory) {
|
if (canReadHistory) {
|
||||||
loadMessages(channelId);
|
loadMessages(channelId);
|
||||||
@@ -545,11 +612,23 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
{!hasMore && <WelcomeHeader channelId={channelId} />}
|
{!hasMore && <WelcomeHeader channelId={channelId} />}
|
||||||
|
|
||||||
<div ref={contentRef} className="pt-4 pb-6 md:pb-20">
|
<div ref={contentRef} className="pt-4 pb-6 md:pb-20">
|
||||||
{messages.map((msg, i) => {
|
{interleavedMessages.map((msg, i) => {
|
||||||
const prevMsg = messages[i - 1];
|
const prevMsg = interleavedMessages[i - 1];
|
||||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||||
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(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 (
|
return (
|
||||||
<React.Fragment key={msg.id}>
|
<React.Fragment key={msg.id}>
|
||||||
{showDate && (
|
{showDate && (
|
||||||
@@ -568,7 +647,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
message={msg}
|
message={msg}
|
||||||
isCompact={!isFirstInGroup}
|
isCompact={!isFirstInGroup}
|
||||||
isFirstInGroup={isFirstInGroup}
|
isFirstInGroup={isFirstInGroup}
|
||||||
previousMessageId={messages[i - 1]?.id ?? null}
|
previousMessageId={realPrevId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
|
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
|
||||||
|
import type { MessageWithUser, Attachment } from '@backspace/shared';
|
||||||
import { useTransferStore } from './transferStore';
|
import { useTransferStore } from './transferStore';
|
||||||
|
|
||||||
export type PendingBubbleState = 'sending' | 'failed';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user