sync: phase 1 backend + frontend from server
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -43,6 +43,7 @@ type channelResponse struct {
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Position int `json:"position"`
|
||||
SlowmodeSeconds int `json:"slowmode_seconds"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -257,6 +258,7 @@ type updateChannelRequest struct {
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,10 +45,20 @@ 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"`
|
||||
@@ -53,8 +66,10 @@ type messageResponse struct {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,11 +13,22 @@ const (
|
||||
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 {
|
||||
|
||||
Generated
+1469
-6
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+115
-15
@@ -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">
|
||||
@{part.mention}
|
||||
@{seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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">
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"><{msg.author_username}></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"><{msg.author_username}></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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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] || [];
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user