Compare commits
13 Commits
5f2b0aea8b
...
v0.3.12
| Author | SHA1 | Date | |
|---|---|---|---|
| 065f036807 | |||
| f53cd49803 | |||
| 4e48815b91 | |||
| d9b3162f1c | |||
| 6384588122 | |||
| 978e94da90 | |||
| 34c18c13ae | |||
| cca6ea0e37 | |||
| 738b9b17ff | |||
| 44370f41e5 | |||
| 1d6c9bdfe6 | |||
| 57aec2c6b3 | |||
| d4ca5f576e |
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
web/dist
|
||||||
|
certs
|
||||||
|
*.zip
|
||||||
|
dumpster-server
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -8,8 +8,31 @@ export NVM_DIR="$HOME/.nvm"
|
|||||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
nvm use 22
|
nvm use 22
|
||||||
|
|
||||||
|
# Snapshot the current binary before pulling
|
||||||
|
if [ -f dumpster-server ]; then
|
||||||
|
cp dumpster-server dumpster-server.bak
|
||||||
|
echo "Backed up existing binary to dumpster-server.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
git pull
|
git pull
|
||||||
make build
|
make build
|
||||||
systemctl restart dumpster
|
|
||||||
|
|
||||||
echo "Deploy complete — $(date)"
|
# Health check after restart — roll back on failure
|
||||||
|
systemctl restart dumpster
|
||||||
|
echo "Waiting for app to become healthy..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
|
||||||
|
echo "Server is listening — deploy complete"
|
||||||
|
rm -f dumpster-server.bak
|
||||||
|
echo "Deploy complete — $(date)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Health check failed after 30s — rolling back..."
|
||||||
|
systemctl stop dumpster
|
||||||
|
cp dumpster-server.bak dumpster-server
|
||||||
|
systemctl start dumpster
|
||||||
|
echo "Rolled back to previous binary — $(date)"
|
||||||
|
exit 1
|
||||||
|
|||||||
+3
-1
@@ -16,8 +16,10 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk --no-cache add ca-certificates
|
RUN apk --no-cache add ca-certificates
|
||||||
|
RUN adduser -D -g '' appuser
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
||||||
COPY --from=web-builder /app/dist /srv/web
|
COPY --from=web-builder /app/dist /srv/web
|
||||||
|
USER appuser
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["sh", "-c", "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"]
|
CMD sh -c "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"
|
||||||
|
|||||||
+22
-3
@@ -1,5 +1,9 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
backend:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -32,6 +36,9 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
valkey:
|
valkey:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -49,6 +56,8 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
valkey:
|
valkey:
|
||||||
image: valkey/valkey:8
|
image: valkey/valkey:8
|
||||||
@@ -62,6 +71,8 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
@@ -75,6 +86,8 @@ services:
|
|||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:latest
|
||||||
@@ -89,6 +102,8 @@ services:
|
|||||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
environment:
|
environment:
|
||||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
@@ -99,8 +114,8 @@ services:
|
|||||||
- "--max-port=50200"
|
- "--max-port=50200"
|
||||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||||
- "--cert=/etc/coturn/certs/turn.crt"
|
- "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
|
||||||
- "--pkey=/etc/coturn/certs/turn.key"
|
- "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
|
||||||
- "--tls-listening-port=5349"
|
- "--tls-listening-port=5349"
|
||||||
ports:
|
ports:
|
||||||
- "3478:3478/tcp"
|
- "3478:3478/tcp"
|
||||||
@@ -109,7 +124,9 @@ services:
|
|||||||
- "5349:5349/udp"
|
- "5349:5349/udp"
|
||||||
- "50101-50200:50101-50200/udp"
|
- "50101-50200:50101-50200/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./certs:/etc/coturn/certs:ro
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: caddy:2
|
||||||
@@ -124,6 +141,8 @@ services:
|
|||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
token := generateToken()
|
token := generateToken()
|
||||||
|
tokenHash := hashToken(token)
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||||
`, userID, token)
|
`, userID, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -63,7 +64,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
DELETE FROM user_tokens
|
DELETE FROM user_tokens
|
||||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
`, req.Token).Scan(&userID)
|
`, hashToken(req.Token)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -100,10 +101,11 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
token := generateToken()
|
token := generateToken()
|
||||||
|
tokenHash := hashToken(token)
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||||
`, userID, token)
|
`, userID, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -134,7 +136,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
DELETE FROM user_tokens
|
DELETE FROM user_tokens
|
||||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
`, req.Token).Scan(&userID)
|
`, hashToken(req.Token)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
|
|||||||
return &SessionStore{db: db, cfg: cfg}
|
return &SessionStore{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashToken computes a SHA-256 hash of a token string.
|
||||||
|
// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions.
|
||||||
|
func hashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", fmt.Errorf("generate token: %w", err)
|
return "", fmt.Errorf("generate token: %w", err)
|
||||||
}
|
}
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO sessions (user_id, token, expires_at)
|
INSERT INTO sessions (user_id, token, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
`, userID, token, time.Now().Add(s.cfg.Session.Duration))
|
`, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert session: %w", err)
|
return "", fmt.Errorf("insert session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -43,7 +52,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
|||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT user_id FROM sessions
|
SELECT user_id FROM sessions
|
||||||
WHERE token = $1 AND expires_at > NOW()
|
WHERE token = $1 AND expires_at > NOW()
|
||||||
`, token).Scan(&userID)
|
`, hashToken(token)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -51,6 +60,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token)
|
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
|
|
||||||
@@ -24,6 +25,11 @@ func New(cfg *config.Config) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("ping database: %w", err)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: prevent unbounded connection growth under burst load
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
db.SetConnMaxLifetime(15 * time.Minute)
|
||||||
|
|
||||||
return &DB{db}, nil
|
return &DB{db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
@@ -12,16 +13,21 @@ import (
|
|||||||
type ipLimiter struct {
|
type ipLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
limiters map[string]*rate.Limiter
|
limiters map[string]*rate.Limiter
|
||||||
|
lastUsed map[string]time.Time
|
||||||
rate rate.Limit
|
rate rate.Limit
|
||||||
burst int
|
burst int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||||
return &ipLimiter{
|
l := &ipLimiter{
|
||||||
limiters: make(map[string]*rate.Limiter),
|
limiters: make(map[string]*rate.Limiter),
|
||||||
|
lastUsed: make(map[string]time.Time),
|
||||||
rate: r,
|
rate: r,
|
||||||
burst: burst,
|
burst: burst,
|
||||||
}
|
}
|
||||||
|
// ponytail: evict inactive limiters every 5 minutes to prevent unbounded map growth
|
||||||
|
go l.cleanup()
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||||
@@ -33,9 +39,27 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
|||||||
lim = rate.NewLimiter(l.rate, l.burst)
|
lim = rate.NewLimiter(l.rate, l.burst)
|
||||||
l.limiters[ip] = lim
|
l.limiters[ip] = lim
|
||||||
}
|
}
|
||||||
|
l.lastUsed[ip] = time.Now()
|
||||||
return lim
|
return lim
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanup runs in a background goroutine and removes limiters that haven't
|
||||||
|
// been accessed in 10 minutes.
|
||||||
|
func (l *ipLimiter) cleanup() {
|
||||||
|
for {
|
||||||
|
time.Sleep(5 * time.Minute)
|
||||||
|
l.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for ip, last := range l.lastUsed {
|
||||||
|
if now.Sub(last) > 10*time.Minute {
|
||||||
|
delete(l.limiters, ip)
|
||||||
|
delete(l.lastUsed, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimit returns middleware that limits requests per IP.
|
// RateLimit returns middleware that limits requests per IP.
|
||||||
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
||||||
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ export function ChatArea() {
|
|||||||
);
|
);
|
||||||
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);
|
||||||
@@ -447,17 +448,16 @@ 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(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -175,6 +175,7 @@ export function DMChat() {
|
|||||||
|
|
||||||
const id = conversationId || activeId;
|
const id = conversationId || activeId;
|
||||||
const conversation = conversations.find((c) => c.id === id);
|
const conversation = conversations.find((c) => c.id === id);
|
||||||
|
const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true);
|
||||||
|
|
||||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -189,7 +190,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,7 +199,7 @@ export function DMChat() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [id, isLoadingOlder, fetchOlderMessages]);
|
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||||
const messages = id ? messagesByConv[id] || [] : [];
|
const messages = id ? messagesByConv[id] || [] : [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -329,9 +330,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 +348,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} />
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineMarkdownToHtml(text: string): string {
|
function inlineMarkdownToHtml(text: string): string {
|
||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+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');
|
||||||
|
|||||||
@@ -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 0;
|
||||||
|
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
|
||||||
|
const t = new Date(normalized).getTime();
|
||||||
|
return isNaN(t) ? 0 : t;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConversationMember {
|
export interface ConversationMember {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -89,17 +96,26 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
`/conversations/${conversationId}/messages${params}`,
|
`/conversations/${conversationId}/messages${params}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const existing = state.messagesByConversation[conversationId] || [];
|
||||||
|
const map = new Map<string, ConversationMessage>();
|
||||||
|
existing.forEach((m) => map.set(m.id, m));
|
||||||
|
list.forEach((m) => map.set(m.id, m));
|
||||||
|
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,
|
[conversationId]: merged,
|
||||||
},
|
},
|
||||||
hasMoreByConversation: {
|
hasMoreByConversation: {
|
||||||
...state.hasMoreByConversation,
|
...state.hasMoreByConversation,
|
||||||
[conversationId]: list.length >= 50,
|
[conversationId]: 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" });
|
||||||
}
|
}
|
||||||
@@ -118,17 +134,24 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
|
`/conversations/${conversationId}/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));
|
||||||
|
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],
|
[conversationId]: merged,
|
||||||
},
|
},
|
||||||
hasMoreByConversation: {
|
hasMoreByConversation: {
|
||||||
...state.hasMoreByConversation,
|
...state.hasMoreByConversation,
|
||||||
[conversationId]: list.length >= 50,
|
[conversationId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoadingOlder: false,
|
isLoadingOlder: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
set({ isLoadingOlder: false });
|
set({ isLoadingOlder: false });
|
||||||
}
|
}
|
||||||
@@ -156,7 +179,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[message.conversation_id]: [...existing, message]
|
[message.conversation_id]: [...existing, message]
|
||||||
.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)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user