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
+69 -9
View File
@@ -5,6 +5,18 @@ import { VoiceChannel } from './VoiceChannel.tsx';
import { CreateChannelModal } from './CreateChannelModal.tsx';
import { InviteModal } from './InviteModal.tsx';
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
import { useReadStatesStore } from '../stores/readStates.ts';
type NotifLevel = 'all' | 'mentions' | 'none';
const NOTIF_LABELS: Record<NotifLevel, string> = {
all: '🔔 All',
mentions: '🔔 @',
none: '🔕 Muted',
};
const NOTIF_CYCLE: NotifLevel[] = ['all', 'mentions', 'none'];
export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId);
@@ -17,12 +29,24 @@ export function ChannelList() {
const [showInvite, setShowInvite] = useState(false);
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
const notifSettings = useNotificationSettingsStore((state) => state.settings);
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
const readStates = useReadStatesStore((state) => state.states);
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
useEffect(() => {
if (activeServerId) {
fetchChannels(activeServerId);
}
}, [activeServerId, fetchChannels]);
useEffect(() => {
fetchNotifSettings();
fetchReadStates();
}, [fetchNotifSettings, fetchReadStates]);
const channels = useMemo(() => {
return activeServerId ? channelsByServer[activeServerId] || [] : [];
}, [activeServerId, channelsByServer]);
@@ -46,6 +70,28 @@ export function ChannelList() {
return Array.from(map.entries());
}, [channels]);
const getLevel = (channelId: string): NotifLevel => {
return notifSettings[channelId] || 'all';
};
const handleNotifClick = (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
const current = getLevel(channelId);
const idx = NOTIF_CYCLE.indexOf(current);
const next = NOTIF_CYCLE[(idx + 1) % NOTIF_CYCLE.length];
setNotifLevel(channelId, next);
};
const notifIcon = (channelId: string) => {
const level = getLevel(channelId);
if (level === 'none') return '🔕';
return level === 'mentions' ? '🔔@' : '🔔';
};
const hasUnread = (channelId: string): boolean => {
return !(channelId in readStates);
};
return (
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
@@ -92,16 +138,30 @@ export function ChannelList() {
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
<span className="text-gb-fg-f">{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}</span>
<span className="text-gb-fg-f">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
</div>
+10
View File
@@ -6,6 +6,7 @@ import { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { MentionDropdown } from "./MentionDropdown";
import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.tsx";
@@ -140,6 +141,7 @@ export function ChatArea() {
const activeChannel = channels.find((c) => c.id === activeChannelId);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
const markRead = useReadStatesStore((s) => s.markRead);
useEffect(() => {
if (activeChannelId) {
@@ -148,6 +150,14 @@ export function ChatArea() {
}
}, [activeChannelId, fetchMessages]);
useEffect(() => {
if (!activeChannelId || messages.length === 0 || isLoading) return;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.id) {
markRead(activeChannelId, lastMsg.id);
}
}, [activeChannelId, messages.length, isLoading]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
+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);
},
}));