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:
@@ -143,6 +143,14 @@ function createTables(db: Database.Database): void {
|
||||
PRIMARY KEY (server_id, user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS read_states (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL,
|
||||
last_read_message_id TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -138,6 +138,15 @@ export const memberRoles = sqliteTable('member_roles', {
|
||||
pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }),
|
||||
}));
|
||||
|
||||
export const readStates = sqliteTable('read_states', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
channelId: text('channel_id').notNull(),
|
||||
lastReadMessageId: text('last_read_message_id').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.channelId] }),
|
||||
}));
|
||||
|
||||
export const serverFolders = sqliteTable('server_folders', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user