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:
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
@@ -22,9 +22,11 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/notification"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/readstate"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice"
|
||||||
@@ -212,6 +214,16 @@ func main() {
|
|||||||
message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r)
|
message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-channel notification settings
|
||||||
|
r.Route("/channels/{channelID}/notifications", func(r chi.Router) {
|
||||||
|
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Read receipts
|
||||||
|
rsHandler := readstate.NewHandler(database.DB, logger)
|
||||||
|
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
||||||
|
r.Get("/users/me/read-states", rsHandler.GetAll)
|
||||||
|
|
||||||
// Giphy search
|
// Giphy search
|
||||||
if giphyClient != nil {
|
if giphyClient != nil {
|
||||||
r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -215,6 +215,23 @@ CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||||
|
|
||||||
|
-- Per-channel notification settings
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_settings (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
level VARCHAR(16) NOT NULL DEFAULT 'all',
|
||||||
|
PRIMARY KEY (user_id, channel_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Read receipts (per-user, per-channel)
|
||||||
|
CREATE TABLE IF NOT EXISTS read_states (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
last_read_message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, channel_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- Direct message conversations
|
-- Direct message conversations
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -76,10 +76,13 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isEveryone {
|
if isEveryone {
|
||||||
// Send to all server members
|
// Send to all server members except those who muted this channel
|
||||||
rows, err := m.db.QueryContext(ctx,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT user_id FROM members WHERE server_id = $1 AND user_id != $2`,
|
`SELECT m.user_id FROM members m
|
||||||
serverID, authorID,
|
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
||||||
|
WHERE m.server_id = $1 AND m.user_id != $2
|
||||||
|
AND (ns.level IS NULL OR ns.level != 'none')`,
|
||||||
|
serverID, authorID, channelID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
m.logger.Error("failed to query server members for @everyone", "error", err)
|
||||||
@@ -141,6 +144,16 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user muted this channel
|
||||||
|
var level string
|
||||||
|
err = m.db.QueryRowContext(ctx,
|
||||||
|
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
||||||
|
userID, channelID,
|
||||||
|
).Scan(&level)
|
||||||
|
if err == nil && level == "none" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
go m.push.SendPush(ctx, userID, payload)
|
go m.push.SendPush(ctx, userID, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *sql.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *sql.DB, logger *slog.Logger) *Handler {
|
||||||
|
return &Handler{db: db, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Put("/{channelID}/notifications", h.Set)
|
||||||
|
r.Get("/me/notifications", h.GetAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
type setNotificationRequest struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Set(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
|
||||||
|
var req setNotificationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Level != "all" && req.Level != "mentions" && req.Level != "none" {
|
||||||
|
http.Error(w, `{"error":"level must be all, mentions, or none"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Exec(`
|
||||||
|
INSERT INTO notification_settings (user_id, channel_id, level)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, channel_id) DO UPDATE SET level = $3
|
||||||
|
`, userID, channelID, req.Level)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to set notification level", "error", err, "user_id", userID, "channel_id", channelID)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "level": req.Level})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelSetting struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
rows, err := h.db.Query(`
|
||||||
|
SELECT channel_id, level FROM notification_settings WHERE user_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to query notification settings", "error", err)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
settings := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var channelID, level string
|
||||||
|
if err := rows.Scan(&channelID, &level); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings[channelID] = level
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(settings)
|
||||||
|
}
|
||||||
@@ -212,8 +212,10 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
|||||||
SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth
|
SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth
|
||||||
FROM push_subscriptions ps
|
FROM push_subscriptions ps
|
||||||
JOIN members m ON m.user_id = ps.user_id
|
JOIN members m ON m.user_id = ps.user_id
|
||||||
|
LEFT JOIN notification_settings ns ON ns.user_id = ps.user_id AND ns.channel_id = $3
|
||||||
WHERE m.server_id = $1 AND ps.user_id != $2
|
WHERE m.server_id = $1 AND ps.user_id != $2
|
||||||
`, serverID, authorID)
|
AND (ns.level IS NULL OR ns.level = 'all')
|
||||||
|
`, serverID, authorID, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("failed to query push subscriptions", "error", err)
|
h.logger.Error("failed to query push subscriptions", "error", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package readstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *sql.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *sql.DB, logger *slog.Logger) *Handler {
|
||||||
|
return &Handler{db: db, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Put("/{channelID}/read", h.MarkRead)
|
||||||
|
r.Get("/users/me/read-states", h.GetAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
type markReadRequest struct {
|
||||||
|
LastReadMessageID string `json:"last_read_message_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
|
||||||
|
var req markReadRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.LastReadMessageID == "" {
|
||||||
|
http.Error(w, `{"error":"last_read_message_id is required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Exec(`
|
||||||
|
INSERT INTO read_states (user_id, channel_id, last_read_message_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (user_id, channel_id) DO UPDATE SET
|
||||||
|
last_read_message_id = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
`, userID, channelID, req.LastReadMessageID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to mark read", "error", err)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelReadState is returned per channel
|
||||||
|
type ChannelReadState struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
LastReadMessageID string `json:"last_read_message_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
rows, err := h.db.Query(`
|
||||||
|
SELECT rs.channel_id, rs.last_read_message_id
|
||||||
|
FROM read_states rs
|
||||||
|
WHERE rs.user_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to query read states", "error", err)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
states := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var channelID, lastReadMessageID string
|
||||||
|
if err := rows.Scan(&channelID, &lastReadMessageID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
states[channelID] = lastReadMessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(states)
|
||||||
|
}
|
||||||
+2
-2
@@ -2,14 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#282828" />
|
<meta name="theme-color" content="#282828" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Dumpster" />
|
<meta name="apple-mobile-web-app-title" content="Dumpster" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
||||||
<title>Dumpster</title>
|
<title>Dumpster</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
@@ -5,6 +5,18 @@ import { VoiceChannel } from './VoiceChannel.tsx';
|
|||||||
import { CreateChannelModal } from './CreateChannelModal.tsx';
|
import { CreateChannelModal } from './CreateChannelModal.tsx';
|
||||||
import { InviteModal } from './InviteModal.tsx';
|
import { InviteModal } from './InviteModal.tsx';
|
||||||
import { ChannelSettingsModal } from './ChannelSettingsModal.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() {
|
export function ChannelList() {
|
||||||
const activeServerId = useServerStore((state) => state.activeServerId);
|
const activeServerId = useServerStore((state) => state.activeServerId);
|
||||||
@@ -17,12 +29,24 @@ export function ChannelList() {
|
|||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeServerId) {
|
if (activeServerId) {
|
||||||
fetchChannels(activeServerId);
|
fetchChannels(activeServerId);
|
||||||
}
|
}
|
||||||
}, [activeServerId, fetchChannels]);
|
}, [activeServerId, fetchChannels]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifSettings();
|
||||||
|
fetchReadStates();
|
||||||
|
}, [fetchNotifSettings, fetchReadStates]);
|
||||||
|
|
||||||
const channels = useMemo(() => {
|
const channels = useMemo(() => {
|
||||||
return activeServerId ? channelsByServer[activeServerId] || [] : [];
|
return activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
}, [activeServerId, channelsByServer]);
|
}, [activeServerId, channelsByServer]);
|
||||||
@@ -46,6 +70,28 @@ export function ChannelList() {
|
|||||||
return Array.from(map.entries());
|
return Array.from(map.entries());
|
||||||
}, [channels]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
<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">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
|
||||||
@@ -92,8 +138,21 @@ export function ChannelList() {
|
|||||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
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 className="truncate flex-1">{channel.name}</span>
|
||||||
|
{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
|
<span
|
||||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
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"
|
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||||
@@ -103,6 +162,7 @@ export function ChannelList() {
|
|||||||
>
|
>
|
||||||
[⚙]
|
[⚙]
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMemberStore } from "../stores/member.ts";
|
|||||||
import { useThreadStore } from "../stores/thread.ts";
|
import { useThreadStore } from "../stores/thread.ts";
|
||||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||||
import { MentionDropdown } from "./MentionDropdown";
|
import { MentionDropdown } from "./MentionDropdown";
|
||||||
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||||
import { MessageSearch } from "./MessageSearch";
|
import { MessageSearch } from "./MessageSearch";
|
||||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||||
import { ThreadPanel } from "./ThreadPanel.tsx";
|
import { ThreadPanel } from "./ThreadPanel.tsx";
|
||||||
@@ -140,6 +141,7 @@ export function ChatArea() {
|
|||||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
||||||
|
const markRead = useReadStatesStore((s) => s.markRead);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeChannelId) {
|
if (activeChannelId) {
|
||||||
@@ -148,6 +150,14 @@ export function ChatArea() {
|
|||||||
}
|
}
|
||||||
}, [activeChannelId, fetchMessages]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user