Compare commits

...

13 Commits

Author SHA1 Message Date
hobokenchicken 065f036807 fix(web): navigate to newly created DM in NewConversationModal
Release Desktop Apps / build-linux (push) Failing after 11m33s
Release Desktop Apps / build-windows (push) Failing after 11m37s
Release Desktop Apps / release (push) Has been skipped
2026-07-27 08:50:14 -04:00
hobokenchicken f53cd49803 fix(web): robust date parsing and merge strategy in conversation store 2026-07-27 08:49:57 -04:00
hobokenchicken 4e48815b91 fix(web): use keyed Fragment in DMChat message mapping 2026-07-27 08:49:41 -04:00
hobokenchicken d9b3162f1c fix(web): move hasMore selector below id declaration to avoid TDZ crash in DMChat 2026-07-22 08:36:12 -04:00
hobokenchicken 6384588122 chore: bump to 0.2.10 (versionCode 2010) 2026-07-21 10:09:20 -04:00
hobokenchicken 978e94da90 fix(android): bump safe-top to 2rem, enlarge toolbar icons, fix login spacing
- --safe-top: 1.5rem → 2rem for extra status bar clearance
- Formatting toolbar: w-3→w-4, p-1→p-1.5 for better touch targets
- Login form: increased vertical spacing between buttons, text-xs on passkey
2026-07-21 09:58:02 -04:00
hobokenchicken 34c18c13ae fix(android): increase login form button spacing, shrink passkey text
- forgot password link: mt-3 → mt-4
- passkey button: mt-3 → mt-4, added text-xs to prevent overflow
- create account: mt-4 → mt-5
2026-07-21 09:45:40 -04:00
hobokenchicken cca6ea0e37 fix: stop infinite scroll feedback when no more messages
Scroll handler now checks !hasMore to avoid calling fetchOlderMessages
when all messages are loaded. Previously the handler would fire on every
scroll event (since scrollTop stayed < 100), creating a .then() callback
loop that adjusted scroll position repeatedly, causing 'stuck' scrolling.
2026-07-21 09:05:39 -04:00
hobokenchicken 738b9b17ff fix: add unsafe-inline to Tauri script-src CSP
Release Desktop Apps / build-linux (push) Successful in 4m50s
Release Desktop Apps / build-windows (push) Successful in 16m59s
Release Desktop Apps / release (push) Successful in 11s
2026-07-20 13:42:21 -04:00
hobokenchicken 44370f41e5 ops: .dockerignore, non-root user, network isolation, deploy rollback
- .dockerignore excludes .git, node_modules, certs, *.zip
- Dockerfile runs as non-root appuser
- compose.yml isolates frontend (Caddy only) and backend (DB/cache/media) networks
- deploy.sh snapshots binary before pull and rolls back on failed health check
2026-07-20 13:15:19 -04:00
hobokenchicken 1d6c9bdfe6 perf: rate limiter cleanup goroutine, DB pool constraints
- ipLimiter evicts stale entries after 10 min of inactivity via 5 min sweep
- DB pool capped at 25 max open, 5 idle, 15 min lifetime
2026-07-20 13:13:58 -04:00
hobokenchicken 57aec2c6b3 sec: hash tokens in DB, set Tauri CSP, escape quotes in XSS guard
- Session and email verification/reset tokens stored as SHA-256 hash in DB
  (raw token stays client-side in cookie/email link)
- Tauri CSP set: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:; connect-src 'self' wss: https:
- escapeHtml now handles double and single quotes to prevent attribute-based XSS
2026-07-20 13:13:09 -04:00
hobokenchicken d4ca5f576e sec: point coturn at live LE certs, remove old ./certs/ mount 2026-07-20 13:10:27 -04:00
16 changed files with 205 additions and 80 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
node_modules
web/dist
certs
*.zip
dumpster-server
.env
.env.local
+24 -1
View File
@@ -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
# 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)" 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
View File
@@ -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
View File
@@ -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:
+6 -4
View File
@@ -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
+12 -3
View File
@@ -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
} }
+6
View File
@@ -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
} }
+25 -1
View File
@@ -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 {
+2 -2
View File
@@ -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": {
+3 -3
View File
@@ -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" });
+6 -6
View File
@@ -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} />
+4 -4
View File
@@ -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={() => {
+26 -26
View File
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
} }
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>
+10 -1
View File
@@ -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
View File
@@ -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');
+30 -7
View File
@@ -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)),
}, },
}; };
}); });