From 89f8381e2d5de2fbab3fad19647ad0e1e2b8d6b8 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 2 Jul 2026 15:34:00 +0000 Subject: [PATCH] added features and fixes --- cmd/migrate/main.go | 3 + cmd/server/main.go | 36 ++ internal/db/db.go | 5 + internal/message/handlers.go | 336 +++++++++++++++++- internal/permissions/checker.go | 14 + internal/reaction/handlers.go | 6 +- internal/server/members.go | 97 ++++- internal/server/roles.go | 11 +- internal/servergroup/handlers.go | 32 +- internal/upload/handlers.go | 9 +- web/public/sw.js | 5 +- web/src/components/ChannelList.tsx | 8 + web/src/components/ChatArea.tsx | 389 +++++++++++++++++---- web/src/components/DMChat.tsx | 47 ++- web/src/components/EmojiPicker.tsx | 248 +++++++++++-- web/src/components/GiphyPicker.tsx | 12 +- web/src/components/MemberList.tsx | 42 ++- web/src/components/MemberRoleAssign.tsx | 20 +- web/src/components/MentionDropdown.tsx | 5 +- web/src/components/MessageSearch.tsx | 2 +- web/src/components/PinnedMessages.tsx | 89 +++++ web/src/components/RoleManager.tsx | 5 +- web/src/components/ServerSettingsModal.tsx | 242 ++++++++++++- web/src/components/UserProfileModal.tsx | 91 ++++- web/src/lib/usePermissions.ts | 7 + web/src/stores/member.ts | 15 + web/src/stores/message.ts | 119 ++++++- web/src/stores/typing.ts | 2 +- web/src/stores/ws.ts | 25 ++ web/tsconfig.app.tsbuildinfo | 2 +- 30 files changed, 1718 insertions(+), 206 deletions(-) create mode 100644 web/src/components/PinnedMessages.tsx diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 8a7ef91..064f4ab 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -103,6 +103,7 @@ CREATE TABLE IF NOT EXISTS messages ( author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL, edited_at TIMESTAMPTZ, + pinned BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -126,6 +127,8 @@ CREATE TABLE IF NOT EXISTS member_roles ( CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id); + +ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE; ` const downSQL = ` diff --git a/cmd/server/main.go b/cmd/server/main.go index 6750577..01e5a46 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,8 +3,10 @@ package main import ( "encoding/json" "fmt" + "io" "log/slog" "net/http" + "net/url" "os" "strings" @@ -262,6 +264,34 @@ func main() { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(gifs) }) + r.Get("/gifs/proxy", func(w http.ResponseWriter, r *http.Request) { + targetURL := r.URL.Query().Get("url") + if targetURL == "" { + http.Error(w, `{"error":"missing url"}`, http.StatusBadRequest) + return + } + + // Verify it's a Giphy domain to prevent general proxy abuse + parsed, err := url.Parse(targetURL) + if err != nil || parsed.Scheme != "https" || !strings.HasSuffix(parsed.Host, ".giphy.com") { + http.Error(w, `{"error":"invalid proxy target"}`, http.StatusBadRequest) + return + } + + resp, err := http.Get(targetURL) + if err != nil { + http.Error(w, `{"error":"failed to fetch image"}`, http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + // Stream response + w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + w.Header().Set("Content-Length", resp.Header.Get("Content-Length")) + w.Header().Set("Cache-Control", "public, max-age=604800") // Cache for 7 days + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + }) } // File upload @@ -312,6 +342,12 @@ func main() { // File serving (public, for viewing uploaded files) if uploadHandler != nil { r.Get("/files/*", uploadHandler.Serve) + + // Legacy redirect: old uploads returned /{bucket}/objectName + r.Get("/dumpster-files/*", func(w http.ResponseWriter, r *http.Request) { + objectName := strings.TrimPrefix(r.URL.Path, "/dumpster-files/") + http.Redirect(w, r, "/files/"+objectName, http.StatusMovedPermanently) + }) } // Public webhook execution (no auth required) diff --git a/internal/db/db.go b/internal/db/db.go index 4702d96..bde312c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -90,10 +90,13 @@ CREATE TABLE IF NOT EXISTS channels ( CREATE TABLE IF NOT EXISTS members ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + nickname VARCHAR(100), joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, server_id) ); +ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100); + CREATE TABLE IF NOT EXISTS messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -101,6 +104,7 @@ CREATE TABLE IF NOT EXISTS messages ( content TEXT NOT NULL, reply_to UUID REFERENCES messages(id) ON DELETE SET NULL, edited_at TIMESTAMPTZ, + pinned BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -204,6 +208,7 @@ ALTER TABLE webhooks DROP COLUMN IF EXISTS token; -- Ensure reply_to column exists on messages (added after initial table creation) ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to UUID REFERENCES messages(id) ON DELETE SET NULL; +ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE; CREATE TABLE IF NOT EXISTS push_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 37e0df9..f314e9c 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -47,6 +47,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/bulk-delete", h.BulkDelete) r.Patch("/{messageID}", h.Update) r.Delete("/{messageID}", h.Delete) + r.Get("/pinned", h.ListPinned) + r.Put("/{messageID}/pin", h.Pin) + r.Delete("/{messageID}/pin", h.Unpin) } type bulkDeleteRequest struct { @@ -135,6 +138,21 @@ type embedResponse struct { SiteName string `json:"site_name"` } +type reactionResponse struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + UserID string `json:"user_id"` + Emoji string `json:"emoji"` + CreatedAt string `json:"created_at"` +} + +type emojiGroup struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Users []string `json:"users"` + Details []reactionResponse `json:"details"` +} + type messageResponse struct { ID string `json:"id"` ChannelID string `json:"channel_id"` @@ -144,8 +162,10 @@ type messageResponse struct { Content string `json:"content"` ReplyTo *string `json:"reply_to,omitempty"` EditedAt *string `json:"edited_at"` + Pinned bool `json:"pinned"` CreatedAt string `json:"created_at"` Embeds []embedResponse `json:"embeds"` + Reactions []emojiGroup `json:"reactions"` } // isMember checks whether the given user is a member of the given server. @@ -229,9 +249,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { err = h.db.QueryRowContext(r.Context(), ` INSERT INTO messages (channel_id, author_id, content, reply_to) VALUES ($1, $2, $3, $4) - RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text + RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text `, channelID, userID, req.Content, replyTo).Scan( - &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt, + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, ) if err != nil { http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError) @@ -254,6 +274,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { msg.EditedAt = &editedAt.String } msg.CreatedAt = createdAt.String + msg.Reactions = make([]emojiGroup, 0) // Broadcast MESSAGE_CREATE event via WebSocket (scoped to server) h.hub.BroadcastToServer(serverID, gateway.Event{ @@ -355,9 +376,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { err := h.db.QueryRowContext(r.Context(), ` UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2 AND author_id = $3 - RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text + RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text `, req.Content, messageID, userID).Scan( - &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt, + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -386,6 +407,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { // Re-fetch embeds after edit. msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds + msg = h.attachReactions(r.Context(), []messageResponse{msg})[0] // Look up serverID for scoped broadcast serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID) @@ -482,7 +504,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var err error if before != "" { rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, 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.pinned, m.created_at::text FROM messages m JOIN users u ON m.author_id = u.id WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2) @@ -491,7 +513,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { `, channelID, before, limit) } else { rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, 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.pinned, m.created_at::text FROM messages m JOIN users u ON m.author_id = u.id WHERE m.channel_id = $1 @@ -511,7 +533,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { var editedAt sql.NullString var createdAt sql.NullString var replyTo sql.NullString - err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt) + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt) if err != nil { continue } @@ -526,6 +548,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { } messages = h.attachEmbeds(r.Context(), messages) + messages = h.attachReactions(r.Context(), messages) // Reverse: query returns DESC (newest first), client expects ASC (oldest first). for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { @@ -570,6 +593,85 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse) return messages } +func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse { + if len(messages) == 0 { + return messages + } + + msgIDs := make([]string, len(messages)) + msgMap := make(map[string]int) + for i, msg := range messages { + msgIDs[i] = msg.ID + msgMap[msg.ID] = i + messages[i].Reactions = make([]emojiGroup, 0) + } + + placeholders := make([]string, len(msgIDs)) + args := make([]interface{}, len(msgIDs)) + for i, id := range msgIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + query := fmt.Sprintf(` + SELECT id, message_id, user_id, emoji, created_at::text + FROM reactions + WHERE message_id IN (%s) + ORDER BY created_at ASC + `, strings.Join(placeholders, ", ")) + + rows, err := h.db.QueryContext(ctx, query, args...) + if err != nil { + return messages + } + defer rows.Close() + + reactionsMap := make(map[string]map[string]*emojiGroup) + emojiOrderMap := make(map[string][]string) + + for rows.Next() { + var reaction reactionResponse + var createdAt sql.NullString + if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil { + continue + } + reaction.CreatedAt = createdAt.String + + msgID := reaction.MessageID + if _, ok := reactionsMap[msgID]; !ok { + reactionsMap[msgID] = make(map[string]*emojiGroup) + emojiOrderMap[msgID] = make([]string, 0) + } + + group, ok := reactionsMap[msgID][reaction.Emoji] + if !ok { + group = &emojiGroup{ + Emoji: reaction.Emoji, + Users: []string{}, + Details: []reactionResponse{}, + } + reactionsMap[msgID][reaction.Emoji] = group + emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji) + } + group.Count++ + group.Users = append(group.Users, reaction.UserID) + group.Details = append(group.Details, reaction) + } + + for msgID, emojisMap := range reactionsMap { + idx, ok := msgMap[msgID] + if !ok { + continue + } + order := emojiOrderMap[msgID] + for _, emoji := range order { + messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji]) + } + } + + 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") @@ -593,7 +695,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { } 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, + 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.pinned, 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 @@ -614,7 +716,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { 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) + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank) if err != nil { continue } @@ -629,6 +731,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { } messages = h.attachEmbeds(r.Context(), messages) + messages = h.attachReactions(r.Context(), messages) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) @@ -673,3 +776,218 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c return userID, serverID, true } + +func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + messageID := chi.URLParam(r, "messageID") + _, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES) + if !ok { + return + } + + // Verify the message exists and belongs to this channel + var msgChannelID string + err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if msgChannelID != channelID { + http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest) + return + } + + // Check if already pinned + var isPinned bool + err = h.db.QueryRowContext(r.Context(), "SELECT pinned FROM messages WHERE id = $1", messageID).Scan(&isPinned) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if isPinned { + http.Error(w, `{"error":"message is already pinned"}`, http.StatusBadRequest) + return + } + + // Count pinned messages in this channel + var count int + err = h.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND pinned = TRUE", channelID).Scan(&count) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if count >= 5 { + http.Error(w, `{"error":"limit reached (max 5 pinned messages per channel)"}`, http.StatusBadRequest) + return + } + + // Update message + var msg messageResponse + var editedAt sql.NullString + var createdAt sql.NullString + var replyTo sql.NullString + err = h.db.QueryRowContext(r.Context(), ` + UPDATE messages SET pinned = TRUE + WHERE id = $1 + RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text + `, messageID).Scan( + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to pin message"}`, http.StatusInternalServerError) + return + } + + // Fetch author details + err = h.db.QueryRowContext(r.Context(), ` + SELECT username, display_name FROM users WHERE id = $1 + `, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + if replyTo.Valid { + msg.ReplyTo = &replyTo.String + } + if editedAt.Valid { + msg.EditedAt = &editedAt.String + } + msg.CreatedAt = createdAt.String + + // Attach embeds + msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds + msg = h.attachReactions(r.Context(), []messageResponse{msg})[0] + + // Broadcast MESSAGE_UPDATE + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageUpdate, + Data: msg, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(msg) +} + +func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + messageID := chi.URLParam(r, "messageID") + _, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES) + if !ok { + return + } + + // Verify the message exists and belongs to this channel + var msgChannelID string + err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if msgChannelID != channelID { + http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest) + return + } + + // Update message + var msg messageResponse + var editedAt sql.NullString + var createdAt sql.NullString + var replyTo sql.NullString + err = h.db.QueryRowContext(r.Context(), ` + UPDATE messages SET pinned = FALSE + WHERE id = $1 + RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text + `, messageID).Scan( + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to unpin message"}`, http.StatusInternalServerError) + return + } + + // Fetch author details + err = h.db.QueryRowContext(r.Context(), ` + SELECT username, display_name FROM users WHERE id = $1 + `, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + if replyTo.Valid { + msg.ReplyTo = &replyTo.String + } + if editedAt.Valid { + msg.EditedAt = &editedAt.String + } + msg.CreatedAt = createdAt.String + + // Attach embeds + msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds + msg = h.attachReactions(r.Context(), []messageResponse{msg})[0] + + // Broadcast MESSAGE_UPDATE + h.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageUpdate, + Data: msg, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(msg) +} + +func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + _, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL) + if !ok { + return + } + + 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.pinned, m.created_at::text + FROM messages m + JOIN users u ON m.author_id = u.id + WHERE m.channel_id = $1 AND m.pinned = TRUE + ORDER BY m.created_at DESC + `, channelID) + if err != nil { + http.Error(w, `{"error":"failed to list pinned 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 + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt) + 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) + messages = h.attachReactions(r.Context(), messages) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} diff --git a/internal/permissions/checker.go b/internal/permissions/checker.go index 8d390f1..2cf251d 100644 --- a/internal/permissions/checker.go +++ b/internal/permissions/checker.go @@ -68,6 +68,13 @@ func (c *Checker) GetUserPermissions(ctx context.Context, serverID string, userI // CheckPermission reports whether the user has all the required permission bits // in the given server. ADMINISTRATOR bypasses all checks. func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) { + // Check if the user is the server owner. + var ownerID string + err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID) + if err == nil && ownerID == userID { + return true, nil + } + perms, err := c.GetUserPermissions(ctx, serverID, userID) if err != nil { return false, err @@ -85,6 +92,13 @@ func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID s // for a specific channel. It layers channel-level overrides on top of server role // permissions. ADMINISTRATOR bypasses all checks. func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) { + // Check if the user is the server owner. + var ownerID string + err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID) + if err == nil && ownerID == userID { + return true, nil + } + perms, err := c.GetUserPermissions(ctx, serverID, userID) if err != nil { return false, err diff --git a/internal/reaction/handlers.go b/internal/reaction/handlers.go index cc5ce59..db62f6b 100644 --- a/internal/reaction/handlers.go +++ b/internal/reaction/handlers.go @@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { } func (h *Handler) RegisterRoutes(r chi.Router) { - r.Post("/{messageID}/reactions", h.Add) - r.Delete("/{messageID}/reactions/{emoji}", h.Remove) - r.Get("/{messageID}/reactions", h.List) + r.Post("/", h.Add) + r.Delete("/{emoji}", h.Remove) + r.Get("/", h.List) } type addReactionRequest struct { diff --git a/internal/server/members.go b/internal/server/members.go index 6d18a79..06b0db8 100644 --- a/internal/server/members.go +++ b/internal/server/members.go @@ -6,28 +6,35 @@ import ( "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" ) type MemberHandler struct { - db *sql.DB + db *sql.DB + checker *permissions.Checker } func NewMemberHandler(db *sql.DB) *MemberHandler { - return &MemberHandler{db: db} + return &MemberHandler{ + db: db, + checker: permissions.NewChecker(db), + } } func (h *MemberHandler) RegisterRoutes(r chi.Router) { r.Get("/members", h.ListMembers) + r.Patch("/members/{userID}", h.UpdateMember) } type memberResponse struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Avatar string `json:"avatar"` - Status string `json:"status"` - StatusText string `json:"status_text"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Nickname *string `json:"nickname"` + Avatar string `json:"avatar"` + Status string `json:"status"` + StatusText string `json:"status_text"` } func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) { @@ -50,7 +57,7 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) { } rows, err := h.db.QueryContext(r.Context(), ` - SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '') + SELECT u.id, u.username, u.display_name, m.nickname, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '') FROM members m JOIN users u ON u.id = m.user_id WHERE m.server_id = $1 @@ -65,12 +72,82 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) { members := make([]memberResponse, 0) for rows.Next() { var m memberResponse - if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar, &m.Status, &m.StatusText); err != nil { + var nick sql.NullString + if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &nick, &m.Avatar, &m.Status, &m.StatusText); err != nil { continue } + if nick.Valid { + m.Nickname = &nick.String + } members = append(members, m) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(members) } + +type updateMemberRequest struct { + Nickname *string `json:"nickname"` +} + +func (h *MemberHandler) UpdateMember(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + serverID := chi.URLParam(r, "serverID") + targetUserID := chi.URLParam(r, "userID") + + var req updateMemberRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + if req.Nickname == nil { + http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) + return + } + + // Check permissions: + // - If self, need CHANGE_NICKNAME + // - If other, need MANAGE_NICKNAMES + var requiredPerm int64 + if userID == targetUserID { + requiredPerm = permissions.CHANGE_NICKNAME + } else { + requiredPerm = permissions.MANAGE_NICKNAMES + } + + allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, requiredPerm) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + // Update nickname in the database + var nickVal sql.NullString + if *req.Nickname != "" { + nickVal = sql.NullString{String: *req.Nickname, Valid: true} + } else { + nickVal = sql.NullString{Valid: false} // NULL value clears the nickname + } + + _, err = h.db.ExecContext(r.Context(), ` + UPDATE members + SET nickname = $1 + WHERE user_id = $2 AND server_id = $3 + `, nickVal, targetUserID, serverID) + if err != nil { + http.Error(w, `{"error":"failed to update nickname"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/roles.go b/internal/server/roles.go index 7b3f47d..f2f1dfc 100644 --- a/internal/server/roles.go +++ b/internal/server/roles.go @@ -80,7 +80,7 @@ type setMemberRolesRequest struct { // Helpers // --------------------------------------------------------------------------- -// requireManageServer checks that the caller has MANAGE_SERVER permission. +// requireManageServer checks that the caller has MANAGE_SERVER or MANAGE_ROLES permission. // Returns false (and writes the error response) if denied. func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool { userID, ok := middleware.UserIDFromContext(r.Context()) @@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request return false } - allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER) + allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_ROLES) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return false } + if !allowed { + allowed, err = h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return false + } + } if !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return false diff --git a/internal/servergroup/handlers.go b/internal/servergroup/handlers.go index 5077a19..7aea373 100644 --- a/internal/servergroup/handlers.go +++ b/internal/servergroup/handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" ) @@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - // Check permission - var hasPerm bool - err = h.db.QueryRowContext(r.Context(), ` - SELECT EXISTS ( - SELECT 1 FROM role_permissions rp - JOIN member_roles mr ON mr.role_id = rp.role_id - WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels' - ) - `, userID, serverID).Scan(&hasPerm) + checker := permissions.NewChecker(h.db) + hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) if err != nil || !hasPerm { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return @@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - var hasPerm bool - err = h.db.QueryRowContext(r.Context(), ` - SELECT EXISTS ( - SELECT 1 FROM role_permissions rp - JOIN member_roles mr ON mr.role_id = rp.role_id - WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels' - ) - `, userID, serverID).Scan(&hasPerm) + checker := permissions.NewChecker(h.db) + hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) if err != nil || !hasPerm { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return @@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } if ownerID != userID { - var hasPerm bool - err = h.db.QueryRowContext(r.Context(), ` - SELECT EXISTS ( - SELECT 1 FROM role_permissions rp - JOIN member_roles mr ON mr.role_id = rp.role_id - WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels' - ) - `, userID, serverID).Scan(&hasPerm) + checker := permissions.NewChecker(h.db) + hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) if err != nil || !hasPerm { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return diff --git a/internal/upload/handlers.go b/internal/upload/handlers.go index f8cf595..87161c5 100644 --- a/internal/upload/handlers.go +++ b/internal/upload/handlers.go @@ -119,7 +119,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"url":"/%s/%s","size":%d}`, h.bucket, objectName, info.Size) + fmt.Fprintf(w, `{"url":"/files/%s","size":%d}`, objectName, info.Size) } func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) { @@ -153,6 +153,11 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", stat.ContentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) - w.Header().Set("Content-Disposition", "attachment") + // Inline display for images; attachment for everything else + if strings.HasPrefix(stat.ContentType, "image/") { + w.Header().Set("Content-Disposition", "inline") + } else { + w.Header().Set("Content-Disposition", "attachment") + } io.Copy(w, obj) } diff --git a/web/public/sw.js b/web/public/sw.js index c98090b..57e6998 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,6 +1,6 @@ // ponytail: bump CACHE_NAME on each deploy, or stale HTML breaks assets. // Network-first for navigation (index.html) keeps hashed asset refs fresh. -const CACHE_NAME = 'dumpster-v2'; +const CACHE_NAME = 'dumpster-v3'; self.addEventListener('install', () => { self.skipWaiting(); @@ -22,6 +22,9 @@ self.addEventListener('fetch', (event) => { // Skip API + WS if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; + // Skip cross-origin requests (e.g. Giphy, LiveKit) + if (url.origin !== self.location.origin) return; + // Network-first for navigation requests (index.html) — always fetch fresh HTML // so hashed asset references stay current after deploys. if (event.request.mode === 'navigate') { diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx index 9f4b902..5f8c698 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -99,6 +99,14 @@ export function ChannelList() { } else { setGroups([]); } + + const handleRefresh = () => { + refreshGroups(); + }; + window.addEventListener('refresh-server-groups', handleRefresh); + return () => { + window.removeEventListener('refresh-server-groups', handleRefresh); + }; }, [activeServerId, fetchChannels]); useEffect(() => { diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 8b6e0ff..69cc998 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react"; import { useMessageStore } from "../stores/message.ts"; +import { api } from "../lib/api.ts"; import { useChannelStore } from "../stores/channel.ts"; import { useServerStore } from "../stores/server.ts"; import { useTypingStore } from "../stores/typing.ts"; @@ -12,6 +13,11 @@ import { useReadStatesStore } from "../stores/readStates.ts"; import { MessageSearch } from "./MessageSearch"; import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadPanel } from "./ThreadPanel.tsx"; +import { useContextMenu } from "./ContextMenu.tsx"; +import { PinnedMessages } from "./PinnedMessages.tsx"; +import { ReactionBar } from "./ReactionBar.tsx"; +import { EmojiPicker } from "./EmojiPicker.tsx"; +import { ReplyBar } from "./ReplyBar.tsx"; import { ForumView } from "./ForumView.tsx"; import { CalendarView } from "./CalendarView.tsx"; import { DocsView } from "./DocsView.tsx"; @@ -59,23 +65,47 @@ function renderContent(content: string, memberUsernames: Set) { ); } + // For plain text segments, render through ReactMarkdown but preserve + // leading/trailing spaces that ReactMarkdown would otherwise trim + const leadingSpace = seg.value.match(/^\s+/)?.[0] || ""; + const trailingSpace = seg.value.match(/\s+$/)?.[0] || ""; + const trimmed = seg.value.slice(leadingSpace.length, seg.value.length - trailingSpace.length); + return ( - , - code: ({ ...props }) => , - pre: ({ ...props }) =>
,
-          blockquote: ({ ...props }) => 
, - table: ({ ...props }) => , - th: ({ ...props }) =>
, - td: ({ ...props }) => , - p: ({ ...props }) => , - }} - > - {seg.value} - + + {leadingSpace} + {trimmed ? ( + , + code: ({ ...props }) => , + pre: ({ ...props }) =>
,
+              blockquote: ({ ...props }) => 
, + table: ({ ...props }) => , + th: ({ ...props }) =>
, + td: ({ ...props }) => , + p: ({ ...props }) => , + img: ({ src, ...props }) => { + const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/") + ? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}` + : src; + return ( + + ); + }, + }} + > + {trimmed} + + ) : null} + {trailingSpace} + ); }); } @@ -109,6 +139,110 @@ function renderEmbeds(embeds?: { url: string; title?: string; description?: stri ); } +const MessageItem = memo(({ + message, + memberUsernames, + selectMode, + isSelected, + onAuthorClick, + onToggleSelect, + onContextMenu, + onAddReaction, + currentUserId, + activeReactionMessageId, + setActiveReactionMessageId, + members, + parentMessage, + onJumpToParent, +}: { + message: any; + memberUsernames: Set; + selectMode: boolean; + isSelected: boolean; + onAuthorClick: (authorId: string) => void; + onToggleSelect: (messageId: string) => void; + onContextMenu: (e: React.MouseEvent, message: any) => void; + onAddReaction: (messageId: string, emoji: string) => void; + currentUserId?: string; + activeReactionMessageId: string | null; + setActiveReactionMessageId: (id: string | null) => void; + members: any[]; + parentMessage: any | null; + onJumpToParent: (id: string) => void; +}) => { + return ( +
selectMode && onToggleSelect(message.id)} + onContextMenu={(e) => onContextMenu(e, message)} + className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${isSelected ? 'bg-gb-bg-s' : ''} relative`} + > + {parentMessage && ( +
{ + e.stopPropagation(); + onJumpToParent(parentMessage.id); + }} + className="cursor-pointer hover:opacity-80 mb-0.5" + > + +
+ )} + {selectMode && ( + + {isSelected ? '[x]' : '[ ]'} + + )} + {formatTime(message.created_at)}{' '} + {message.pinned && [PIN]} + { + e.stopPropagation(); + onAuthorClick(message.author_id); + }}> + <{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}> + {" "} + {renderContent(message.content, memberUsernames)} + {renderEmbeds(message.embeds)} + + {/* Message reactions */} + { + const usernames = r.users.map((uid: string) => { + const m = members.find((member: any) => member.id === uid); + return m ? (m.display_name || m.username) : "unknown user"; + }); + return { + emoji: r.emoji, + count: r.count, + users: usernames, + reacted: r.users.includes(currentUserId), + }; + })} + onRefresh={() => {}} + /> + + {/* Kaomoji picker popover */} + {activeReactionMessageId === message.id && ( + onAddReaction(message.id, emoji)} + onClose={() => setActiveReactionMessageId(null)} + /> + )} +
+ ); +}); +MessageItem.displayName = "MessageItem"; + export function ChatArea() { const activeChannelId = useChannelStore((s) => s.activeChannelId); const channelsByServer = useChannelStore((s) => s.channelsByServer); @@ -155,6 +289,85 @@ export function ChatArea() { const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]); const markRead = useReadStatesStore((s) => s.markRead); + const [showPinned, setShowPinned] = useState(false); + const [activeReactionMessageId, setActiveReactionMessageId] = useState(null); + const [replyToMessage, setReplyToMessage] = useState(null); + const pinMessage = useMessageStore((s) => s.pinMessage); + const unpinMessage = useMessageStore((s) => s.unpinMessage); + const fetchPinnedMessages = useMessageStore((s) => s.fetchPinnedMessages); + const pinnedMessages = useMessageStore((s) => activeChannelId ? s.pinnedMessagesByChannel[activeChannelId] || [] : []); + const pinnedCount = pinnedMessages.length; + + const { showMenu, MenuPortal } = useContextMenu(); + + const handleJumpToMessage = useCallback((messageId: string) => { + const el = document.getElementById(`msg-${messageId}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("terminal-mention"); + setTimeout(() => { + el.classList.remove("terminal-mention"); + }, 2000); + } + }, []); + + const handleAddReaction = useCallback(async (messageId: string, emoji: string) => { + try { + await api.post(`/messages/${messageId}/reactions`, { emoji }); + setActiveReactionMessageId(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add reaction"); + } + }, []); + + const handleMessageContextMenu = useCallback((e: React.MouseEvent, msg: any) => { + if (selectMode) return; + showMenu(e, [ + { + label: "[REPLY]", + onClick: () => { + setReplyToMessage(msg); + } + }, + { + label: "[ADD REACTION]", + onClick: () => { + setActiveReactionMessageId(msg.id); + } + }, + { + label: msg.pinned ? "[UNPIN MESSAGE]" : "[PIN MESSAGE]", + onClick: async () => { + try { + if (msg.pinned) { + await unpinMessage(msg.channel_id, msg.id); + } else { + await pinMessage(msg.channel_id, msg.id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Pin operation failed"); + } + } + } + ]); + }, [selectMode, showMenu, pinMessage, unpinMessage]); + + useEffect(() => { + if (activeChannelId) { + fetchPinnedMessages(activeChannelId); + } + }, [activeChannelId, fetchPinnedMessages]); + + const handleToggleSelect = useCallback((messageId: string) => { + if (activeChannelId) { + toggleSelectedMessage(activeChannelId, messageId); + } + }, [activeChannelId, toggleSelectedMessage]); + + const handleAuthorClick = useCallback((authorId: string) => { + setProfileUserId(authorId); + }, []); + useEffect(() => { if (activeChannelId) { fetchMessages(activeChannelId); @@ -237,20 +450,22 @@ export function ChatArea() { setMentionQuery(null); return; } - const cursor = inputEl.selectionStart ?? input.length; - const beforeCursor = input.slice(0, cursor); + // Read directly from DOM to avoid stale closure + const currentValue = inputEl.value; + const cursor = inputEl.selectionStart ?? currentValue.length; + const beforeCursor = currentValue.slice(0, cursor); const atIndex = beforeCursor.lastIndexOf("@"); if (atIndex === -1) return; - const before = input.slice(0, atIndex); - const after = input.slice(cursor); + const before = currentValue.slice(0, atIndex); + const after = currentValue.slice(cursor); const next = `${before}@${username} ${after}`; setInput(next); setMentionQuery(null); - requestAnimationFrame(() => { - const pos = atIndex + username.length + 2; - inputEl.focus(); - inputEl.setSelectionRange(pos, pos); - }); + // Also set the DOM value immediately so cursor positioning works + inputEl.value = next; + const pos = atIndex + username.length + 2; + inputEl.focus(); + inputEl.setSelectionRange(pos, pos); }; const handleSubmit = async (event: React.FormEvent) => { @@ -258,8 +473,9 @@ export function ChatArea() { if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return; setError(null); try { - await sendMessage(activeChannelId, input.trim()); + await sendMessage(activeChannelId, input.trim(), replyToMessage?.id); setInput(""); + setReplyToMessage(null); setMentionQuery(null); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to send"; @@ -322,29 +538,29 @@ export function ChatArea() { [SELECT] + )} - {showSearch && activeChannelId && activeChannel && ( - setShowSearch(false)} - /> - )} - {showThreads && activeChannelId && activeChannel?.type !== 'forum' && ( - setActiveThread(t)} - onClose={() => setShowThreads(false)} - /> - )} + {selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
{selectedIds.size} selected @@ -384,7 +600,30 @@ export function ChatArea() { ) : ( <> -
+
+ {showSearch && activeChannelId && activeChannel && ( + setShowSearch(false)} + onJump={handleJumpToMessage} + /> + )} + {showPinned && activeChannelId && activeChannel && ( + setShowPinned(false)} + onJump={handleJumpToMessage} + /> + )} + {showThreads && activeChannelId && activeChannel?.type !== 'forum' && ( + setActiveThread(t)} + onClose={() => setShowThreads(false)} + /> + )}
{isLoadingOlder &&

[loading older messages...]

} {isLoading &&

[loading...]

} @@ -392,23 +631,23 @@ export function ChatArea() {

[no messages in this channel]

)} {messages.map((message) => ( -
selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)} - className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`} - > - {selectMode && activeChannelId && ( - - {selectedIds.has(message.id) ? '[x]' : '[ ]'} - - )} - {formatTime(message.created_at)}{' '} - setProfileUserId(message.author_id)}> - <{message.author_username}> - {" "} - {renderContent(message.content, memberUsernames)} - {renderEmbeds(message.embeds)} -
+ message={message} + memberUsernames={memberUsernames} + selectMode={selectMode} + isSelected={selectedIds.has(message.id)} + onAuthorClick={handleAuthorClick} + onToggleSelect={handleToggleSelect} + onContextMenu={handleMessageContextMenu} + onAddReaction={handleAddReaction} + currentUserId={currentUser?.id} + activeReactionMessageId={activeReactionMessageId} + setActiveReactionMessageId={setActiveReactionMessageId} + members={members} + parentMessage={message.reply_to ? messages.find((m) => m.id === message.reply_to) : null} + onJumpToParent={handleJumpToMessage} + /> ))}
@@ -417,13 +656,28 @@ export function ChatArea() { setShowGifPicker(false)} />
)} - {(() => { - const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : []; - if (chTyping.length === 0) return null; - const names = chTyping.map(u => u.username); - const text = names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`; - return
{text}
; - })()} +
+ {(() => { + const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : []; + if (chTyping.length === 0) return "\u00A0"; + const names = chTyping.map(u => u.username); + return names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`; + })()} +
+ {replyToMessage && ( +
+ setReplyToMessage(null)} + /> +
+ )} {error && (

ERR: {error}

@@ -470,6 +724,7 @@ export function ChatArea() { {profileUserId && ( setProfileUserId(null)} /> )} + {MenuPortal}
); } diff --git a/web/src/components/DMChat.tsx b/web/src/components/DMChat.tsx index b807fa5..9f3cfcc 100644 --- a/web/src/components/DMChat.tsx +++ b/web/src/components/DMChat.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useRef, useState, useCallback, memo } from "react"; import { useParams } from "react-router-dom"; import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts"; import { useAuthStore } from "../stores/auth.ts"; @@ -23,6 +23,19 @@ function renderDMContent(content: string) { pre: ({ ...props }) =>
,
         blockquote: ({ ...props }) => 
, p: ({ ...props }) => , + img: ({ src, ...props }) => { + const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/") + ? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}` + : src; + return ( + + ); + }, }} > {content} @@ -30,6 +43,17 @@ function renderDMContent(content: string) { ); } +const DMMessageItem = memo(({ msg }: { msg: ConversationMessage }) => { + return ( +
+ [{formatTime(msg.created_at)}]{" "} + <{msg.author_username}>{" "} + {renderDMContent(msg.content)} +
+ ); +}); +DMMessageItem.displayName = "DMMessageItem"; + export function DMChat() { const { conversationId } = useParams<{ conversationId: string }>(); const activeId = useConversationStore((s) => s.activeConversationId); @@ -116,21 +140,18 @@ export function DMChat() { {!id &&

[select a conversation]

} {id && messages.length === 0 &&

[no messages]

} {messages.map((msg: ConversationMessage) => ( -
- [{formatTime(msg.created_at)}]{" "} - <{msg.author_username}>{" "} - {renderDMContent(msg.content)} -
+ ))}
- {(() => { - const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : []; - if (convTyping.length === 0) return null; - const names = convTyping.map(u => u.username); - const text = names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`; - return
{text}
; - })()} +
+ {(() => { + const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : []; + if (convTyping.length === 0) return "\u00A0"; + const names = convTyping.map(u => u.username); + return names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`; + })()} +
{error && (

ERR: {error}

diff --git a/web/src/components/EmojiPicker.tsx b/web/src/components/EmojiPicker.tsx index ff32315..59be690 100644 --- a/web/src/components/EmojiPicker.tsx +++ b/web/src/components/EmojiPicker.tsx @@ -1,10 +1,127 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; -const EMOJI_LIST = [ - '👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥', - '👀', '💯', '✨', '🙏', '💀', '🤡', '🤔', '😎', - '🫡', '🫠', '🤯', '🥳', '😴', '🤮', '👻', '🎃', - '🚀', '⭐', '🌈', '🍕', '🍺', '☕', '🎮', '🎵', +interface KaomojiItem { + emoji: string; + tags: string[]; +} + +interface Category { + name: string; + tabLabel: string; + items: KaomojiItem[]; +} + +const KAOMOJI_CATEGORIES: Category[] = [ + { + name: 'Bear (Kuma)', + tabLabel: 'Bear', + items: [ + { emoji: 'ʕ•ᴥ•ʔ', tags: ['bear', 'kuma', 'animal', 'happy', 'neutral', 'cute'] }, + { emoji: 'ʕっ•ᴥ•ʔっ', tags: ['bear', 'kuma', 'hug', 'love', 'happy', 'cute'] }, + { emoji: 'ʕ•ᴥ•ʔノ♡', tags: ['bear', 'kuma', 'love', 'wave', 'happy', 'cute'] }, + { emoji: 'ʕ º ᴥ º ʔ', tags: ['bear', 'kuma', 'surprised', 'shocked', 'gasp'] }, + { emoji: 'ʕ◕ᴥ◕ʔ', tags: ['bear', 'kuma', 'cute', 'happy', 'smile'] }, + { emoji: 'ʕ ; ᴥ ; ʔ', tags: ['bear', 'kuma', 'sad', 'crying', 'tear'] }, + { emoji: 'ʕಠᴥಠʔ', tags: ['bear', 'kuma', 'angry', 'mad', 'glare'] }, + { emoji: 'ʕ*ノᴥノʔ', tags: ['bear', 'kuma', 'shy', 'blush', 'cute'] }, + { emoji: 'ʕ⊙ᴥ⊙ʔ', tags: ['bear', 'kuma', 'shocked', 'surprised', 'gasp'] }, + { emoji: 'ʕ/•ᴥ•ʔ/', tags: ['bear', 'kuma', 'happy', 'celebrate', 'dance'] }, + { emoji: 'ʕ·ᴥ·ʔ', tags: ['bear', 'kuma', 'small', 'cute', 'neutral'] }, + { emoji: '⊂( ̄(エ) ̄)⊃', tags: ['bear', 'kuma', 'big', 'hug', 'animal'] }, + { emoji: '(● ̄(エ) ̄●)', tags: ['bear', 'kuma', 'chubby', 'cute', 'neutral'] }, + { emoji: 'ʕ •ᴥ•ʔゝ☆', tags: ['bear', 'kuma', 'magic', 'star', 'happy'] }, + { emoji: 'ʕ•ᴥ•ʔ/', tags: ['bear', 'kuma', 'wave', 'hello', 'happy'] }, + { emoji: 'ʕっ•ᴥ•ʔ', tags: ['bear', 'kuma', 'reach', 'cute'] }, + { emoji: 'ʕ ⊃・ ェ ・ ʔ⊃', tags: ['bear', 'kuma', 'hug', 'reach'] }, + { emoji: 'ʕ ꈍ ᴥ ꈍ ʔ', tags: ['bear', 'kuma', 'shy', 'happy', 'smug'] }, + { emoji: 'ʕ ·(ェ)· ʔ', tags: ['bear', 'kuma', 'classic', 'neutral'] }, + ] + }, + { + name: 'Happy / Laughing', + tabLabel: 'Joy', + items: [ + { emoji: '(^_^)', tags: ['happy', 'smile', 'classic', 'joy'] }, + { emoji: '(^o^)', tags: ['happy', 'laugh', 'excited', 'joy'] }, + { emoji: '(*^ω^*)', tags: ['happy', 'cute', 'smile', 'blush'] }, + { emoji: '(≧▽≦)', tags: ['happy', 'laugh', 'excited', 'joy', 'grin'] }, + { emoji: '(✿◠‿◠)', tags: ['happy', 'flower', 'cute', 'smile', 'peace'] }, + { emoji: '(•‿•)', tags: ['happy', 'smile', 'cute'] }, + { emoji: '(o^^o)', tags: ['happy', 'cute', 'blush', 'smile'] }, + { emoji: '( ‘‿’ )', tags: ['happy', 'smile', 'cute'] }, + { emoji: '\(〇‿〇)/', tags: ['happy', 'celebrate', 'excited', 'dance'] }, + { emoji: '(◕‿◕)', tags: ['happy', 'smile', 'cute'] }, + { emoji: '(>ω<)', tags: ['happy', 'excited', 'cute', 'joy'] }, + { emoji: '(★ω★)', tags: ['happy', 'excited', 'star', 'sparkle', 'eyes'] }, + { emoji: '(*^.^*)', tags: ['happy', 'smile', 'cute', 'shy'] }, + ] + }, + { + name: 'Sad / Crying', + tabLabel: 'Sad', + items: [ + { emoji: '(T_T)', tags: ['sad', 'crying', 'tear', 'classic'] }, + { emoji: '(ಥ﹏ಥ)', tags: ['sad', 'crying', 'tear', 'cute', 'pity'] }, + { emoji: '(;_;)', tags: ['sad', 'crying', 'tear'] }, + { emoji: '(._.)', tags: ['sad', 'depressed', 'down', 'neutral'] }, + { emoji: '(._. )', tags: ['sad', 'depressed', 'down', 'shy'] }, + { emoji: '(╥﹏╥)', tags: ['sad', 'crying', 'tear', 'heavy'] }, + { emoji: '((>_<))', tags: ['sad', 'worried', 'frustrated', 'nervous'] }, + { emoji: '(#>_<)', tags: ['sad', 'frustrated', 'mad', 'angry'] }, + { emoji: '(;﹏;)', tags: ['sad', 'crying', 'tear'] }, + ] + }, + { + name: 'Angry / Glaring', + tabLabel: 'Mad', + items: [ + { emoji: '(ಠ_ಠ)', tags: ['angry', 'mad', 'glare', 'disapproval', 'look'] }, + { emoji: '(╯°□°)╯︵ ┻━┻', tags: ['angry', 'mad', 'table', 'flip', 'rage'] }, + { emoji: '┬─┬ノ( º _ ºノ)', tags: ['neutral', 'table', 'put', 'back', 'calm'] }, + { emoji: '(#`д´)ノ', tags: ['angry', 'mad', 'yell', 'shake'] }, + { emoji: '(¬_¬)', tags: ['suspicious', 'glare', 'annoyed', 'side-eye'] }, + { emoji: '( ̄︿ ̄)', tags: ['annoyed', 'disappointed', 'unhappy'] }, + { emoji: '(╬ಠ益ಠ)', tags: ['angry', 'mad', 'rage', 'furious'] }, + { emoji: '(凸ಠ益ಠ)凸', tags: ['angry', 'mad', 'middle', 'finger', 'rage'] }, + ] + }, + { + name: 'Shy / Cute', + tabLabel: 'Shy', + items: [ + { emoji: '(*n_n*)', tags: ['shy', 'blush', 'cute', 'smile'] }, + { emoji: '(*ノ∀`*)', tags: ['shy', 'blush', 'embarrassed', 'cute'] }, + { emoji: '(ノωヽ)', tags: ['shy', 'blush', 'embarrassed', 'hide', 'cute'] }, + { emoji: '( ꈍ ꈍ )', tags: ['cute', 'shy', 'blush', 'happy'] }, + { emoji: '(⁄ ⁄•⁄ω⁄•⁄ ⁄)', tags: ['cute', 'shy', 'blush', 'super', 'embarrassed'] }, + { emoji: '(❤ω❤)', tags: ['love', 'cute', 'excited', 'heart'] }, + ] + }, + { + name: 'Cats & Animals', + tabLabel: 'Pets', + items: [ + { emoji: '(=^·^=)', tags: ['cat', 'animal', 'cute', 'happy'] }, + { emoji: '(=^·ェ·^=)', tags: ['cat', 'animal', 'cute'] }, + { emoji: '(=^ ◡ ^=)', tags: ['cat', 'animal', 'happy', 'smile'] }, + { emoji: '(=^·^=)ノ', tags: ['cat', 'animal', 'wave', 'hello'] }, + { emoji: '(◕ᴥ◕)', tags: ['animal', 'cute', 'seal'] }, + { emoji: '(=^ ᆺ ^=)', tags: ['cat', 'animal', 'cute'] }, + ] + }, + { + name: 'Action & Funny', + tabLabel: 'Misc', + items: [ + { emoji: '¯\\_(ツ)_/¯', tags: ['funny', 'shrug', 'meh', 'whatever'] }, + { emoji: '( ͡° ͜ʖ ͡°)', tags: ['funny', 'lenny', 'smug', 'face'] }, + { emoji: '(ง •̀_•́)ง', tags: ['action', 'fight', 'determined', 'fist'] }, + { emoji: '(′-′)ノ(._.)', tags: ['action', 'pat', 'comfort', 'friend'] }, + { emoji: '(/°▽°)/', tags: ['action', 'happy', 'throw', 'hands'] }, + { emoji: '(°ロ°)', tags: ['surprised', 'shocked', 'gasp'] }, + { emoji: 'ヽ(°〇°)ノ', tags: ['surprised', 'shocked', 'amaze'] }, + ] + } ]; interface EmojiPickerProps { @@ -14,41 +131,120 @@ interface EmojiPickerProps { export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState('All'); - const filtered = search - ? EMOJI_LIST.filter(e => e.includes(search)) - : EMOJI_LIST; + // Search logic (checks emoji or tags matching) + const searchResults = useMemo(() => { + if (!search) return []; + const query = search.toLowerCase().trim(); + const results: KaomojiItem[] = []; + const seen = new Set(); + + for (const cat of KAOMOJI_CATEGORIES) { + for (const item of cat.items) { + if (seen.has(item.emoji)) continue; + const matchesEmoji = item.emoji.includes(query); + const matchesTags = item.tags.some(tag => tag.includes(query)); + if (matchesEmoji || matchesTags) { + results.push(item); + seen.add(item.emoji); + } + } + } + return results; + }, [search]); + + // Tab filter logic + const displayedItems = useMemo(() => { + if (activeTab === 'All') { + const all: KaomojiItem[] = []; + const seen = new Set(); + for (const cat of KAOMOJI_CATEGORIES) { + for (const item of cat.items) { + if (!seen.has(item.emoji)) { + all.push(item); + seen.add(item.emoji); + } + } + } + return all; + } + const cat = KAOMOJI_CATEGORIES.find(c => c.tabLabel === activeTab); + return cat ? cat.items : []; + }, [activeTab]); return ( -
+
- EMOJI - + KAOMOJI PICKER +
+ setSearch(e.target.value)} - placeholder="search..." - className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none" + placeholder="search (happy, bear, sad, table...)" + className="w-full px-2 py-1 mb-2 text-xs bg-gb-bg-s text-gb-fg border border-gb-bg-t outline-none focus:border-gb-orange" autoFocus /> -
- {filtered.map((emoji) => ( + + {/* Tabs list (only shown if not searching) */} + {!search && ( +
- ))} + {KAOMOJI_CATEGORIES.map(cat => ( + + ))} +
+ )} + + {/* Emoticons grid */} +
+ {search ? ( + searchResults.map((item) => ( + + )) + ) : ( + displayedItems.map((item) => ( + + )) + )}
- {filtered.length === 0 && ( -
[no results]
+ + {search && searchResults.length === 0 && ( +
[no matches found]
)}
); diff --git a/web/src/components/GiphyPicker.tsx b/web/src/components/GiphyPicker.tsx index f99c02d..46836ab 100644 --- a/web/src/components/GiphyPicker.tsx +++ b/web/src/components/GiphyPicker.tsx @@ -97,7 +97,7 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) { ); return ( -
+
{/* Header with search */}
{'>'} search: @@ -120,7 +120,7 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
{/* Results grid */} -
+
{loading && (

[loading...]

)} @@ -128,18 +128,18 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {

[no results]

)} {!loading && results.length > 0 && ( -
+
{results.map((gif) => ( diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx index 93311da..7bf33ef 100644 --- a/web/src/components/MemberList.tsx +++ b/web/src/components/MemberList.tsx @@ -8,6 +8,7 @@ import { MemberContextMenu } from "./MemberContextMenu.tsx"; import { useConversationStore } from "../stores/conversation.ts"; import { useLayoutStore } from "../stores/layout.ts"; import { useNavigate } from "react-router-dom"; +import { UserProfileModal } from "./UserProfileModal.tsx"; function statusIcon(status: UserStatus): string { switch (status) { @@ -48,7 +49,12 @@ function usernameColor(status: UserStatus): string { } } -function MemberRow({ member }: { member: Member }) { +interface MemberRowProps { + member: Member; + onProfileClick: (userId: string) => void; +} + +function MemberRow({ member, onProfileClick }: MemberRowProps) { const presence = usePresenceStore((s) => s.presences[member.id]); const status = presence?.status ?? member.status; const [menuOpen, setMenuOpen] = useState(false); @@ -67,16 +73,27 @@ function MemberRow({ member }: { member: Member }) { return (
- + {!isSelf && ( + + )} +
{menuOpen && !isSelf && activeServerId && ( s.fetchMembers); const presences = usePresenceStore((s) => s.presences); + const [profileUserId, setProfileUserId] = useState(null); + const members = activeServerId ? membersByServer[activeServerId] || [] : []; @@ -164,7 +183,7 @@ export function MemberList() {
ONLINE
---
{online.map((m) => ( - + ))}
)} @@ -173,11 +192,14 @@ export function MemberList() {
OFFLINE
---
{offline.map((m) => ( - + ))}
)}
+ {profileUserId && ( + setProfileUserId(null)} /> + )}
); } diff --git a/web/src/components/MemberRoleAssign.tsx b/web/src/components/MemberRoleAssign.tsx index 47bd003..cd20638 100644 --- a/web/src/components/MemberRoleAssign.tsx +++ b/web/src/components/MemberRoleAssign.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useRoleStore, type Role } from '../stores/role.ts'; import type { User } from '../stores/auth.ts'; +import { usePermissions } from '../lib/usePermissions.ts'; interface MemberRoleAssignProps { serverId: string; @@ -14,6 +15,9 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) { const setMemberRoles = useRoleStore((s) => s.setMemberRoles); const getMemberRoles = useRoleStore((s) => s.getMemberRoles); + const { isOwner, canManageRoles } = usePermissions(serverId); + const canEdit = isOwner || canManageRoles; + const [open, setOpen] = useState(false); const [memberRoles, setMemberRolesState] = useState([]); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -73,13 +77,15 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) { {r.name} ))} - + {canEdit && ( + + )}
{/* Modal */} diff --git a/web/src/components/MentionDropdown.tsx b/web/src/components/MentionDropdown.tsx index e5401b0..4dec02a 100644 --- a/web/src/components/MentionDropdown.tsx +++ b/web/src/components/MentionDropdown.tsx @@ -27,7 +27,10 @@ export function MentionDropdown({ query, members, onSelect }: MentionDropdownPro diff --git a/web/src/components/PinnedMessages.tsx b/web/src/components/PinnedMessages.tsx new file mode 100644 index 0000000..dd6e288 --- /dev/null +++ b/web/src/components/PinnedMessages.tsx @@ -0,0 +1,89 @@ +import { useEffect } from "react"; +import { useMessageStore } from "../stores/message.ts"; + +interface PinnedMessagesProps { + channelId: string; + channelName: string; + onClose: () => void; + onJump?: (messageId: string) => void; +} + +export function PinnedMessages({ channelId, channelName, onClose, onJump }: PinnedMessagesProps) { + const pinnedMessages = useMessageStore((s) => s.pinnedMessagesByChannel[channelId] || []); + const fetchPinnedMessages = useMessageStore((s) => s.fetchPinnedMessages); + const unpinMessage = useMessageStore((s) => s.unpinMessage); + + useEffect(() => { + fetchPinnedMessages(channelId); + }, [channelId, fetchPinnedMessages]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + return ( +
+
+ PINNED IN #{channelName.toUpperCase()} ({pinnedMessages.length}/5) + +
+
+ {pinnedMessages.length === 0 ? ( +
[no pinned messages in this channel]
+ ) : ( + pinnedMessages.map((msg) => ( +
+
+
+ <{msg.author_username}> + + {new Date(msg.created_at).toLocaleString()} + +
+
+ {onJump && ( + + )} + +
+
+
{msg.content}
+
+ )) + )} +
+
+ ); +} diff --git a/web/src/components/RoleManager.tsx b/web/src/components/RoleManager.tsx index 3032598..c36ec8b 100644 --- a/web/src/components/RoleManager.tsx +++ b/web/src/components/RoleManager.tsx @@ -257,8 +257,9 @@ function RoleForm({ serverId, role, onClose }: RoleFormProps) { ); } -export function RoleManager() { - const { serverId } = useParams<{ serverId: string }>(); +export function RoleManager({ serverId: propServerId }: { serverId?: string } = {}) { + const { serverId: paramServerId } = useParams<{ serverId: string }>(); + const serverId = propServerId || paramServerId; const roles = useRoleStore((s) => s.roles); const loading = useRoleStore((s) => s.loading); const error = useRoleStore((s) => s.error); diff --git a/web/src/components/ServerSettingsModal.tsx b/web/src/components/ServerSettingsModal.tsx index 01eec46..2ea83ec 100644 --- a/web/src/components/ServerSettingsModal.tsx +++ b/web/src/components/ServerSettingsModal.tsx @@ -1,6 +1,15 @@ import { useEffect, useState } from 'react'; import { api } from '../lib/api.ts'; import { useServerStore } from '../stores/server.ts'; +import { usePermissions } from '../lib/usePermissions.ts'; +import { RoleManager } from './RoleManager.tsx'; + +interface ServerGroup { + id: string; + server_id: string; + name: string; + position: number; +} interface ServerSettingsModalProps { serverId: string; @@ -47,13 +56,15 @@ const ACTION_LABELS: Record = { const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) { - const [tab, setTab] = useState<'audit' | 'availability'>('audit'); + const [tab, setTab] = useState<'audit' | 'availability' | 'roles' | 'groups'>('audit'); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const servers = useServerStore((s) => s.servers); const server = servers.find((s) => s.id === serverId); + const { isOwner, canManageRoles, canManageChannels, canManageServer } = usePermissions(serverId); + // Availability state const [slots, setSlots] = useState([]); const [availLoading, setAvailLoading] = useState(false); @@ -62,11 +73,23 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr const [editStart, setEditStart] = useState('09:00'); const [editEnd, setEditEnd] = useState('17:00'); + // Groups state + const [groups, setGroups] = useState([]); + const [groupName, setGroupName] = useState(''); + const [editingGroupId, setEditingGroupId] = useState(null); + const [editingGroupName, setEditingGroupName] = useState(''); + const [groupsLoading, setGroupsLoading] = useState(false); + + const handleClose = () => { + window.dispatchEvent(new CustomEvent('refresh-server-groups')); + onClose(); + }; + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); - onClose(); + handleClose(); } }; window.addEventListener('keydown', handleKeyDown); @@ -74,7 +97,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr }, [onClose]); useEffect(() => { - if (tab === 'audit') { + if (tab === 'audit' && (isOwner || canManageServer)) { setLoading(true); setError(null); api.get(`/servers/${serverId}/audit-log`) @@ -87,8 +110,15 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr .then((data) => setSlots(Array.isArray(data) ? data : [])) .catch(() => {}) .finally(() => setAvailLoading(false)); + } else if (tab === 'groups' && (isOwner || canManageChannels)) { + setGroupsLoading(true); + setError(null); + api.get(`/servers/${serverId}/groups`) + .then((data) => setGroups(Array.isArray(data) ? data : [])) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load groups')) + .finally(() => setGroupsLoading(false)); } - }, [tab, serverId]); + }, [tab, serverId, isOwner, canManageServer, canManageChannels]); const formatTime = (iso: string) => { const d = new Date(iso); @@ -116,39 +146,116 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr const slotsForDay = (day: string) => slots.filter((s) => s.day_of_week === day); + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!groupName.trim()) return; + setError(null); + try { + await api.post(`/servers/${serverId}/groups`, { name: groupName.trim() }); + setGroupName(''); + const data = await api.get(`/servers/${serverId}/groups`); + setGroups(Array.isArray(data) ? data : []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create group'); + } + }; + + const handleRenameGroup = async (groupId: string, name: string) => { + if (!name.trim()) return; + setError(null); + try { + await api.patch(`/servers/${serverId}/groups/${groupId}`, { name: name.trim() }); + setEditingGroupId(null); + const data = await api.get(`/servers/${serverId}/groups`); + setGroups(Array.isArray(data) ? data : []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rename group'); + } + }; + + const handleDeleteGroup = async (groupId: string) => { + if (!confirm('Are you sure you want to delete this group? Channels inside will be moved to ungrouped.')) return; + setError(null); + try { + await api.delete(`/servers/${serverId}/groups/${groupId}`); + const data = await api.get(`/servers/${serverId}/groups`); + setGroups(Array.isArray(data) ? data : []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete group'); + } + }; + + const handleMoveGroup = async (groupId: string, direction: 'up' | 'down') => { + const idx = groups.findIndex((g) => g.id === groupId); + if (idx === -1) return; + const targetIdx = direction === 'up' ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= groups.length) return; + + setError(null); + const g1 = groups[idx]; + const g2 = groups[targetIdx]; + try { + await api.patch(`/servers/${serverId}/groups/${g1.id}`, { position: g2.position }); + await api.patch(`/servers/${serverId}/groups/${g2.id}`, { position: g1.position }); + const data = await api.get(`/servers/${serverId}/groups`); + setGroups(Array.isArray(data) ? data : []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reorder group'); + } + }; + return (
e.stopPropagation()} >
SERVER SETTINGS: {server?.name ?? serverId} - +
- + {(isOwner || canManageServer) && ( + + )} + {(isOwner || canManageRoles) && ( + + )} + {(isOwner || canManageChannels) && ( + + )}
+ {error &&

ERR: {error}

} + {tab === 'audit' && ( <> {loading &&

[loading...]

} - {error &&

ERR: {error}

} - {!loading && !error && entries.length === 0 && ( + {!loading && entries.length === 0 && (

[no audit log entries]

)}
@@ -173,6 +280,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
)} + {tab === 'availability' && (
{availLoading &&

[loading...]

} @@ -232,6 +340,110 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
)} + + {tab === 'roles' && ( +
+ +
+ )} + + {tab === 'groups' && ( +
+

manage channel groups for layout organization

+ + {/* Create group form */} +
+ setGroupName(e.target.value)} + className="terminal-input text-xs px-2 py-1 flex-1" + /> + +
+ + {groupsLoading &&

[loading...]

} + {!groupsLoading && groups.length === 0 && ( +

[no groups created yet]

+ )} + +
+ {groups.map((g, index) => { + const isEditing = editingGroupId === g.id; + return ( +
+ {isEditing ? ( +
+ setEditingGroupName(e.target.value)} + className="bg-gb-bg-t text-gb-fg px-1 py-0.5 text-xs outline-none border border-gb-orange flex-1" + /> + + +
+ ) : ( + <> + {g.name} +
+ + + + +
+ + )} +
+ ); + })} +
+
+ )}
diff --git a/web/src/components/UserProfileModal.tsx b/web/src/components/UserProfileModal.tsx index 474da75..d140575 100644 --- a/web/src/components/UserProfileModal.tsx +++ b/web/src/components/UserProfileModal.tsx @@ -1,5 +1,9 @@ import { useEffect, useState } from 'react'; import { useAuthStore, type PublicProfile } from '../stores/auth.ts'; +import { useServerStore } from '../stores/server.ts'; +import { useMemberStore } from '../stores/member.ts'; +import { usePermissions } from '../lib/usePermissions.ts'; +import { MemberRoleAssign } from './MemberRoleAssign.tsx'; interface UserProfileModalProps { userId: string; @@ -9,9 +13,44 @@ interface UserProfileModalProps { export function UserProfileModal({ userId, onClose }: UserProfileModalProps) { const getPublicProfile = useAuthStore((s) => s.getPublicProfile); const blockUser = useAuthStore((s) => s.blockUser); + const currentUser = useAuthStore((s) => s.user); + const activeServerId = useServerStore((s) => s.activeServerId); const [profile, setProfile] = useState(null); const [error, setError] = useState(null); + const isSelf = currentUser?.id === userId; + const { isOwner, canManageServer, canChangeNickname, canManageNicknames } = usePermissions(activeServerId); + const canEditNickname = isOwner || canManageServer || (isSelf ? canChangeNickname : canManageNicknames); + + const membersByServer = useMemberStore((s) => s.membersByServer); + const serverMembers = activeServerId ? membersByServer[activeServerId] || [] : []; + const memberData = serverMembers.find((m) => m.id === userId); + const currentNickname = memberData?.nickname || ''; + + const setMemberNickname = useMemberStore((s) => s.setMemberNickname); + + const [nickname, setNickname] = useState(currentNickname); + const [isEditingNickname, setIsEditingNickname] = useState(false); + const [savingNickname, setSavingNickname] = useState(false); + + useEffect(() => { + setNickname(currentNickname); + }, [currentNickname]); + + const handleSaveNickname = async () => { + if (!activeServerId) return; + setSavingNickname(true); + setError(null); + try { + await setMemberNickname(activeServerId, userId, nickname.trim()); + setIsEditingNickname(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save nickname'); + } finally { + setSavingNickname(false); + } + }; + useEffect(() => { getPublicProfile(userId).then(setProfile).catch(setError); }, [userId, getPublicProfile]); @@ -40,10 +79,54 @@ export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
- {profile.display_name || profile.username} + {currentNickname || profile.display_name || profile.username}
@{profile.username}
{profile.pronouns &&
{profile.pronouns}
} + {activeServerId && ( +
+ NICKNAME: + {isEditingNickname ? ( +
+ setNickname(e.target.value)} + placeholder="no nickname" + className="bg-gb-bg-t text-gb-fg px-1 py-0.5 text-[10px] outline-none border border-gb-orange max-w-[120px]" + /> + + +
+ ) : ( +
+ {currentNickname || '[NONE]'} + {canEditNickname && ( + + )} +
+ )} +
+ )}
{profile.tagline &&
"{profile.tagline}"
} @@ -67,6 +150,12 @@ export function UserProfileModal({ userId, onClose }: UserProfileModalProps) { ))}
)} + {activeServerId && ( +
+
{'>'} ROLES:
+ +
+ )}