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/channel"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
"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/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
|
"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/middleware"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
@@ -139,6 +141,8 @@ func main() {
|
|||||||
r.Post("/", serverHandler.Create)
|
r.Post("/", serverHandler.Create)
|
||||||
r.Get("/", serverHandler.List)
|
r.Get("/", serverHandler.List)
|
||||||
|
|
||||||
|
modHandler := moderation.NewHandler(database.DB, hub)
|
||||||
|
|
||||||
r.Route("/{serverID}", func(r chi.Router) {
|
r.Route("/{serverID}", func(r chi.Router) {
|
||||||
r.Get("/", serverHandler.Get)
|
r.Get("/", serverHandler.Get)
|
||||||
r.Patch("/", serverHandler.Update)
|
r.Patch("/", serverHandler.Update)
|
||||||
@@ -147,6 +151,14 @@ func main() {
|
|||||||
// Server members
|
// Server members
|
||||||
memberHandler.RegisterRoutes(r)
|
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)
|
// Channels (nested under servers)
|
||||||
r.Route("/channels", func(r chi.Router) {
|
r.Route("/channels", func(r chi.Router) {
|
||||||
channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r)
|
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
|
// Roles and member-role assignment
|
||||||
roleHandler := server.NewRoleHandler(database.DB)
|
roleHandler := server.NewRoleHandler(database.DB)
|
||||||
roleHandler.RegisterRoleRoutes(r)
|
roleHandler.RegisterRoleRoutes(r)
|
||||||
|
|||||||
@@ -37,13 +37,14 @@ type createChannelRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type channelResponse struct {
|
type channelResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ServerID string `json:"server_id"`
|
ServerID string `json:"server_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
CreatedAt string `json:"created_at"`
|
SlowmodeSeconds int `json:"slowmode_seconds"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// isMember checks whether the given user is a member of the given server.
|
// isMember checks whether the given user is a member of the given server.
|
||||||
@@ -124,11 +125,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO channels (server_id, name, type, category, position)
|
INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, 0)
|
||||||
RETURNING id, server_id, name, type, category, position, created_at
|
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||||
`, serverID, req.Name, channelType, category, position).Scan(
|
`, 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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict)
|
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(), `
|
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
|
FROM channels
|
||||||
WHERE server_id = $1
|
WHERE server_id = $1
|
||||||
ORDER BY position, name
|
ORDER BY position, name
|
||||||
@@ -189,7 +190,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
channels := make([]channelResponse, 0)
|
channels := make([]channelResponse, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ch channelResponse
|
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)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -226,9 +227,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
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
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
@@ -253,10 +254,11 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type updateChannelRequest struct {
|
type updateChannelRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Category *string `json:"category"`
|
Category *string `json:"category"`
|
||||||
Position *int `json:"position"`
|
Position *int `json:"position"`
|
||||||
|
SlowmodeSeconds *int `json:"slowmode_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Update a channel
|
// @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)
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
return
|
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)
|
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -330,11 +332,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
type = COALESCE($2, type),
|
type = COALESCE($2, type),
|
||||||
category = COALESCE($3, category),
|
category = COALESCE($3, category),
|
||||||
position = COALESCE($4, position)
|
position = COALESCE($4, position),
|
||||||
WHERE id = $5
|
slowmode_seconds = COALESCE($5, slowmode_seconds)
|
||||||
RETURNING id, server_id, name, type, category, position, created_at
|
WHERE id = $6
|
||||||
`, req.Name, req.Type, req.Category, req.Position, channelID).Scan(
|
RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at
|
||||||
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
|
`, 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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
|
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_push_subscriptions_user ON push_subscriptions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||||
|
|
||||||
|
-- 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.
|
// ServerIDForChannel looks up the server_id that owns the given channel.
|
||||||
// Used by handlers to route events to the correct server scope.
|
// Used by handlers to route events to the correct server scope.
|
||||||
func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) {
|
func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) {
|
||||||
|
|||||||
+198
-17
@@ -8,8 +8,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"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/middleware"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -42,19 +45,31 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger
|
|||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
|
r.Get("/search", h.Search)
|
||||||
r.Patch("/{messageID}", h.Update)
|
r.Patch("/{messageID}", h.Update)
|
||||||
r.Delete("/{messageID}", h.Delete)
|
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 {
|
type messageResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
AuthorID string `json:"author_id"`
|
AuthorID string `json:"author_id"`
|
||||||
AuthorName string `json:"author_username"`
|
AuthorName string `json:"author_username"`
|
||||||
DisplayName *string `json:"author_display_name"`
|
DisplayName *string `json:"author_display_name"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
EditedAt *string `json:"edited_at"`
|
ReplyTo *string `json:"reply_to,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
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.
|
// 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
|
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 msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
@@ -112,12 +150,12 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.ReplyTo != nil {
|
if req.ReplyTo != nil {
|
||||||
replyTo = sql.NullString{String: *req.ReplyTo, Valid: true}
|
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)
|
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
||||||
VALUES ($1, $2, $3, $4)
|
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(
|
`, 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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if replyTo.Valid {
|
||||||
|
msg.ReplyTo = &replyTo.String
|
||||||
|
}
|
||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
@@ -154,14 +195,43 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if channelName == "" {
|
if channelName == "" {
|
||||||
channelName = channelID
|
channelName = channelID
|
||||||
}
|
}
|
||||||
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
|
if h.pushHandler != nil {
|
||||||
|
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Fetch and store link embeds asynchronously.
|
||||||
|
go embed.FetchAndStore(h.db, msg.ID, req.Content)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(msg)
|
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 {
|
type updateMessageRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
@@ -205,12 +275,13 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var replyTo sql.NullString
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE messages SET content = $1, edited_at = NOW()
|
UPDATE messages SET content = $1, edited_at = NOW()
|
||||||
WHERE id = $2 AND author_id = $3
|
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(
|
`, 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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -229,11 +300,17 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if replyTo.Valid {
|
||||||
|
msg.ReplyTo = &replyTo.String
|
||||||
|
}
|
||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
msg.CreatedAt = createdAt.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
|
// Look up serverID for scoped broadcast
|
||||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
||||||
if serverID != "" {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(msg)
|
json.NewEncoder(w).Encode(msg)
|
||||||
}
|
}
|
||||||
@@ -327,7 +406,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var err error
|
var err error
|
||||||
if before != "" {
|
if before != "" {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
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
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
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)
|
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)
|
`, channelID, before, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
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
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
WHERE m.channel_id = $1
|
WHERE m.channel_id = $1
|
||||||
@@ -355,10 +434,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt 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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if replyTo.Valid {
|
||||||
|
msg.ReplyTo = &replyTo.String
|
||||||
|
}
|
||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
@@ -366,12 +449,110 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
messages = h.attachEmbeds(r.Context(), messages)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(messages)
|
json.NewEncoder(w).Encode(messages)
|
||||||
|
|
||||||
_ = userID
|
_ = 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.
|
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
|
||||||
// Returns (userID, serverID, ok).
|
// Returns (userID, serverID, ok).
|
||||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) {
|
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
|
||||||
|
}
|
||||||
@@ -2,22 +2,33 @@ package permissions
|
|||||||
|
|
||||||
// Permission bitflags.
|
// Permission bitflags.
|
||||||
const (
|
const (
|
||||||
VIEW_CHANNEL int64 = 1 << 0
|
VIEW_CHANNEL int64 = 1 << 0
|
||||||
SEND_MESSAGES int64 = 1 << 1
|
SEND_MESSAGES int64 = 1 << 1
|
||||||
MANAGE_MESSAGES int64 = 1 << 2
|
MANAGE_MESSAGES int64 = 1 << 2
|
||||||
KICK_MEMBERS int64 = 1 << 3
|
KICK_MEMBERS int64 = 1 << 3
|
||||||
BAN_MEMBERS int64 = 1 << 4
|
BAN_MEMBERS int64 = 1 << 4
|
||||||
MANAGE_SERVER int64 = 1 << 5
|
MANAGE_SERVER int64 = 1 << 5
|
||||||
MANAGE_CHANNELS int64 = 1 << 6
|
MANAGE_CHANNELS int64 = 1 << 6
|
||||||
ADMINISTRATOR int64 = 1 << 7
|
ADMINISTRATOR int64 = 1 << 7
|
||||||
CONNECT_VOICE int64 = 1 << 8
|
CONNECT_VOICE int64 = 1 << 8
|
||||||
SPEAK_VOICE int64 = 1 << 9
|
SPEAK_VOICE int64 = 1 << 9
|
||||||
SHARE_SCREEN int64 = 1 << 10
|
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.
|
// 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.
|
// 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
|
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.
|
// Has reports whether the permission set contains the given permission bits.
|
||||||
func Has(permissions int64, perm int64) bool {
|
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",
|
"livekit-client": "^2.20.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BotManager } from './components/BotManager.tsx';
|
|||||||
import { CommandManager } from './components/CommandManager.tsx';
|
import { CommandManager } from './components/CommandManager.tsx';
|
||||||
import { RoleManager } from './components/RoleManager.tsx';
|
import { RoleManager } from './components/RoleManager.tsx';
|
||||||
import { JoinServer } from './components/JoinServer.tsx';
|
import { JoinServer } from './components/JoinServer.tsx';
|
||||||
|
import { DMChat } from './components/DMChat.tsx';
|
||||||
import { useAuthStore } from './stores/auth.ts';
|
import { useAuthStore } from './stores/auth.ts';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -109,6 +110,8 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<ChatArea />} />
|
<Route index element={<ChatArea />} />
|
||||||
<Route path="channels/:channelId" element={<ChatArea />} />
|
<Route path="channels/:channelId" element={<ChatArea />} />
|
||||||
|
<Route path="dm/:conversationId" element={<DMChat />} />
|
||||||
|
<Route path="dm" element={<DMChat />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
+121
-21
@@ -5,6 +5,9 @@ import { useServerStore } from "../stores/server.ts";
|
|||||||
import { useMemberStore } from "../stores/member.ts";
|
import { useMemberStore } from "../stores/member.ts";
|
||||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||||
import { MentionDropdown } from "./MentionDropdown";
|
import { MentionDropdown } from "./MentionDropdown";
|
||||||
|
import { MessageSearch } from "./MessageSearch";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
@@ -15,39 +18,85 @@ function formatTime(iso: string): string {
|
|||||||
|
|
||||||
|
|
||||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||||
// Split on @username, preserving mentions.
|
// Split on @username, preserving mentions; render plain text segments as markdown.
|
||||||
const parts: (string | { mention: string })[] = [];
|
const segments: { type: "text" | "mention"; value: string }[] = [];
|
||||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = mentionRe.exec(content)) !== null) {
|
while ((match = mentionRe.exec(content)) !== null) {
|
||||||
if (match.index > last) {
|
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];
|
const username = match[1];
|
||||||
if (memberUsernames.has(username)) {
|
if (memberUsernames.has(username)) {
|
||||||
parts.push({ mention: username });
|
segments.push({ type: "mention", value: username });
|
||||||
} else {
|
} else {
|
||||||
parts.push(match[0]);
|
segments.push({ type: "text", value: match[0] });
|
||||||
}
|
}
|
||||||
last = mentionRe.lastIndex;
|
last = mentionRe.lastIndex;
|
||||||
}
|
}
|
||||||
if (last < content.length) {
|
if (last < content.length) {
|
||||||
parts.push(content.slice(last));
|
segments.push({ type: "text", value: content.slice(last) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.map((part, idx) => {
|
return segments.map((seg, idx) => {
|
||||||
if (typeof part === "string") {
|
if (seg.type === "mention") {
|
||||||
return <span key={idx}>{part}</span>;
|
return (
|
||||||
|
<span key={idx} className="text-gb-aqua">
|
||||||
|
@{seg.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-gb-aqua">
|
<ReactMarkdown
|
||||||
@{part.mention}
|
key={idx}
|
||||||
</span>
|
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() {
|
export function ChatArea() {
|
||||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||||
@@ -62,7 +111,9 @@ export function ChatArea() {
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
|
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -87,6 +138,20 @@ export function ChatArea() {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const cursor = e.target.selectionStart ?? value.length;
|
const cursor = e.target.selectionStart ?? value.length;
|
||||||
@@ -132,14 +197,25 @@ export function ChatArea() {
|
|||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeChannelId || !input.trim()) return;
|
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await sendMessage(activeChannelId, input.trim());
|
await sendMessage(activeChannelId, input.trim());
|
||||||
setInput("");
|
setInput("");
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="flex flex-col h-full bg-gb-bg">
|
<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">
|
||||||
{activeChannel
|
<span>
|
||||||
? `# ${activeChannel.name}`
|
{activeChannel
|
||||||
: activeChannelId
|
? `# ${activeChannel.name}`
|
||||||
? `#${activeChannelId}`
|
: activeChannelId
|
||||||
: "[NO CHANNEL SELECTED]"}
|
? `#${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>
|
</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">
|
<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 && <p className="text-gb-fg-f">[loading...]</p>}
|
||||||
{!isLoading && messages.length === 0 && (
|
{!isLoading && messages.length === 0 && (
|
||||||
@@ -179,6 +273,7 @@ export function ChatArea() {
|
|||||||
<span className="text-gb-fg">
|
<span className="text-gb-fg">
|
||||||
{renderContent(message.content, memberUsernames)}
|
{renderContent(message.content, memberUsernames)}
|
||||||
</span>
|
</span>
|
||||||
|
{renderEmbeds(message.embeds)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
@@ -196,6 +291,11 @@ export function ChatArea() {
|
|||||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||||
</div>
|
</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">
|
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
|
||||||
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
||||||
<div className="flex-1 min-w-0 relative">
|
<div className="flex-1 min-w-0 relative">
|
||||||
@@ -206,7 +306,7 @@ export function ChatArea() {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder="type a message..."
|
placeholder="type a message..."
|
||||||
className="terminal-input w-full"
|
className="terminal-input w-full"
|
||||||
disabled={!activeChannelId}
|
disabled={!activeChannelId || slowmodeRemaining > 0}
|
||||||
/>
|
/>
|
||||||
{mentionQuery !== null && (
|
{mentionQuery !== null && (
|
||||||
<MentionDropdown
|
<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;
|
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) {
|
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [type, setType] = useState<'text' | 'voice'>('text');
|
const [type, setType] = useState<'text' | 'voice'>('text');
|
||||||
const [category, setCategory] = useState('general');
|
const [category, setCategory] = useState('general');
|
||||||
|
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -41,10 +52,11 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.post<ChannelApiResponse>(`/servers/${serverId}/channels`, {
|
const result = await api.post<ChannelApiResponse>("/servers/" + serverId + "/channels", {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
type,
|
type,
|
||||||
category: category.trim() || 'general',
|
category: category.trim() || 'general',
|
||||||
|
slowmode_seconds: slowmodeSeconds,
|
||||||
});
|
});
|
||||||
const newChannel = {
|
const newChannel = {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
@@ -110,6 +122,18 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
|||||||
className="terminal-input w-full"
|
className="terminal-input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>}
|
{error && <div className="text-xs text-gb-red">{error}</div>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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 { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
||||||
import { useWebSocketStore } from '../stores/ws.ts';
|
import { useWebSocketStore } from '../stores/ws.ts';
|
||||||
|
import { useServerStore } from '../stores/server.ts';
|
||||||
import { ServerBar } from './ServerBar.tsx';
|
import { ServerBar } from './ServerBar.tsx';
|
||||||
import { ChannelList } from './ChannelList.tsx';
|
import { ChannelList } from './ChannelList.tsx';
|
||||||
|
import { ConversationList } from './ConversationList.tsx';
|
||||||
import { MemberList } from './MemberList.tsx';
|
import { MemberList } from './MemberList.tsx';
|
||||||
import { VoicePanel } from './VoicePanel.tsx';
|
import { VoicePanel } from './VoicePanel.tsx';
|
||||||
|
|
||||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||||
|
|
||||||
function statusColor(status: UserStatus): string {
|
function statusColor(status: UserStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'online':
|
case 'online':
|
||||||
@@ -21,11 +21,9 @@ function statusColor(status: UserStatus): string {
|
|||||||
return 'bg-gb-gray';
|
return 'bg-gb-gray';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: UserStatus): string {
|
function statusLabel(status: UserStatus): string {
|
||||||
return status.toUpperCase();
|
return status.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const isLoading = useAuthStore((state) => state.isLoading);
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
@@ -35,22 +33,21 @@ export function Layout() {
|
|||||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||||
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
||||||
|
const setActiveServer = useServerStore((s) => s.setActiveServer);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
|
const [dmMode, setDmMode] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMe();
|
fetchMe();
|
||||||
wsConnect();
|
wsConnect();
|
||||||
return () => { wsDisconnect(); };
|
return () => { wsDisconnect(); };
|
||||||
}, [fetchMe, wsConnect, wsDisconnect]);
|
}, [fetchMe, wsConnect, wsDisconnect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
||||||
|
|
||||||
const handleStatusChange = async (status: UserStatus) => {
|
const handleStatusChange = async (status: UserStatus) => {
|
||||||
setShowStatusMenu(false);
|
setShowStatusMenu(false);
|
||||||
try {
|
try {
|
||||||
@@ -59,9 +56,7 @@ export function Layout() {
|
|||||||
// Error is surfaced via auth store; menu closes optimistically.
|
// Error is surfaced via auth store; menu closes optimistically.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentStatus = user?.status ?? 'offline';
|
const currentStatus = user?.status ?? 'offline';
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2">
|
<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">
|
<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>
|
</div>
|
||||||
<div className="flex-1 flex min-h-0">
|
<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 />
|
<ServerBar />
|
||||||
<ChannelList />
|
{dmMode ? <ConversationList /> : <ChannelList />}
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<VoicePanel />
|
<VoicePanel />
|
||||||
@@ -124,7 +144,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MemberList />
|
{!dmMode && <MemberList />}
|
||||||
</div>
|
</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">
|
<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>
|
<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 { useServerStore } from "../stores/server.ts";
|
||||||
|
import { useAuthStore } from "../stores/auth.ts";
|
||||||
import { useMemberStore, type Member } from "../stores/member.ts";
|
import { useMemberStore, type Member } from "../stores/member.ts";
|
||||||
import { usePresenceStore } from "../stores/presence.ts";
|
import { usePresenceStore } from "../stores/presence.ts";
|
||||||
import type { UserStatus } from "../stores/auth.ts";
|
import type { UserStatus } from "../stores/auth.ts";
|
||||||
|
import { MemberContextMenu } from "./MemberContextMenu.tsx";
|
||||||
|
import { useConversationStore } from "../stores/conversation.ts";
|
||||||
|
|
||||||
function statusIcon(status: UserStatus): string {
|
function statusIcon(status: UserStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -46,13 +49,63 @@ function usernameColor(status: UserStatus): string {
|
|||||||
function MemberRow({ member }: { member: Member }) {
|
function MemberRow({ member }: { member: Member }) {
|
||||||
const presence = usePresenceStore((s) => s.presences[member.id]);
|
const presence = usePresenceStore((s) => s.presences[member.id]);
|
||||||
const status = presence?.status ?? member.status;
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-2 px-2 py-1">
|
<div className="relative">
|
||||||
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
<button
|
||||||
<span className={`truncate ${usernameColor(status)}`}>
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
{member.display_name || member.username}
|
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t"
|
||||||
</span>
|
>
|
||||||
|
<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>
|
</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 { useEffect, useState } from 'react';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
import { useChannelStore } from '../stores/channel.ts';
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
import { useLayoutStore } from '../stores/layout.ts';
|
||||||
import { CreateServerModal } from './CreateServerModal.tsx';
|
import { CreateServerModal } from './CreateServerModal.tsx';
|
||||||
import { JoinServerModal } from './JoinServerModal.tsx';
|
import { JoinServerModal } from './JoinServerModal.tsx';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function ServerBar() {
|
|||||||
const fetchServers = useServerStore((state) => state.fetchServers);
|
const fetchServers = useServerStore((state) => state.fetchServers);
|
||||||
const setActiveServer = useServerStore((state) => state.setActiveServer);
|
const setActiveServer = useServerStore((state) => state.setActiveServer);
|
||||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||||
|
const { isDM, setDM } = useLayoutStore();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showJoin, setShowJoin] = useState(false);
|
const [showJoin, setShowJoin] = useState(false);
|
||||||
|
|
||||||
@@ -28,26 +30,43 @@ export function ServerBar() {
|
|||||||
const handleSelect = (id: string) => {
|
const handleSelect = (id: string) => {
|
||||||
setActiveServer(id);
|
setActiveServer(id);
|
||||||
setActiveChannel(null);
|
setActiveChannel(null);
|
||||||
|
setDM(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDM = () => {
|
||||||
|
setActiveServer(null);
|
||||||
|
setActiveChannel(null);
|
||||||
|
setDM(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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) => (
|
{servers.map((server) => (
|
||||||
<button
|
<button
|
||||||
key={server.id}
|
key={server.id}
|
||||||
onClick={() => handleSelect(server.id)}
|
onClick={() => handleSelect(server.id)}
|
||||||
className={`w-11 h-11 flex items-center justify-center font-mono text-sm border ${
|
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||||
server.id === activeServerId
|
(server.id === activeServerId && !isDM
|
||||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
? '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}
|
title={server.name}
|
||||||
>
|
>
|
||||||
{server.unread && server.id !== activeServerId ? (
|
{server.unread && server.id !== activeServerId ? (
|
||||||
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
|
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
|
||||||
) : (
|
) : (
|
||||||
`[${getInitials(server.name)}]`
|
'[' + getInitials(server.name) + ']'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+18
-3
@@ -1,12 +1,17 @@
|
|||||||
const API_BASE = '/api/v1';
|
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> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(API_BASE + path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -14,7 +19,8 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = `Request failed: ${response.status}`;
|
let errorMessage = 'Request failed: ' + response.status;
|
||||||
|
let retryAfter: number | undefined;
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
if (typeof errorData?.message === 'string') {
|
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') {
|
} else if (typeof errorData?.error === 'string') {
|
||||||
errorMessage = errorData.error;
|
errorMessage = errorData.error;
|
||||||
}
|
}
|
||||||
|
if (response.status === 429 && typeof errorData?.retry_after === 'number') {
|
||||||
|
retryAfter = errorData.retry_after;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse error
|
// 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) {
|
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 { create } from "zustand";
|
||||||
import { api } from "../lib/api.ts";
|
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 {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
@@ -8,16 +17,24 @@ export interface Message {
|
|||||||
author_username: string;
|
author_username: string;
|
||||||
author_display_name: string | null;
|
author_display_name: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
|
reply_to?: string | null;
|
||||||
|
embeds?: MessageEmbed[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
edited_at: string | null;
|
edited_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchResultMessage extends Message {
|
||||||
|
channel_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageState {
|
interface MessageState {
|
||||||
messagesByChannel: Record<string, Message[]>;
|
messagesByChannel: Record<string, Message[]>;
|
||||||
|
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
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;
|
addMessage: (message: Message) => void;
|
||||||
updateMessage: (message: Message) => void;
|
updateMessage: (message: Message) => void;
|
||||||
removeMessage: (channelId: string, messageId: string) => void;
|
removeMessage: (channelId: string, messageId: string) => void;
|
||||||
@@ -25,6 +42,7 @@ interface MessageState {
|
|||||||
|
|
||||||
export const useMessageStore = create<MessageState>((set) => ({
|
export const useMessageStore = create<MessageState>((set) => ({
|
||||||
messagesByChannel: {},
|
messagesByChannel: {},
|
||||||
|
searchResultsByChannel: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
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>(
|
const message = await api.post<Message>(
|
||||||
`/channels/${channelId}/messages`,
|
`/channels/${channelId}/messages`,
|
||||||
{ content },
|
body,
|
||||||
);
|
);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[channelId] || [];
|
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 {
|
interface ServerState {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
activeServerId: string | null;
|
activeServerId: string | null;
|
||||||
|
userPermissions: {
|
||||||
|
kick_members: boolean;
|
||||||
|
ban_members: boolean;
|
||||||
|
mute_members: boolean;
|
||||||
|
manage_channels: boolean;
|
||||||
|
manage_server: boolean;
|
||||||
|
administrator: boolean;
|
||||||
|
};
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchServers: () => Promise<void>;
|
fetchServers: () => Promise<void>;
|
||||||
@@ -19,11 +27,20 @@ interface ServerState {
|
|||||||
addServer: (server: Server) => void;
|
addServer: (server: Server) => void;
|
||||||
updateServer: (server: Server) => void;
|
updateServer: (server: Server) => void;
|
||||||
removeServer: (id: string) => void;
|
removeServer: (id: string) => void;
|
||||||
|
setUserPermissions: (perms: Partial<ServerState['userPermissions']>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerState>((set) => ({
|
export const useServerStore = create<ServerState>((set) => ({
|
||||||
servers: [],
|
servers: [],
|
||||||
activeServerId: null,
|
activeServerId: null,
|
||||||
|
userPermissions: {
|
||||||
|
kick_members: false,
|
||||||
|
ban_members: false,
|
||||||
|
mute_members: false,
|
||||||
|
manage_channels: false,
|
||||||
|
manage_server: false,
|
||||||
|
administrator: false,
|
||||||
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -57,4 +74,9 @@ export const useServerStore = create<ServerState>((set) => ({
|
|||||||
servers: state.servers.filter((s) => s.id !== id),
|
servers: state.servers.filter((s) => s.id !== id),
|
||||||
activeServerId: state.activeServerId === id ? null : state.activeServerId,
|
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