feat(phase7): per-channel notification settings and read receipts

Phase 7.1 — Per-channel notification settings
- New notification_settings table (user_id, channel_id, level)
- GET/PUT/DELETE handlers under /channels/{channelID}/notifications
- Push dispatch and @mention loops filtered by notification level
- Frontend: bell icon per channel cycling all/mentions/none

Phase 7.4 — Read receipts
- New read_states table (user_id, channel_id, last_read_message_id)
- PUT /channels/{channelID}/read and GET /users/me/read-states
- Auto-mark-read when messages load in ChatArea
- Unread dot for channels never opened

Also: favicon/icon refresh in index.html
This commit is contained in:
2026-06-30 12:39:14 -04:00
parent ab3e6053c8
commit cc6ed741f0
15 changed files with 389 additions and 16 deletions
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand';
import { api } from '../lib/api';
type NotificationLevel = 'all' | 'mentions' | 'none';
interface NotificationSettingsState {
settings: Record<string, NotificationLevel>;
initialized: boolean;
loaded: boolean;
fetchSettings: () => Promise<void>;
setLevel: (channelId: string, level: NotificationLevel) => Promise<void>;
}
export const useNotificationSettingsStore = create<NotificationSettingsState>()((set) => ({
settings: {},
initialized: false,
loaded: false,
fetchSettings: async () => {
try {
const settings = await api.get<Record<string, NotificationLevel>>('/channels/me/notifications');
set({ settings, loaded: true, initialized: true });
} catch {
set({ loaded: true, initialized: true });
}
},
setLevel: async (channelId: string, level: NotificationLevel) => {
await api.put(`/channels/${channelId}/notifications`, { level });
set((state) => ({
settings: { ...state.settings, [channelId]: level },
}));
},
}));
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { api } from '../lib/api';
interface ReadStatesState {
states: Record<string, string>; // channel_id -> last_read_message_id
loaded: boolean;
fetchStates: () => Promise<void>;
markRead: (channelId: string, messageId: string) => Promise<void>;
hasUnread: (channelId: string) => boolean; // true if no read state (never viewed)
}
export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
states: {},
loaded: false,
fetchStates: async () => {
try {
const states = await api.get<Record<string, string>>('/users/me/read-states');
set({ states, loaded: true });
} catch {
set({ loaded: true });
}
},
markRead: async (channelId: string, messageId: string) => {
set((state) => ({
states: { ...state.states, [channelId]: messageId },
}));
try {
await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId });
} catch {
// optimistic update, ignore failure
}
},
hasUnread: (channelId: string): boolean => {
const state = get().states;
// No read state means the user has never viewed this channel
return !(channelId in state);
},
}));