Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02db6c719c | |||
| df2a992fa7 | |||
| 410b7a4d6b | |||
| 5eeb659b70 | |||
| 13e3aec2d5 | |||
| 83c6badc20 | |||
| 43b20c5ce3 | |||
| b0087e12af | |||
| 1a1f2fc99c | |||
| e927dd2cb0 | |||
| beb04196ca | |||
| a54a67e41b | |||
| 86717a2867 | |||
| 4a416427e9 | |||
| 8261555026 | |||
| 3cde62bdc6 | |||
| 065f036807 | |||
| f53cd49803 | |||
| 4e48815b91 | |||
| d9b3162f1c | |||
| 6384588122 | |||
| 978e94da90 | |||
| 34c18c13ae | |||
| cca6ea0e37 |
@@ -8,7 +8,7 @@ build: build-web build-server
|
|||||||
|
|
||||||
# Build the Go server
|
# Build the Go server
|
||||||
build-server:
|
build-server:
|
||||||
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
|
CGO_ENABLED=0 go build -ldflags "-X main.GitSHA=$(GIT_SHA)" -o dumpster-server ./cmd/server
|
||||||
|
|
||||||
# Build the TUI client
|
# Build the TUI client
|
||||||
build-tui:
|
build-tui:
|
||||||
|
|||||||
+14
-3
@@ -47,9 +47,8 @@ import (
|
|||||||
// @host localhost:8080
|
// @host localhost:8080
|
||||||
// @BasePath /api/v1
|
// @BasePath /api/v1
|
||||||
// @schemes http https
|
// @schemes http https
|
||||||
// @securityDefinitions.apikey SessionAuth
|
var GitSHA = "dev"
|
||||||
// @in cookie
|
|
||||||
// @name dumpster_session
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||||
|
|
||||||
@@ -153,6 +152,14 @@ func main() {
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"version": GitSHA,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Auth (public: register, login, logout) with strict rate limiting
|
// Auth (public: register, login, logout) with strict rate limiting
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
||||||
@@ -446,9 +453,13 @@ func main() {
|
|||||||
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
||||||
path := staticDir + r.URL.Path
|
path := staticDir + r.URL.Path
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
http.ServeFile(w, r, staticDir+"/index.html")
|
http.ServeFile(w, r, staticDir+"/index.html")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
}
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ fi
|
|||||||
git pull
|
git pull
|
||||||
make build
|
make build
|
||||||
|
|
||||||
|
# Sync web dist to Docker Caddy volume if present
|
||||||
|
if [ -d /var/lib/docker/volumes/docker_web_assets/_data ]; then
|
||||||
|
rsync -a --delete web/dist/ /var/lib/docker/volumes/docker_web_assets/_data/
|
||||||
|
echo "Synced web assets to Docker Caddy volume"
|
||||||
|
fi
|
||||||
|
|
||||||
# Health check after restart — roll back on failure
|
# Health check after restart — roll back on failure
|
||||||
systemctl restart dumpster
|
systemctl restart dumpster
|
||||||
echo "Waiting for app to become healthy..."
|
echo "Waiting for app to become healthy..."
|
||||||
|
|||||||
+4
-4
@@ -4,14 +4,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
# API routes
|
# API routes — use handle (not handle_path) to preserve the /api/v1 prefix
|
||||||
handle_path /api/* {
|
handle /api/* {
|
||||||
reverse_proxy app:8080
|
reverse_proxy 172.18.0.1:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket gateway
|
# WebSocket gateway
|
||||||
handle /ws {
|
handle /ws {
|
||||||
reverse_proxy app:8080 {
|
reverse_proxy 172.18.0.1:8080 {
|
||||||
header_up Connection {>Connection}
|
header_up Connection {>Connection}
|
||||||
header_up Upgrade {>Upgrade}
|
header_up Upgrade {>Upgrade}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-31
@@ -263,40 +263,52 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try cookie-based auth first (browser clients).
|
|
||||||
var token string
|
|
||||||
if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" {
|
|
||||||
token = cookie.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to message-frame auth (TUI, bots).
|
|
||||||
if token == "" {
|
|
||||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
_, raw, readErr := conn.ReadMessage()
|
|
||||||
if readErr != nil {
|
|
||||||
logger.Warn("ws auth: failed to read auth message", "error", readErr)
|
|
||||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`))
|
|
||||||
conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var auth authMessage
|
|
||||||
if jsonErr := json.Unmarshal(raw, &auth); jsonErr != nil || auth.Token == "" {
|
|
||||||
logger.Warn("ws auth: invalid auth message")
|
|
||||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`))
|
|
||||||
conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token = auth.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
var userID, username string
|
var userID, username string
|
||||||
err = db.QueryRowContext(context.Background(),
|
|
||||||
|
// Collect candidate tokens from Query param, Authorization header, and Cookies.
|
||||||
|
var candidateTokens []string
|
||||||
|
if qToken := r.URL.Query().Get("token"); qToken != "" {
|
||||||
|
candidateTokens = append(candidateTokens, qToken)
|
||||||
|
}
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
candidateTokens = append(candidateTokens, authHeader[7:])
|
||||||
|
}
|
||||||
|
for _, c := range r.Cookies() {
|
||||||
|
if c.Name == cookieName && c.Value != "" {
|
||||||
|
candidateTokens = append(candidateTokens, c.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test candidates against Postgres sessions (tokens are stored hashed)
|
||||||
|
for _, token := range candidateTokens {
|
||||||
|
err := db.QueryRowContext(context.Background(),
|
||||||
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
|
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
|
||||||
token,
|
hashToken(token),
|
||||||
).Scan(&userID, &username)
|
).Scan(&userID, &username)
|
||||||
if err != nil {
|
if err == nil && userID != "" {
|
||||||
logger.Warn("ws auth: invalid session", "error", err)
|
break
|
||||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to message-frame auth if no candidate token authenticated (wait up to 5s)
|
||||||
|
if userID == "" {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
_, raw, readErr := conn.ReadMessage()
|
||||||
|
if readErr == nil {
|
||||||
|
var auth authMessage
|
||||||
|
if jsonErr := json.Unmarshal(raw, &auth); jsonErr == nil && auth.Token != "" {
|
||||||
|
_ = db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
|
||||||
|
hashToken(auth.Token),
|
||||||
|
).Scan(&userID, &username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
logger.Warn("ws auth: no valid session found")
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","error":"invalid_session"}`))
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-8
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -285,15 +286,30 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM members WHERE server_id = $1`, serverID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to load server members", "server_id", serverID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
members := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid); err == nil {
|
||||||
|
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
servers, ok := h.userServers[client.UserID]
|
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
||||||
if !ok {
|
if members[cID] {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, member := servers[serverID]; member {
|
|
||||||
select {
|
select {
|
||||||
case client.send <- data:
|
case client.send <- data:
|
||||||
default:
|
default:
|
||||||
@@ -322,18 +338,19 @@ func (h *Hub) BroadcastToConversation(convID string, event Event) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
members := make(map[string]struct{})
|
members := make(map[string]bool)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var uid string
|
var uid string
|
||||||
if err := rows.Scan(&uid); err == nil {
|
if err := rows.Scan(&uid); err == nil {
|
||||||
members[uid] = struct{}{}
|
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
if _, ok := members[client.UserID]; ok {
|
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
||||||
|
if members[cID] {
|
||||||
select {
|
select {
|
||||||
case client.send <- data:
|
case client.send <- data:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "dumpsterChat",
|
"productName": "dumpsterChat",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"identifier": "coffee.dustin.dumpster",
|
"identifier": "coffee.dustin.dumpster",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -117,9 +117,21 @@ export function ChannelList() {
|
|||||||
}, [fetchNotifSettings, fetchReadStates]);
|
}, [fetchNotifSettings, fetchReadStates]);
|
||||||
|
|
||||||
const channels = useMemo(() => {
|
const channels = useMemo(() => {
|
||||||
return activeServerId ? channelsByServer[activeServerId] || [] : [];
|
return activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
|
||||||
}, [activeServerId, channelsByServer]);
|
}, [activeServerId, channelsByServer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channels.length > 0) {
|
||||||
|
const activeExists = activeChannelId && channels.some((c) => c.id === activeChannelId);
|
||||||
|
if (!activeExists) {
|
||||||
|
const defaultChannel = channels.find((c) => c.type === 'text') || channels[0];
|
||||||
|
if (defaultChannel) {
|
||||||
|
setActiveChannel(defaultChannel.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [channels, activeChannelId, setActiveChannel]);
|
||||||
|
|
||||||
const activeServer = useMemo(() => {
|
const activeServer = useMemo(() => {
|
||||||
if (!activeServerId) return null;
|
if (!activeServerId) return null;
|
||||||
return servers.find((s) => s.id === activeServerId) || null;
|
return servers.find((s) => s.id === activeServerId) || null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
|
import { useEffect, useRef, useState, useMemo, useCallback, memo, Fragment } from "react";
|
||||||
import { useMessageStore } from "../stores/message.ts";
|
import { useMessageStore } from "../stores/message.ts";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
@@ -284,14 +284,14 @@ const MessageItem = memo(({
|
|||||||
MessageItem.displayName = "MessageItem";
|
MessageItem.displayName = "MessageItem";
|
||||||
|
|
||||||
export function ChatArea() {
|
export function ChatArea() {
|
||||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
const rawActiveChannelId = useChannelStore((s) => s.activeChannelId);
|
||||||
|
const activeChannelId = rawActiveChannelId ? rawActiveChannelId.toLowerCase() : null;
|
||||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
const messages = useMessageStore((s) =>
|
const messages = useMessageStore((s) => (activeChannelId ? s.messagesByChannel[activeChannelId] || [] : []));
|
||||||
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
|
|
||||||
);
|
|
||||||
const isLoading = useMessageStore((s) => s.isLoading);
|
const isLoading = useMessageStore((s) => s.isLoading);
|
||||||
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
||||||
|
const hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
|
||||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||||
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||||
@@ -332,8 +332,8 @@ export function ChatArea() {
|
|||||||
);
|
);
|
||||||
const canBulkDelete = true;
|
const canBulkDelete = true;
|
||||||
|
|
||||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
const channels = activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
|
||||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
const activeChannel = channels.find((c) => c.id.toLowerCase() === activeChannelId);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
||||||
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
||||||
@@ -447,19 +447,21 @@ export function ChatArea() {
|
|||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollContainerRef.current;
|
const el = scrollContainerRef.current;
|
||||||
if (!el || !activeChannelId || isLoadingOlder) return;
|
if (!el || !activeChannelId || isLoadingOlder || !hasMore) return;
|
||||||
if (el.scrollTop < 100) {
|
if (el.scrollTop < 100) {
|
||||||
const prevHeight = el.scrollHeight;
|
const prevHeight = el.scrollHeight;
|
||||||
fetchOlderMessages(activeChannelId).then(() => {
|
fetchOlderMessages(activeChannelId).then(() => {
|
||||||
// ponytail: maintain scroll position after prepending older messages
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
el.scrollTop = el.scrollHeight - prevHeight;
|
el.scrollTop = el.scrollHeight - prevHeight;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
|
}, [activeChannelId, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
@@ -846,9 +848,8 @@ export function ChatArea() {
|
|||||||
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
|
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
|
||||||
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
|
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={message.id}>
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={message.id}
|
|
||||||
message={message}
|
message={message}
|
||||||
memberUsernames={memberUsernames}
|
memberUsernames={memberUsernames}
|
||||||
selectMode={selectMode}
|
selectMode={selectMode}
|
||||||
@@ -873,7 +874,7 @@ export function ChatArea() {
|
|||||||
<span className="flex-1 border-t border-gb-red"></span>
|
<span className="flex-1 border-t border-gb-red"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
|
|||||||
|
|
||||||
export function ConversationList() {
|
export function ConversationList() {
|
||||||
const conversations = useConversationStore((s) => s.conversations);
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
const activeId = useConversationStore((s) => s.activeConversationId?.toLowerCase() ?? null);
|
||||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||||
const createConversation = useConversationStore((s) => s.createConversation);
|
const createConversation = useConversationStore((s) => s.createConversation);
|
||||||
|
const setActiveConversation = useConversationStore((s) => s.setActiveConversation);
|
||||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
||||||
@@ -31,8 +32,9 @@ export function ConversationList() {
|
|||||||
fetchConversations();
|
fetchConversations();
|
||||||
}, [fetchConversations]);
|
}, [fetchConversations]);
|
||||||
|
|
||||||
const openConversation = (convId: string) => {
|
const openConversation = (rawId: string) => {
|
||||||
// mark as read when opening
|
const convId = rawId.toLowerCase();
|
||||||
|
setActiveConversation(convId);
|
||||||
const msgs = messagesByConv[convId] || [];
|
const msgs = messagesByConv[convId] || [];
|
||||||
if (msgs.length > 0) {
|
if (msgs.length > 0) {
|
||||||
markConvRead(convId, msgs[msgs.length - 1].id);
|
markConvRead(convId, msgs[msgs.length - 1].id);
|
||||||
@@ -50,7 +52,7 @@ export function ConversationList() {
|
|||||||
try {
|
try {
|
||||||
const conv = await createConversation([]);
|
const conv = await createConversation([]);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
navigate(`/dm/${conv.id}`);
|
openConversation(conv.id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create notes:", err);
|
console.error("Failed to create notes:", err);
|
||||||
@@ -59,7 +61,7 @@ export function ConversationList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getLatestMessageId = (convId: string): string | undefined => {
|
const getLatestMessageId = (convId: string): string | undefined => {
|
||||||
const msgs = messagesByConv[convId] || [];
|
const msgs = messagesByConv[convId.toLowerCase()] || [];
|
||||||
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,19 +91,20 @@ export function ConversationList() {
|
|||||||
<p className="text-gb-fg-f">[no conversations]</p>
|
<p className="text-gb-fg-f">[no conversations]</p>
|
||||||
)}
|
)}
|
||||||
{conversations.map((conv) => {
|
{conversations.map((conv) => {
|
||||||
|
const convId = conv.id.toLowerCase();
|
||||||
const self = isSelfDM(conv, currentUser?.id || "");
|
const self = isSelfDM(conv, currentUser?.id || "");
|
||||||
const name = self
|
const name = self
|
||||||
? "Notes"
|
? "Notes"
|
||||||
: conv.type === "group_dm"
|
: conv.type === "group_dm"
|
||||||
? conv.name || conv.members.map((m) => m.username).join(", ")
|
? conv.name || conv.members.map((m) => m.username).join(", ")
|
||||||
: otherMemberName(conv, currentUser?.id || "");
|
: otherMemberName(conv, currentUser?.id || "");
|
||||||
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
|
const unread = hasConvUnread(convId, getLatestMessageId(convId));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
onClick={() => openConversation(conv.id)}
|
onClick={() => openConversation(conv.id)}
|
||||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||||
conv.id === activeId
|
convId === activeId
|
||||||
? "terminal-active"
|
? "terminal-active"
|
||||||
: "hover:bg-gb-bg-t text-gb-fg-s"
|
: "hover:bg-gb-bg-t text-gb-fg-s"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
import { useEffect, useRef, useState, useCallback, memo, Fragment } 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";
|
||||||
@@ -152,7 +152,6 @@ export function DMChat() {
|
|||||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||||
const setActive = useConversationStore((s) => s.setActiveConversation);
|
const setActive = useConversationStore((s) => s.setActiveConversation);
|
||||||
const conversations = useConversationStore((s) => s.conversations);
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
|
||||||
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
||||||
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
||||||
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
||||||
@@ -173,8 +172,18 @@ export function DMChat() {
|
|||||||
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
||||||
const convStates = useReadStatesStore((s) => s.convStates);
|
const convStates = useReadStatesStore((s) => s.convStates);
|
||||||
|
|
||||||
const id = conversationId || activeId;
|
const rawId = conversationId || activeId;
|
||||||
const conversation = conversations.find((c) => c.id === id);
|
const id = rawId ? rawId.toLowerCase() : undefined;
|
||||||
|
const conversation = conversations.find((c) => c.id.toLowerCase() === id);
|
||||||
|
const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true);
|
||||||
|
const messages = useConversationStore((s) => (id ? s.messagesByConversation[id] || [] : []));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -189,7 +198,7 @@ export function DMChat() {
|
|||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollContainerRef.current;
|
const el = scrollContainerRef.current;
|
||||||
if (!el || !id || isLoadingOlder) return;
|
if (!el || !id || isLoadingOlder || !hasMore) return;
|
||||||
if (el.scrollTop < 100) {
|
if (el.scrollTop < 100) {
|
||||||
const prevHeight = el.scrollHeight;
|
const prevHeight = el.scrollHeight;
|
||||||
fetchOlderMessages(id).then(() => {
|
fetchOlderMessages(id).then(() => {
|
||||||
@@ -198,8 +207,7 @@ export function DMChat() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [id, isLoadingOlder, fetchOlderMessages]);
|
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||||
const messages = id ? messagesByConv[id] || [] : [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConversations();
|
fetchConversations();
|
||||||
@@ -213,9 +221,7 @@ export function DMChat() {
|
|||||||
}
|
}
|
||||||
}, [id, setActive, fetchMessages]);
|
}, [id, setActive, fetchMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || messages.length === 0 || isLoading) return;
|
if (!id || messages.length === 0 || isLoading) return;
|
||||||
@@ -329,9 +335,8 @@ export function DMChat() {
|
|||||||
const lastReadId = id ? convStates[id] : undefined;
|
const lastReadId = id ? convStates[id] : undefined;
|
||||||
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={msg.id}>
|
||||||
<DMMessageItem
|
<DMMessageItem
|
||||||
key={msg.id}
|
|
||||||
msg={msg}
|
msg={msg}
|
||||||
onAddReaction={handleAddReaction}
|
onAddReaction={handleAddReaction}
|
||||||
activeReactionMessageId={activeReactionMessageId}
|
activeReactionMessageId={activeReactionMessageId}
|
||||||
@@ -348,7 +353,7 @@ export function DMChat() {
|
|||||||
<span className="flex-1 border-t border-gb-red"></span>
|
<span className="flex-1 border-t border-gb-red"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MemberList } from './MemberList.tsx';
|
|||||||
import { VoicePanel } from './VoicePanel.tsx';
|
import { VoicePanel } from './VoicePanel.tsx';
|
||||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||||
|
import { VersionNotifier } from './VersionNotifier.tsx';
|
||||||
|
|
||||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||||
function statusColor(status: UserStatus): string {
|
function statusColor(status: UserStatus): string {
|
||||||
@@ -35,8 +36,6 @@ export function Layout() {
|
|||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
|
||||||
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
@@ -63,9 +62,8 @@ export function Layout() {
|
|||||||
}, [currentVoiceRoom]);
|
}, [currentVoiceRoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wsConnect();
|
useWebSocketStore.getState().connect();
|
||||||
return () => { wsDisconnect(); };
|
}, []);
|
||||||
}, [wsConnect, wsDisconnect]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||||
@@ -102,6 +100,7 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
||||||
|
<VersionNotifier />
|
||||||
{/* Outer terminal frame with safe area */}
|
{/* Outer terminal frame with safe area */}
|
||||||
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
||||||
style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}>
|
style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function LoginForm() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{!isRegister && (
|
{!isRegister && (
|
||||||
<div className="mt-3 text-center">
|
<div className="mt-4 text-center">
|
||||||
<Link
|
<Link
|
||||||
to="/forgot-password"
|
to="/forgot-password"
|
||||||
className="text-gb-yellow hover:text-gb-orange text-xs"
|
className="text-gb-yellow hover:text-gb-orange text-xs"
|
||||||
@@ -127,7 +127,7 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRegister && (
|
{!isRegister && (
|
||||||
<div className="mt-3">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -157,13 +157,13 @@ export function LoginForm() {
|
|||||||
console.error("Passkey login failed:", err);
|
console.error("Passkey login failed:", err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="terminal-button w-full border-gb-aqua text-gb-aqua"
|
className="terminal-button w-full border-gb-aqua text-gb-aqua text-xs"
|
||||||
>
|
>
|
||||||
[SIGN IN WITH PASSKEY]
|
[SIGN IN WITH PASSKEY]
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-5 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -610,8 +610,8 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
<button type="button" disabled={disabled || uploading}
|
<button type="button" disabled={disabled || uploading}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Upload file"
|
title="Upload file"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
|
<FontAwesomeIcon icon={faPlus} className={`w-4 h-4 ${uploading ? "animate-pulse" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-gb-bg-t mx-1">│</span>
|
<span className="text-gb-bg-t mx-1">│</span>
|
||||||
@@ -619,28 +619,28 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
{/* block formatting */}
|
{/* block formatting */}
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("ul")} title="Unordered list"
|
onClick={() => execBlock("ul")} title="Unordered list"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faListUl} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("ol")} title="Ordered list"
|
onClick={() => execBlock("ol")} title="Ordered list"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faListOl} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("blockquote")} title="Blockquote"
|
onClick={() => execBlock("blockquote")} title="Blockquote"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faQuoteRight} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={execLink} title="Insert link"
|
onClick={execLink} title="Insert link"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faLink} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("h2")} title="Heading"
|
onClick={() => execBlock("h2")} title="Heading"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faHeading} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-gb-bg-t mx-1">│</span>
|
<span className="text-gb-bg-t mx-1">│</span>
|
||||||
@@ -648,43 +648,43 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
{/* inline formatting */}
|
{/* inline formatting */}
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("**", "**")} title="Bold"
|
onClick={() => execInline("**", "**")} title="Bold"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faBold} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("*", "*")} title="Italic"
|
onClick={() => execInline("*", "*")} title="Italic"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faItalic} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("~~", "~~")} title="Strikethrough"
|
onClick={() => execInline("~~", "~~")} title="Strikethrough"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faStrikethrough} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("`", "`")} title="Code"
|
onClick={() => execInline("`", "`")} title="Code"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faCode} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("||", "||")} title="Spoiler"
|
onClick={() => execInline("||", "||")} title="Spoiler"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
|
<FontAwesomeIcon icon={faEyeSlash} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-gb-bg-t mx-1">│</span>
|
<span className="text-gb-bg-t mx-1">│</span>
|
||||||
|
|
||||||
{/* emoji / kaomoji / gif */}
|
{/* emoji / kaomoji / gif */}
|
||||||
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
|
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
|
||||||
className={`p-1 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
className={`p-1.5 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||||
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
|
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
|
||||||
className={`p-1 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
className={`p-1.5 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||||
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
|
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
|
||||||
className={`p-1 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
className={`p-1.5 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||||
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useConversationStore } from "../stores/conversation.ts";
|
import { useConversationStore } from "../stores/conversation.ts";
|
||||||
import { useMemberStore } from "../stores/member.ts";
|
import { useMemberStore } from "../stores/member.ts";
|
||||||
import { useServerStore } from "../stores/server.ts";
|
import { useServerStore } from "../stores/server.ts";
|
||||||
@@ -11,6 +12,7 @@ interface NewConversationModalProps {
|
|||||||
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const navigate = useNavigate();
|
||||||
const createConversation = useConversationStore((s) => s.createConversation);
|
const createConversation = useConversationStore((s) => s.createConversation);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
@@ -48,7 +50,14 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
await createConversation(selected);
|
try {
|
||||||
|
const conv = await createConversation(selected);
|
||||||
|
if (conv?.id) {
|
||||||
|
navigate(`/dm/${conv.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create conversation:", err);
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../lib/api.ts";
|
||||||
|
import { useWebSocketStore } from "../stores/ws.ts";
|
||||||
|
|
||||||
|
export function VersionNotifier() {
|
||||||
|
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const connected = useWebSocketStore((s) => s.connected);
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ version: string }>("/version");
|
||||||
|
if (data && data.version && data.version !== "dev") {
|
||||||
|
setServerVersion((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return data.version;
|
||||||
|
}
|
||||||
|
if (prev !== data.version) {
|
||||||
|
setUpdateAvailable(true);
|
||||||
|
}
|
||||||
|
return data.version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore check errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkVersion();
|
||||||
|
const interval = setInterval(checkVersion, 2 * 60 * 1000); // Check every 2 mins
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected) {
|
||||||
|
checkVersion();
|
||||||
|
}
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!updateAvailable) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gb-accent text-gb-bg-h px-4 py-2 text-xs font-semibold flex items-center justify-between shadow-lg z-50">
|
||||||
|
<span>🚀 A new update is available! ({serverVersion})</span>
|
||||||
|
<button
|
||||||
|
onClick={handleReload}
|
||||||
|
className="bg-gb-bg-h text-gb-accent px-3 py-1 rounded hover:opacity-90 font-bold transition-all"
|
||||||
|
>
|
||||||
|
Update Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ if (isTauri) {
|
|||||||
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
|
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
|
||||||
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
||||||
if (navigator.userAgent.includes('Android')) {
|
if (navigator.userAgent.includes('Android')) {
|
||||||
document.documentElement.style.setProperty('--safe-top', '1.5rem');
|
document.documentElement.style.setProperty('--safe-top', '2rem');
|
||||||
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
||||||
document.documentElement.style.setProperty('--safe-left', '0px');
|
document.documentElement.style.setProperty('--safe-left', '0px');
|
||||||
document.documentElement.style.setProperty('--safe-right', '0px');
|
document.documentElement.style.setProperty('--safe-right', '0px');
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function autoSubscribePush() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -146,7 +146,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchMe: async () => {
|
fetchMe: async () => {
|
||||||
|
if (!get().user) {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
|
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
|
|||||||
+41
-18
@@ -33,11 +33,18 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchChannels: async (serverId) => {
|
fetchChannels: async (serverId) => {
|
||||||
|
const srvId = serverId.toLowerCase();
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const channels = await api.get<Channel[]>(`/servers/${serverId}/channels`);
|
const channels = await api.get<Channel[]>(`/servers/${srvId}/channels`);
|
||||||
|
const list = Array.isArray(channels) ? channels : [];
|
||||||
|
const normalized = list.map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: c.id.toLowerCase(),
|
||||||
|
server_id: c.server_id.toLowerCase(),
|
||||||
|
}));
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
channelsByServer: { ...state.channelsByServer, [serverId]: channels },
|
channelsByServer: { ...state.channelsByServer, [srvId]: normalized },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -49,50 +56,66 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setActiveChannel: (id) => {
|
setActiveChannel: (id) => {
|
||||||
set({ activeChannelId: id });
|
const chId = id ? id.toLowerCase() : null;
|
||||||
if (id) {
|
set({ activeChannelId: chId });
|
||||||
|
if (chId) {
|
||||||
// ponytail: persist last active channel for session restore
|
// ponytail: persist last active channel for session restore
|
||||||
const state = useChannelStore.getState();
|
const state = useChannelStore.getState();
|
||||||
for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
|
for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
|
||||||
if (channels.some((c) => c.id === id)) {
|
if (channels.some((c) => c.id.toLowerCase() === chId)) {
|
||||||
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: id }));
|
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: chId }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addChannel: (channel) =>
|
addChannel: (channel) => {
|
||||||
|
const norm = {
|
||||||
|
...channel,
|
||||||
|
id: channel.id.toLowerCase(),
|
||||||
|
server_id: channel.server_id.toLowerCase(),
|
||||||
|
};
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.channelsByServer[channel.server_id] || [];
|
const list = state.channelsByServer[norm.server_id] || [];
|
||||||
|
if (list.some((c) => c.id === norm.id)) return state;
|
||||||
return {
|
return {
|
||||||
channelsByServer: {
|
channelsByServer: {
|
||||||
...state.channelsByServer,
|
...state.channelsByServer,
|
||||||
[channel.server_id]: [...list, channel],
|
[norm.server_id]: [...list, norm],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
updateChannel: (channel) =>
|
updateChannel: (channel) => {
|
||||||
|
const norm = {
|
||||||
|
...channel,
|
||||||
|
id: channel.id.toLowerCase(),
|
||||||
|
server_id: channel.server_id.toLowerCase(),
|
||||||
|
};
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.channelsByServer[channel.server_id] || [];
|
const list = state.channelsByServer[norm.server_id] || [];
|
||||||
return {
|
return {
|
||||||
channelsByServer: {
|
channelsByServer: {
|
||||||
...state.channelsByServer,
|
...state.channelsByServer,
|
||||||
[channel.server_id]: list.map((c) => (c.id === channel.id ? channel : c)),
|
[norm.server_id]: list.map((c) => (c.id === norm.id ? norm : c)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
removeChannel: (id) =>
|
removeChannel: (id) => {
|
||||||
|
const chId = id.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next: Record<string, Channel[]> = {};
|
const next: Record<string, Channel[]> = {};
|
||||||
for (const serverId of Object.keys(state.channelsByServer)) {
|
for (const serverId of Object.keys(state.channelsByServer)) {
|
||||||
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== id);
|
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== chId);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
channelsByServer: next,
|
channelsByServer: next,
|
||||||
activeChannelId: state.activeChannelId === id ? null : state.activeChannelId,
|
activeChannelId: state.activeChannelId === chId ? null : state.activeChannelId,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { create } from "zustand";
|
|||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
import { type Reaction } from "./message.ts";
|
import { type Reaction } from "./message.ts";
|
||||||
|
|
||||||
|
function parseDate(iso: string): number {
|
||||||
|
if (!iso) return Date.now();
|
||||||
|
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
|
||||||
|
const t = new Date(normalized).getTime();
|
||||||
|
return isNaN(t) ? Date.now() : t;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConversationMember {
|
export interface ConversationMember {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -72,121 +79,147 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
|
|
||||||
createConversation: async (userIds) => {
|
createConversation: async (userIds) => {
|
||||||
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
|
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
|
||||||
|
const convId = conversation.id.toLowerCase();
|
||||||
|
const normalizedConv = { ...conversation, id: convId };
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
conversations: [conversation, ...state.conversations],
|
conversations: [normalizedConv, ...state.conversations],
|
||||||
activeConversationId: conversation.id,
|
activeConversationId: convId,
|
||||||
}));
|
}));
|
||||||
return conversation;
|
return normalizedConv;
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveConversation: (id) => set({ activeConversationId: id }),
|
setActiveConversation: (id) => set({ activeConversationId: id ? id.toLowerCase() : null }),
|
||||||
|
|
||||||
fetchMessages: async (conversationId, before) => {
|
fetchMessages: async (conversationId, before) => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const params = before ? "?before=" + encodeURIComponent(before) : "";
|
const params = before ? "?before=" + encodeURIComponent(before) : "";
|
||||||
const messages = await api.get<ConversationMessage[]>(
|
const messages = await api.get<ConversationMessage[]>(
|
||||||
`/conversations/${conversationId}/messages${params}`,
|
`/conversations/${convId}/messages${params}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const existing = state.messagesByConversation[convId] || [];
|
||||||
|
const map = new Map<string, ConversationMessage>();
|
||||||
|
existing.forEach((m) => map.set(m.id, m));
|
||||||
|
list.forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
|
||||||
|
const merged = Array.from(map.values()).sort(
|
||||||
|
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||||
|
);
|
||||||
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: list,
|
[convId]: merged,
|
||||||
},
|
},
|
||||||
hasMoreByConversation: {
|
hasMoreByConversation: {
|
||||||
...state.hasMoreByConversation,
|
...state.hasMoreByConversation,
|
||||||
[conversationId]: list.length >= 50,
|
[convId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchOlderMessages: async (conversationId) => {
|
fetchOlderMessages: async (conversationId) => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
|
if (state.isLoadingOlder || state.hasMoreByConversation[convId] === false) return;
|
||||||
const existing = state.messagesByConversation[conversationId] || [];
|
const existing = state.messagesByConversation[convId] || [];
|
||||||
if (existing.length === 0) return;
|
if (existing.length === 0) return;
|
||||||
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
|
||||||
const oldestId = existing[0].id;
|
const oldestId = existing[0].id;
|
||||||
set({ isLoadingOlder: true });
|
set({ isLoadingOlder: true });
|
||||||
try {
|
try {
|
||||||
const older = await api.get<ConversationMessage[]>(
|
const older = await api.get<ConversationMessage[]>(
|
||||||
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
|
`/conversations/${convId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(older) ? older : [];
|
const list = Array.isArray(older) ? older : [];
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const map = new Map<string, ConversationMessage>();
|
||||||
|
[...list, ...existing].forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
|
||||||
|
const merged = Array.from(map.values()).sort(
|
||||||
|
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||||
|
);
|
||||||
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: [...list, ...existing],
|
[convId]: merged,
|
||||||
},
|
},
|
||||||
hasMoreByConversation: {
|
hasMoreByConversation: {
|
||||||
...state.hasMoreByConversation,
|
...state.hasMoreByConversation,
|
||||||
[conversationId]: list.length >= 50,
|
[convId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoadingOlder: false,
|
isLoadingOlder: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
set({ isLoadingOlder: false });
|
set({ isLoadingOlder: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (conversationId, content) => {
|
sendMessage: async (conversationId, content) => {
|
||||||
// Add locally so the message appears immediately even if WS lags.
|
const convId = conversationId.toLowerCase();
|
||||||
// addMessage dedupes, so a late WS event won't double it.
|
|
||||||
const message = await api.post<ConversationMessage>(
|
const message = await api.post<ConversationMessage>(
|
||||||
`/conversations/${conversationId}/messages`,
|
`/conversations/${convId}/messages`,
|
||||||
{ content },
|
{ content },
|
||||||
);
|
);
|
||||||
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
const normalizedMessage = { ...message, conversation_id: (message.conversation_id || convId).toLowerCase() };
|
||||||
get().addMessage(message);
|
get().addMessage(normalizedMessage);
|
||||||
return message;
|
return normalizedMessage;
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (message) => {
|
addMessage: (message) => {
|
||||||
|
const convId = (message.conversation_id || "").toLowerCase();
|
||||||
|
const normalizedMessage = { ...message, conversation_id: convId };
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const existing = state.messagesByConversation[message.conversation_id] || [];
|
const existing = state.messagesByConversation[convId] || [];
|
||||||
if (existing.some((m) => m.id === message.id)) {
|
if (existing.some((m) => m.id === normalizedMessage.id)) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[message.conversation_id]: [...existing, message]
|
[convId]: [...existing, normalizedMessage]
|
||||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMessage: (message) => {
|
updateMessage: (message) => {
|
||||||
|
const convId = (message.conversation_id || "").toLowerCase();
|
||||||
|
const normalizedMessage = { ...message, conversation_id: convId };
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const convMsgs = state.messagesByConversation[message.conversation_id] || [];
|
const convMsgs = state.messagesByConversation[convId] || [];
|
||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[message.conversation_id]: convMsgs.map((m) => (m.id === message.id ? message : m)),
|
[convId]: convMsgs.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMessage: (conversationId, messageId) => {
|
deleteMessage: (conversationId, messageId) => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const convMsgs = state.messagesByConversation[conversationId] || [];
|
const convMsgs = state.messagesByConversation[convId] || [];
|
||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: convMsgs.filter((m) => m.id !== messageId),
|
[convId]: convMsgs.filter((m) => m.id !== messageId),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addReaction: (conversationId, messageId, emoji, userId) => {
|
addReaction: (conversationId, messageId, emoji, userId) => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const messages = state.messagesByConversation[conversationId];
|
const messages = state.messagesByConversation[convId];
|
||||||
if (!messages) return state;
|
if (!messages) return state;
|
||||||
|
|
||||||
const newMessages = messages.map((m) => {
|
const newMessages = messages.map((m) => {
|
||||||
@@ -210,15 +243,16 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: newMessages,
|
[convId]: newMessages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeReaction: (conversationId, messageId, emoji, userId) => {
|
removeReaction: (conversationId, messageId, emoji, userId) => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const messages = state.messagesByConversation[conversationId];
|
const messages = state.messagesByConversation[convId];
|
||||||
if (!messages) return state;
|
if (!messages) return state;
|
||||||
|
|
||||||
const newMessages = messages.map((m) => {
|
const newMessages = messages.map((m) => {
|
||||||
@@ -241,7 +275,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: newMessages,
|
[convId]: newMessages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+152
-100
@@ -1,6 +1,13 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
|
|
||||||
|
function parseDate(iso: string): number {
|
||||||
|
if (!iso) return Date.now();
|
||||||
|
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
|
||||||
|
const t = new Date(normalized).getTime();
|
||||||
|
return isNaN(t) ? Date.now() : t;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageEmbed {
|
export interface MessageEmbed {
|
||||||
id?: string;
|
id?: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -59,7 +66,7 @@ export interface MessageState {
|
|||||||
messagesByChannel: Record<string, Message[]>;
|
messagesByChannel: Record<string, Message[]>;
|
||||||
pinnedMessagesByChannel: 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>>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isLoadingOlder: boolean;
|
isLoadingOlder: boolean;
|
||||||
hasMoreByChannel: Record<string, boolean>;
|
hasMoreByChannel: Record<string, boolean>;
|
||||||
@@ -95,24 +102,34 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchMessages: async (channelId, before) => {
|
fetchMessages: async (channelId, before) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const params = before ? `?before=${encodeURIComponent(before)}` : "";
|
const params = before ? `?before=${encodeURIComponent(before)}` : "";
|
||||||
const messages = await api.get<Message[]>(
|
const messages = await api.get<Message[]>(
|
||||||
`/channels/${channelId}/messages${params}`,
|
`/channels/${chId}/messages${params}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const existing = state.messagesByChannel[chId] || [];
|
||||||
|
const map = new Map<string, Message>();
|
||||||
|
existing.forEach((m) => map.set(m.id, m));
|
||||||
|
list.forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
|
||||||
|
const merged = Array.from(map.values()).sort(
|
||||||
|
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||||
|
);
|
||||||
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: list,
|
[chId]: merged,
|
||||||
},
|
},
|
||||||
hasMoreByChannel: {
|
hasMoreByChannel: {
|
||||||
...state.hasMoreByChannel,
|
...state.hasMoreByChannel,
|
||||||
[channelId]: list.length >= 50,
|
[chId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -125,116 +142,136 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchOlderMessages: async (channelId) => {
|
fetchOlderMessages: async (channelId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
|
if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return;
|
||||||
const existing = state.messagesByChannel[channelId] || [];
|
const existing = state.messagesByChannel[chId] || [];
|
||||||
if (existing.length === 0) return;
|
if (existing.length === 0) return;
|
||||||
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
|
||||||
const oldestId = existing[0].id;
|
const oldestId = existing[0].id;
|
||||||
set({ isLoadingOlder: true });
|
set({ isLoadingOlder: true });
|
||||||
try {
|
try {
|
||||||
const older = await api.get<Message[]>(
|
const older = await api.get<Message[]>(
|
||||||
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`,
|
`/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(older) ? older : [];
|
const list = Array.isArray(older) ? older : [];
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const map = new Map<string, Message>();
|
||||||
|
[...list, ...existing].forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
|
||||||
|
const merged = Array.from(map.values()).sort(
|
||||||
|
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||||
|
);
|
||||||
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: [...list, ...existing],
|
[chId]: merged,
|
||||||
},
|
},
|
||||||
hasMoreByChannel: {
|
hasMoreByChannel: {
|
||||||
...state.hasMoreByChannel,
|
...state.hasMoreByChannel,
|
||||||
[channelId]: list.length >= 50,
|
[chId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoadingOlder: false,
|
isLoadingOlder: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
set({ isLoadingOlder: false });
|
set({ isLoadingOlder: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
searchMessages: async (channelId, query) => {
|
searchMessages: async (channelId, query) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
const results = await api.get<SearchResultMessage[]>(
|
const results = await api.get<SearchResultMessage[]>(
|
||||||
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
|
`/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`,
|
||||||
);
|
);
|
||||||
|
const list = Array.isArray(results) ? results : [];
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
searchResultsByChannel: {
|
searchResultsByChannel: {
|
||||||
...state.searchResultsByChannel,
|
...state.searchResultsByChannel,
|
||||||
[channelId]: Array.isArray(results) ? results : [],
|
[chId]: list,
|
||||||
},
|
|
||||||
}));
|
|
||||||
return Array.isArray(results) ? results : [];
|
|
||||||
},
|
|
||||||
|
|
||||||
sendMessage: async (channelId, content, replyTo) => {
|
|
||||||
const body: { content: string; reply_to?: string } = { content };
|
|
||||||
if (replyTo) body.reply_to = replyTo;
|
|
||||||
// Add locally so the message appears immediately even if WS lags.
|
|
||||||
// addMessage dedupes, so a late WS event won't double it.
|
|
||||||
const message = await api.post<Message>(
|
|
||||||
`/channels/${channelId}/messages`,
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
|
||||||
get().addMessage(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;
|
return list;
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (message) =>
|
sendMessage: async (channelId, content, replyTo) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
|
const body: { content: string; reply_to?: string } = { content };
|
||||||
|
if (replyTo) body.reply_to = replyTo;
|
||||||
|
const message = await api.post<Message>(
|
||||||
|
`/channels/${chId}/messages`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
const normalizedMessage = { ...message, channel_id: (message.channel_id || chId).toLowerCase() };
|
||||||
|
get().addMessage(normalizedMessage);
|
||||||
|
return normalizedMessage;
|
||||||
|
},
|
||||||
|
|
||||||
|
pinMessage: async (channelId, messageId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
|
const updated = await api.put<Message>(`/channels/${chId}/messages/${messageId}/pin`, {});
|
||||||
|
const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() };
|
||||||
|
get().updateMessage(normalizedMessage);
|
||||||
|
},
|
||||||
|
|
||||||
|
unpinMessage: async (channelId, messageId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
|
const updated = await api.delete<Message>(`/channels/${chId}/messages/${messageId}/pin`);
|
||||||
|
const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() };
|
||||||
|
get().updateMessage(normalizedMessage);
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchPinnedMessages: async (channelId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
|
const pinned = await api.get<Message[]>(`/channels/${chId}/messages/pinned`);
|
||||||
|
const list = Array.isArray(pinned) ? pinned : [];
|
||||||
|
set((state) => ({
|
||||||
|
pinnedMessagesByChannel: {
|
||||||
|
...state.pinnedMessagesByChannel,
|
||||||
|
[chId]: list,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
addMessage: (message) => {
|
||||||
|
const chId = (message.channel_id || "").toLowerCase();
|
||||||
|
const normalizedMessage = { ...message, channel_id: chId };
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[message.channel_id] || [];
|
const list = state.messagesByChannel[chId] || [];
|
||||||
if (list.some((m) => m.id === message.id)) {
|
if (list.some((m) => m.id === normalizedMessage.id)) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[message.channel_id]: [...list, message],
|
[chId]: [...list, normalizedMessage].sort(
|
||||||
|
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
updateMessage: (message) =>
|
updateMessage: (message) => {
|
||||||
|
const chId = (message.channel_id || "").toLowerCase();
|
||||||
|
const normalizedMessage = { ...message, channel_id: chId };
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[message.channel_id] || [];
|
const list = state.messagesByChannel[chId] || [];
|
||||||
const updatedList = list.map((m) =>
|
const updatedList = list.map((m) =>
|
||||||
m.id === message.id ? message : m,
|
m.id === normalizedMessage.id ? normalizedMessage : m,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || [];
|
const pinnedList = state.pinnedMessagesByChannel[chId] || [];
|
||||||
let updatedPinned = [...pinnedList];
|
let updatedPinned = [...pinnedList];
|
||||||
if (message.pinned) {
|
if (normalizedMessage.pinned) {
|
||||||
if (!pinnedList.some((m) => m.id === message.id)) {
|
if (!pinnedList.some((m) => m.id === normalizedMessage.id)) {
|
||||||
updatedPinned = [message, ...pinnedList].sort(
|
updatedPinned = [normalizedMessage, ...pinnedList].sort(
|
||||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
(a, b) => parseDate(b.created_at) - parseDate(a.created_at)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
|
updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
|
updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id);
|
||||||
}
|
}
|
||||||
if (updatedPinned.length > 5) {
|
if (updatedPinned.length > 5) {
|
||||||
updatedPinned = updatedPinned.slice(0, 5);
|
updatedPinned = updatedPinned.slice(0, 5);
|
||||||
@@ -243,34 +280,38 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[message.channel_id]: updatedList,
|
[chId]: updatedList,
|
||||||
},
|
},
|
||||||
pinnedMessagesByChannel: {
|
pinnedMessagesByChannel: {
|
||||||
...state.pinnedMessagesByChannel,
|
...state.pinnedMessagesByChannel,
|
||||||
[message.channel_id]: updatedPinned,
|
[chId]: updatedPinned,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
removeMessage: (channelId, messageId) =>
|
removeMessage: (channelId, messageId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[channelId] || [];
|
const list = state.messagesByChannel[chId] || [];
|
||||||
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
|
const pinnedList = state.pinnedMessagesByChannel[chId] || [];
|
||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: list.filter((m) => m.id !== messageId),
|
[chId]: list.filter((m) => m.id !== messageId),
|
||||||
},
|
},
|
||||||
pinnedMessagesByChannel: {
|
pinnedMessagesByChannel: {
|
||||||
...state.pinnedMessagesByChannel,
|
...state.pinnedMessagesByChannel,
|
||||||
[channelId]: pinnedList.filter((m) => m.id !== messageId),
|
[chId]: pinnedList.filter((m) => m.id !== messageId),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
addReaction: (channelId, messageId, emoji, userId) =>
|
addReaction: (channelId, messageId, emoji, userId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[channelId] || [];
|
const list = state.messagesByChannel[chId] || [];
|
||||||
const updatedList = list.map((m) => {
|
const updatedList = list.map((m) => {
|
||||||
if (m.id !== messageId) return m;
|
if (m.id !== messageId) return m;
|
||||||
const reactions = m.reactions ? [...m.reactions] : [];
|
const reactions = m.reactions ? [...m.reactions] : [];
|
||||||
@@ -288,14 +329,16 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: updatedList,
|
[chId]: updatedList,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
removeReaction: (channelId, messageId, emoji, userId) =>
|
removeReaction: (channelId, messageId, emoji, userId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[channelId] || [];
|
const list = state.messagesByChannel[chId] || [];
|
||||||
const updatedList = list.map((m) => {
|
const updatedList = list.map((m) => {
|
||||||
if (m.id !== messageId) return m;
|
if (m.id !== messageId) return m;
|
||||||
if (!m.reactions) return m;
|
if (!m.reactions) return m;
|
||||||
@@ -311,25 +354,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: updatedList,
|
[chId]: updatedList,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
bulkDeleteMessages: async (channelId, messageIds) => {
|
bulkDeleteMessages: async (channelId, messageIds) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
const res = await api.post<{ deleted: number }>(
|
const res = await api.post<{ deleted: number }>(
|
||||||
`/channels/${channelId}/messages/bulk-delete`,
|
`/channels/${chId}/messages/bulk-delete`,
|
||||||
{ messages: messageIds },
|
{ messages: messageIds },
|
||||||
);
|
);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[channelId] || [];
|
const list = state.messagesByChannel[chId] || [];
|
||||||
const ids = new Set(messageIds);
|
const ids = new Set(messageIds);
|
||||||
const selected = { ...state.selectedMessageIds };
|
const selected = { ...state.selectedMessageIds };
|
||||||
delete selected[channelId];
|
delete selected[chId];
|
||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: list.filter((m) => !ids.has(m.id)),
|
[chId]: list.filter((m) => !ids.has(m.id)),
|
||||||
},
|
},
|
||||||
selectedMessageIds: selected,
|
selectedMessageIds: selected,
|
||||||
};
|
};
|
||||||
@@ -337,9 +382,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSelectedMessage: (channelId, messageId) =>
|
toggleSelectedMessage: (channelId, messageId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.selectedMessageIds[channelId] || new Set<string>();
|
const current = state.selectedMessageIds[chId] || new Set<string>();
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (next.has(messageId)) {
|
if (next.has(messageId)) {
|
||||||
next.delete(messageId);
|
next.delete(messageId);
|
||||||
@@ -349,21 +395,25 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
selectedMessageIds: {
|
selectedMessageIds: {
|
||||||
...state.selectedMessageIds,
|
...state.selectedMessageIds,
|
||||||
[channelId]: next,
|
[chId]: next,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
clearSelectedMessages: (channelId) =>
|
clearSelectedMessages: (channelId) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = { ...state.selectedMessageIds };
|
const next = { ...state.selectedMessageIds };
|
||||||
delete next[channelId];
|
delete next[chId];
|
||||||
return { selectedMessageIds: next };
|
return { selectedMessageIds: next };
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
createPoll: async (channelId, question, options) => {
|
createPoll: async (channelId, question, options) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
const resp = await api.post<Poll>("/polls", {
|
const resp = await api.post<Poll>("/polls", {
|
||||||
channel_id: channelId,
|
channel_id: chId,
|
||||||
question,
|
question,
|
||||||
options,
|
options,
|
||||||
});
|
});
|
||||||
@@ -374,9 +424,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
|
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePoll: (channelId, poll) =>
|
updatePoll: (channelId, poll) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const messages = state.messagesByChannel[channelId];
|
const messages = state.messagesByChannel[chId];
|
||||||
if (!messages) return state;
|
if (!messages) return state;
|
||||||
const updated = messages.map((m) =>
|
const updated = messages.map((m) =>
|
||||||
m.poll?.id === poll.id ? { ...m, poll } : m,
|
m.poll?.id === poll.id ? { ...m, poll } : m,
|
||||||
@@ -384,8 +435,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: updated,
|
[chId]: updated,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -27,25 +27,28 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
markRead: async (channelId: string, messageId: string) => {
|
markRead: async (channelId: string, messageId: string) => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
states: { ...state.states, [channelId]: messageId },
|
states: { ...state.states, [chId]: messageId },
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId });
|
await api.put(`/channels/${chId}/read`, { last_read_message_id: messageId });
|
||||||
} catch {
|
} catch {
|
||||||
// optimistic update, ignore failure
|
// optimistic update, ignore failure
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markConvRead: (conversationId: string, messageId: string) => {
|
markConvRead: (conversationId: string, messageId: string) => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
convStates: { ...state.convStates, [conversationId]: messageId },
|
convStates: { ...state.convStates, [convId]: messageId },
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
|
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
|
||||||
|
const chId = channelId.toLowerCase();
|
||||||
const state = get().states;
|
const state = get().states;
|
||||||
const lastRead = state[channelId];
|
const lastRead = state[chId];
|
||||||
// never viewed: unread if there are messages
|
// never viewed: unread if there are messages
|
||||||
if (!lastRead) return !!latestMessageId;
|
if (!lastRead) return !!latestMessageId;
|
||||||
// viewed but newer messages exist
|
// viewed but newer messages exist
|
||||||
@@ -54,8 +57,9 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
|
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
|
||||||
|
const convId = conversationId.toLowerCase();
|
||||||
const state = get().convStates;
|
const state = get().convStates;
|
||||||
const lastRead = state[conversationId];
|
const lastRead = state[convId];
|
||||||
if (!lastRead) return !!latestMessageId;
|
if (!lastRead) return !!latestMessageId;
|
||||||
if (latestMessageId && lastRead !== latestMessageId) return true;
|
if (latestMessageId && lastRead !== latestMessageId) return true;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+26
-14
@@ -61,11 +61,15 @@ function extractIds(payload: UnknownPayload | undefined): { channel_id?: string;
|
|||||||
? payload.id
|
? payload.id
|
||||||
: null;
|
: null;
|
||||||
if (!messageId) return null;
|
if (!messageId) return null;
|
||||||
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId };
|
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id.toLowerCase(), message_id: messageId };
|
||||||
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId };
|
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id.toLowerCase(), message_id: messageId };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
const maxReconnectDelay = 30000;
|
||||||
|
let reconnectTimeout: number | null = null;
|
||||||
|
|
||||||
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||||
socket: null,
|
socket: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -78,10 +82,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
const socket = new WebSocket(`${getWsHost()}/ws`);
|
const socket = new WebSocket(`${getWsHost()}/ws`);
|
||||||
|
|
||||||
let reconnectDelay = 1000;
|
|
||||||
const maxReconnectDelay = 30000;
|
|
||||||
let reconnectTimeout: number | null = null;
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
const scheduleReconnect = () => {
|
||||||
if (reconnectTimeout) {
|
if (reconnectTimeout) {
|
||||||
window.clearTimeout(reconnectTimeout);
|
window.clearTimeout(reconnectTimeout);
|
||||||
@@ -95,8 +95,15 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
// Cookie-based auth: browser sends session cookie automatically.
|
reconnectDelay = 1000; // ponytail: reset backoff on successful connect
|
||||||
// No need to send a token frame.
|
const token = localStorage.getItem('dumpster_session_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ token }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
@@ -110,6 +117,10 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
if (data.type === 'ready') {
|
if (data.type === 'ready') {
|
||||||
set({ connected: true });
|
set({ connected: true });
|
||||||
reconnectDelay = 1000;
|
reconnectDelay = 1000;
|
||||||
|
const activeChan = useChannelStore.getState().activeChannelId;
|
||||||
|
if (activeChan) useMessageStore.getState().fetchMessages(activeChan);
|
||||||
|
const activeConv = useConversationStore.getState().activeConversationId;
|
||||||
|
if (activeConv) useConversationStore.getState().fetchMessages(activeConv);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +135,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 addReaction = useMessageStore.getState().addReaction;
|
||||||
@@ -141,15 +151,17 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
if (isRecord(payload)) {
|
if (isRecord(payload)) {
|
||||||
if (payload.conversation_id) {
|
if (payload.conversation_id) {
|
||||||
const msg = payload as unknown as ConversationMessage;
|
const msg = payload as unknown as ConversationMessage;
|
||||||
useConversationStore.getState().addMessage(msg);
|
const normalizedMsg = { ...msg, conversation_id: (msg.conversation_id || '').toLowerCase() };
|
||||||
|
useConversationStore.getState().addMessage(normalizedMsg);
|
||||||
// auto-mark DM as read if this conversation is active
|
// auto-mark DM as read if this conversation is active
|
||||||
const activeConvId = useConversationStore.getState().activeConversationId;
|
const activeConvId = (useConversationStore.getState().activeConversationId || '').toLowerCase();
|
||||||
if (msg.conversation_id === activeConvId && document.hasFocus()) {
|
if (normalizedMsg.conversation_id === activeConvId && document.hasFocus()) {
|
||||||
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id);
|
useReadStatesStore.getState().markConvRead(normalizedMsg.conversation_id, normalizedMsg.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = payload as unknown as Message;
|
const msg = payload as unknown as Message;
|
||||||
addMessage(msg);
|
const normalizedMsg = { ...msg, channel_id: (msg.channel_id || '').toLowerCase() };
|
||||||
|
useMessageStore.getState().addMessage(normalizedMsg);
|
||||||
|
|
||||||
// Desktop notification
|
// Desktop notification
|
||||||
const currentUserId = useAuthStore.getState().user?.id;
|
const currentUserId = useAuthStore.getState().user?.id;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.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/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.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/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.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/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.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/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.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/VersionNotifier.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.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/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user