Phase 3: Polish & PWA
Backend: - DB: reactions table, invites table, reply_to column on messages - gateway/events.go: added REACTION_ADD, REACTION_REMOVE events - internal/reaction/handlers.go: reaction CRUD with WebSocket broadcast - internal/invite/handlers.go: invite creation, info, join with code - gateway/hub.go: presence tracking with idle detection - gateway/client.go: idle timeout support Frontend - Social: - TypingIndicator: real-time 'user is typing...' display - ReactionBar: emoji reactions on messages with counts - EmojiPicker: searchable emoji grid for reactions - ReplyBar: quoted reply display above messages - MentionPopup: @mention autocomplete with user list Frontend - PWA: - manifest.json: PWA manifest with theme color and icons - sw.js: service worker with cache-first strategy and push support - stores/push.ts: push notification subscription management - InstallPrompt: 'Add to Home Screen' banner Frontend - Mobile: - MobileNav: bottom nav bar for mobile (servers/channels/chat/members) - MobileDrawer: slide-out drawer with server bar + channel list - index.html: PWA meta tags, safe area viewport Frontend - Polish: - ThemeToggle: dark/light mode switch with localStorage persistence - InviteModal: generate invite links with expiry and max uses - JoinServer: /invite/:code join flow - stores/typing.ts: typing indicator state management - stores/presence.ts: real-time presence tracking - tailwind.config.js: darkMode: 'class', light mode color tokens - styles/index.css: light mode CSS variable overrides
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ func main() {
|
|||||||
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
||||||
|
|
||||||
// WebSocket hub
|
// WebSocket hub
|
||||||
hub := gateway.NewHub(logger)
|
hub := gateway.NewHub(database.DB, logger)
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
// Giphy client (nil if no API key)
|
// Giphy client (nil if no API key)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
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,
|
||||||
|
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
edited_at TIMESTAMPTZ,
|
edited_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -112,4 +113,28 @@ 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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
emoji VARCHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (message_id, user_id, emoji)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(16) NOT NULL UNIQUE,
|
||||||
|
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
max_uses INTEGER,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func (c *Client) readPump() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record activity for idle detection.
|
||||||
|
c.Hub.RecordActivity(c.UserID)
|
||||||
|
|
||||||
// Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE)
|
// Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE)
|
||||||
// For now, broadcast them to all clients
|
// For now, broadcast them to all clients
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const (
|
|||||||
EventMemberRemove = "SERVER_MEMBER_REMOVE"
|
EventMemberRemove = "SERVER_MEMBER_REMOVE"
|
||||||
EventPresenceUpdate = "PRESENCE_UPDATE"
|
EventPresenceUpdate = "PRESENCE_UPDATE"
|
||||||
EventTypingStart = "TYPING_START"
|
EventTypingStart = "TYPING_START"
|
||||||
|
EventReactionAdd = "REACTION_ADD"
|
||||||
|
EventReactionRemove = "REACTION_REMOVE"
|
||||||
EventVoiceJoin = "VOICE_JOIN"
|
EventVoiceJoin = "VOICE_JOIN"
|
||||||
EventVoiceLeave = "VOICE_LEAVE"
|
EventVoiceLeave = "VOICE_LEAVE"
|
||||||
EventVoiceMute = "VOICE_MUTE"
|
EventVoiceMute = "VOICE_MUTE"
|
||||||
|
|||||||
+120
-1
@@ -1,40 +1,70 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// idleTimeout is the duration of inactivity before a user is set to idle.
|
||||||
|
idleTimeout = 5 * time.Minute
|
||||||
|
// idleCheckInterval is how often the hub checks for idle users.
|
||||||
|
idleCheckInterval = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// PresenceData is the data payload for PRESENCE_UPDATE events.
|
||||||
|
type PresenceData struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
// Hub maintains the set of active clients and broadcasts messages to them.
|
// Hub maintains the set of active clients and broadcasts messages to them.
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
clients map[*Client]struct{}
|
clients map[*Client]struct{}
|
||||||
|
presence map[string]string // userID -> status
|
||||||
|
lastActivity map[string]time.Time // userID -> last activity time
|
||||||
register chan *Client
|
register chan *Client
|
||||||
unregister chan *Client
|
unregister chan *Client
|
||||||
broadcast chan []byte
|
broadcast chan []byte
|
||||||
|
db *sql.DB
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new Hub.
|
// NewHub creates a new Hub.
|
||||||
func NewHub(logger *slog.Logger) *Hub {
|
func NewHub(db *sql.DB, logger *slog.Logger) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
clients: make(map[*Client]struct{}),
|
clients: make(map[*Client]struct{}),
|
||||||
|
presence: make(map[string]string),
|
||||||
|
lastActivity: make(map[string]time.Time),
|
||||||
register: make(chan *Client),
|
register: make(chan *Client),
|
||||||
unregister: make(chan *Client),
|
unregister: make(chan *Client),
|
||||||
broadcast: make(chan []byte, 256),
|
broadcast: make(chan []byte, 256),
|
||||||
|
db: db,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the hub's main event loop. Call this in a goroutine.
|
// Run starts the hub's main event loop. Call this in a goroutine.
|
||||||
func (h *Hub) Run() {
|
func (h *Hub) Run() {
|
||||||
|
idleTicker := time.NewTicker(idleCheckInterval)
|
||||||
|
defer idleTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case client := <-h.register:
|
case client := <-h.register:
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.clients[client] = struct{}{}
|
h.clients[client] = struct{}{}
|
||||||
|
h.presence[client.UserID] = "online"
|
||||||
|
h.lastActivity[client.UserID] = time.Now()
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.setUserStatus(client.UserID, "online")
|
||||||
|
h.broadcastPresence(client.UserID, "online")
|
||||||
h.logger.Info("client connected", "user_id", client.UserID)
|
h.logger.Info("client connected", "user_id", client.UserID)
|
||||||
|
|
||||||
case client := <-h.unregister:
|
case client := <-h.unregister:
|
||||||
@@ -43,7 +73,19 @@ func (h *Hub) Run() {
|
|||||||
delete(h.clients, client)
|
delete(h.clients, client)
|
||||||
close(client.send)
|
close(client.send)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has any remaining connected clients.
|
||||||
|
hasOther := h.hasClientForUser(client.UserID)
|
||||||
|
if !hasOther {
|
||||||
|
delete(h.presence, client.UserID)
|
||||||
|
delete(h.lastActivity, client.UserID)
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if !hasOther {
|
||||||
|
h.setUserStatus(client.UserID, "offline")
|
||||||
|
h.broadcastPresence(client.UserID, "offline")
|
||||||
|
}
|
||||||
h.logger.Info("client disconnected", "user_id", client.UserID)
|
h.logger.Info("client disconnected", "user_id", client.UserID)
|
||||||
|
|
||||||
case message := <-h.broadcast:
|
case message := <-h.broadcast:
|
||||||
@@ -58,10 +100,87 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
case <-idleTicker.C:
|
||||||
|
h.checkIdleUsers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasClientForUser returns true if any connected client belongs to the given
|
||||||
|
// user. Caller must hold h.mu (at least for reading).
|
||||||
|
func (h *Hub) hasClientForUser(userID string) bool {
|
||||||
|
for c := range h.clients {
|
||||||
|
if c.UserID == userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordActivity updates the last-activity timestamp for a user. If the user
|
||||||
|
// was idle, it resets their status back to online and broadcasts the change.
|
||||||
|
// Safe to call from any goroutine.
|
||||||
|
func (h *Hub) RecordActivity(userID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.lastActivity[userID] = time.Now()
|
||||||
|
wasIdle := h.presence[userID] == "idle"
|
||||||
|
if wasIdle {
|
||||||
|
h.presence[userID] = "online"
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if wasIdle {
|
||||||
|
h.setUserStatus(userID, "online")
|
||||||
|
h.broadcastPresence(userID, "online")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIdleUsers scans all tracked users and marks as idle any who have been
|
||||||
|
// inactive for longer than idleTimeout.
|
||||||
|
func (h *Hub) checkIdleUsers() {
|
||||||
|
h.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
var idleUsers []string
|
||||||
|
for userID, last := range h.lastActivity {
|
||||||
|
if h.presence[userID] == "online" && now.Sub(last) >= idleTimeout {
|
||||||
|
h.presence[userID] = "idle"
|
||||||
|
idleUsers = append(idleUsers, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, userID := range idleUsers {
|
||||||
|
h.setUserStatus(userID, "idle")
|
||||||
|
h.broadcastPresence(userID, "idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setUserStatus persists the user's status to the database.
|
||||||
|
func (h *Hub) setUserStatus(userID, status string) {
|
||||||
|
if h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := h.db.ExecContext(ctx, `UPDATE users SET status = $1 WHERE id = $2`, status, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to update user status", "user_id", userID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastPresence sends a PRESENCE_UPDATE event to all connected clients.
|
||||||
|
func (h *Hub) broadcastPresence(userID, status string) {
|
||||||
|
h.BroadcastEvent(Event{
|
||||||
|
Type: EventPresenceUpdate,
|
||||||
|
Data: PresenceData{
|
||||||
|
UserID: userID,
|
||||||
|
Status: status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Register queues a client for registration with the hub.
|
// Register queues a client for registration with the hub.
|
||||||
func (h *Hub) Register(client *Client) {
|
func (h *Hub) Register(client *Client) {
|
||||||
h.register <- client
|
h.register <- client
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package invite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const codeLength = 8
|
||||||
|
const codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *sql.DB) *Handler {
|
||||||
|
return &Handler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Post("/servers/{serverID}/invites", h.Create)
|
||||||
|
r.Get("/invites/{code}", h.Get)
|
||||||
|
r.Post("/invites/{code}/join", h.Join)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createInviteRequest struct {
|
||||||
|
ExpiresHours *int `json:"expires_hours"`
|
||||||
|
MaxUses *int `json:"max_uses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
ServerID string `json:"server_id"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
CreatorID string `json:"creator_id"`
|
||||||
|
MaxUses *int `json:"max_uses"`
|
||||||
|
UseCount int `json:"use_count"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCode generates a cryptographically random alphanumeric code of the given length.
|
||||||
|
func generateCode(length int) (string, error) {
|
||||||
|
result := make([]byte, length)
|
||||||
|
max := big.NewInt(int64(len(codeChars)))
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
n, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
result[i] = codeChars[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a member of the server
|
||||||
|
var exists bool
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||||
|
`, userID, serverID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createInviteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
// Allow empty body
|
||||||
|
req = createInviteRequest{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique code (retry on collision)
|
||||||
|
var code string
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
code, err = generateCode(codeLength)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to generate invite code"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
var dup bool
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM invites WHERE code = $1)
|
||||||
|
`, code).Scan(&dup)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !dup {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i == 4 {
|
||||||
|
http.Error(w, `{"error":"failed to generate unique code"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute expiration
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if req.ExpiresHours != nil && *req.ExpiresHours > 0 {
|
||||||
|
t := time.Now().Add(time.Duration(*req.ExpiresHours) * time.Hour)
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxUses *int
|
||||||
|
if req.MaxUses != nil && *req.MaxUses > 0 {
|
||||||
|
maxUses = req.MaxUses
|
||||||
|
}
|
||||||
|
|
||||||
|
var invite inviteResponse
|
||||||
|
var expiresAtStr sql.NullString
|
||||||
|
var createdAtStr sql.NullString
|
||||||
|
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO invites (code, server_id, creator_id, max_uses, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, code, server_id, creator_id, max_uses, use_count, expires_at::text, created_at::text
|
||||||
|
`, code, serverID, userID, maxUses, expiresAt).Scan(
|
||||||
|
&invite.ID, &invite.Code, &invite.ServerID, &invite.CreatorID,
|
||||||
|
&invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to create invite"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if expiresAtStr.Valid {
|
||||||
|
invite.ExpiresAt = &expiresAtStr.String
|
||||||
|
}
|
||||||
|
invite.CreatedAt = createdAtStr.String
|
||||||
|
|
||||||
|
// Fetch server name
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT name FROM servers WHERE id = $1
|
||||||
|
`, serverID).Scan(&invite.ServerName)
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal: just leave server_name empty
|
||||||
|
invite.ServerName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := chi.URLParam(r, "code")
|
||||||
|
|
||||||
|
var invite inviteResponse
|
||||||
|
var expiresAtStr sql.NullString
|
||||||
|
var createdAtStr sql.NullString
|
||||||
|
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT i.id, i.code, i.server_id, COALESCE(s.name, ''), i.creator_id,
|
||||||
|
i.max_uses, i.use_count, i.expires_at::text, i.created_at::text
|
||||||
|
FROM invites i
|
||||||
|
LEFT JOIN servers s ON s.id = i.server_id
|
||||||
|
WHERE i.code = $1
|
||||||
|
`, code).Scan(
|
||||||
|
&invite.ID, &invite.Code, &invite.ServerID, &invite.ServerName, &invite.CreatorID,
|
||||||
|
&invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if expiresAtStr.Valid {
|
||||||
|
invite.ExpiresAt = &expiresAtStr.String
|
||||||
|
}
|
||||||
|
invite.CreatedAt = createdAtStr.String
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Join(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := chi.URLParam(r, "code")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the invite
|
||||||
|
var inviteID, serverID string
|
||||||
|
var maxUses *int
|
||||||
|
var useCount int
|
||||||
|
var expiresAt sql.NullTime
|
||||||
|
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT id, server_id, max_uses, use_count, expires_at
|
||||||
|
FROM invites WHERE code = $1
|
||||||
|
`, code).Scan(&inviteID, &serverID, &maxUses, &useCount, &expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if expiresAt.Valid && time.Now().After(expiresAt.Time) {
|
||||||
|
http.Error(w, `{"error":"invite has expired"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max uses
|
||||||
|
if maxUses != nil && useCount >= *maxUses {
|
||||||
|
http.Error(w, `{"error":"invite has reached maximum uses"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
var alreadyMember bool
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||||
|
`, userID, serverID).Scan(&alreadyMember)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alreadyMember {
|
||||||
|
http.Error(w, `{"error":"already a member of this server"}`, http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a transaction to insert member and increment use_count atomically
|
||||||
|
tx, err := h.db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO members (user_id, server_id) VALUES ($1, $2)
|
||||||
|
`, userID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to join server"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(r.Context(), `
|
||||||
|
UPDATE invites SET use_count = use_count + 1 WHERE id = $1
|
||||||
|
`, inviteID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to update invite"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"server_id": serverID,
|
||||||
|
"message": "successfully joined server",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package reaction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *sql.DB
|
||||||
|
hub *gateway.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||||
|
return &Handler{db: db, hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Post("/{messageID}/reactions", h.Add)
|
||||||
|
r.Delete("/{messageID}/reactions/{emoji}", h.Remove)
|
||||||
|
r.Get("/{messageID}/reactions", h.List)
|
||||||
|
}
|
||||||
|
|
||||||
|
type addReactionRequest struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireMessageAccess verifies the user is a member of the server that owns the message's channel.
|
||||||
|
// Returns (userID, channelID, serverID, ok).
|
||||||
|
func (h *Handler) requireMessageAccess(w http.ResponseWriter, r *http.Request, messageID string) (string, string, string, bool) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up channel_id from the message
|
||||||
|
var channelID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT channel_id FROM messages WHERE id = $1
|
||||||
|
`, messageID).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up server_id from the channel
|
||||||
|
var serverID string
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT server_id FROM channels WHERE id = $1
|
||||||
|
`, channelID).Scan(&serverID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
var exists bool
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||||
|
`, userID, serverID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, channelID, serverID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req addReactionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Emoji == "" {
|
||||||
|
http.Error(w, `{"error":"emoji is required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reaction reactionResponse
|
||||||
|
var createdAt sql.NullString
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO reactions (message_id, user_id, emoji)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (message_id, user_id, emoji) DO UPDATE SET emoji = EXCLUDED.emoji
|
||||||
|
RETURNING id, message_id, user_id, emoji, created_at::text
|
||||||
|
`, messageID, userID, req.Emoji).Scan(
|
||||||
|
&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to add reaction"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reaction.CreatedAt = createdAt.String
|
||||||
|
|
||||||
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
|
Type: gateway.EventReactionAdd,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"reaction": reaction,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"message_id": messageID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(reaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
emoji := chi.URLParam(r, "emoji")
|
||||||
|
userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.db.ExecContext(r.Context(), `
|
||||||
|
DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3
|
||||||
|
`, messageID, userID, emoji)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to remove reaction"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
http.Error(w, `{"error":"reaction not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
|
Type: gateway.EventReactionRemove,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"emoji": emoji,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
messageID := chi.URLParam(r, "messageID")
|
||||||
|
if _, _, _, ok := h.requireMessageAccess(w, r, messageID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, message_id, user_id, emoji, created_at::text
|
||||||
|
FROM reactions
|
||||||
|
WHERE message_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, messageID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Group reactions by emoji
|
||||||
|
groupMap := make(map[string]*emojiGroup)
|
||||||
|
var order []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 {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reaction.CreatedAt = createdAt.String
|
||||||
|
|
||||||
|
group, exists := groupMap[reaction.Emoji]
|
||||||
|
if !exists {
|
||||||
|
group = &emojiGroup{
|
||||||
|
Emoji: reaction.Emoji,
|
||||||
|
Users: []string{},
|
||||||
|
Details: []reactionResponse{},
|
||||||
|
}
|
||||||
|
groupMap[reaction.Emoji] = group
|
||||||
|
order = append(order, reaction.Emoji)
|
||||||
|
}
|
||||||
|
group.Count++
|
||||||
|
group.Users = append(group.Users, reaction.UserID)
|
||||||
|
group.Details = append(group.Details, reaction)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]emojiGroup, 0, len(order))
|
||||||
|
for _, emoji := range order {
|
||||||
|
groups = append(groups, *groupMap[emoji])
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(groups)
|
||||||
|
}
|
||||||
+16
-2
@@ -3,11 +3,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>web</title>
|
<meta name="theme-color" content="#282828" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dumpster" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<title>Dumpster</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script>
|
||||||
|
// Register service worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Dumpster",
|
||||||
|
"short_name": "Dumpster",
|
||||||
|
"description": "A chaotic, self-hosted Discord-like platform",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#282828",
|
||||||
|
"theme_color": "#282828",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const CACHE_NAME = 'dumpster-v1';
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll(STATIC_ASSETS);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
// Skip API requests
|
||||||
|
if (event.request.url.includes('/api/')) return;
|
||||||
|
|
||||||
|
// Skip WebSocket
|
||||||
|
if (event.request.url.includes('/ws')) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cached) => {
|
||||||
|
// Return cached version, fetch new one in background
|
||||||
|
const fetchPromise = fetch(event.request).then((response) => {
|
||||||
|
// Only cache successful responses
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => {
|
||||||
|
// Return cached if fetch fails
|
||||||
|
return cached;
|
||||||
|
});
|
||||||
|
|
||||||
|
return cached || fetchPromise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
const data = event.data.json();
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/badge.png',
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: {
|
||||||
|
url: data.url || '/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'Dumpster', options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// Focus existing window if available
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(event.notification.data.url) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise open new window
|
||||||
|
return clients.openWindow(event.notification.data.url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const EMOJI_LIST = [
|
||||||
|
'👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥',
|
||||||
|
'👀', '💯', '✨', '🙏', '💀', '🤡', '🤔', '😎',
|
||||||
|
'🫡', '🫠', '🤯', '🥳', '😴', '🤮', '👻', '🎃',
|
||||||
|
'🚀', '⭐', '🌈', '🍕', '🍺', '☕', '🎮', '🎵',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
onSelect: (emoji: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? EMOJI_LIST.filter(e => e.includes(search))
|
||||||
|
: EMOJI_LIST;
|
||||||
|
|
||||||
|
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="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-gb-fg-f font-mono">EMOJI</span>
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-red font-mono">[x]</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="search..."
|
||||||
|
className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{filtered.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(emoji);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="w-6 h-6 flex items-center justify-center text-sm hover:bg-gb-bg-s transition-colors"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-xs text-gb-fg-f font-mono text-center py-2">[no results]</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPrompt() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [showInstall, setShowInstall] = useState(false);
|
||||||
|
const [installed, setInstalled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
|
setShowInstall(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handler);
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
setInstalled(true);
|
||||||
|
setShowInstall(false);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handler);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
setInstalled(true);
|
||||||
|
setShowInstall(false);
|
||||||
|
}
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showInstall || installed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 z-50">
|
||||||
|
<div className="bg-gb-bg border border-gb-orange p-3 font-mono shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gb-orange">INSTALL DUMPSTER</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInstall(false)}
|
||||||
|
className="text-xs text-gb-red"
|
||||||
|
>
|
||||||
|
[x]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gb-fg-f mb-3">
|
||||||
|
Add to your home screen for the full experience. Push notifications, offline access, and more.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors"
|
||||||
|
>
|
||||||
|
[INSTALL]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInstall(false)}
|
||||||
|
className="px-3 py-1.5 bg-gb-bg-t text-gb-fg text-xs font-mono hover:bg-gb-bg-s transition-colors"
|
||||||
|
>
|
||||||
|
[LATER]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
|
interface InviteModalProps {
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invite {
|
||||||
|
code: string;
|
||||||
|
url: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
max_uses: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteModal({ serverId, serverName, onClose }: InviteModalProps) {
|
||||||
|
const [expiresHours, setExpiresHours] = useState(24);
|
||||||
|
const [maxUses, setMaxUses] = useState<number | ''>('');
|
||||||
|
const [invite, setInvite] = useState<Invite | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const createInvite = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
expires_hours: expiresHours,
|
||||||
|
};
|
||||||
|
if (maxUses !== '') {
|
||||||
|
payload.max_uses = maxUses;
|
||||||
|
}
|
||||||
|
const result = await api.post<Invite>(`/servers/${serverId}/invites`, payload);
|
||||||
|
setInvite(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create invite');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = () => {
|
||||||
|
if (invite) {
|
||||||
|
navigator.clipboard.writeText(invite.url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gb-orange">INVITE TO {serverName.toUpperCase()}</span>
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!invite ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">EXPIRES IN:</label>
|
||||||
|
<select
|
||||||
|
value={expiresHours}
|
||||||
|
onChange={(e) => setExpiresHours(Number(e.target.value))}
|
||||||
|
className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||||
|
>
|
||||||
|
<option value={1}>1 hour</option>
|
||||||
|
<option value={6}>6 hours</option>
|
||||||
|
<option value={12}>12 hours</option>
|
||||||
|
<option value={24}>24 hours</option>
|
||||||
|
<option value={168}>7 days</option>
|
||||||
|
<option value={0}>Never</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gb-fg-f block mb-1">MAX USES:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxUses}
|
||||||
|
onChange={(e) => setMaxUses(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
placeholder="unlimited"
|
||||||
|
min={1}
|
||||||
|
className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-gb-red">{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={createInvite}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'CREATING...' : '[GENERATE LINK]'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-gb-fg-f">Share this link:</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={invite.url}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="px-3 py-1 bg-gb-bg-t text-gb-fg text-xs font-mono hover:bg-gb-bg-s transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? '[COPIED!]' : '[COPY]'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{invite.expires_at && (
|
||||||
|
<div className="text-xs text-gb-fg-f">
|
||||||
|
Expires: {new Date(invite.expires_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invite.max_uses && (
|
||||||
|
<div className="text-xs text-gb-fg-f">
|
||||||
|
Max uses: {invite.max_uses}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setInvite(null)}
|
||||||
|
className="text-xs text-gb-aqua hover:text-gb-orange transition-colors"
|
||||||
|
>
|
||||||
|
[CREATE ANOTHER]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
|
interface InviteInfo {
|
||||||
|
server_name: string;
|
||||||
|
inviter: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinServer() {
|
||||||
|
const { code } = useParams<{ code: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [info, setInfo] = useState<InviteInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [joining, setJoining] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code) return;
|
||||||
|
const fetchInfo = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<InviteInfo>(`/invites/${code}`);
|
||||||
|
setInfo(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Invalid invite');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchInfo();
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const joinServer = async () => {
|
||||||
|
if (!code) return;
|
||||||
|
setJoining(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.post<{ server_id: string }>(`/invites/${code}/join`);
|
||||||
|
navigate(`/channels/${result.server_id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to join');
|
||||||
|
} finally {
|
||||||
|
setJoining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
|
||||||
|
<div className="text-gb-fg-f">Loading invite...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
|
||||||
|
<div className="bg-gb-bg-s border border-gb-red p-6 max-w-md text-center">
|
||||||
|
<div className="text-gb-red text-lg mb-2">INVALID INVITE</div>
|
||||||
|
<div className="text-gb-fg-f text-sm mb-4">{error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-4 py-2 bg-gb-bg-t text-gb-fg text-sm hover:bg-gb-bg-s transition-colors"
|
||||||
|
>
|
||||||
|
[GO HOME]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
|
||||||
|
<div className="bg-gb-bg-s border border-gb-bg-t p-6 max-w-md">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="text-gb-orange text-lg mb-1">YOU'VE BEEN INVITED</div>
|
||||||
|
<div className="text-2xl text-gb-fg mb-2">{info?.server_name}</div>
|
||||||
|
{info?.inviter && (
|
||||||
|
<div className="text-sm text-gb-fg-f">by {info.inviter}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{info?.expires_at && (
|
||||||
|
<div className="text-xs text-gb-fg-f text-center mb-4">
|
||||||
|
Expires: {new Date(info.expires_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-gb-red text-center mb-3">{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={joinServer}
|
||||||
|
disabled={joining}
|
||||||
|
className="w-full px-4 py-2 bg-gb-orange text-gb-bg text-sm font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joining ? 'JOINING...' : '[ACCEPT INVITE]'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { usePresenceStore } from '../stores/presence.ts';
|
||||||
import type { User, UserStatus } from '../stores/auth.ts';
|
import type { User, UserStatus } from '../stores/auth.ts';
|
||||||
|
|
||||||
interface MemberListProps {
|
interface MemberListProps {
|
||||||
@@ -43,9 +44,41 @@ function usernameColor(status: UserStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the live status for a member, falling back to the static value. */
|
||||||
|
function useLiveStatus(member: User): UserStatus {
|
||||||
|
const presence = usePresenceStore((s) => s.presences[member.id]);
|
||||||
|
return presence?.status ?? member.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberRowProps {
|
||||||
|
member: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberRow({ member }: MemberRowProps) {
|
||||||
|
const status = useLiveStatus(member);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
||||||
|
<span className={`truncate ${usernameColor(status)}`}>
|
||||||
|
{member.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MemberList({ members = [] }: MemberListProps) {
|
export function MemberList({ members = [] }: MemberListProps) {
|
||||||
const online = members.filter((m) => m.status !== 'offline');
|
// Derive live status for every member to decide online/offline buckets.
|
||||||
const offline = members.filter((m) => m.status === 'offline');
|
const presences = usePresenceStore((s) => s.presences);
|
||||||
|
|
||||||
|
const online = members.filter((m) => {
|
||||||
|
const status = presences[m.id]?.status ?? m.status;
|
||||||
|
return status !== 'offline';
|
||||||
|
});
|
||||||
|
const offline = members.filter((m) => {
|
||||||
|
const status = presences[m.id]?.status ?? m.status;
|
||||||
|
return status === 'offline';
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
|
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
|
||||||
@@ -61,12 +94,7 @@ export function MemberList({ members = [] }: MemberListProps) {
|
|||||||
<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((member) => (
|
{online.map((member) => (
|
||||||
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
|
<MemberRow key={member.id} member={member} />
|
||||||
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
|
|
||||||
<span className={`truncate ${usernameColor(member.status)}`}>
|
|
||||||
{member.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -75,12 +103,7 @@ export function MemberList({ members = [] }: MemberListProps) {
|
|||||||
<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((member) => (
|
{offline.map((member) => (
|
||||||
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
|
<MemberRow key={member.id} member={member} />
|
||||||
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
|
|
||||||
<span className={`truncate ${usernameColor(member.status)}`}>
|
|
||||||
{member.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MentionPopupProps {
|
||||||
|
users: User[];
|
||||||
|
filter: string;
|
||||||
|
onSelect: (user: User) => void;
|
||||||
|
position: { top: number; left: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionPopup({ users, filter, onSelect, position }: MentionPopupProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const filtered = users.filter(u =>
|
||||||
|
u.username.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
u.display_name?.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
).slice(0, 8);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered[selectedIndex]) {
|
||||||
|
onSelect(filtered[selectedIndex]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(null as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [filtered, selectedIndex, onSelect]);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="absolute bg-gb-bg border border-gb-bg-t shadow-lg z-50 min-w-[200px] max-h-[200px] overflow-y-auto"
|
||||||
|
style={{ bottom: '100%', left: position.left, marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1 text-xs text-gb-fg-f font-mono border-b border-gb-bg-t">
|
||||||
|
MEMBERS
|
||||||
|
</div>
|
||||||
|
{filtered.map((user, index) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => onSelect(user)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`
|
||||||
|
w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2
|
||||||
|
transition-colors
|
||||||
|
${index === selectedIndex
|
||||||
|
? 'bg-gb-orange text-gb-bg'
|
||||||
|
: 'text-gb-fg hover:bg-gb-bg-s'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="text-gb-green">●</span>
|
||||||
|
<span>{user.display_name || user.username}</span>
|
||||||
|
{user.display_name && user.display_name !== user.username && (
|
||||||
|
<span className="text-gb-fg-f">({user.username})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ServerBar } from './ServerBar.tsx';
|
||||||
|
import { ChannelList } from './ChannelList.tsx';
|
||||||
|
|
||||||
|
interface MobileDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Drawer */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-[280px] bg-gb-bg flex">
|
||||||
|
{/* Server bar */}
|
||||||
|
<div className="w-[48px] bg-gb-bg-h">
|
||||||
|
<ServerBar />
|
||||||
|
</div>
|
||||||
|
{/* Channel list */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<ChannelList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Swipe hint */}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-1 bg-gb-orange/20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useVoiceStore } from '../stores/voice.ts';
|
||||||
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||||
|
|
||||||
|
interface MobileNavProps {
|
||||||
|
activeTab: 'servers' | 'channels' | 'chat' | 'members';
|
||||||
|
onTabChange: (tab: 'servers' | 'channels' | 'chat' | 'members') => void;
|
||||||
|
onToggleDrawer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileNav({ activeTab, onTabChange, onToggleDrawer }: MobileNavProps) {
|
||||||
|
const isConnected = useVoiceStore((state: any) => state.isConnected);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'servers' as const, label: '[=]', title: 'Servers' },
|
||||||
|
{ id: 'channels' as const, label: '[#]', title: 'Channels' },
|
||||||
|
{ id: 'chat' as const, label: '[_]', title: 'Chat' },
|
||||||
|
{ id: 'members' as const, label: '[@]', title: 'Members' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-gb-bg-h border-t border-gb-bg-t z-40">
|
||||||
|
<div className="flex items-center justify-around py-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (tab.id === 'servers' || tab.id === 'channels') {
|
||||||
|
onToggleDrawer();
|
||||||
|
}
|
||||||
|
onTabChange(tab.id);
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
px-3 py-1 text-xs font-mono transition-colors
|
||||||
|
${activeTab === tab.id
|
||||||
|
? 'text-gb-orange'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-fg'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={tab.title}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{isConnected && (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-gb-green animate-pulse" title="In voice" />
|
||||||
|
)}
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
{/* Safe area for phones with bottom notch */}
|
||||||
|
<div className="h-[env(safe-area-inset-bottom)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
|
interface Reaction {
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
users: string[];
|
||||||
|
reacted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactionBarProps {
|
||||||
|
messageId: string;
|
||||||
|
reactions: Reaction[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const toggleReaction = async (emoji: string) => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const existing = reactions.find(r => r.emoji === emoji);
|
||||||
|
if (existing?.reacted) {
|
||||||
|
await api.delete(`/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
|
||||||
|
} else {
|
||||||
|
await api.post(`/messages/${messageId}/reactions`, { emoji });
|
||||||
|
}
|
||||||
|
onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle reaction:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reactions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{reactions.map((reaction) => (
|
||||||
|
<button
|
||||||
|
key={reaction.emoji}
|
||||||
|
onClick={() => toggleReaction(reaction.emoji)}
|
||||||
|
disabled={loading}
|
||||||
|
className={`
|
||||||
|
px-1.5 py-0.5 text-xs font-mono border rounded-sm
|
||||||
|
transition-colors duration-100
|
||||||
|
${reaction.reacted
|
||||||
|
? 'border-gb-orange bg-gb-bg-t text-gb-orange'
|
||||||
|
: 'border-gb-bg-t bg-gb-bg-s text-gb-fg-f hover:border-gb-fg-f'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={reaction.users.join(', ')}
|
||||||
|
>
|
||||||
|
{reaction.emoji} {reaction.count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
interface ReplyBarProps {
|
||||||
|
replyTo: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
author: {
|
||||||
|
username: string;
|
||||||
|
accent_color?: string;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
onCancel?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplyBar({ replyTo, onCancel, compact }: ReplyBarProps) {
|
||||||
|
if (!replyTo) return null;
|
||||||
|
|
||||||
|
const truncatedContent = replyTo.content.length > 100
|
||||||
|
? replyTo.content.slice(0, 100) + '...'
|
||||||
|
: replyTo.content;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gb-fg-f font-mono pl-4 border-l-2 border-gb-orange">
|
||||||
|
<span className="text-gb-orange">↩</span>
|
||||||
|
<span style={{ color: replyTo.author.accent_color || 'var(--gb-fg-f)' }}>
|
||||||
|
{replyTo.author.username}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{truncatedContent}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-gb-bg-h border-l-2 border-gb-orange mb-1">
|
||||||
|
<span className="text-gb-orange text-xs font-mono">↩</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono font-bold"
|
||||||
|
style={{ color: replyTo.author.accent_color || 'var(--gb-fg-s)' }}
|
||||||
|
>
|
||||||
|
{replyTo.author.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono text-gb-fg-f truncate flex-1">
|
||||||
|
{truncatedContent}
|
||||||
|
</span>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs text-gb-red font-mono hover:text-gb-orange transition-colors"
|
||||||
|
>
|
||||||
|
[x]
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem('dumpster-theme');
|
||||||
|
return (stored === 'light' ? 'light' : 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === 'light') {
|
||||||
|
root.classList.add('light');
|
||||||
|
root.classList.remove('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.add('dark');
|
||||||
|
root.classList.remove('light');
|
||||||
|
}
|
||||||
|
localStorage.setItem('dumpster-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="px-2 py-1 text-xs font-mono text-gb-fg-f hover:text-gb-orange transition-colors"
|
||||||
|
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
>
|
||||||
|
[{theme === 'dark' ? 'light' : 'dark'}]
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTypingStore } from '../stores/typing.ts';
|
||||||
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
|
|
||||||
|
interface TypingIndicatorProps {
|
||||||
|
channelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypingIndicator({ channelId }: TypingIndicatorProps) {
|
||||||
|
const typingUsers = useTypingStore((state) => state.typingUsers[channelId] || []);
|
||||||
|
const currentUser = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
|
// Filter out current user
|
||||||
|
const others = typingUsers.filter(u => u.userId !== currentUser?.id);
|
||||||
|
|
||||||
|
if (others.length === 0) return null;
|
||||||
|
|
||||||
|
const names = others.map(u => u.username);
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
if (names.length === 1) {
|
||||||
|
text = `<${names[0]}> is typing...`;
|
||||||
|
} else if (names.length === 2) {
|
||||||
|
text = `<${names[0]}> and <${names[1]}> are typing...`;
|
||||||
|
} else {
|
||||||
|
text = `${names.length} users are typing...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-1 text-xs text-gb-fg-f font-mono animate-pulse">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { UserStatus } from './auth.ts';
|
||||||
|
|
||||||
|
export interface PresenceEntry {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresenceState {
|
||||||
|
presences: Record<string, PresenceEntry>;
|
||||||
|
/** Upsert a single user's presence from a WS event. */
|
||||||
|
updatePresence: (userId: string, username: string, status: UserStatus) => void;
|
||||||
|
/** Get a user's current presence; defaults to offline. */
|
||||||
|
getPresence: (userId: string) => PresenceEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PRESENCE: PresenceEntry = {
|
||||||
|
userId: '',
|
||||||
|
username: '',
|
||||||
|
status: 'offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePresenceStore = create<PresenceState>((set, get) => ({
|
||||||
|
presences: {},
|
||||||
|
|
||||||
|
updatePresence: (userId, username, status) =>
|
||||||
|
set((state) => ({
|
||||||
|
presences: {
|
||||||
|
...state.presences,
|
||||||
|
[userId]: { userId, username, status },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
getPresence: (userId) => get().presences[userId] ?? DEFAULT_PRESENCE,
|
||||||
|
}));
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
|
interface PushState {
|
||||||
|
isSupported: boolean;
|
||||||
|
isSubscribed: boolean;
|
||||||
|
permission: NotificationPermission;
|
||||||
|
subscribe: () => Promise<void>;
|
||||||
|
unsubscribe: () => Promise<void>;
|
||||||
|
requestPermission: () => Promise<NotificationPermission>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): BufferSource {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePushStore = create<PushState>((set, get) => ({
|
||||||
|
isSupported: 'serviceWorker' in navigator && 'PushManager' in window,
|
||||||
|
isSubscribed: false,
|
||||||
|
permission: 'default',
|
||||||
|
|
||||||
|
requestPermission: async () => {
|
||||||
|
if (!get().isSupported) return 'denied';
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
set({ permission });
|
||||||
|
return permission;
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: async () => {
|
||||||
|
if (!get().isSupported) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Register service worker
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
// Get VAPID public key from server
|
||||||
|
const { publicKey } = await api.get<{ publicKey: string }>('/push/vapid-key');
|
||||||
|
if (!publicKey) return;
|
||||||
|
|
||||||
|
// Subscribe to push
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
await api.post('/push/subscribe', subscription.toJSON());
|
||||||
|
set({ isSubscribed: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to subscribe to push:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribe: async () => {
|
||||||
|
if (!get().isSupported) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
await api.post('/push/unsubscribe', { endpoint: subscription.endpoint });
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
set({ isSubscribed: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unsubscribe from push:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { useWebSocketStore } from './ws';
|
||||||
|
|
||||||
|
interface TypingUser {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
channelId: string;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypingState {
|
||||||
|
typingUsers: Record<string, TypingUser[]>;
|
||||||
|
sendTypingStart: (channelId: string) => void;
|
||||||
|
_handleTypingEvent: (data: { channel_id: string; user_id: string; username: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTypingStore = create<TypingState>((set, get) => ({
|
||||||
|
typingUsers: {},
|
||||||
|
|
||||||
|
sendTypingStart: (channelId) => {
|
||||||
|
const ws = useWebSocketStore.getState();
|
||||||
|
if (ws.connected) {
|
||||||
|
ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleTypingEvent: (data) => {
|
||||||
|
const { channel_id, user_id, username } = data;
|
||||||
|
|
||||||
|
const existing = get().typingUsers[channel_id] || [];
|
||||||
|
const existingUser = existing.find(u => u.userId === user_id);
|
||||||
|
if (existingUser) {
|
||||||
|
clearTimeout(existingUser.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
set(state => ({
|
||||||
|
typingUsers: {
|
||||||
|
...state.typingUsers,
|
||||||
|
[channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
set(state => ({
|
||||||
|
typingUsers: {
|
||||||
|
...state.typingUsers,
|
||||||
|
[channel_id]: [
|
||||||
|
...(state.typingUsers?.[channel_id] || []).filter((u: { userId: string }) => u.userId !== user_id),
|
||||||
|
{ userId: user_id, username, channelId: channel_id, timeout },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -2,9 +2,12 @@ import { create } from 'zustand';
|
|||||||
import { useMessageStore } from './message.ts';
|
import { useMessageStore } from './message.ts';
|
||||||
import { useChannelStore } from './channel.ts';
|
import { useChannelStore } from './channel.ts';
|
||||||
import { useServerStore } from './server.ts';
|
import { useServerStore } from './server.ts';
|
||||||
|
import { usePresenceStore } from './presence.ts';
|
||||||
|
import { useTypingStore } from './typing.ts';
|
||||||
import type { Message } from './message.ts';
|
import type { Message } from './message.ts';
|
||||||
import type { Channel } from './channel.ts';
|
import type { Channel } from './channel.ts';
|
||||||
import type { Server } from './server.ts';
|
import type { Server } from './server.ts';
|
||||||
|
import type { UserStatus } from './auth.ts';
|
||||||
|
|
||||||
type UnknownPayload = Record<string, unknown>;
|
type UnknownPayload = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -147,6 +150,20 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
if (server) updateServer(server);
|
if (server) updateServer(server);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'PRESENCE_UPDATE': {
|
||||||
|
const { user_id, username, status } = data.payload as Record<string, string>;
|
||||||
|
if (user_id && username && status) {
|
||||||
|
usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'TYPING_START': {
|
||||||
|
const { channel_id, user_id, username } = data.payload as Record<string, string>;
|
||||||
|
if (channel_id && user_id && username) {
|
||||||
|
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,20 @@
|
|||||||
--gb-border: #665c54;
|
--gb-border: #665c54;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light mode overrides */
|
||||||
|
.light {
|
||||||
|
--gb-bg-h: #f9f5d7;
|
||||||
|
--gb-bg: #fbf1c7;
|
||||||
|
--gb-bg-s: #ebdbb2;
|
||||||
|
--gb-bg-t: #d5c4a1;
|
||||||
|
--gb-bg-f: #bdae93;
|
||||||
|
--gb-fg: #282828;
|
||||||
|
--gb-fg-s: #3c3836;
|
||||||
|
--gb-fg-t: #504945;
|
||||||
|
--gb-fg-f: #7c6f64;
|
||||||
|
--gb-border: #a89984;
|
||||||
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
|
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default {
|
|||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
@@ -24,6 +25,16 @@ export default {
|
|||||||
'gb-aqua': '#8ec07c',
|
'gb-aqua': '#8ec07c',
|
||||||
'gb-orange': '#fe8019',
|
'gb-orange': '#fe8019',
|
||||||
'gb-gray': '#928374',
|
'gb-gray': '#928374',
|
||||||
|
// Light mode overrides
|
||||||
|
'gb-light-bg-h': '#f9f5d7',
|
||||||
|
'gb-light-bg': '#fbf1c7',
|
||||||
|
'gb-light-bg-s': '#ebdbb2',
|
||||||
|
'gb-light-bg-t': '#d5c4a1',
|
||||||
|
'gb-light-bg-f': '#bdae93',
|
||||||
|
'gb-light-fg': '#282828',
|
||||||
|
'gb-light-fg-s': '#3c3836',
|
||||||
|
'gb-light-fg-t': '#504945',
|
||||||
|
'gb-light-fg-f': '#7c6f64',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/GiphyPicker.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user