diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 4d3d0aa0..38389e87 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -1070,12 +1070,22 @@ function buildReadyPayload(userId: string): { : []; const dmUserMap = new Map(allDmUsers.map(u => [u.id, u])); - // Batch: last message per DM channel (1 query — fixes the full-table-scan bug) + // Batch: last message per DM channel. + // Use MAX(created_at) instead of MAX(id) because federated relay messages + // can have local snowflake IDs that don't match chronological order — a + // message sent earlier on the remote instance can arrive (and get a higher + // local ID) after a message sent later. The DM REST API already uses + // ORDER BY created_at DESC, so this keeps the ready payload consistent. const dmLastMsgIdRows = batchInArray( dmChannelIds, ids => db.select({ dmChannelId: schema.dmMessages.dmChannelId, - lastId: sql`max(${schema.dmMessages.id})`, + lastId: sql`( + SELECT id FROM ${schema.dmMessages} sub + WHERE sub.dm_channel_id = ${schema.dmMessages.dmChannelId} + ORDER BY sub.created_at DESC, sub.id DESC + LIMIT 1 + )`, }).from(schema.dmMessages).where(inArray(schema.dmMessages.dmChannelId, ids)).groupBy(schema.dmMessages.dmChannelId).all(), ); const dmLastMsgIds = dmLastMsgIdRows.map(r => r.lastId).filter((id): id is string => id != null); diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 3668f6d5..aafb8b77 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -625,8 +625,8 @@ function UserDiscoverCard({ username: string; } | null>(null); - const displayName = user.displayName ?? user.username; const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username; + const displayName = user.displayName ?? baseName; const gradient = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor); const originLabel = user._instanceOrigin ? (() => { try { return new URL(user._instanceOrigin).host; } catch { return user._instanceOrigin; } })() diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index deead698..04b2b846 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -623,13 +623,22 @@ export const useChatStore = create((set, get) => ({ const msgs = get().messages.get(channelId); if (!msgs || msgs.length === 0) return; - // Walk backward to find the last server-confirmed (non-temp) message + // Find the highest server-confirmed (non-temp) message ID. + // We use MAX(id) rather than "last in display order" because federated + // relay messages can have local snowflake IDs that don't match createdAt + // order — the ack must cover the highest ID to stay consistent with the + // server's read-state comparison (which uses BigInt ID comparison). let messageId: string | null = null; - for (let i = msgs.length - 1; i >= 0; i--) { - const msg = msgs[i]; + let maxId = 0n; + for (const msg of msgs) { if (msg && !msg.id.startsWith('temp_')) { - messageId = msg.id; - break; + try { + const id = BigInt(msg.id); + if (id > maxId) { + maxId = id; + messageId = msg.id; + } + } catch { /* non-numeric ID — skip */ } } } if (!messageId) return; // All messages are temp — nothing to ack yet