feat: unread indicators + DM bug fixes + data-driven isDmChannel

- Fix stale message cache: add force param to loadMessages, clearAllMessages action
- Fix reload race condition: URL-based isDmChannel fallback before WS ready
- Add read_states DB table for persistent unread tracking
- Add channel_ack WS event (client→server→echo) with BigInt comparison
- Wire up unread state in chatStore (readStates, unreadChannels, ackChannel)
- Auto-ack channels on MessageList view (200ms debounced)
- Unread pill indicators on server icons in ServerSidebar
- Bold text + white dot on unread channels/DMs in ChannelSidebar
- Replace all showDms reads with data-driven isDmChannel() across 8 files
- Design system, UI polish, and component fixes from previous sessions
This commit is contained in:
Jannis Braun
2026-02-18 20:48:48 +01:00
parent 7168149d98
commit 435d12e5b8
50 changed files with 711 additions and 285 deletions
+48
View File
@@ -141,6 +141,9 @@ export function handleClientEvent(
case 'reaction_remove':
handleReactionRemove(event, userId);
break;
case 'channel_ack':
handleChannelAck(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
@@ -677,3 +680,48 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
});
}
}
function handleChannelAck(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
const messageId = event.messageId as string;
if (!channelId || !messageId) return;
const db = getDb();
const existing = db.select()
.from(schema.readStates)
.where(and(
eq(schema.readStates.userId, userId),
eq(schema.readStates.channelId, channelId),
))
.get();
const now = Date.now();
if (existing) {
// Only update if the new messageId is newer (larger snowflake)
if (BigInt(messageId) > BigInt(existing.lastReadMessageId)) {
db.update(schema.readStates)
.set({ lastReadMessageId: messageId, updatedAt: now })
.where(and(
eq(schema.readStates.userId, userId),
eq(schema.readStates.channelId, channelId),
))
.run();
}
} else {
db.insert(schema.readStates).values({
userId,
channelId,
lastReadMessageId: messageId,
updatedAt: now,
}).run();
}
// Echo ack back to all of this user's connections (multi-tab sync)
connectionManager.sendToUser(userId, {
type: 'channel_ack',
channelId,
messageId,
});
}
+33 -11
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { getDb, schema } from '../db/index.js';
import { eq, inArray } from 'drizzle-orm';
import { eq, inArray, desc } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
import type {
User,
@@ -12,6 +12,7 @@ import type {
DmChannel,
ServerEvent,
ServerFolder,
ReadState,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -188,6 +189,7 @@ function buildReadyPayload(userId: string): {
dmChannels: DmChannel[];
folders: ServerFolder[];
voiceStates: Record<string, string[]>;
readStates: ReadState[];
} {
const db = getDb();
@@ -281,15 +283,24 @@ function buildReadyPayload(userId: string): {
ownerId: serverRow.ownerId,
inviteCode: serverRow.inviteCode,
createdAt: serverRow.createdAt,
channels: channels.map(ch => ({
id: ch.id,
serverId: ch.serverId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
createdAt: ch.createdAt,
})),
channels: channels.map(ch => {
const lastMsg = db.select({ id: schema.messages.id })
.from(schema.messages)
.where(eq(schema.messages.channelId, ch.id))
.orderBy(desc(schema.messages.createdAt))
.limit(1)
.get();
return {
id: ch.id,
serverId: ch.serverId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
createdAt: ch.createdAt,
lastMessageId: lastMsg?.id ?? null,
};
}),
members,
roles: roles.map(r => ({
id: r.id,
@@ -394,7 +405,18 @@ function buildReadyPayload(userId: string): {
}
}
return { user, servers, dmChannels, folders, voiceStates };
// Fetch read states for unread tracking
const readStateRows = db.select()
.from(schema.readStates)
.where(eq(schema.readStates.userId, userId))
.all();
const readStates: ReadState[] = readStateRows.map(rs => ({
channelId: rs.channelId,
lastReadMessageId: rs.lastReadMessageId,
}));
return { user, servers, dmChannels, folders, voiceStates, readStates };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {