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:
@@ -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_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
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@@ -76,10 +76,13 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
}
|
||||
|
||||
if isEveryone {
|
||||
// Send to all server members
|
||||
// Send to all server members except those who muted this channel
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT user_id FROM members WHERE server_id = $1 AND user_id != $2`,
|
||||
serverID, authorID,
|
||||
`SELECT m.user_id FROM members m
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
FROM push_subscriptions ps
|
||||
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
|
||||
`, serverID, authorID)
|
||||
AND (ns.level IS NULL OR ns.level = 'all')
|
||||
`, serverID, authorID, channelID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to query push subscriptions", "error", err)
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user