sync: phase 1 backend + frontend from server

This commit is contained in:
2026-06-30 09:26:03 -04:00
parent d4cdd89544
commit 30e159cbdb
31 changed files with 4758 additions and 114 deletions
File diff suppressed because it is too large Load Diff
+18
View File
@@ -14,10 +14,12 @@ import (
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
@@ -139,6 +141,8 @@ func main() {
r.Post("/", serverHandler.Create)
r.Get("/", serverHandler.List)
modHandler := moderation.NewHandler(database.DB, hub)
r.Route("/{serverID}", func(r chi.Router) {
r.Get("/", serverHandler.Get)
r.Patch("/", serverHandler.Update)
@@ -147,6 +151,14 @@ func main() {
// Server members
memberHandler.RegisterRoutes(r)
// Moderation
r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", modHandler.Kick)
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", modHandler.Ban)
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans)
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", modHandler.Unban)
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", modHandler.Mute)
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", modHandler.Unmute)
// Channels (nested under servers)
r.Route("/channels", func(r chi.Router) {
channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r)
@@ -154,6 +166,12 @@ func main() {
})
})
// Direct messages
dmHandler := dm.NewHandler(database.DB, hub, logger)
r.Route("/conversations", func(r chi.Router) {
dmHandler.RegisterRoutes(r)
})
// Roles and member-role assignment
roleHandler := server.NewRoleHandler(database.DB)
roleHandler.RegisterRoleRoutes(r)
+28 -25
View File
@@ -37,13 +37,14 @@ type createChannelRequest struct {
}
type channelResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position int `json:"position"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position int `json:"position"`
SlowmodeSeconds int `json:"slowmode_seconds"`
CreatedAt string `json:"created_at"`
}
// isMember checks whether the given user is a member of the given server.
@@ -124,11 +125,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var ch channelResponse
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO channels (server_id, name, type, category, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, server_id, name, type, category, position, created_at
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds)
VALUES ($1, $2, $3, $4, $5, 0)
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
`, serverID, req.Name, channelType, category, position).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict)
@@ -175,7 +176,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
FROM channels
WHERE server_id = $1
ORDER BY position, name
@@ -189,7 +190,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
channels := make([]channelResponse, 0)
for rows.Next() {
var ch channelResponse
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt); err != nil {
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
@@ -226,9 +227,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
var ch channelResponse
err := h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at
FROM channels WHERE id = $1
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt)
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
@@ -253,10 +254,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
}
type updateChannelRequest struct {
Name *string `json:"name"`
Type *string `json:"type"`
Category *string `json:"category"`
Position *int `json:"position"`
Name *string `json:"name"`
Type *string `json:"type"`
Category *string `json:"category"`
Position *int `json:"position"`
SlowmodeSeconds *int `json:"slowmode_seconds"`
}
// @Summary Update a channel
@@ -287,7 +289,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil {
if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil {
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
return
}
@@ -330,11 +332,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
SET name = COALESCE($1, name),
type = COALESCE($2, type),
category = COALESCE($3, category),
position = COALESCE($4, position)
WHERE id = $5
RETURNING id, server_id, name, type, category, position, created_at
`, req.Name, req.Type, req.Category, req.Position, channelID).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
position = COALESCE($4, position),
slowmode_seconds = COALESCE($5, slowmode_seconds)
WHERE id = $6
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt,
)
if err != nil {
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
+85
View File
@@ -214,4 +214,89 @@ 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);
-- Direct message conversations
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(16) NOT NULL DEFAULT 'dm',
name VARCHAR(100),
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE IF NOT EXISTS conversation_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content VARCHAR(4000) NOT NULL,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC);
-- Moderation
CREATE TABLE IF NOT EXISTS bans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
banned_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, user_id)
);
CREATE TABLE IF NOT EXISTS server_mutes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id);
CREATE INDEX IF NOT EXISTS idx_server_mutes_server ON server_mutes(server_id);
-- Link embeds
CREATE TABLE IF NOT EXISTS embeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
description TEXT,
image_url TEXT,
site_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id);
-- Channel slowmode
ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0;
-- Message full-text search
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
CREATE OR REPLACE FUNCTION messages_search_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('english', COALESCE(NEW.content, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS messages_search_trigger ON messages;
CREATE TRIGGER messages_search_trigger
BEFORE INSERT OR UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION messages_search_update();
`
+426
View File
@@ -0,0 +1,426 @@
package dm
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strconv"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler handles direct-message conversations.
type Handler struct {
db *sql.DB
hub *gateway.Hub
logger *slog.Logger
}
// NewHandler creates a new DM handler.
func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler {
return &Handler{db: db, hub: hub, logger: logger}
}
// RegisterRoutes registers conversation routes.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
r.Route("/{conversationID}", func(r chi.Router) {
r.Get("/", h.Get)
r.Get("/messages", h.ListMessages)
r.Post("/messages", h.SendMessage)
})
}
type createConversationRequest struct {
UserIDs []string `json:"user_ids"`
}
type conversationResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Members []member `json:"members"`
CreatedAt string `json:"created_at"`
}
type member struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Avatar string `json:"avatar"`
}
// Create creates a new DM or group DM.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req createConversationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if len(req.UserIDs) == 0 {
http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest)
return
}
// Build unique member set including creator.
memberSet := map[string]struct{}{userID: {}}
for _, uid := range req.UserIDs {
if uid != "" {
memberSet[uid] = struct{}{}
}
}
if len(memberSet) == 1 {
http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest)
return
}
memberIDs := make([]string, 0, len(memberSet))
for uid := range memberSet {
memberIDs = append(memberIDs, uid)
}
sort.Strings(memberIDs)
// For a 1:1 DM, check if it already exists.
if len(memberIDs) == 2 {
var existingID string
err := h.db.QueryRowContext(r.Context(), `
SELECT c.id FROM conversations c
WHERE c.type = 'dm'
AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversation_id = c.id) = 2
AND NOT EXISTS (
SELECT 1 FROM conversation_members cm2
WHERE cm2.conversation_id = c.id AND cm2.user_id NOT IN ($1, $2)
)
LIMIT 1
`, memberIDs[0], memberIDs[1]).Scan(&existingID)
if err == nil {
h.getByID(w, r, existingID)
return
}
if !errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
}
convType := "dm"
if len(memberIDs) > 2 {
convType = "group_dm"
}
tx, err := h.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer tx.Rollback()
var convID string
var createdAt string
err = tx.QueryRowContext(r.Context(), `
INSERT INTO conversations (type, created_by)
VALUES ($1, $2)
RETURNING id, created_at::text
`, convType, userID).Scan(&convID, &createdAt)
if err != nil {
http.Error(w, `{"error":"failed to create conversation"}`, http.StatusInternalServerError)
return
}
for _, uid := range memberIDs {
_, err = tx.ExecContext(r.Context(), `
INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1, $2)
`, convID, uid)
if err != nil {
http.Error(w, `{"error":"failed to add member"}`, http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
resp, err := h.buildResponse(r.Context(), convID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
// Get returns a single conversation.
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
convID := chi.URLParam(r, "conversationID")
resp, err := h.buildResponse(r.Context(), convID)
if err != nil {
http.Error(w, `{"error":"conversation not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (h *Handler) getByID(w http.ResponseWriter, r *http.Request, convID string) {
resp, err := h.buildResponse(r.Context(), convID)
if err != nil {
http.Error(w, `{"error":"conversation not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// List returns the current user's conversations.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT c.id FROM conversations c
JOIN conversation_members cm ON cm.conversation_id = c.id
WHERE cm.user_id = $1
ORDER BY c.created_at DESC
`, userID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var conversations []conversationResponse
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
resp, err := h.buildResponse(r.Context(), id)
if err != nil {
continue
}
conversations = append(conversations, resp)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(conversations)
}
func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
var resp conversationResponse
err := h.db.QueryRowContext(ctx, `
SELECT id, type, name, created_at::text FROM conversations WHERE id = $1
`, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
if err != nil {
return resp, err
}
memberRows, err := h.db.QueryContext(ctx, `
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, '')
FROM conversation_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.conversation_id = $1
`, convID)
if err != nil {
return resp, err
}
defer memberRows.Close()
for memberRows.Next() {
var m member
if err := memberRows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar); err != nil {
continue
}
resp.Members = append(resp.Members, m)
}
return resp, nil
}
type messageResponse struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_username"`
DisplayName *string `json:"author_display_name"`
Content string `json:"content"`
EditedAt *string `json:"edited_at"`
CreatedAt string `json:"created_at"`
}
// ListMessages lists messages in a conversation.
func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
convID := chi.URLParam(r, "conversationID")
if !h.isMember(r.Context(), convID, userID) {
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
before := r.URL.Query().Get("before")
var rows *sql.Rows
var err error
if before != "" {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.conversation_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
FROM conversation_messages m
JOIN users u ON u.id = m.author_id
WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2)
ORDER BY m.created_at ASC
LIMIT $3
`, convID, before, limit)
} else {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.conversation_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
FROM conversation_messages m
JOIN users u ON u.id = m.author_id
WHERE m.conversation_id = $1
ORDER BY m.created_at ASC
LIMIT $2
`, convID, limit)
}
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
messages := make([]messageResponse, 0)
for rows.Next() {
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var displayName sql.NullString
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.AuthorID, &msg.AuthorName, &displayName, &msg.Content, &editedAt, &createdAt); err != nil {
continue
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
if displayName.Valid {
msg.DisplayName = &displayName.String
}
messages = append(messages, msg)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
// SendMessage sends a message to a conversation.
func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
convID := chi.URLParam(r, "conversationID")
if !h.isMember(r.Context(), convID, userID) {
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Content == "" || len(req.Content) > 4000 {
http.Error(w, `{"error":"invalid content"}`, http.StatusBadRequest)
return
}
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var displayName sql.NullString
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO conversation_messages (conversation_id, author_id, content)
VALUES ($1, $2, $3)
RETURNING id, conversation_id, author_id, content, edited_at::text, created_at::text
`, convID, userID, req.Content).Scan(&msg.ID, &msg.ConversationID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt)
if err != nil {
http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError)
return
}
err = h.db.QueryRowContext(r.Context(), `SELECT username, display_name FROM users WHERE id = $1`, userID).Scan(&msg.AuthorName, &displayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
if displayName.Valid {
msg.DisplayName = &displayName.String
}
if h.hub != nil {
h.hub.BroadcastToConversation(convID, gateway.Event{
Type: gateway.EventMessageCreate,
Data: msg,
})
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) isMember(ctx context.Context, convID, userID string) bool {
var exists bool
err := h.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2)`, convID, userID).Scan(&exists)
return err == nil && exists
}
// MemberIDs returns all user IDs in a conversation.
func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error) {
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM conversation_members WHERE conversation_id = $1`, convID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
ids = append(ids, id)
}
}
return ids, nil
}
+149
View File
@@ -0,0 +1,149 @@
package embed
import (
"context"
"database/sql"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
var urlRegex = regexp.MustCompile(`https?://[^\s<>"{}|\^\[\]]+`)
// Embed represents a stored link preview.
type Embed struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
SiteName string `json:"site_name"`
}
// ExtractURLs returns all HTTP/HTTPS URLs found in text.
func ExtractURLs(text string) []string {
matches := urlRegex.FindAllString(text, -1)
seen := make(map[string]struct{})
var out []string
for _, u := range matches {
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
if len(out) > 5 {
out = out[:5]
}
return out
}
// FetchEmbed retrieves OpenGraph meta tags for a URL.
func FetchEmbed(url string) *Embed {
client := &http.Client{Timeout: 2 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DumpsterBot/1.0)")
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return nil
}
html := string(body)
return &Embed{
URL: url,
Title: ogTag(html, "og:title"),
Description: ogTag(html, "og:description"),
ImageURL: ogTag(html, "og:image"),
SiteName: ogTag(html, "og:site_name"),
}
}
func ogTag(html, property string) string {
// Try property
re := regexp.MustCompile(`<meta[^>]+property=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`)
m := re.FindStringSubmatch(html)
if len(m) > 1 && m[1] != "" {
return strings.TrimSpace(m[1])
}
// Try name
re = regexp.MustCompile(`<meta[^>]+name=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']*)["']`)
m = re.FindStringSubmatch(html)
if len(m) > 1 && m[1] != "" {
return strings.TrimSpace(m[1])
}
// Fallback for title
if property == "og:title" {
re = regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
m = re.FindStringSubmatch(html)
if len(m) > 1 {
return strings.TrimSpace(m[1])
}
}
return ""
}
// FetchAndStore extracts URLs from content, fetches embeds, and stores them.
func FetchAndStore(db *sql.DB, messageID string, content string) {
urls := ExtractURLs(content)
if len(urls) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, url := range urls {
emb := FetchEmbed(url)
if emb == nil || (emb.Title == "" && emb.Description == "" && emb.ImageURL == "") {
continue
}
_, err := db.ExecContext(ctx, `
INSERT INTO embeds (message_id, url, title, description, image_url, site_name)
VALUES ($1, $2, $3, $4, $5, $6)
`, messageID, emb.URL, emb.Title, emb.Description, emb.ImageURL, emb.SiteName)
if err != nil {
fmt.Printf("failed to store embed: %v\n", err)
}
}
}
// LoadForMessages returns embeds grouped by message_id.
func LoadForMessages(ctx context.Context, db *sql.DB, messageIDs []string) (map[string][]Embed, error) {
result := make(map[string][]Embed)
if len(messageIDs) == 0 {
return result, nil
}
placeholders := make([]string, len(messageIDs))
args := make([]interface{}, len(messageIDs))
for i, id := range messageIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := "SELECT message_id, id, url, title, description, image_url, site_name FROM embeds WHERE message_id IN (" + strings.Join(placeholders, ",") + ")"
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var e Embed
var mid string
if err := rows.Scan(&mid, &e.ID, &e.URL, &e.Title, &e.Description, &e.ImageURL, &e.SiteName); err != nil {
continue
}
result[mid] = append(result[mid], e)
}
return result, nil
}
+40
View File
@@ -281,6 +281,46 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
}
}
// BroadcastToConversation sends an event to all clients who are members of a
// given direct-message conversation.
func (h *Hub) BroadcastToConversation(convID string, event Event) {
data, err := json.Marshal(event)
if err != nil {
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM conversation_members WHERE conversation_id = $1`, convID)
if err != nil {
h.logger.Error("failed to load conversation members", "conversation_id", convID, "error", err)
return
}
defer rows.Close()
members := make(map[string]struct{})
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err == nil {
members[uid] = struct{}{}
}
}
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
if _, ok := members[client.UserID]; ok {
select {
case client.send <- data:
default:
go func(c *Client) { h.unregister <- c }(client)
}
}
}
}
// ServerIDForChannel looks up the server_id that owns the given channel.
// Used by handlers to route events to the correct server scope.
func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) {
+198 -17
View File
@@ -8,8 +8,11 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
"github.com/go-chi/chi/v5"
@@ -42,19 +45,31 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Get("/search", h.Search)
r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete)
}
type embedResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
SiteName string `json:"site_name"`
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_username"`
DisplayName *string `json:"author_display_name"`
Content string `json:"content"`
EditedAt *string `json:"edited_at"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
ChannelID string `json:"channel_id"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_username"`
DisplayName *string `json:"author_display_name"`
Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"`
EditedAt *string `json:"edited_at"`
CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"`
}
// isMember checks whether the given user is a member of the given server.
@@ -105,6 +120,29 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
// Check server mute.
muted, err := moderation.IsMuted(r.Context(), h.db, serverID, userID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if muted {
http.Error(w, `{"error":"you are muted in this server"}`, http.StatusForbidden)
return
}
// Check slowmode.
if retryAfter, active := h.checkSlowmode(r.Context(), channelID, userID); active {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "slowmode",
"retry_after": retryAfter,
"retry_after_ms": retryAfter * 1000,
})
return
}
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
@@ -112,12 +150,12 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
if req.ReplyTo != nil {
replyTo = sql.NullString{String: *req.ReplyTo, Valid: true}
}
err := h.db.QueryRowContext(r.Context(), `
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO messages (channel_id, author_id, content, reply_to)
VALUES ($1, $2, $3, $4)
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
`, channelID, userID, req.Content, replyTo).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
@@ -133,6 +171,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
@@ -154,14 +195,43 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
if channelName == "" {
channelName = channelID
}
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
if h.pushHandler != nil {
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
}
}()
// Fetch and store link embeds asynchronously.
go embed.FetchAndStore(h.db, msg.ID, req.Content)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) checkSlowmode(ctx context.Context, channelID, userID string) (int, bool) {
var slowmode int
err := h.db.QueryRowContext(ctx, `SELECT slowmode_seconds FROM channels WHERE id = $1`, channelID).Scan(&slowmode)
if err != nil || slowmode <= 0 {
return 0, false
}
var elapsed int
err = h.db.QueryRowContext(ctx, `
SELECT COALESCE(EXTRACT(EPOCH FROM (NOW() - created_at))::int, 0)
FROM messages
WHERE channel_id = $1 AND author_id = $2
ORDER BY created_at DESC
LIMIT 1
`, channelID, userID).Scan(&elapsed)
if err != nil {
return 0, false
}
if elapsed < slowmode {
return slowmode - elapsed, true
}
return 0, false
}
type updateMessageRequest struct {
Content string `json:"content"`
}
@@ -205,12 +275,13 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err := h.db.QueryRowContext(r.Context(), `
UPDATE messages SET content = $1, edited_at = NOW()
WHERE id = $2 AND author_id = $3
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
`, req.Content, messageID, userID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -229,11 +300,17 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Re-fetch embeds after edit.
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
// Look up serverID for scoped broadcast
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
if serverID != "" {
@@ -243,6 +320,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
})
}
go embed.FetchAndStore(h.db, msg.ID, req.Content)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
@@ -327,7 +406,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var err error
if before != "" {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
@@ -336,7 +415,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
`, channelID, before, limit)
} else {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1
@@ -355,10 +434,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &editedAt, &createdAt)
var replyTo sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
if err != nil {
continue
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
@@ -366,12 +449,110 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
messages = append(messages, msg)
}
messages = h.attachEmbeds(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
_ = userID
}
// attachEmbeds loads embeds for the given messages and attaches them.
func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse) []messageResponse {
if len(messages) == 0 {
return messages
}
ids := make([]string, len(messages))
for i, m := range messages {
ids[i] = m.ID
}
embedMap, err := embed.LoadForMessages(ctx, h.db, ids)
if err != nil {
return messages
}
for i := range messages {
if embs, ok := embedMap[messages[i].ID]; ok {
out := make([]embedResponse, len(embs))
for j, e := range embs {
out[j] = embedResponse{
ID: e.ID,
URL: e.URL,
Title: e.Title,
Description: e.Description,
ImageURL: e.ImageURL,
SiteName: e.SiteName,
}
}
messages[i].Embeds = out
}
}
return messages
}
// Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
_, _, ok := h.requireChannelAccess(w, r, channelID)
if !ok {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
http.Error(w, `{"error":"missing q parameter"}`, http.StatusBadRequest)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 25
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC, m.created_at DESC
LIMIT $3
`, channelID, q, limit)
if err != nil {
http.Error(w, `{"error":"failed to search messages"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
messages := make([]messageResponse, 0)
for rows.Next() {
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
var rank float64
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
if err != nil {
continue
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
messages = append(messages, msg)
}
messages = h.attachEmbeds(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
// Returns (userID, serverID, ok).
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) {
+207
View File
@@ -0,0 +1,207 @@
package moderation
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler handles server moderation actions.
type Handler struct {
db *sql.DB
hub *gateway.Hub
}
// NewHandler creates a new moderation handler.
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
return &Handler{db: db, hub: hub}
}
type banRequest struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
}
type muteRequest struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
Duration string `json:"duration"` // e.g. "15m", "1h", "1d", empty = permanent
}
// banResponse is the JSON shape returned when banning a user.
type banResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
UserID string `json:"user_id"`
BannedBy string `json:"banned_by"`
Reason string `json:"reason"`
CreatedAt string `json:"created_at"`
}
// Ban bans a user from a server.
func (h *Handler) Ban(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req banRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
// Remove member if present.
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, req.UserID)
var ban banResponse
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO bans (server_id, user_id, banned_by, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, server_id, user_id, banned_by, reason, created_at::text
`, serverID, req.UserID, userID, req.Reason).Scan(&ban.ID, &ban.ServerID, &ban.UserID, &ban.BannedBy, &ban.Reason, &ban.CreatedAt)
if err != nil {
http.Error(w, `{"error":"failed to ban user"}`, http.StatusInternalServerError)
return
}
if h.hub != nil {
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMemberRemove,
Data: map[string]string{"server_id": serverID, "user_id": req.UserID},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ban)
}
// Unban removes a ban.
func (h *Handler) Unban(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
targetID := chi.URLParam(r, "userID")
_, err := h.db.ExecContext(r.Context(), `DELETE FROM bans WHERE server_id = $1 AND user_id = $2`, serverID, targetID)
if err != nil {
http.Error(w, `{"error":"failed to unban user"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListBans lists banned users.
func (h *Handler) ListBans(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
rows, err := h.db.QueryContext(r.Context(), `
SELECT b.id, b.server_id, b.user_id, b.banned_by, b.reason, b.created_at::text, u.username
FROM bans b
JOIN users u ON u.id = b.user_id
WHERE b.server_id = $1
ORDER BY b.created_at DESC
`, serverID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
type item struct {
banResponse
Username string `json:"username"`
}
var bans []item
for rows.Next() {
var b item
if err := rows.Scan(&b.ID, &b.ServerID, &b.UserID, &b.BannedBy, &b.Reason, &b.CreatedAt, &b.Username); err != nil {
continue
}
bans = append(bans, b)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bans)
}
// Kick removes a member from a server.
func (h *Handler) Kick(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
targetID := chi.URLParam(r, "userID")
_, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, targetID)
if err != nil {
http.Error(w, `{"error":"failed to kick user"}`, http.StatusInternalServerError)
return
}
if h.hub != nil {
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMemberRemove,
Data: map[string]string{"server_id": serverID, "user_id": targetID},
})
}
w.WriteHeader(http.StatusNoContent)
}
// Mute creates a server mute.
func (h *Handler) Mute(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req muteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var expiresAt sql.NullTime
if req.Duration != "" {
d, err := time.ParseDuration(req.Duration)
if err != nil {
http.Error(w, `{"error":"invalid duration"}`, http.StatusBadRequest)
return
}
expiresAt = sql.NullTime{Time: time.Now().Add(d), Valid: true}
}
_, err := h.db.ExecContext(r.Context(), `
INSERT INTO server_mutes (server_id, user_id, muted_by, reason, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (server_id, user_id) DO UPDATE SET muted_by = EXCLUDED.muted_by, reason = EXCLUDED.reason, expires_at = EXCLUDED.expires_at, created_at = NOW()
`, serverID, req.UserID, userID, req.Reason, expiresAt)
if err != nil {
http.Error(w, `{"error":"failed to mute user"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Unmute removes a server mute.
func (h *Handler) Unmute(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
targetID := chi.URLParam(r, "userID")
_, err := h.db.ExecContext(r.Context(), `DELETE FROM server_mutes WHERE server_id = $1 AND user_id = $2`, serverID, targetID)
if err != nil {
http.Error(w, `{"error":"failed to unmute user"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// IsMuted checks whether a user is currently muted in a server.
func IsMuted(ctx context.Context, db *sql.DB, serverID, userID string) (bool, error) {
var exists bool
err := db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM server_mutes WHERE server_id = $1 AND user_id = $2 AND (expires_at IS NULL OR expires_at > NOW()))
`, serverID, userID).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return exists, err
}
+24 -13
View File
@@ -2,22 +2,33 @@ package permissions
// Permission bitflags.
const (
VIEW_CHANNEL int64 = 1 << 0
SEND_MESSAGES int64 = 1 << 1
MANAGE_MESSAGES int64 = 1 << 2
KICK_MEMBERS int64 = 1 << 3
BAN_MEMBERS int64 = 1 << 4
MANAGE_SERVER int64 = 1 << 5
MANAGE_CHANNELS int64 = 1 << 6
ADMINISTRATOR int64 = 1 << 7
CONNECT_VOICE int64 = 1 << 8
SPEAK_VOICE int64 = 1 << 9
SHARE_SCREEN int64 = 1 << 10
VIEW_CHANNEL int64 = 1 << 0
SEND_MESSAGES int64 = 1 << 1
MANAGE_MESSAGES int64 = 1 << 2
KICK_MEMBERS int64 = 1 << 3
BAN_MEMBERS int64 = 1 << 4
MANAGE_SERVER int64 = 1 << 5
MANAGE_CHANNELS int64 = 1 << 6
ADMINISTRATOR int64 = 1 << 7
CONNECT_VOICE int64 = 1 << 8
SPEAK_VOICE int64 = 1 << 9
SHARE_SCREEN int64 = 1 << 10
MUTE_MEMBERS int64 = 1 << 11
CREATE_INSTANT_INVITE int64 = 1 << 12
CHANGE_NICKNAME int64 = 1 << 13
MANAGE_NICKNAMES int64 = 1 << 14
MANAGE_ROLES int64 = 1 << 15
MANAGE_WEBHOOKS int64 = 1 << 16
EMBED_LINKS int64 = 1 << 17
ATTACH_FILES int64 = 1 << 18
ADD_REACTIONS int64 = 1 << 19
USE_EXTERNAL_EMOJIS int64 = 1 << 20
MENTION_EVERYONE int64 = 1 << 21
)
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
// Bits 0,1,8,9,10 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN = 1539.
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN
// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19.
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS
// Has reports whether the permission set contains the given permission bits.
func Has(permissions int64, perm int64) bool {
+1469 -6
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,7 +14,9 @@
"livekit-client": "^2.20.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.23.1",
"remark-gfm": "^4.0.1",
"zustand": "^4.5.2"
},
"devDependencies": {
+3
View File
@@ -7,6 +7,7 @@ import { BotManager } from './components/BotManager.tsx';
import { CommandManager } from './components/CommandManager.tsx';
import { RoleManager } from './components/RoleManager.tsx';
import { JoinServer } from './components/JoinServer.tsx';
import { DMChat } from './components/DMChat.tsx';
import { useAuthStore } from './stores/auth.ts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -109,6 +110,8 @@ function App() {
>
<Route index element={<ChatArea />} />
<Route path="channels/:channelId" element={<ChatArea />} />
<Route path="dm/:conversationId" element={<DMChat />} />
<Route path="dm" element={<DMChat />} />
</Route>
</Routes>
</BrowserRouter>
+121 -21
View File
@@ -5,6 +5,9 @@ import { useServerStore } from "../stores/server.ts";
import { useMemberStore } from "../stores/member.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { MentionDropdown } from "./MentionDropdown";
import { MessageSearch } from "./MessageSearch";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function formatTime(iso: string): string {
const date = new Date(iso);
@@ -15,39 +18,85 @@ function formatTime(iso: string): string {
function renderContent(content: string, memberUsernames: Set<string>) {
// Split on @username, preserving mentions.
const parts: (string | { mention: string })[] = [];
// Split on @username, preserving mentions; render plain text segments as markdown.
const segments: { type: "text" | "mention"; value: string }[] = [];
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(content)) !== null) {
if (match.index > last) {
parts.push(content.slice(last, match.index));
segments.push({ type: "text", value: content.slice(last, match.index) });
}
const username = match[1];
if (memberUsernames.has(username)) {
parts.push({ mention: username });
segments.push({ type: "mention", value: username });
} else {
parts.push(match[0]);
segments.push({ type: "text", value: match[0] });
}
last = mentionRe.lastIndex;
}
if (last < content.length) {
parts.push(content.slice(last));
segments.push({ type: "text", value: content.slice(last) });
}
return parts.map((part, idx) => {
if (typeof part === "string") {
return <span key={idx}>{part}</span>;
return segments.map((seg, idx) => {
if (seg.type === "mention") {
return (
<span key={idx} className="text-gb-aqua">
@{seg.value}
</span>
);
}
return (
<span key={idx} className="text-gb-aqua">
@{part.mention}
</span>
<ReactMarkdown
key={idx}
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
p: ({ ...props }) => <span {...props} className="inline" />,
}}
>
{seg.value}
</ReactMarkdown>
);
});
}
function renderEmbeds(embeds?: { url: string; title?: string; description?: string; image_url?: string; site_name?: string }[]) {
if (!embeds || embeds.length === 0) return null;
return (
<div className="mt-1 space-y-1">
{embeds.map((embed) => (
<a
key={embed.url}
href={embed.url}
target="_blank"
rel="noreferrer"
className="block bg-gb-bg-s border border-gb-bg-t p-2 hover:border-gb-fg-t transition-colors"
>
{embed.site_name && <div className="text-gb-fg-f text-[10px] uppercase">{embed.site_name}</div>}
{embed.title && <div className="text-gb-fg text-xs font-bold truncate">{embed.title}</div>}
{embed.description && <div className="text-gb-fg-s text-xs line-clamp-2">{embed.description}</div>}
{embed.image_url && (
<img
src={embed.image_url}
alt=""
className="mt-1 max-h-24 object-cover border border-gb-bg-t"
loading="lazy"
/>
)}
</a>
))}
</div>
);
}
export function ChatArea() {
const activeChannelId = useChannelStore((s) => s.activeChannelId);
const channelsByServer = useChannelStore((s) => s.channelsByServer);
@@ -62,7 +111,9 @@ export function ChatArea() {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -87,6 +138,20 @@ export function ChatArea() {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
useEffect(() => {
if (slowmodeRemaining <= 0) return;
const t = setInterval(() => {
setSlowmodeRemaining((r) => {
if (r <= 1) {
clearInterval(t);
return 0;
}
return r - 1;
});
}, 1000);
return () => clearInterval(t);
}, [slowmodeRemaining]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
@@ -132,14 +197,25 @@ export function ChatArea() {
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeChannelId || !input.trim()) return;
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
await sendMessage(activeChannelId, input.trim());
setInput("");
setMentionQuery(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
const msg = err instanceof Error ? err.message : "Failed to send";
// Parse slowmode error from API.
try {
const parsed = JSON.parse(msg);
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
setSlowmodeRemaining(parsed.retry_after);
return;
}
} catch {
// not json
}
setError(msg);
}
};
@@ -156,13 +232,31 @@ export function ChatArea() {
return (
<div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
{activeChannel
? `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
<span>
{activeChannel
? `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
</span>
{activeChannelId && (
<button
onClick={() => setShowSearch((prev) => !prev)}
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
title="Search messages"
>
[SEARCH]
</button>
)}
</div>
{showSearch && activeChannelId && activeChannel && (
<MessageSearch
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowSearch(false)}
/>
)}
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
@@ -179,6 +273,7 @@ export function ChatArea() {
<span className="text-gb-fg">
{renderContent(message.content, memberUsernames)}
</span>
{renderEmbeds(message.embeds)}
</div>
))}
<div ref={bottomRef} />
@@ -196,6 +291,11 @@ export function ChatArea() {
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
@@ -206,7 +306,7 @@ export function ChatArea() {
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId}
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { useConversationStore } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
import { NewConversationModal } from "./NewConversationModal.tsx";
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
const other = conv.members.find((m) => m.id !== currentUserId);
return other?.username || "unknown";
}
export function ConversationList() {
const conversations = useConversationStore((s) => s.conversations);
const activeId = useConversationStore((s) => s.activeConversationId);
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const setActive = useConversationStore((s) => s.setActiveConversation);
const currentUser = useAuthStore((s) => s.user);
const [showNew, setShowNew] = useState(false);
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
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">
<span>[DIRECT MESSAGES]</span>
<button
onClick={() => setShowNew(true)}
className="terminal-button text-xs"
title="New conversation"
>
[+]
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{conversations.length === 0 && (
<p className="text-gb-fg-f">[no conversations]</p>
)}
{conversations.map((conv) => {
const name =
conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || "");
return (
<button
key={conv.id}
onClick={() => setActive(conv.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
conv.id === activeId
? "terminal-active"
: "hover:bg-gb-bg-t text-gb-fg-s"
}`}
>
<span className="text-gb-fg-f">@</span>
<span className="truncate">{name}</span>
</button>
);
})}
</div>
{showNew && <NewConversationModal onClose={() => setShowNew(false)} />}
</div>
);
}
+25 -1
View File
@@ -16,10 +16,21 @@ interface ChannelApiResponse {
position: number;
}
const SLOWMODE_OPTIONS = [
{ label: 'Off', value: 0 },
{ label: '5 seconds', value: 5 },
{ label: '10 seconds', value: 10 },
{ label: '30 seconds', value: 30 },
{ label: '1 minute', value: 60 },
{ label: '2 minutes', value: 120 },
{ label: '5 minutes', value: 300 },
];
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice'>('text');
const [category, setCategory] = useState('general');
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -41,10 +52,11 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
setLoading(true);
setError(null);
try {
const result = await api.post<ChannelApiResponse>(`/servers/${serverId}/channels`, {
const result = await api.post<ChannelApiResponse>("/servers/" + serverId + "/channels", {
name: name.trim(),
type,
category: category.trim() || 'general',
slowmode_seconds: slowmodeSeconds,
});
const newChannel = {
id: result.id,
@@ -110,6 +122,18 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
className="terminal-input w-full"
/>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">SLOWMODE:</label>
<select
value={slowmodeSeconds}
onChange={(e) => setSlowmodeSeconds(parseInt(e.target.value, 10))}
className="terminal-input w-full"
>
{SLOWMODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{error && <div className="text-xs text-gb-red">{error}</div>}
<button
type="submit"
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function formatTime(iso: string): string {
const date = new Date(iso);
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function renderDMContent(content: string) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
p: ({ ...props }) => <span {...props} className="inline" />,
}}
>
{content}
</ReactMarkdown>
);
}
export function DMChat() {
const { conversationId } = useParams<{ conversationId: string }>();
const activeId = useConversationStore((s) => s.activeConversationId);
const setActive = useConversationStore((s) => s.setActiveConversation);
const conversations = useConversationStore((s) => s.conversations);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const fetchMessages = useConversationStore((s) => s.fetchMessages);
const sendMessage = useConversationStore((s) => s.sendMessage);
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const currentUser = useAuthStore((s) => s.user);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const id = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id);
const messages = id ? messagesByConv[id] || [] : [];
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
useEffect(() => {
if (id) {
setActive(id);
fetchMessages(id);
setError(null);
}
}, [id, setActive, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id || !input.trim()) return;
setError(null);
try {
await sendMessage(id, input.trim());
setInput("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
const title = conversation
? conversation.type === "group_dm"
? conversation.name || conversation.members.map((m) => m.username).join(", ")
: conversation.members.find((m) => m.id !== currentUser?.id)?.username || "DM"
: id
? "[LOADING...]"
: "[NO CONVERSATION]";
return (
<div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
@ {title}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
{messages.map((msg: ConversationMessage) => (
<div key={msg.id} className="break-words">
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</div>
))}
<div ref={bottomRef} />
</div>
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!id}
/>
</form>
</div>
);
}
+33 -13
View File
@@ -2,13 +2,13 @@ import { useEffect, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
import { useWebSocketStore } from '../stores/ws.ts';
import { useServerStore } from '../stores/server.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx';
import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string {
switch (status) {
case 'online':
@@ -21,11 +21,9 @@ function statusColor(status: UserStatus): string {
return 'bg-gb-gray';
}
}
function statusLabel(status: UserStatus): string {
return status.toUpperCase();
}
export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
@@ -35,22 +33,21 @@ export function Layout() {
const updateProfile = useAuthStore((state) => state.updateProfile);
const wsConnect = useWebSocketStore((s) => s.connect);
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
const setActiveServer = useServerStore((s) => s.setActiveServer);
const navigate = useNavigate();
const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [dmMode, setDmMode] = useState(false);
useEffect(() => {
fetchMe();
wsConnect();
return () => { wsDisconnect(); };
}, [fetchMe, wsConnect, wsDisconnect]);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
}
}, [isLoading, isAuthenticated, location.pathname, navigate]);
const handleStatusChange = async (status: UserStatus) => {
setShowStatusMenu(false);
try {
@@ -59,9 +56,7 @@ export function Layout() {
// Error is surfaced via auth store; menu closes optimistically.
}
};
const currentStatus = user?.status ?? 'offline';
if (isLoading) {
return (
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
@@ -69,11 +64,9 @@ export function Layout() {
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2">
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
@@ -114,8 +107,35 @@ export function Layout() {
</div>
</div>
<div className="flex-1 flex min-h-0">
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
<button
onClick={() => setDmMode(false)}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
!dmMode
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
}`}
title="Servers"
>
[S]
</button>
<button
onClick={() => {
setDmMode(true);
setActiveServer(null);
}}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
dmMode
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
}`}
title="Direct Messages"
>
[@]
</button>
</div>
<ServerBar />
<ChannelList />
{dmMode ? <ConversationList /> : <ChannelList />}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<VoicePanel />
@@ -124,7 +144,7 @@ export function Layout() {
</div>
</div>
</div>
<MemberList />
{!dmMode && <MemberList />}
</div>
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
<span>TERM v1.0</span>
+138
View File
@@ -0,0 +1,138 @@
import { useState } from "react";
interface MemberContextMenuProps {
memberId: string;
username: string;
canKick: boolean;
canBan: boolean;
canMute: boolean;
onMessage?: () => void;
onKick?: (reason: string) => void;
onBan?: (reason: string) => void;
onMute?: (duration: string, reason: string) => void;
onClose: () => void;
}
export function MemberContextMenu({
username,
canKick,
canBan,
canMute,
onMessage,
onKick,
onBan,
onMute,
onClose,
}: MemberContextMenuProps) {
const [mode, setMode] = useState<"menu" | "kick" | "ban" | "mute">("menu");
const [reason, setReason] = useState("");
const [duration, setDuration] = useState("1h");
if (mode === "kick") {
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
<div className="text-gb-orange mb-2">KICK {username.toUpperCase()}</div>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (optional)"
className="terminal-input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={() => onKick?.(reason)} className="flex-1 bg-gb-red text-gb-bg py-1 hover:bg-gb-orange">
[KICK]
</button>
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
[BACK]
</button>
</div>
</div>
);
}
if (mode === "ban") {
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
<div className="text-gb-orange mb-2">BAN {username.toUpperCase()}</div>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (optional)"
className="terminal-input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={() => onBan?.(reason)} className="flex-1 bg-gb-red text-gb-bg py-1 hover:bg-gb-orange">
[BAN]
</button>
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
[BACK]
</button>
</div>
</div>
);
}
if (mode === "mute") {
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
<div className="text-gb-orange mb-2">MUTE {username.toUpperCase()}</div>
<select
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full mb-2 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
>
<option value="15m">15 minutes</option>
<option value="1h">1 hour</option>
<option value="6h">6 hours</option>
<option value="1d">1 day</option>
<option value="7d">7 days</option>
<option value="">permanent</option>
</select>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (optional)"
className="terminal-input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={() => onMute?.(duration, reason)} className="flex-1 bg-gb-yellow text-gb-bg py-1 hover:bg-gb-orange">
[MUTE]
</button>
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
[BACK]
</button>
</div>
</div>
);
}
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t py-1 font-mono text-xs shadow-lg min-w-[140px]">
<button onClick={onMessage} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-fg">
Message
</button>
{canKick && (
<button onClick={() => setMode("kick")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-yellow">
Kick
</button>
)}
{canMute && (
<button onClick={() => setMode("mute")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-orange">
Mute
</button>
)}
{canBan && (
<button onClick={() => setMode("ban")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-red">
Ban
</button>
)}
<div className="border-t border-gb-bg-t my-1" />
<button onClick={onClose} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-fg-f">
Cancel
</button>
</div>
);
}
+59 -6
View File
@@ -1,8 +1,11 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useServerStore } from "../stores/server.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useMemberStore, type Member } from "../stores/member.ts";
import { usePresenceStore } from "../stores/presence.ts";
import type { UserStatus } from "../stores/auth.ts";
import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts";
function statusIcon(status: UserStatus): string {
switch (status) {
@@ -46,13 +49,63 @@ function usernameColor(status: UserStatus): string {
function MemberRow({ member }: { member: Member }) {
const presence = usePresenceStore((s) => s.presences[member.id]);
const status = presence?.status ?? member.status;
const [menuOpen, setMenuOpen] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const userPerms = useServerStore((s) => s.userPermissions);
const activeServerId = useServerStore((s) => s.activeServerId);
const createConversation = useConversationStore((s) => s.createConversation);
const canKick = userPerms?.kick_members ?? false;
const canBan = userPerms?.ban_members ?? false;
const canMute = userPerms?.mute_members ?? false;
const isSelf = currentUser?.id === member.id;
return (
<div className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}>
{member.display_name || member.username}
</span>
<div className="relative">
<button
onClick={() => setMenuOpen((v) => !v)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t"
>
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}>
{member.display_name || member.username}
</span>
<span className="ml-auto text-gb-fg-f text-xs">[...]</span>
</button>
{menuOpen && !isSelf && activeServerId && (
<MemberContextMenu
memberId={member.id}
username={member.username}
canKick={canKick}
canBan={canBan}
canMute={canMute}
onMessage={() => {
createConversation([member.id]);
setMenuOpen(false);
}}
onKick={(_reason) => {
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
.then(() => setMenuOpen(false));
}}
onBan={(reason) => {
fetch(`/api/v1/servers/${activeServerId}/bans`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: member.id, reason }),
}).then(() => setMenuOpen(false));
}}
onMute={(duration, reason) => {
fetch(`/api/v1/servers/${activeServerId}/mutes`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: member.id, duration, reason }),
}).then(() => setMenuOpen(false));
}}
onClose={() => setMenuOpen(false)}
/>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState, useRef, useEffect } from "react";
import { useMessageStore } from "../stores/message.ts";
interface MessageSearchProps {
channelId: string;
channelName: string;
onClose: () => void;
onJump?: (messageId: string) => void;
}
export function MessageSearch({ channelId, channelName, onClose, onJump }: MessageSearchProps) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const results = useMessageStore((s) => s.searchResultsByChannel[channelId] || []);
const searchMessages = useMessageStore((s) => s.searchMessages);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const t = setTimeout(() => {
if (query.trim()) {
searchMessages(channelId, query.trim());
setSelectedIndex(0);
}
}, 200);
return () => clearTimeout(t);
}, [query, channelId, searchMessages]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => (i + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => (i - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
const msg = results[selectedIndex];
if (msg && onJump) {
onJump(msg.id);
}
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [results, selectedIndex, onClose, onJump]);
return (
<div className="absolute inset-x-0 top-10 z-20 bg-gb-bg border border-gb-bg-t shadow-lg mx-3">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between">
<span className="text-xs text-gb-orange font-mono">SEARCH #{channelName.toUpperCase()}</span>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
</div>
<div className="p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="search messages..."
className="terminal-input w-full text-sm"
/>
</div>
<div className="max-h-64 overflow-y-auto font-mono text-xs">
{results.length === 0 && query.trim() && (
<div className="px-3 py-2 text-gb-fg-f">[no results]</div>
)}
{results.map((msg, idx) => (
<button
key={msg.id}
onClick={() => {
onJump?.(msg.id);
onClose();
}}
className={`w-full text-left px-3 py-2 border-b border-gb-bg-t last:border-b-0 ${
idx === selectedIndex ? "bg-gb-bg-s" : "hover:bg-gb-bg-s/50"
}`}
>
<div className="flex items-center gap-2 text-gb-fg-f">
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>
<span className="text-gb-fg-s">{new Date(msg.created_at).toLocaleString()}</span>
</div>
<div className="text-gb-fg truncate">{msg.content}</div>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { useState, useMemo } from "react";
import { useConversationStore } from "../stores/conversation.ts";
import { useMemberStore } from "../stores/member.ts";
import { useServerStore } from "../stores/server.ts";
import { useAuthStore } from "../stores/auth.ts";
interface NewConversationModalProps {
onClose: () => void;
}
export function NewConversationModal({ onClose }: NewConversationModalProps) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const createConversation = useConversationStore((s) => s.createConversation);
const activeServerId = useServerStore((s) => s.activeServerId);
const membersByServer = useMemberStore((s) => s.membersByServer);
const currentUserId = useAuthStore((s) => s.user?.id);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const filtered = useMemo(() => {
const q = query.toLowerCase();
return members.filter(
(m) =>
m.id !== currentUserId &&
(m.username.toLowerCase().includes(q) ||
(m.display_name || "").toLowerCase().includes(q)),
);
}, [members, query, currentUserId]);
const toggle = (id: string) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleCreate = async () => {
if (selected.length === 0) return;
await createConversation(selected);
onClose();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[300px] font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gb-orange">NEW DIRECT MESSAGE</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="search members..."
className="terminal-input w-full mb-3 text-sm"
/>
<div className="max-h-48 overflow-y-auto space-y-1 mb-3">
{filtered.map((m) => (
<button
key={m.id}
onClick={() => toggle(m.id)}
className={`w-full text-left px-2 py-1 text-sm ${
selected.includes(m.id)
? "bg-gb-aqua text-gb-bg"
: "hover:bg-gb-bg-t text-gb-fg-s"
}`}
>
{m.username}
</button>
))}
{filtered.length === 0 && (
<p className="text-gb-fg-f text-xs">[no members found]</p>
)}
</div>
<button
onClick={handleCreate}
disabled={selected.length === 0}
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
[START CONVERSATION]
</button>
</div>
</div>
);
}
+24 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
import { useLayoutStore } from '../stores/layout.ts';
import { CreateServerModal } from './CreateServerModal.tsx';
import { JoinServerModal } from './JoinServerModal.tsx';
@@ -18,6 +19,7 @@ export function ServerBar() {
const fetchServers = useServerStore((state) => state.fetchServers);
const setActiveServer = useServerStore((state) => state.setActiveServer);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const { isDM, setDM } = useLayoutStore();
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
@@ -28,26 +30,43 @@ export function ServerBar() {
const handleSelect = (id: string) => {
setActiveServer(id);
setActiveChannel(null);
setDM(false);
};
const handleDM = () => {
setActiveServer(null);
setActiveChannel(null);
setDM(true);
};
return (
<>
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
<button
onClick={handleDM}
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
(isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
title="Direct messages"
>
[@]
</button>
<div className="w-8 h-px bg-gb-bg-t" />
{servers.map((server) => (
<button
key={server.id}
onClick={() => handleSelect(server.id)}
className={`w-11 h-11 flex items-center justify-center font-mono text-sm border ${
server.id === activeServerId
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
(server.id === activeServerId && !isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua'
}`}
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
title={server.name}
>
{server.unread && server.id !== activeServerId ? (
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
) : (
`[${getInitials(server.name)}]`
'[' + getInitials(server.name) + ']'
)}
</button>
))}
+18 -3
View File
@@ -1,12 +1,17 @@
const API_BASE = '/api/v1';
export interface ApiError extends Error {
status?: number;
retryAfter?: number;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, {
const response = await fetch(API_BASE + path, {
method,
headers,
credentials: 'include',
@@ -14,7 +19,8 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
let errorMessage = 'Request failed: ' + response.status;
let retryAfter: number | undefined;
try {
const errorData = await response.json();
if (typeof errorData?.message === 'string') {
@@ -22,10 +28,19 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
} else if (typeof errorData?.error === 'string') {
errorMessage = errorData.error;
}
if (response.status === 429 && typeof errorData?.retry_after === 'number') {
retryAfter = errorData.retry_after;
}
} catch {
// ignore parse error
}
throw new Error(errorMessage);
const err = new Error(errorMessage) as ApiError;
err.status = response.status;
if (response.status === 429) {
const ra = response.headers.get('Retry-After');
err.retryAfter = retryAfter ?? (ra ? parseInt(ra, 10) : undefined);
}
throw err;
}
if (response.status === 204) {
+50
View File
@@ -0,0 +1,50 @@
import { useCallback } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useAuthStore } from '../stores/auth.ts';
import { useRoleStore } from '../stores/role.ts';
const PERMS = {
VIEW_CHANNEL: 1,
SEND_MESSAGES: 2,
MANAGE_MESSAGES: 4,
KICK_MEMBERS: 8,
BAN_MEMBERS: 16,
MANAGE_SERVER: 32,
MANAGE_CHANNELS: 64,
ADMINISTRATOR: 128,
CONNECT_VOICE: 256,
SPEAK_VOICE: 512,
SHARE_SCREEN: 1024,
} as const;
export { PERMS };
export function usePermissions(serverId: string | null) {
const user = useAuthStore((s) => s.user);
const servers = useServerStore((s) => s.servers);
const roles = useRoleStore((s) => s.roles);
const server = serverId ? servers.find((s) => s.id === serverId) : null;
const isOwner = Boolean(server && user && server.ownerId === user.id);
const memberRoles = roles.filter((r) => r.server_id === serverId);
const has = useCallback(
(flag: number) => {
if (!serverId || !user) return false;
if (isOwner) return true;
const effective = memberRoles.reduce((acc, r) => acc | r.permissions, 0);
if ((effective & PERMS.ADMINISTRATOR) !== 0) return true;
return (effective & flag) === flag;
},
[serverId, user, isOwner, memberRoles],
);
return {
canKick: has(PERMS.KICK_MEMBERS),
canBan: has(PERMS.BAN_MEMBERS),
canMute: has(PERMS.MANAGE_MESSAGES),
canManageChannels: has(PERMS.MANAGE_CHANNELS),
isOwner,
};
}
+120
View File
@@ -0,0 +1,120 @@
import { create } from "zustand";
import { api } from "../lib/api.ts";
export interface ConversationMember {
id: string;
username: string;
display_name: string;
avatar: string;
}
export interface Conversation {
id: string;
type: "dm" | "group_dm";
name: string;
members: ConversationMember[];
created_at: string;
}
export interface ConversationMessage {
id: string;
conversation_id: string;
author_id: string;
author_username: string;
author_display_name: string | null;
content: string;
created_at: string;
edited_at: string | null;
}
interface ConversationState {
conversations: Conversation[];
activeConversationId: string | null;
messagesByConversation: Record<string, ConversationMessage[]>;
isLoading: boolean;
error: string | null;
fetchConversations: () => Promise<void>;
createConversation: (userIds: string[]) => Promise<Conversation>;
setActiveConversation: (id: string | null) => void;
fetchMessages: (conversationId: string, before?: string) => Promise<void>;
sendMessage: (conversationId: string, content: string) => Promise<ConversationMessage>;
addMessage: (message: ConversationMessage) => void;
}
export const useConversationStore = create<ConversationState>((set, _get) => ({
conversations: [],
activeConversationId: null,
messagesByConversation: {},
isLoading: false,
error: null,
fetchConversations: async () => {
try {
const conversations = await api.get<Conversation[]>("/conversations");
set({ conversations: Array.isArray(conversations) ? conversations : [] });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch conversations",
});
}
},
createConversation: async (userIds) => {
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
set((state) => ({
conversations: [conversation, ...state.conversations],
activeConversationId: conversation.id,
}));
return conversation;
},
setActiveConversation: (id) => set({ activeConversationId: id }),
fetchMessages: async (conversationId, before) => {
set({ isLoading: true });
try {
const params = before ? "?before=" + encodeURIComponent(before) : "";
const messages = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages${params}`,
);
set((state) => ({
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: Array.isArray(messages) ? messages : [],
},
isLoading: false,
}));
} catch (error) {
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
}
},
sendMessage: async (conversationId, content) => {
const message = await api.post<ConversationMessage>(
`/conversations/${conversationId}/messages`,
{ content },
);
set((state) => ({
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: [
...(state.messagesByConversation[conversationId] || []),
message,
],
},
}));
return message;
},
addMessage: (message) => {
set((state) => ({
messagesByConversation: {
...state.messagesByConversation,
[message.conversation_id]: [
...(state.messagesByConversation[message.conversation_id] || []),
message,
],
},
}));
},
}));
+11
View File
@@ -0,0 +1,11 @@
import { create } from 'zustand';
interface LayoutState {
isDM: boolean;
setDM: (value: boolean) => void;
}
export const useLayoutStore = create<LayoutState>((set) => ({
isDM: false,
setDM: (value) => set({ isDM: value }),
}));
+36 -3
View File
@@ -1,6 +1,15 @@
import { create } from "zustand";
import { api } from "../lib/api.ts";
export interface MessageEmbed {
id?: string;
url: string;
title?: string;
description?: string;
image_url?: string;
site_name?: string;
}
export interface Message {
id: string;
channel_id: string;
@@ -8,16 +17,24 @@ export interface Message {
author_username: string;
author_display_name: string | null;
content: string;
reply_to?: string | null;
embeds?: MessageEmbed[];
created_at: string;
edited_at: string | null;
}
export interface SearchResultMessage extends Message {
channel_name?: string;
}
interface MessageState {
messagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>;
isLoading: boolean;
error: string | null;
fetchMessages: (channelId: string, before?: string) => Promise<void>;
sendMessage: (channelId: string, content: string) => Promise<Message>;
sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>;
searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (channelId: string, messageId: string) => void;
@@ -25,6 +42,7 @@ interface MessageState {
export const useMessageStore = create<MessageState>((set) => ({
messagesByChannel: {},
searchResultsByChannel: {},
isLoading: false,
error: null,
@@ -53,10 +71,25 @@ export const useMessageStore = create<MessageState>((set) => ({
}
},
sendMessage: async (channelId, content) => {
searchMessages: async (channelId, query) => {
const results = await api.get<SearchResultMessage[]>(
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
);
set((state) => ({
searchResultsByChannel: {
...state.searchResultsByChannel,
[channelId]: Array.isArray(results) ? results : [],
},
}));
return Array.isArray(results) ? results : [];
},
sendMessage: async (channelId, content, replyTo) => {
const body: { content: string; reply_to?: string } = { content };
if (replyTo) body.reply_to = replyTo;
const message = await api.post<Message>(
`/channels/${channelId}/messages`,
{ content },
body,
);
set((state) => {
const list = state.messagesByChannel[channelId] || [];
+67
View File
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export interface ModerationState {
isLoading: boolean;
error: string | null;
kickMember: (serverId: string, userId: string, reason?: string) => Promise<void>;
banMember: (serverId: string, userId: string, reason?: string) => Promise<void>;
unbanMember: (serverId: string, userId: string) => Promise<void>;
muteMember: (serverId: string, userId: string, durationSeconds: number) => Promise<void>;
clearError: () => void;
}
export const useModerationStore = create<ModerationState>((set) => ({
isLoading: false,
error: null,
kickMember: async (serverId, userId, reason) => {
set({ isLoading: true, error: null });
try {
await api.post(`/servers/${serverId}/members/${userId}/kick`, { reason });
set({ isLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to kick member';
set({ isLoading: false, error: message });
throw error;
}
},
banMember: async (serverId, userId, reason) => {
set({ isLoading: true, error: null });
try {
await api.post(`/servers/${serverId}/members/${userId}/ban`, { reason });
set({ isLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to ban member';
set({ isLoading: false, error: message });
throw error;
}
},
unbanMember: async (serverId, userId) => {
set({ isLoading: true, error: null });
try {
await api.post(`/servers/${serverId}/members/${userId}/unban`, {});
set({ isLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unban member';
set({ isLoading: false, error: message });
throw error;
}
},
muteMember: async (serverId, userId, durationSeconds) => {
set({ isLoading: true, error: null });
try {
await api.post(`/servers/${serverId}/members/${userId}/mute`, { duration_seconds: durationSeconds });
set({ isLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to mute member';
set({ isLoading: false, error: message });
throw error;
}
},
clearError: () => set({ error: null }),
}));
+22
View File
@@ -12,6 +12,14 @@ export interface Server {
interface ServerState {
servers: Server[];
activeServerId: string | null;
userPermissions: {
kick_members: boolean;
ban_members: boolean;
mute_members: boolean;
manage_channels: boolean;
manage_server: boolean;
administrator: boolean;
};
isLoading: boolean;
error: string | null;
fetchServers: () => Promise<void>;
@@ -19,11 +27,20 @@ interface ServerState {
addServer: (server: Server) => void;
updateServer: (server: Server) => void;
removeServer: (id: string) => void;
setUserPermissions: (perms: Partial<ServerState['userPermissions']>) => void;
}
export const useServerStore = create<ServerState>((set) => ({
servers: [],
activeServerId: null,
userPermissions: {
kick_members: false,
ban_members: false,
mute_members: false,
manage_channels: false,
manage_server: false,
administrator: false,
},
isLoading: false,
error: null,
@@ -57,4 +74,9 @@ export const useServerStore = create<ServerState>((set) => ({
servers: state.servers.filter((s) => s.id !== id),
activeServerId: state.activeServerId === id ? null : state.activeServerId,
})),
setUserPermissions: (perms) =>
set((state) => ({
userPermissions: { ...state.userPermissions, ...perms },
})),
}));
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/EmojiPicker.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/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.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/ChannelList.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/EmojiPicker.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/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/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.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/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}