fix(federation): resolve persistent unread indicator on federated DMs
Federated relay messages can have local snowflake IDs that don't match chronological (createdAt) order — a message sent earlier on a remote instance can arrive later and get a higher local ID. This caused a permanent mismatch between the ready event's lastMessage (MAX id) and the acked message (last in createdAt display order), making federated DM channels appear unread after every server restart. - Server: change ready event DM lastMessage query from MAX(id) to ORDER BY created_at DESC (matching the DM REST API) - Frontend: change ackChannel to ack MAX(id) among loaded messages instead of last in display order (consistent with server comparison) - Fix federated username display fallback in UserDiscoverCard
This commit is contained in:
@@ -1070,12 +1070,22 @@ function buildReadyPayload(userId: string): {
|
|||||||
: [];
|
: [];
|
||||||
const dmUserMap = new Map(allDmUsers.map(u => [u.id, u]));
|
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(
|
const dmLastMsgIdRows = batchInArray(
|
||||||
dmChannelIds,
|
dmChannelIds,
|
||||||
ids => db.select({
|
ids => db.select({
|
||||||
dmChannelId: schema.dmMessages.dmChannelId,
|
dmChannelId: schema.dmMessages.dmChannelId,
|
||||||
lastId: sql<string>`max(${schema.dmMessages.id})`,
|
lastId: sql<string>`(
|
||||||
|
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(),
|
}).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);
|
const dmLastMsgIds = dmLastMsgIdRows.map(r => r.lastId).filter((id): id is string => id != null);
|
||||||
|
|||||||
@@ -625,8 +625,8 @@ function UserDiscoverCard({
|
|||||||
username: string;
|
username: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const displayName = user.displayName ?? user.username;
|
|
||||||
const baseName = user.username.includes('@') ? user.username.split('@')[0]! : 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 gradient = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
|
||||||
const originLabel = user._instanceOrigin
|
const originLabel = user._instanceOrigin
|
||||||
? (() => { try { return new URL(user._instanceOrigin).host; } catch { return user._instanceOrigin; } })()
|
? (() => { try { return new URL(user._instanceOrigin).host; } catch { return user._instanceOrigin; } })()
|
||||||
|
|||||||
@@ -623,13 +623,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const msgs = get().messages.get(channelId);
|
const msgs = get().messages.get(channelId);
|
||||||
if (!msgs || msgs.length === 0) return;
|
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;
|
let messageId: string | null = null;
|
||||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
let maxId = 0n;
|
||||||
const msg = msgs[i];
|
for (const msg of msgs) {
|
||||||
if (msg && !msg.id.startsWith('temp_')) {
|
if (msg && !msg.id.startsWith('temp_')) {
|
||||||
messageId = msg.id;
|
try {
|
||||||
break;
|
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
|
if (!messageId) return; // All messages are temp — nothing to ack yet
|
||||||
|
|||||||
Reference in New Issue
Block a user