added features and fixes

This commit is contained in:
2026-07-02 15:34:00 +00:00
parent eb5b7d55a4
commit 89f8381e2d
30 changed files with 1718 additions and 206 deletions
+3
View File
@@ -103,6 +103,7 @@ CREATE TABLE IF NOT EXISTS messages (
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL, content TEXT NOT NULL,
edited_at TIMESTAMPTZ, edited_at TIMESTAMPTZ,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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_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_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id); 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 = ` const downSQL = `
+36
View File
@@ -3,8 +3,10 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
@@ -262,6 +264,34 @@ func main() {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gifs) 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 // File upload
@@ -312,6 +342,12 @@ func main() {
// File serving (public, for viewing uploaded files) // File serving (public, for viewing uploaded files)
if uploadHandler != nil { if uploadHandler != nil {
r.Get("/files/*", uploadHandler.Serve) 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) // Public webhook execution (no auth required)
+5
View File
@@ -90,10 +90,13 @@ CREATE TABLE IF NOT EXISTS channels (
CREATE TABLE IF NOT EXISTS members ( CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(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(), joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id) PRIMARY KEY (user_id, server_id)
); );
ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100);
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, 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, content TEXT NOT NULL,
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL, reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
edited_at TIMESTAMPTZ, edited_at TIMESTAMPTZ,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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) -- 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 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 ( CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+327 -9
View File
@@ -47,6 +47,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/bulk-delete", h.BulkDelete) r.Post("/bulk-delete", h.BulkDelete)
r.Patch("/{messageID}", h.Update) r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete) 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 { type bulkDeleteRequest struct {
@@ -135,6 +138,21 @@ type embedResponse struct {
SiteName string `json:"site_name"` 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 { type messageResponse struct {
ID string `json:"id"` ID string `json:"id"`
ChannelID string `json:"channel_id"` ChannelID string `json:"channel_id"`
@@ -144,8 +162,10 @@ type messageResponse struct {
Content string `json:"content"` Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"` ReplyTo *string `json:"reply_to,omitempty"`
EditedAt *string `json:"edited_at"` EditedAt *string `json:"edited_at"`
Pinned bool `json:"pinned"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"` Embeds []embedResponse `json:"embeds"`
Reactions []emojiGroup `json:"reactions"`
} }
// 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.
@@ -229,9 +249,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
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, 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( `, 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 { if err != nil {
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError) 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.EditedAt = &editedAt.String
} }
msg.CreatedAt = createdAt.String msg.CreatedAt = createdAt.String
msg.Reactions = make([]emojiGroup, 0)
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server) // Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
h.hub.BroadcastToServer(serverID, gateway.Event{ 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(), ` 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, 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( `, 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 err != nil {
if errors.Is(err, sql.ErrNoRows) { 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. // Re-fetch embeds after edit.
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// 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)
@@ -482,7 +504,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.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 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)
@@ -491,7 +513,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.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 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
@@ -511,7 +533,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var editedAt sql.NullString var editedAt sql.NullString
var createdAt sql.NullString var createdAt sql.NullString
var replyTo 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 { if err != nil {
continue continue
} }
@@ -526,6 +548,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
} }
messages = h.attachEmbeds(r.Context(), messages) messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
// Reverse: query returns DESC (newest first), client expects ASC (oldest first). // 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 { 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 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. // Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID") 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(), ` 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 ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
FROM messages m FROM messages m
JOIN users u ON m.author_id = u.id 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 createdAt sql.NullString
var replyTo sql.NullString var replyTo sql.NullString
var rank float64 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 { if err != nil {
continue continue
} }
@@ -629,6 +731,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
} }
messages = h.attachEmbeds(r.Context(), messages) messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(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)
@@ -673,3 +776,218 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
return userID, serverID, true 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)
}
+14
View File
@@ -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 // CheckPermission reports whether the user has all the required permission bits
// in the given server. ADMINISTRATOR bypasses all checks. // in the given server. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) { 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) perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil { if err != nil {
return false, err 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 // for a specific channel. It layers channel-level overrides on top of server role
// permissions. ADMINISTRATOR bypasses all checks. // permissions. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) { 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) perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil { if err != nil {
return false, err return false, err
+3 -3
View File
@@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
} }
func (h *Handler) RegisterRoutes(r chi.Router) { func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/{messageID}/reactions", h.Add) r.Post("/", h.Add)
r.Delete("/{messageID}/reactions/{emoji}", h.Remove) r.Delete("/{emoji}", h.Remove)
r.Get("/{messageID}/reactions", h.List) r.Get("/", h.List)
} }
type addReactionRequest struct { type addReactionRequest struct {
+80 -3
View File
@@ -6,25 +6,32 @@ import (
"net/http" "net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type MemberHandler struct { type MemberHandler struct {
db *sql.DB db *sql.DB
checker *permissions.Checker
} }
func NewMemberHandler(db *sql.DB) *MemberHandler { 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) { func (h *MemberHandler) RegisterRoutes(r chi.Router) {
r.Get("/members", h.ListMembers) r.Get("/members", h.ListMembers)
r.Patch("/members/{userID}", h.UpdateMember)
} }
type memberResponse struct { type memberResponse struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Nickname *string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Status string `json:"status"` Status string `json:"status"`
StatusText string `json:"status_text"` StatusText string `json:"status_text"`
@@ -50,7 +57,7 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
} }
rows, err := h.db.QueryContext(r.Context(), ` 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 FROM members m
JOIN users u ON u.id = m.user_id JOIN users u ON u.id = m.user_id
WHERE m.server_id = $1 WHERE m.server_id = $1
@@ -65,12 +72,82 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members := make([]memberResponse, 0) members := make([]memberResponse, 0)
for rows.Next() { for rows.Next() {
var m memberResponse 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 continue
} }
if nick.Valid {
m.Nickname = &nick.String
}
members = append(members, m) members = append(members, m)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members) 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)
}
+9 -2
View File
@@ -80,7 +80,7 @@ type setMemberRolesRequest struct {
// Helpers // 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. // Returns false (and writes the error response) if denied.
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool { func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
userID, ok := middleware.UserIDFromContext(r.Context()) userID, ok := middleware.UserIDFromContext(r.Context())
@@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request
return false 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 { if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return false 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 { if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return false return false
+7 -25
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
if ownerID != userID { if ownerID != userID {
// Check permission checker := permissions.NewChecker(h.db)
var hasPerm bool hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
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)
if err != nil || !hasPerm { if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
@@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return return
} }
if ownerID != userID { if ownerID != userID {
var hasPerm bool checker := permissions.NewChecker(h.db)
err = h.db.QueryRowContext(r.Context(), ` hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
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)
if err != nil || !hasPerm { if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
@@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
if ownerID != userID { if ownerID != userID {
var hasPerm bool checker := permissions.NewChecker(h.db)
err = h.db.QueryRowContext(r.Context(), ` hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
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)
if err != nil || !hasPerm { if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
+6 -1
View File
@@ -119,7 +119,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") 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) { 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-Type", stat.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
// 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") w.Header().Set("Content-Disposition", "attachment")
}
io.Copy(w, obj) io.Copy(w, obj)
} }
+4 -1
View File
@@ -1,6 +1,6 @@
// ponytail: bump CACHE_NAME on each deploy, or stale HTML breaks assets. // ponytail: bump CACHE_NAME on each deploy, or stale HTML breaks assets.
// Network-first for navigation (index.html) keeps hashed asset refs fresh. // 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.addEventListener('install', () => {
self.skipWaiting(); self.skipWaiting();
@@ -22,6 +22,9 @@ self.addEventListener('fetch', (event) => {
// Skip API + WS // Skip API + WS
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; 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 // Network-first for navigation requests (index.html) — always fetch fresh HTML
// so hashed asset references stay current after deploys. // so hashed asset references stay current after deploys.
if (event.request.mode === 'navigate') { if (event.request.mode === 'navigate') {
+8
View File
@@ -99,6 +99,14 @@ export function ChannelList() {
} else { } else {
setGroups([]); setGroups([]);
} }
const handleRefresh = () => {
refreshGroups();
};
window.addEventListener('refresh-server-groups', handleRefresh);
return () => {
window.removeEventListener('refresh-server-groups', handleRefresh);
};
}, [activeServerId, fetchChannels]); }, [activeServerId, fetchChannels]);
useEffect(() => { useEffect(() => {
+301 -46
View File
@@ -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 { useMessageStore } from "../stores/message.ts";
import { api } from "../lib/api.ts";
import { useChannelStore } from "../stores/channel.ts"; import { useChannelStore } from "../stores/channel.ts";
import { useServerStore } from "../stores/server.ts"; import { useServerStore } from "../stores/server.ts";
import { useTypingStore } from "../stores/typing.ts"; import { useTypingStore } from "../stores/typing.ts";
@@ -12,6 +13,11 @@ import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch"; import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.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 { ForumView } from "./ForumView.tsx";
import { CalendarView } from "./CalendarView.tsx"; import { CalendarView } from "./CalendarView.tsx";
import { DocsView } from "./DocsView.tsx"; import { DocsView } from "./DocsView.tsx";
@@ -59,9 +65,17 @@ function renderContent(content: string, memberUsernames: Set<string>) {
</span> </span>
); );
} }
// 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 ( return (
<span key={idx}>
{leadingSpace}
{trimmed ? (
<ReactMarkdown <ReactMarkdown
key={idx}
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />, a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
@@ -72,10 +86,26 @@ function renderContent(content: string, memberUsernames: Set<string>) {
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />, 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" />, td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
p: ({ ...props }) => <span {...props} className="inline" />, p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, ...props }) => {
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
: src;
return (
<img
{...props}
src={finalSrc}
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
loading="lazy"
/>
);
},
}} }}
> >
{seg.value} {trimmed}
</ReactMarkdown> </ReactMarkdown>
) : null}
{trailingSpace}
</span>
); );
}); });
} }
@@ -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<string>;
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 (
<div
id={`msg-${message.id}`}
onClick={() => 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 && (
<div
onClick={(e) => {
e.stopPropagation();
onJumpToParent(parentMessage.id);
}}
className="cursor-pointer hover:opacity-80 mb-0.5"
>
<ReplyBar
replyTo={{
id: parentMessage.id,
content: parentMessage.content,
author: {
username: parentMessage.author_username,
}
}}
compact
/>
</div>
)}
{selectMode && (
<span className="text-gb-fg-f mr-2">
{isSelected ? '[x]' : '[ ]'}
</span>
)}
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
e.stopPropagation();
onAuthorClick(message.author_id);
}}>
&lt;{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}&gt;
</span>{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)}
{/* Message reactions */}
<ReactionBar
messageId={message.id}
reactions={(message.reactions || []).map((r: any) => {
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 && (
<EmojiPicker
onSelect={(emoji) => onAddReaction(message.id, emoji)}
onClose={() => setActiveReactionMessageId(null)}
/>
)}
</div>
);
});
MessageItem.displayName = "MessageItem";
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);
@@ -155,6 +289,85 @@ export function ChatArea() {
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]); const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
const markRead = useReadStatesStore((s) => s.markRead); const markRead = useReadStatesStore((s) => s.markRead);
const [showPinned, setShowPinned] = useState(false);
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
const [replyToMessage, setReplyToMessage] = useState<any | null>(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(() => { useEffect(() => {
if (activeChannelId) { if (activeChannelId) {
fetchMessages(activeChannelId); fetchMessages(activeChannelId);
@@ -237,20 +450,22 @@ export function ChatArea() {
setMentionQuery(null); setMentionQuery(null);
return; return;
} }
const cursor = inputEl.selectionStart ?? input.length; // Read directly from DOM to avoid stale closure
const beforeCursor = input.slice(0, cursor); const currentValue = inputEl.value;
const cursor = inputEl.selectionStart ?? currentValue.length;
const beforeCursor = currentValue.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@"); const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) return; if (atIndex === -1) return;
const before = input.slice(0, atIndex); const before = currentValue.slice(0, atIndex);
const after = input.slice(cursor); const after = currentValue.slice(cursor);
const next = `${before}@${username} ${after}`; const next = `${before}@${username} ${after}`;
setInput(next); setInput(next);
setMentionQuery(null); setMentionQuery(null);
requestAnimationFrame(() => { // Also set the DOM value immediately so cursor positioning works
inputEl.value = next;
const pos = atIndex + username.length + 2; const pos = atIndex + username.length + 2;
inputEl.focus(); inputEl.focus();
inputEl.setSelectionRange(pos, pos); inputEl.setSelectionRange(pos, pos);
});
}; };
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
@@ -258,8 +473,9 @@ export function ChatArea() {
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return; if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null); setError(null);
try { try {
await sendMessage(activeChannelId, input.trim()); await sendMessage(activeChannelId, input.trim(), replyToMessage?.id);
setInput(""); setInput("");
setReplyToMessage(null);
setMentionQuery(null); setMentionQuery(null);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Failed to send"; const msg = err instanceof Error ? err.message : "Failed to send";
@@ -322,29 +538,29 @@ export function ChatArea() {
[SELECT] [SELECT]
</button> </button>
<button <button
onClick={() => setShowSearch((prev) => !prev)} onClick={() => {
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono" setShowPinned(false);
setShowSearch((prev) => !prev);
}}
className={`text-xs font-mono ${showSearch ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Search messages" title="Search messages"
> >
[SEARCH] [SEARCH]
</button> </button>
<button
onClick={() => {
setShowSearch(false);
setShowPinned((prev) => !prev);
}}
className={`text-xs font-mono ${showPinned ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Show pinned messages"
>
[PINNED{pinnedCount > 0 ? `:${pinnedCount}` : ''}]
</button>
</div> </div>
)} )}
</div> </div>
{showSearch && activeChannelId && activeChannel && (
<MessageSearch
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowSearch(false)}
/>
)}
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
onClose={() => setShowThreads(false)}
/>
)}
{selectMode && activeChannelId && activeChannel?.type !== 'forum' && ( {selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t"> <div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
<span>{selectedIds.size} selected</span> <span>{selectedIds.size} selected</span>
@@ -384,7 +600,30 @@ export function ChatArea() {
<ListView channelId={activeChannelId} channelName={activeChannel.name} /> <ListView channelId={activeChannelId} channelName={activeChannel.name} />
) : ( ) : (
<> <>
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0 relative">
{showSearch && activeChannelId && activeChannel && (
<MessageSearch
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowSearch(false)}
onJump={handleJumpToMessage}
/>
)}
{showPinned && activeChannelId && activeChannel && (
<PinnedMessages
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowPinned(false)}
onJump={handleJumpToMessage}
/>
)}
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
onClose={() => setShowThreads(false)}
/>
)}
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm"> <div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>} {isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
{isLoading && <p className="text-gb-fg-f">[loading...]</p>} {isLoading && <p className="text-gb-fg-f">[loading...]</p>}
@@ -392,23 +631,23 @@ export function ChatArea() {
<p className="text-gb-fg-f">[no messages in this channel]</p> <p className="text-gb-fg-f">[no messages in this channel]</p>
)} )}
{messages.map((message) => ( {messages.map((message) => (
<div <MessageItem
key={message.id} key={message.id}
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)} message={message}
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`} memberUsernames={memberUsernames}
> selectMode={selectMode}
{selectMode && activeChannelId && ( isSelected={selectedIds.has(message.id)}
<span className="text-gb-fg-f mr-2"> onAuthorClick={handleAuthorClick}
{selectedIds.has(message.id) ? '[x]' : '[ ]'} onToggleSelect={handleToggleSelect}
</span> onContextMenu={handleMessageContextMenu}
)} onAddReaction={handleAddReaction}
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '} currentUserId={currentUser?.id}
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={() => setProfileUserId(message.author_id)}> activeReactionMessageId={activeReactionMessageId}
&lt;{message.author_username}&gt; setActiveReactionMessageId={setActiveReactionMessageId}
</span>{" "} members={members}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span> parentMessage={message.reply_to ? messages.find((m) => m.id === message.reply_to) : null}
{renderEmbeds(message.embeds)} onJumpToParent={handleJumpToMessage}
</div> />
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
@@ -417,13 +656,28 @@ export function ChatArea() {
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} /> <GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div> </div>
)} )}
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
{(() => { {(() => {
const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : []; const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
if (chTyping.length === 0) return null; if (chTyping.length === 0) return "\u00A0";
const names = chTyping.map(u => u.username); 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 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 <div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic">{text}</div>;
})()} })()}
</div>
{replyToMessage && (
<div className="px-3 pb-1">
<ReplyBar
replyTo={{
id: replyToMessage.id,
content: replyToMessage.content,
author: {
username: replyToMessage.author_username,
}
}}
onCancel={() => setReplyToMessage(null)}
/>
</div>
)}
{error && ( {error && (
<div className="px-3 pb-1"> <div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p> <p className="text-gb-red text-xs font-mono">ERR: {error}</p>
@@ -470,6 +724,7 @@ export function ChatArea() {
{profileUserId && ( {profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} /> <UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)} )}
{MenuPortal}
</div> </div>
); );
} }
+30 -9
View File
@@ -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 { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts"; import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts"; import { useAuthStore } from "../stores/auth.ts";
@@ -23,6 +23,19 @@ function renderDMContent(content: string) {
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto 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" />, 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" />, p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, ...props }) => {
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
: src;
return (
<img
{...props}
src={finalSrc}
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
loading="lazy"
/>
);
},
}} }}
> >
{content} {content}
@@ -30,6 +43,17 @@ function renderDMContent(content: string) {
); );
} }
const DMMessageItem = memo(({ msg }: { msg: ConversationMessage }) => {
return (
<div className="break-words">
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</div>
);
});
DMMessageItem.displayName = "DMMessageItem";
export function DMChat() { export function DMChat() {
const { conversationId } = useParams<{ conversationId: string }>(); const { conversationId } = useParams<{ conversationId: string }>();
const activeId = useConversationStore((s) => s.activeConversationId); const activeId = useConversationStore((s) => s.activeConversationId);
@@ -116,21 +140,18 @@ export function DMChat() {
{!id && <p className="text-gb-fg-f">[select a conversation]</p>} {!id && <p className="text-gb-fg-f">[select a conversation]</p>}
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>} {id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
{messages.map((msg: ConversationMessage) => ( {messages.map((msg: ConversationMessage) => (
<div key={msg.id} className="break-words"> <DMMessageItem key={msg.id} msg={msg} />
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</div>
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
{(() => { {(() => {
const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : []; const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : [];
if (convTyping.length === 0) return null; if (convTyping.length === 0) return "\u00A0";
const names = convTyping.map(u => u.username); 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 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 <div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic">{text}</div>;
})()} })()}
</div>
{error && ( {error && (
<div className="px-3 pb-1"> <div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p> <p className="text-gb-red text-xs font-mono">ERR: {error}</p>
+221 -25
View File
@@ -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 { interface EmojiPickerProps {
@@ -14,41 +131,120 @@ interface EmojiPickerProps {
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState('All');
const filtered = search // Search logic (checks emoji or tags matching)
? EMOJI_LIST.filter(e => e.includes(search)) const searchResults = useMemo(() => {
: EMOJI_LIST; if (!search) return [];
const query = search.toLowerCase().trim();
const results: KaomojiItem[] = [];
const seen = new Set<string>();
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<string>();
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 ( return (
<div className="absolute bottom-full left-0 mb-1 bg-gb-bg border border-gb-bg-t p-2 min-w-[200px] z-50"> <div className="absolute bottom-full left-0 mb-1 bg-gb-bg border border-gb-bg-t p-2 w-[340px] max-h-[340px] overflow-y-auto z-50 shadow-lg font-mono">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-gb-fg-f font-mono">EMOJI</span> <span className="text-xs text-gb-fg-f">KAOMOJI PICKER</span>
<button onClick={onClose} className="text-xs text-gb-red font-mono">[x]</button> <button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange font-mono">[x]</button>
</div> </div>
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="search..." placeholder="search (happy, bear, sad, table...)"
className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none" 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 autoFocus
/> />
<div className="grid grid-cols-8 gap-1">
{filtered.map((emoji) => ( {/* Tabs list (only shown if not searching) */}
{!search && (
<div className="flex flex-wrap gap-1 mb-2 border-b border-gb-bg-t pb-1.5 text-[10px]">
<button <button
key={emoji} onClick={() => setActiveTab('All')}
onClick={() => { className={`px-1.5 py-0.5 border ${activeTab === 'All' ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg'}`}
onSelect(emoji);
onClose();
}}
className="w-6 h-6 flex items-center justify-center text-sm hover:bg-gb-bg-s transition-colors"
> >
{emoji} [All]
</button>
{KAOMOJI_CATEGORIES.map(cat => (
<button
key={cat.tabLabel}
onClick={() => setActiveTab(cat.tabLabel)}
className={`px-1.5 py-0.5 border ${activeTab === cat.tabLabel ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg'}`}
>
[{cat.tabLabel}]
</button> </button>
))} ))}
</div> </div>
{filtered.length === 0 && ( )}
<div className="text-xs text-gb-fg-f font-mono text-center py-2">[no results]</div>
{/* Emoticons grid */}
<div className="flex flex-wrap gap-1.5 max-h-56 overflow-y-auto">
{search ? (
searchResults.map((item) => (
<button
key={item.emoji}
onClick={() => {
onSelect(item.emoji);
onClose();
}}
className="px-2 py-1 text-xs bg-gb-bg-s text-gb-fg border border-gb-bg-t hover:border-gb-orange hover:text-gb-orange transition-colors"
title={item.tags.join(', ')}
>
{item.emoji}
</button>
))
) : (
displayedItems.map((item) => (
<button
key={item.emoji}
onClick={() => {
onSelect(item.emoji);
onClose();
}}
className="px-2 py-1 text-xs bg-gb-bg-s text-gb-fg border border-gb-bg-t hover:border-gb-orange hover:text-gb-orange transition-colors"
title={item.tags.join(', ')}
>
{item.emoji}
</button>
))
)}
</div>
{search && searchResults.length === 0 && (
<div className="text-xs text-gb-fg-f text-center py-4">[no matches found]</div>
)} )}
</div> </div>
); );
+6 -6
View File
@@ -97,7 +97,7 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
); );
return ( return (
<div className="border border-gb-bg-t bg-gb-bg rounded font-mono text-sm flex flex-col max-h-80 w-full"> <div className="border border-gb-bg-t bg-gb-bg rounded font-mono text-sm flex flex-col h-72 w-full">
{/* Header with search */} {/* Header with search */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-gb-bg-t"> <div className="flex items-center gap-1 px-2 py-1.5 border-b border-gb-bg-t">
<span className="text-gb-fg-f select-none">{'>'} search:</span> <span className="text-gb-fg-f select-none">{'>'} search:</span>
@@ -120,7 +120,7 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
</div> </div>
{/* Results grid */} {/* Results grid */}
<div className="overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{loading && ( {loading && (
<p className="text-gb-fg-f text-center py-2">[loading...]</p> <p className="text-gb-fg-f text-center py-2">[loading...]</p>
)} )}
@@ -128,18 +128,18 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
<p className="text-gb-fg-f text-center py-2">[no results]</p> <p className="text-gb-fg-f text-center py-2">[no results]</p>
)} )}
{!loading && results.length > 0 && ( {!loading && results.length > 0 && (
<div className="grid grid-cols-3 gap-1"> <div className="columns-3 gap-1 space-y-1">
{results.map((gif) => ( {results.map((gif) => (
<button <button
key={gif.id} key={gif.id}
type="button" type="button"
onClick={() => handleClick(gif)} onClick={() => handleClick(gif)}
className="cursor-pointer border border-transparent hover:border-gb-orange rounded overflow-hidden focus:outline-none focus:border-gb-orange" className="cursor-pointer border border-transparent hover:border-gb-orange rounded overflow-hidden focus:outline-none focus:border-gb-orange w-full break-inside-avoid block"
> >
<img <img
src={gif.images.fixed_height.url} src={`/api/v1/gifs/proxy?url=${encodeURIComponent(gif.images.fixed_height.url)}`}
alt={gif.title || 'GIF'} alt={gif.title || 'GIF'}
className="w-full h-auto object-cover" className="w-full h-auto block"
loading="lazy" loading="lazy"
/> />
</button> </button>
+31 -9
View File
@@ -8,6 +8,7 @@ import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts"; import { useConversationStore } from "../stores/conversation.ts";
import { useLayoutStore } from "../stores/layout.ts"; import { useLayoutStore } from "../stores/layout.ts";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { UserProfileModal } from "./UserProfileModal.tsx";
function statusIcon(status: UserStatus): string { function statusIcon(status: UserStatus): string {
switch (status) { 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 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 [menuOpen, setMenuOpen] = useState(false);
@@ -67,16 +73,27 @@ function MemberRow({ member }: { member: Member }) {
return ( return (
<div className="relative"> <div className="relative">
<button <div
onClick={() => setMenuOpen((v) => !v)} onClick={() => onProfileClick(member.id)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t" className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group"
> >
<span className={statusColor(status)}>{statusIcon(status)}</span> <span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}> <span className={`truncate ${usernameColor(status)} flex-1`}>
{member.display_name || member.username} {member.nickname || member.display_name || member.username}
</span> </span>
<span className="ml-auto text-gb-fg-f text-xs">[...]</span> {!isSelf && (
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="text-gb-fg-f hover:text-gb-orange font-mono text-xs px-1 hover:bg-gb-bg-s rounded shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
title="Member options"
>
[...]
</button> </button>
)}
</div>
{menuOpen && !isSelf && activeServerId && ( {menuOpen && !isSelf && activeServerId && (
<MemberContextMenu <MemberContextMenu
memberId={member.id} memberId={member.id}
@@ -128,6 +145,8 @@ export function MemberList() {
const fetchMembers = useMemberStore((s) => s.fetchMembers); const fetchMembers = useMemberStore((s) => s.fetchMembers);
const presences = usePresenceStore((s) => s.presences); const presences = usePresenceStore((s) => s.presences);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const members = activeServerId const members = activeServerId
? membersByServer[activeServerId] || [] ? membersByServer[activeServerId] || []
: []; : [];
@@ -164,7 +183,7 @@ export function MemberList() {
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div> <div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((m) => ( {online.map((m) => (
<MemberRow key={m.id} member={m} /> <MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))} ))}
</div> </div>
)} )}
@@ -173,11 +192,14 @@ export function MemberList() {
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div> <div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((m) => ( {offline.map((m) => (
<MemberRow key={m.id} member={m} /> <MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))} ))}
</div> </div>
)} )}
</div> </div>
{profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)}
</div> </div>
); );
} }
+6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRoleStore, type Role } from '../stores/role.ts'; import { useRoleStore, type Role } from '../stores/role.ts';
import type { User } from '../stores/auth.ts'; import type { User } from '../stores/auth.ts';
import { usePermissions } from '../lib/usePermissions.ts';
interface MemberRoleAssignProps { interface MemberRoleAssignProps {
serverId: string; serverId: string;
@@ -14,6 +15,9 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
const setMemberRoles = useRoleStore((s) => s.setMemberRoles); const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
const getMemberRoles = useRoleStore((s) => s.getMemberRoles); const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
const { isOwner, canManageRoles } = usePermissions(serverId);
const canEdit = isOwner || canManageRoles;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [memberRoles, setMemberRolesState] = useState<Role[]>([]); const [memberRoles, setMemberRolesState] = useState<Role[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -73,6 +77,7 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
{r.name} {r.name}
</span> </span>
))} ))}
{canEdit && (
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
@@ -80,6 +85,7 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
> >
[ROLES] [ROLES]
</button> </button>
)}
</div> </div>
{/* Modal */} {/* Modal */}
+4 -1
View File
@@ -27,7 +27,10 @@ export function MentionDropdown({ query, members, onSelect }: MentionDropdownPro
<button <button
key={m.id} key={m.id}
type="button" type="button"
onClick={() => onSelect(m.username)} onMouseDown={(e) => {
e.preventDefault();
onSelect(m.username);
}}
className="w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors" className="w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
> >
<span className="text-gb-green"></span> <span className="text-gb-green"></span>
+1 -1
View File
@@ -56,7 +56,7 @@ export function MessageSearch({ channelId, channelName, onClose, onJump }: Messa
}, [results, selectedIndex, onClose, onJump]); }, [results, selectedIndex, onClose, onJump]);
return ( 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="absolute inset-x-0 top-0 z-20 bg-gb-bg border-b border-gb-bg-t shadow-lg">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between"> <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> <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> <button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
+89
View File
@@ -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 (
<div className="absolute inset-x-0 top-0 z-20 bg-gb-bg border-b border-gb-bg-t shadow-lg">
<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">PINNED IN #{channelName.toUpperCase()} ({pinnedMessages.length}/5)</span>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
</div>
<div className="max-h-64 overflow-y-auto font-mono text-xs">
{pinnedMessages.length === 0 ? (
<div className="px-3 py-4 text-center text-gb-fg-f">[no pinned messages in this channel]</div>
) : (
pinnedMessages.map((msg) => (
<div
key={msg.id}
className="w-full text-left px-3 py-2 border-b border-gb-bg-t last:border-b-0 hover:bg-gb-bg-s/20 flex flex-col gap-1"
>
<div className="flex items-center justify-between text-gb-fg-f">
<div className="flex items-center gap-2">
<span className="text-gb-aqua font-bold">&lt;{msg.author_username}&gt;</span>
<span className="text-[10px] text-gb-fg-f">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2">
{onJump && (
<button
onClick={() => {
onJump(msg.id);
onClose();
}}
className="text-gb-aqua hover:text-gb-orange"
title="Jump to message"
>
[JUMP]
</button>
)}
<button
onClick={async () => {
if (confirm("Unpin this message?")) {
try {
await unpinMessage(channelId, msg.id);
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to unpin");
}
}
}}
className="text-gb-red hover:text-gb-orange"
title="Unpin message"
>
[UNPIN]
</button>
</div>
</div>
<div className="text-gb-fg break-words whitespace-pre-wrap">{msg.content}</div>
</div>
))
)}
</div>
</div>
);
}
+3 -2
View File
@@ -257,8 +257,9 @@ function RoleForm({ serverId, role, onClose }: RoleFormProps) {
); );
} }
export function RoleManager() { export function RoleManager({ serverId: propServerId }: { serverId?: string } = {}) {
const { serverId } = useParams<{ serverId: string }>(); const { serverId: paramServerId } = useParams<{ serverId: string }>();
const serverId = propServerId || paramServerId;
const roles = useRoleStore((s) => s.roles); const roles = useRoleStore((s) => s.roles);
const loading = useRoleStore((s) => s.loading); const loading = useRoleStore((s) => s.loading);
const error = useRoleStore((s) => s.error); const error = useRoleStore((s) => s.error);
+221 -9
View File
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../lib/api.ts'; import { api } from '../lib/api.ts';
import { useServerStore } from '../stores/server.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 { interface ServerSettingsModalProps {
serverId: string; serverId: string;
@@ -47,13 +56,15 @@ const ACTION_LABELS: Record<string, string> = {
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) { 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<AuditEntry[]>([]); const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const servers = useServerStore((s) => s.servers); const servers = useServerStore((s) => s.servers);
const server = servers.find((s) => s.id === serverId); const server = servers.find((s) => s.id === serverId);
const { isOwner, canManageRoles, canManageChannels, canManageServer } = usePermissions(serverId);
// Availability state // Availability state
const [slots, setSlots] = useState<AvailabilitySlot[]>([]); const [slots, setSlots] = useState<AvailabilitySlot[]>([]);
const [availLoading, setAvailLoading] = useState(false); const [availLoading, setAvailLoading] = useState(false);
@@ -62,11 +73,23 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
const [editStart, setEditStart] = useState('09:00'); const [editStart, setEditStart] = useState('09:00');
const [editEnd, setEditEnd] = useState('17:00'); const [editEnd, setEditEnd] = useState('17:00');
// Groups state
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [groupName, setGroupName] = useState('');
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editingGroupName, setEditingGroupName] = useState('');
const [groupsLoading, setGroupsLoading] = useState(false);
const handleClose = () => {
window.dispatchEvent(new CustomEvent('refresh-server-groups'));
onClose();
};
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
onClose(); handleClose();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
@@ -74,7 +97,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
}, [onClose]); }, [onClose]);
useEffect(() => { useEffect(() => {
if (tab === 'audit') { if (tab === 'audit' && (isOwner || canManageServer)) {
setLoading(true); setLoading(true);
setError(null); setError(null);
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`) api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
@@ -87,8 +110,15 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
.then((data) => setSlots(Array.isArray(data) ? data : [])) .then((data) => setSlots(Array.isArray(data) ? data : []))
.catch(() => {}) .catch(() => {})
.finally(() => setAvailLoading(false)); .finally(() => setAvailLoading(false));
} else if (tab === 'groups' && (isOwner || canManageChannels)) {
setGroupsLoading(true);
setError(null);
api.get<ServerGroup[]>(`/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 formatTime = (iso: string) => {
const d = new Date(iso); 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 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<ServerGroup[]>(`/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<ServerGroup[]>(`/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<ServerGroup[]>(`/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<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reorder group');
}
};
return ( return (
<div <div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose} onClick={handleClose}
> >
<div <div
className="bg-gb-bg border border-gb-bg-t w-[700px] max-h-[80vh] flex flex-col font-mono" className="bg-gb-bg border border-gb-bg-t w-[750px] max-h-[85vh] flex flex-col font-mono"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t"> <div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
<span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span> <span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button> <button onClick={handleClose} className="text-xs text-gb-red">[x]</button>
</div> </div>
<div className="flex border-b border-gb-bg-t"> <div className="flex border-b border-gb-bg-t">
{(isOwner || canManageServer) && (
<button <button
onClick={() => setTab('audit')} onClick={() => setTab('audit')}
className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`} className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
> >
[AUDIT LOG] [AUDIT LOG]
</button> </button>
)}
<button <button
onClick={() => setTab('availability')} onClick={() => setTab('availability')}
className={`px-4 py-2 text-xs ${tab === 'availability' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`} className={`px-4 py-2 text-xs ${tab === 'availability' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
> >
[AVAILABILITY] [AVAILABILITY]
</button> </button>
{(isOwner || canManageRoles) && (
<button
onClick={() => setTab('roles')}
className={`px-4 py-2 text-xs ${tab === 'roles' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[ROLES]
</button>
)}
{(isOwner || canManageChannels) && (
<button
onClick={() => setTab('groups')}
className={`px-4 py-2 text-xs ${tab === 'groups' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[GROUPS]
</button>
)}
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{error && <p className="text-gb-red text-xs mb-3">ERR: {error}</p>}
{tab === 'audit' && ( {tab === 'audit' && (
<> <>
{loading && <p className="text-gb-fg-f text-xs">[loading...]</p>} {loading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{error && <p className="text-gb-red text-xs">ERR: {error}</p>} {!loading && entries.length === 0 && (
{!loading && !error && entries.length === 0 && (
<p className="text-gb-fg-f text-xs">[no audit log entries]</p> <p className="text-gb-fg-f text-xs">[no audit log entries]</p>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -173,6 +280,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
</div> </div>
</> </>
)} )}
{tab === 'availability' && ( {tab === 'availability' && (
<div className="space-y-4"> <div className="space-y-4">
{availLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>} {availLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
@@ -232,6 +340,110 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
</div> </div>
</div> </div>
)} )}
{tab === 'roles' && (
<div className="h-[60vh] overflow-y-auto">
<RoleManager serverId={serverId} />
</div>
)}
{tab === 'groups' && (
<div className="space-y-4">
<p className="text-xs text-gb-fg-s">manage channel groups for layout organization</p>
{/* Create group form */}
<form onSubmit={handleCreateGroup} className="flex gap-2 items-center">
<input
type="text"
placeholder="group name..."
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="terminal-input text-xs px-2 py-1 flex-1"
/>
<button type="submit" className="px-3 py-1 bg-gb-orange text-gb-bg text-xs">[CREATE GROUP]</button>
</form>
{groupsLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{!groupsLoading && groups.length === 0 && (
<p className="text-gb-fg-f text-xs">[no groups created yet]</p>
)}
<div className="space-y-2 mt-2">
{groups.map((g, index) => {
const isEditing = editingGroupId === g.id;
return (
<div key={g.id} className="flex items-center justify-between border border-gb-bg-t p-2 bg-gb-bg-s text-xs">
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editingGroupName}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => handleRenameGroup(g.id, editingGroupName)}
className="text-gb-green hover:underline font-bold"
>
[SAVE]
</button>
<button
type="button"
onClick={() => setEditingGroupId(null)}
className="text-gb-fg-f hover:underline"
>
[CANCEL]
</button>
</div>
) : (
<>
<span className="text-gb-fg-s font-bold uppercase">{g.name}</span>
<div className="flex items-center gap-2">
<button
type="button"
disabled={index === 0}
onClick={() => handleMoveGroup(g.id, 'up')}
className="text-gb-fg-f hover:text-gb-orange disabled:opacity-30"
title="Move Up"
>
</button>
<button
type="button"
disabled={index === groups.length - 1}
onClick={() => handleMoveGroup(g.id, 'down')}
className="text-gb-fg-f hover:text-gb-orange disabled:opacity-30"
title="Move Down"
>
</button>
<button
type="button"
onClick={() => {
setEditingGroupId(g.id);
setEditingGroupName(g.name);
}}
className="text-gb-aqua hover:underline"
>
[RENAME]
</button>
<button
type="button"
onClick={() => handleDeleteGroup(g.id)}
className="text-gb-red hover:underline"
>
[DELETE]
</button>
</div>
</>
)}
</div>
);
})}
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+90 -1
View File
@@ -1,5 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuthStore, type PublicProfile } from '../stores/auth.ts'; 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 { interface UserProfileModalProps {
userId: string; userId: string;
@@ -9,9 +13,44 @@ interface UserProfileModalProps {
export function UserProfileModal({ userId, onClose }: UserProfileModalProps) { export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
const getPublicProfile = useAuthStore((s) => s.getPublicProfile); const getPublicProfile = useAuthStore((s) => s.getPublicProfile);
const blockUser = useAuthStore((s) => s.blockUser); const blockUser = useAuthStore((s) => s.blockUser);
const currentUser = useAuthStore((s) => s.user);
const activeServerId = useServerStore((s) => s.activeServerId);
const [profile, setProfile] = useState<PublicProfile | null>(null); const [profile, setProfile] = useState<PublicProfile | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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(() => { useEffect(() => {
getPublicProfile(userId).then(setProfile).catch(setError); getPublicProfile(userId).then(setProfile).catch(setError);
}, [userId, getPublicProfile]); }, [userId, getPublicProfile]);
@@ -40,10 +79,54 @@ export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
<div className="mt-12"> <div className="mt-12">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${statusColor}`} /> <span className={`w-2 h-2 rounded-full ${statusColor}`} />
<span className="text-gb-fg font-bold">{profile.display_name || profile.username}</span> <span className="text-gb-fg font-bold">{currentNickname || profile.display_name || profile.username}</span>
</div> </div>
<div className="text-gb-fg-s text-xs">@{profile.username}</div> <div className="text-gb-fg-s text-xs">@{profile.username}</div>
{profile.pronouns && <div className="text-gb-fg-t text-[10px]">{profile.pronouns}</div>} {profile.pronouns && <div className="text-gb-fg-t text-[10px]">{profile.pronouns}</div>}
{activeServerId && (
<div className="text-[10px] font-mono mt-1 flex items-center gap-1.5">
<span className="text-gb-fg-t">NICKNAME:</span>
{isEditingNickname ? (
<div className="flex items-center gap-1">
<input
type="text"
value={nickname}
onChange={(e) => 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]"
/>
<button
onClick={handleSaveNickname}
disabled={savingNickname}
className="text-gb-green hover:underline font-bold"
>
[SAVE]
</button>
<button
onClick={() => {
setNickname(currentNickname);
setIsEditingNickname(false);
}}
className="text-gb-fg-f hover:underline"
>
[X]
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="text-gb-fg-s">{currentNickname || '[NONE]'}</span>
{canEditNickname && (
<button
onClick={() => setIsEditingNickname(true)}
className="text-gb-aqua hover:underline"
>
[EDIT]
</button>
)}
</div>
)}
</div>
)}
</div> </div>
</div> </div>
{profile.tagline && <div className="text-gb-fg text-sm mb-2 italic">"{profile.tagline}"</div>} {profile.tagline && <div className="text-gb-fg text-sm mb-2 italic">"{profile.tagline}"</div>}
@@ -67,6 +150,12 @@ export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
))} ))}
</div> </div>
)} )}
{activeServerId && (
<div className="mb-4 mt-2">
<div className="text-xs text-gb-fg-t font-mono mb-1">{'>'} ROLES:</div>
<MemberRoleAssign serverId={activeServerId} member={profile as any} />
</div>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[CLOSE]</button> <button onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[CLOSE]</button>
<button <button
+7
View File
@@ -15,6 +15,9 @@ const PERMS = {
CONNECT_VOICE: 256, CONNECT_VOICE: 256,
SPEAK_VOICE: 512, SPEAK_VOICE: 512,
SHARE_SCREEN: 1024, SHARE_SCREEN: 1024,
CHANGE_NICKNAME: 8192,
MANAGE_NICKNAMES: 16384,
MANAGE_ROLES: 32768,
} as const; } as const;
export { PERMS }; export { PERMS };
@@ -45,6 +48,10 @@ export function usePermissions(serverId: string | null) {
canBan: has(PERMS.BAN_MEMBERS), canBan: has(PERMS.BAN_MEMBERS),
canMute: has(PERMS.MANAGE_MESSAGES), canMute: has(PERMS.MANAGE_MESSAGES),
canManageChannels: has(PERMS.MANAGE_CHANNELS), canManageChannels: has(PERMS.MANAGE_CHANNELS),
canManageRoles: has(PERMS.MANAGE_ROLES),
canManageServer: has(PERMS.MANAGE_SERVER),
canChangeNickname: has(PERMS.CHANGE_NICKNAME),
canManageNicknames: has(PERMS.MANAGE_NICKNAMES),
isOwner, isOwner,
}; };
} }
+15
View File
@@ -5,6 +5,7 @@ export interface Member {
id: string; id: string;
username: string; username: string;
display_name: string; display_name: string;
nickname?: string;
avatar: string; avatar: string;
status: "online" | "idle" | "dnd" | "offline"; status: "online" | "idle" | "dnd" | "offline";
status_text: string; status_text: string;
@@ -15,6 +16,7 @@ interface MemberState {
isLoading: boolean; isLoading: boolean;
fetchMembers: (serverId: string) => Promise<void>; fetchMembers: (serverId: string) => Promise<void>;
updateMemberStatus: (userId: string, status: Member["status"]) => void; updateMemberStatus: (userId: string, status: Member["status"]) => void;
setMemberNickname: (serverId: string, userId: string, nickname: string) => Promise<void>;
} }
export const useMemberStore = create<MemberState>((set) => ({ export const useMemberStore = create<MemberState>((set) => ({
@@ -46,4 +48,17 @@ export const useMemberStore = create<MemberState>((set) => ({
} }
return { membersByServer: next }; return { membersByServer: next };
}), }),
setMemberNickname: async (serverId, userId, nickname) => {
await api.patch(`/servers/${serverId}/members/${userId}`, { nickname });
set((state) => {
const list = state.membersByServer[serverId] || [];
const updated = list.map((m) =>
m.id === userId ? { ...m, nickname: nickname || undefined } : m
);
return {
membersByServer: { ...state.membersByServer, [serverId]: updated },
};
});
},
})); }));
+116 -3
View File
@@ -10,6 +10,12 @@ export interface MessageEmbed {
site_name?: string; site_name?: string;
} }
export interface Reaction {
emoji: string;
count: number;
users: string[];
}
export interface Message { export interface Message {
id: string; id: string;
channel_id: string; channel_id: string;
@@ -19,6 +25,8 @@ export interface Message {
content: string; content: string;
reply_to?: string | null; reply_to?: string | null;
embeds?: MessageEmbed[]; embeds?: MessageEmbed[];
pinned: boolean;
reactions?: Reaction[];
created_at: string; created_at: string;
edited_at: string | null; edited_at: string | null;
} }
@@ -29,6 +37,7 @@ export interface SearchResultMessage extends Message {
export interface MessageState { export interface MessageState {
messagesByChannel: Record<string, Message[]>; messagesByChannel: Record<string, Message[]>;
pinnedMessagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>; searchResultsByChannel: Record<string, SearchResultMessage[]>;
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
isLoading: boolean; isLoading: boolean;
@@ -39,9 +48,14 @@ export interface MessageState {
fetchOlderMessages: (channelId: string) => Promise<void>; fetchOlderMessages: (channelId: string) => Promise<void>;
sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>; sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>;
searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>; searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>;
pinMessage: (channelId: string, messageId: string) => Promise<void>;
unpinMessage: (channelId: string, messageId: string) => Promise<void>;
fetchPinnedMessages: (channelId: string) => Promise<Message[]>;
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;
addReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void;
removeReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void;
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>; bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
toggleSelectedMessage: (channelId: string, messageId: string) => void; toggleSelectedMessage: (channelId: string, messageId: string) => void;
clearSelectedMessages: (channelId: string) => void; clearSelectedMessages: (channelId: string) => void;
@@ -49,6 +63,7 @@ export interface MessageState {
export const useMessageStore = create<MessageState>((set, get) => ({ export const useMessageStore = create<MessageState>((set, get) => ({
messagesByChannel: {}, messagesByChannel: {},
pinnedMessagesByChannel: {},
searchResultsByChannel: {}, searchResultsByChannel: {},
selectedMessageIds: {}, selectedMessageIds: {},
isLoading: false, isLoading: false,
@@ -139,6 +154,28 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return message; return message;
}, },
pinMessage: async (channelId, messageId) => {
const updated = await api.put<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
get().updateMessage(updated);
},
unpinMessage: async (channelId, messageId) => {
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
get().updateMessage(updated);
},
fetchPinnedMessages: async (channelId) => {
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
const list = Array.isArray(pinned) ? pinned : [];
set((state) => ({
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[channelId]: list,
},
}));
return list;
},
addMessage: (message) => addMessage: (message) =>
set((state) => { set((state) => {
const list = state.messagesByChannel[message.channel_id] || []; const list = state.messagesByChannel[message.channel_id] || [];
@@ -156,12 +193,35 @@ export const useMessageStore = create<MessageState>((set, get) => ({
updateMessage: (message) => updateMessage: (message) =>
set((state) => { set((state) => {
const list = state.messagesByChannel[message.channel_id] || []; const list = state.messagesByChannel[message.channel_id] || [];
const updatedList = list.map((m) =>
m.id === message.id ? message : m,
);
const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || [];
let updatedPinned = [...pinnedList];
if (message.pinned) {
if (!pinnedList.some((m) => m.id === message.id)) {
updatedPinned = [message, ...pinnedList].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} else {
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
}
} else {
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
}
if (updatedPinned.length > 5) {
updatedPinned = updatedPinned.slice(0, 5);
}
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[message.channel_id]: list.map((m) => [message.channel_id]: updatedList,
m.id === message.id ? message : m, },
), pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[message.channel_id]: updatedPinned,
}, },
}; };
}), }),
@@ -169,11 +229,64 @@ export const useMessageStore = create<MessageState>((set, get) => ({
removeMessage: (channelId, messageId) => removeMessage: (channelId, messageId) =>
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[channelId] || [];
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId), [channelId]: list.filter((m) => m.id !== messageId),
}, },
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[channelId]: pinnedList.filter((m) => m.id !== messageId),
},
};
}),
addReaction: (channelId, messageId, emoji, userId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const updatedList = list.map((m) => {
if (m.id !== messageId) return m;
const reactions = m.reactions ? [...m.reactions] : [];
const existing = reactions.find((r) => r.emoji === emoji);
if (existing) {
if (!existing.users.includes(userId)) {
existing.users = [...existing.users, userId];
existing.count = existing.users.length;
}
} else {
reactions.push({ emoji, count: 1, users: [userId] });
}
return { ...m, reactions };
});
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: updatedList,
},
};
}),
removeReaction: (channelId, messageId, emoji, userId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const updatedList = list.map((m) => {
if (m.id !== messageId) return m;
if (!m.reactions) return m;
const reactions = m.reactions
.map((r) => {
if (r.emoji !== emoji) return r;
const users = r.users.filter((id) => id !== userId);
return { ...r, users, count: users.length };
})
.filter((r) => r.count > 0);
return { ...m, reactions };
});
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: updatedList,
},
}; };
}), }),
+1 -1
View File
@@ -40,7 +40,7 @@ export const useTypingStore = create<TypingState>((set, get) => ({
[channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id), [channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id),
}, },
})); }));
}, 3000); }, 6000);
set(state => ({ set(state => ({
typingUsers: { typingUsers: {
+25
View File
@@ -115,6 +115,8 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
const addMessage = useMessageStore.getState().addMessage; const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage; const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage; const removeMessage = useMessageStore.getState().removeMessage;
const addReaction = useMessageStore.getState().addReaction;
const removeReaction = useMessageStore.getState().removeReaction;
const addChannel = useChannelStore.getState().addChannel; const addChannel = useChannelStore.getState().addChannel;
const updateChannel = useChannelStore.getState().updateChannel; const updateChannel = useChannelStore.getState().updateChannel;
const removeChannel = useChannelStore.getState().removeChannel; const removeChannel = useChannelStore.getState().removeChannel;
@@ -163,6 +165,29 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (ids) removeMessage(ids.channel_id, ids.message_id); if (ids) removeMessage(ids.channel_id, ids.message_id);
break; break;
} }
case 'REACTION_ADD': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const reaction = isRecord(payload.reaction) ? payload.reaction : null;
if (channelId && messageId && reaction) {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.user_id === 'string' ? reaction.user_id : '';
if (emoji && userId) {
addReaction(channelId, messageId, emoji, userId);
}
}
break;
}
case 'REACTION_REMOVE': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const emoji = typeof payload.emoji === 'string' ? payload.emoji : '';
const userId = typeof payload.user_id === 'string' ? payload.user_id : '';
if (channelId && messageId && emoji && userId) {
removeReaction(channelId, messageId, emoji, userId);
}
break;
}
case 'CHANNEL_CREATE': { case 'CHANNEL_CREATE': {
if (isRecord(payload)) addChannel(payload as unknown as Channel); if (isRecord(payload)) addChannel(payload as unknown as Channel);
break; break;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.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/ListView.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/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.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/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.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/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.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/ListView.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/PinnedMessages.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.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/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}