Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d0bc76ef2 | |||
| 088b27f9c8 | |||
| 7894d1f796 | |||
| 2332e5a21c | |||
| 478e90d305 | |||
| 062bdfddc5 | |||
| c5be9cea52 | |||
| afd1bec53e | |||
| f4590f28ea | |||
| 40ce05b8ce | |||
| fb9f06468f | |||
| ec5cb80844 | |||
| 27342e727d | |||
| ddead767ae | |||
| 09a59c6124 | |||
| f20f4aa6fa | |||
| 9491f3a831 | |||
| 08e5d92059 | |||
| 92be2a30d1 | |||
| f4f6e8560b | |||
| 1900dd9cb1 | |||
| 1226bd28aa | |||
| 7cdee73542 | |||
| e8ba8ffdba | |||
| 52298d1d46 | |||
| 7fc5d66b8c | |||
| 7a6b4f961a | |||
| 71ee9c59c4 | |||
| 3e343a9c9b | |||
| 9371616508 | |||
| 5951c91102 | |||
| ff431d7f81 | |||
| aa5fdbe8c4 | |||
| 4da08d91bc | |||
| 11b1089126 | |||
| fd7fa4a147 | |||
| 7bf1eaf845 | |||
| 13bd4478f6 | |||
| 53530ce6dd | |||
| f215f000b8 | |||
| 191fe2a89f | |||
| eb5f38de1c | |||
| 51eb2ed310 | |||
| bd73d79b56 | |||
| 4b32655e67 | |||
| c839e67c47 | |||
| 5bdb758d23 | |||
| 56af584ede | |||
| 87d7345155 | |||
| a7646481a4 | |||
| 5d37fb899d | |||
| af913b9923 | |||
| 9a6f15662b | |||
| 7d1bd02e31 | |||
| 8542243745 | |||
| 90bddc65d4 | |||
| 67a5a54244 | |||
| 33c9dc4f15 | |||
| e6dfe43926 | |||
| 62e8354d03 | |||
| 1139e90fc3 | |||
| 60edc0b5b4 | |||
| 836bcd8b8d | |||
| 8fd649356c | |||
| 4a02d054cc | |||
| dbd4de8e61 | |||
| a0bb229173 | |||
| c86c602294 | |||
| aa39b6a5be | |||
| 8cf1e91b2b | |||
| f068e15398 | |||
| 559d17d1f2 | |||
| ea72050202 | |||
| 88194b17c4 | |||
| 2b45b11ea8 | |||
| b3b5ff495d | |||
| 9512dedec1 | |||
| 8bbe2a0ff0 | |||
| c2fb3165f4 | |||
| 0c26e2402b | |||
| 88e7faaafb | |||
| 1d37284c25 | |||
| b1b41ca5e1 | |||
| e10cb22811 | |||
| 2631e6ce4a | |||
| 239640975f | |||
| ed408c4044 | |||
| 9aeedb1a53 | |||
| 59846fc1e4 | |||
| 944ae4074e | |||
| 549bf842d0 | |||
| 83c05731ed | |||
| 5d8f420882 | |||
| 074742ddb4 | |||
| f9b1e16e3a | |||
| f9edd8c069 | |||
| 7e1c0b822b | |||
| f61b33ff81 | |||
| ed50815902 | |||
| b4e2f97dba | |||
| 53f8c6b2ef | |||
| d1022d7c2a | |||
| 15f9f6ae07 | |||
| c803991cda | |||
| a502cd8fd4 | |||
| a593f04d7f | |||
| 6b7dc2addd | |||
| 8336ca5d87 | |||
| 85f9c21f4c | |||
| 92af2e21b1 | |||
| 1e22daead3 | |||
| 9e70884a16 | |||
| b6d4614881 | |||
| 0b645fe765 | |||
| f6f63ecd2b | |||
| f30ddb1323 | |||
| 5ca5e018ca | |||
| 3002626ac4 | |||
| 4ed2e5bfd0 | |||
| aa80e354c9 | |||
| cf2f1c96a3 | |||
| 40f8d193ff | |||
| d8b4defaff | |||
| 722eab8e94 | |||
| 19ac68d058 | |||
| 3a04eff42b | |||
| 59f6a40882 | |||
| 5a7d4dc57e | |||
| 89f8381e2d | |||
| eb5b7d55a4 | |||
| 7d67d6f360 | |||
| 08dfc4e400 | |||
| cbfbcb2627 | |||
| ca7a9e30f1 | |||
| 10a702f732 | |||
| af961d805e | |||
| 7f2a58599d | |||
| 67ce6bbfd9 | |||
| c0686d4ca3 | |||
| 74a1db8392 | |||
| 5cc9d76394 | |||
| 3aa1ca3ead | |||
| 3ed66f3d43 | |||
| a5c3b25cc0 | |||
| 17c09edc4f | |||
| d0da35e816 | |||
| 31cb295a24 | |||
| ecb26cb731 | |||
| cb8aeddde6 | |||
| 33dc63b94c | |||
| 48a99d58ee | |||
| c80a36bacb | |||
| 65de4efb0d | |||
| b8ae43e250 | |||
| 82ddf914e4 | |||
| f4437c5e1d |
@@ -1,8 +0,0 @@
|
||||
.git
|
||||
node_modules
|
||||
web/dist
|
||||
certs
|
||||
*.zip
|
||||
dumpster-server
|
||||
.env
|
||||
.env.local
|
||||
+24
-22
@@ -1,23 +1,25 @@
|
||||
# dumpsterChat Environment Configuration
|
||||
# Copy this file to .env and fill in your values.
|
||||
|
||||
# Server
|
||||
DUMPSTER_HOST=dumpster.dustin.coffee
|
||||
DUMPSTER_SECRET=generate-a-random-secret-here
|
||||
|
||||
# Database
|
||||
# App
|
||||
DUMPSTER_HOST=localhost
|
||||
DUMPSTER_PORT=8080
|
||||
DUMPSTER_SECRET=*** Database
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=dumpster
|
||||
POSTGRES_PASSWORD=generate-a-random-password-here
|
||||
POSTGRES_DB=dumpster
|
||||
|
||||
# MinIO (optional — omit for local filesystem)
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
|
||||
# LiveKit (WebRTC / voice)
|
||||
LIVEKIT_API_KEY=generate-a-random-key-here
|
||||
LIVEKIT_API_SECRET=generate-a-random-secret-here
|
||||
|
||||
# Giphy (optional — for GIF search)
|
||||
GIPHY_API_KEY=
|
||||
POSTGRES_PASSWORD=dumpster
|
||||
POSTGRES_SSLMODE=disable
|
||||
# Valkey
|
||||
VALKEY_URL=redis://localhost:***@example.com
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=minio:9000
|
||||
MINIO_ACCESS_KEY=<generated>
|
||||
MINIO_SECRET_KEY=<gener...n
|
||||
# LiveKit
|
||||
LIVEKIT_URL=wss://livekit.your.domain
|
||||
LIVEKIT_API_KEY=<generated>
|
||||
LIVEKIT_API_SECRET=<gener...n
|
||||
# Web Push
|
||||
VAPID_PUBLIC_KEY=<generated>
|
||||
VAPID_PRIVATE_KEY=<generated>
|
||||
VAPID_SUBJECT=admin@your.domain
|
||||
# Integrations
|
||||
GIPHY_API_KEY=your_giphy_api_key_here
|
||||
|
||||
@@ -46,7 +46,3 @@ steamfree
|
||||
# Tauri Android keystore (contains signing passwords)
|
||||
web/src-tauri/gen/android/keystore.properties
|
||||
|
||||
# Secrets and certificates
|
||||
/certs/
|
||||
cookie.txt
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ build: build-web build-server
|
||||
|
||||
# Build the Go server
|
||||
build-server:
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.GitSHA=$(GIT_SHA)" -o dumpster-server ./cmd/server
|
||||
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
|
||||
|
||||
# Build the TUI client
|
||||
build-tui:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDkTCCAxegAwIBAgISBQjShZO8+oxeU80iyTOUKHhdMAoGCCqGSM49BAMDMDMx
|
||||
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZ
|
||||
RTIwHhcNMjYwNzA2MDEzMDM1WhcNMjYxMDA0MDEzMDM0WjAdMRswGQYDVQQDExJ0
|
||||
dXJuLmR1c3Rpbi5jb2ZmZWUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATB70bx
|
||||
IRjXyxdwR/3k56AkiFRKwApxq/h7xNXK5/I5xZ+5+HCLMHjEmFOIUtUj87DpcPHs
|
||||
rAfJZcH19gQIt7Xlo4ICHzCCAhswDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG
|
||||
CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFE8HllwR3SN9Ay0rvVLX
|
||||
9mOsdJ0UMB8GA1UdIwQYMBaAFLlZ8o7PIvCG0zdI/3YUGLqC2FWHMDMGCCsGAQUF
|
||||
BwEBBCcwJTAjBggrBgEFBQcwAoYXaHR0cDovL3llMi5pLmxlbmNyLm9yZy8wHQYD
|
||||
VR0RBBYwFIISdHVybi5kdXN0aW4uY29mZmVlMBMGA1UdIAQMMAowCAYGZ4EMAQIB
|
||||
MC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly95ZTIuYy5sZW5jci5vcmcvOTQuY3Js
|
||||
MIIBCwYKKwYBBAHWeQIEAgSB/ASB+QD3AHUAwjF+V0UZo0XufzjespBB68fCIVoi
|
||||
v3/Vta12mtkOUs0AAAGfNUH9yAAABAMARjBEAiBUIFA17umU+cAdlBJlkjhivt7h
|
||||
4f1HF7kdlQtt0hURaAIgRWAiqclKJy3QikfbGF7EFK3sNRyf/PZNBLtjJeieP1IA
|
||||
fgAm42RuWGkhI7w0P0ckNZs3ks0kWojYFdOTM/2ZGKtHIwAAAZ81QfvIAAgAAAUA
|
||||
JeWwugQDAEcwRQIhAJ9gzEj26/VOpYO0HkS2ZcItIhoBf7bDfzt9gpIK2RH2AiA2
|
||||
66DvuRKPYthZ4AFBXbVtSX4rf4aX2IvgQIWTudJMlDAKBggqhkjOPQQDAwNoADBl
|
||||
AjANu3rbmE/SqrfsMNGEv7rl+qvblv0btupKZ2qHg87079XD4gTh3Sqyl+Z4RnHq
|
||||
JkkCMQDI3q5ZQ6GVF3geVTbNvLlJ+R35eB+EIcoX0Cbs20S5k9N/SiWS2BlQzlUM
|
||||
QpPxaok=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICjDCCAhGgAwIBAgIQTfOxXdbAeExQfNN7WObxFTAKBggqhkjOPQQDAzAuMQsw
|
||||
CQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZRTAeFw0y
|
||||
NTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYD
|
||||
VQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZRTIwdjAQBgcqhkjOPQIBBgUr
|
||||
gQQAIgNiAARxmrQzkdbEEL3MqXt3dJQttYc47axkdDTHud5TPqM2z5uSD5cmk0Wr
|
||||
HlWXvnlvqBLqiB34kluxIbmMyAiq3/YD6e80/vV259K8XQIdjFXloYOa0mIU71f7
|
||||
HQ09PvYDlw+jge4wgeswDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
|
||||
BwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLlZ8o7PIvCG0zdI/3YU
|
||||
GLqC2FWHMB8GA1UdIwQYMBaAFKPIJlqOoUzQNWP8myPIOq5W809WMDIGCCsGAQUF
|
||||
BwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3llLmkubGVuY3Iub3JnLzATBgNV
|
||||
HSAEDDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veWUuYy5s
|
||||
ZW5jci5vcmcvMAoGCCqGSM49BAMDA2kAMGYCMQDIcnw5dcZLN9ffynXnnkLD/itS
|
||||
JEycJPb3sRkzeqBowup7vOsAwaqoCnNn/jh9wycCMQCJM6CPlaOC4pQYYbJtVPYb
|
||||
DKrIb2EKk5NpOpE6/XttQYZV/3gilB9l+Cc/DOVwmyg=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICpjCCAiugAwIBAgIRAIchZfw0tuX7qK3Vs3BftTowCgYIKoZIzj0EAwMwTzEL
|
||||
MAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNo
|
||||
IEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDIwHhcNMjYwNTEzMDAwMDAwWhcN
|
||||
MzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4G
|
||||
A1UEAxMHUm9vdCBZRTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDwS/6vhrcVqcbBo
|
||||
+wgdI3fwn9x7DNJJOY/lTOti0vkwuRN87RhEhTH17E7XyFjWsPYhIPt/wzOqxTd2
|
||||
b+4ZJNy9ID04YywF9U5zasDVyGSNErVNtz8uSGh5izW87j77GaOB6zCB6DAOBgNV
|
||||
HQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB
|
||||
/zAdBgNVHQ4EFgQUo8gmWo6hTNA1Y/ybI8g6rlbzT1YwHwYDVR0jBBgwFoAUfEKW
|
||||
rt5LSDv6kviejM9ti6lyN5UwMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZo
|
||||
dHRwOi8veDIuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1Ud
|
||||
HwQgMB4wHKAaoBiGFmh0dHA6Ly94Mi5jLmxlbmNyLm9yZy8wCgYIKoZIzj0EAwMD
|
||||
aQAwZgIxAMU19WCtmxVND8UHBZRoma49Z7jPs64Dma0eTu1OChVbB/2J7GV3nvYK
|
||||
Ax54uk1G9QIxAO0miLVJu8PLNiXXXkiE/gsK3CTRTF/aeo4bMX42Zw40csRU6AC2
|
||||
6hSW1/IWaas6dg==
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEcDCCAligAwIBAgIQbI8dxyfHEX97r4U6yYD5zTANBgkqhkiG9w0BAQsFADBP
|
||||
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
|
||||
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNjA1MTMwMDAwMDBa
|
||||
Fw0zMjA5MDIyMzU5NTlaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5l
|
||||
dCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgy
|
||||
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0H
|
||||
ttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7
|
||||
AlF9ItgKbppbd9/w+kHsOdx1ymgHDB/qo4H1MIHyMA4GA1UdDwEB/wQEAwIBBjAd
|
||||
BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd
|
||||
BgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwHwYDVR0jBBgwFoAUebRZ5nu2
|
||||
5eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRw
|
||||
Oi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1UdHwQg
|
||||
MB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcNAQELBQAD
|
||||
ggIBAD2/e9frmMxNpCV03qUHegg+MV2wz9644YoXdqtH8RyWYcBO7xfjjGEXdU1e
|
||||
/o0OkEFiynUCOSIk/vLLo7ttz6CPAeNlWfC0XNkoGeWgK6jjXvozBaGuGH5n0Ufo
|
||||
shMeWTuURqNN5G00sSXDTBrpp2+mgvdZQjb8K11TYMA25QA+YHNfbIEL0BniAhKS
|
||||
2gsnJjSzrdZLI+EZ7SEyqdR2rkjd1KutLDU+n3TFyxjniZVGur4YlhMP3mY/dV95
|
||||
IruAkkjOZier6hGBdEgZXXvaCz9u9iVEadsIE75pAGL8oHV5vxdARDiotRpul1IN
|
||||
/UZwzAbrfUFcw1HkAcYD/mlZfnQ2ieCF2MS7j3Vhv7JPDKp45fmykmzYNSrumRW0
|
||||
upFFKDBOoF7hsOb7oLyHS+Uft6jOUfOrogj8YUx38hKb2K20r42OgsSdDdxdeYWc
|
||||
MS3Sb6mwJeSZEYxJ2gaXnDSPaKhhrNkYwljyVQyr4Nq+MEJytXNTnHqaAcrNwZlV
|
||||
pcJL1KBnMrMjP7eanvUwL3FYj3cF17jtboLt7gLoi4+2rWZFvn+w54jmd/FIuhhZ
|
||||
cEaU/wvU6BUNMtcVquVGHp7itQeDth5j+XL3j4WJ2SABwzUl6OeYdgpIt/ITZa+p
|
||||
TT0mQ/r5XyA4MEAiabn7XJjvCERlF2dcn2wqJw+CreTkkQ2R
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggnJOfQM8flEJFULr
|
||||
8vvHwWRlXlJ1a0sI5Jw/Y6KRQeGhRANCAATB70bxIRjXyxdwR/3k56AkiFRKwApx
|
||||
q/h7xNXK5/I5xZ+5+HCLMHjEmFOIUtUj87DpcPHsrAfJZcH19gQIt7Xl
|
||||
-----END PRIVATE KEY-----
|
||||
+3
-14
@@ -47,8 +47,9 @@ import (
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
// @schemes http https
|
||||
var GitSHA = "dev"
|
||||
|
||||
// @securityDefinitions.apikey SessionAuth
|
||||
// @in cookie
|
||||
// @name dumpster_session
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
@@ -152,14 +153,6 @@ func main() {
|
||||
|
||||
// API routes
|
||||
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
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
||||
@@ -453,13 +446,9 @@ func main() {
|
||||
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
||||
path := staticDir + r.URL.Path
|
||||
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")
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / TRUE 1785435515 dumpster_session 720a3aad74029e6c8e0c4b93f8db0b48381afc5355e1c1345143a0f5e3d5ae34
|
||||
@@ -8,37 +8,8 @@ export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
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
|
||||
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
|
||||
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
|
||||
echo "Deploy complete — $(date)"
|
||||
|
||||
+4
-4
@@ -4,14 +4,14 @@
|
||||
}
|
||||
|
||||
:80 {
|
||||
# API routes — use handle (not handle_path) to preserve the /api/v1 prefix
|
||||
handle /api/* {
|
||||
reverse_proxy 172.18.0.1:8080
|
||||
# API routes
|
||||
handle_path /api/* {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
# WebSocket gateway
|
||||
handle /ws {
|
||||
reverse_proxy 172.18.0.1:8080 {
|
||||
reverse_proxy app:8080 {
|
||||
header_up Connection {>Connection}
|
||||
header_up Upgrade {>Upgrade}
|
||||
}
|
||||
|
||||
+1
-3
@@ -16,10 +16,8 @@ RUN npm run build
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates
|
||||
RUN adduser -D -g '' appuser
|
||||
WORKDIR /app
|
||||
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
||||
COPY --from=web-builder /app/dist /srv/web
|
||||
USER appuser
|
||||
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"]
|
||||
|
||||
+3
-22
@@ -1,9 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
@@ -36,9 +32,6 @@ services:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
@@ -56,8 +49,6 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8
|
||||
@@ -71,8 +62,6 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
@@ -86,8 +75,6 @@ services:
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks:
|
||||
- backend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
@@ -102,8 +89,6 @@ services:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
||||
networks:
|
||||
- backend
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:latest
|
||||
@@ -114,8 +99,8 @@ services:
|
||||
- "--max-port=50200"
|
||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||
- "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
|
||||
- "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
|
||||
- "--cert=/etc/coturn/certs/turn.crt"
|
||||
- "--pkey=/etc/coturn/certs/turn.key"
|
||||
- "--tls-listening-port=5349"
|
||||
ports:
|
||||
- "3478:3478/tcp"
|
||||
@@ -124,9 +109,7 @@ services:
|
||||
- "5349:5349/udp"
|
||||
- "50101-50200:50101-50200/udp"
|
||||
volumes:
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
networks:
|
||||
- backend
|
||||
- ./certs:/etc/coturn/certs:ro
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
@@ -141,8 +124,6 @@ services:
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
+3
-3
@@ -9,9 +9,9 @@ rtc:
|
||||
- host: turn.dustin.coffee
|
||||
port: 443
|
||||
protocol: tls
|
||||
username: "${LIVEKIT_API_KEY}"
|
||||
credential: "${LIVEKIT_API_SECRET}"
|
||||
username: V8S6EWOU1Lqp
|
||||
credential: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||
turn:
|
||||
enabled: false
|
||||
keys:
|
||||
"${LIVEKIT_API_KEY}": "${LIVEKIT_API_SECRET}"
|
||||
V8S6EWOU1Lqp: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||
|
||||
@@ -30,11 +30,10 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
tokenHash := hashToken(token)
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||
`, userID, tokenHash)
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
@@ -64,7 +63,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, hashToken(req.Token)).Scan(&userID)
|
||||
`, req.Token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
@@ -101,11 +100,10 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
tokenHash := hashToken(token)
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||
`, userID, tokenHash)
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
@@ -136,7 +134,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, hashToken(req.Token)).Scan(&userID)
|
||||
`, req.Token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
@@ -21,25 +20,17 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
|
||||
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) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sessions (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
|
||||
`, userID, token, time.Now().Add(s.cfg.Session.Duration))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
@@ -52,7 +43,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT user_id FROM sessions
|
||||
WHERE token = $1 AND expires_at > NOW()
|
||||
`, hashToken(token)).Scan(&userID)
|
||||
`, token).Scan(&userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -60,6 +51,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token))
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
|
||||
@@ -25,11 +24,6 @@ func New(cfg *config.Config) (*DB, error) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+27
-39
@@ -263,52 +263,40 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Try cookie-based auth first (browser clients).
|
||||
var token string
|
||||
if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" {
|
||||
token = cookie.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()`,
|
||||
hashToken(token),
|
||||
).Scan(&userID, &username)
|
||||
if err == nil && userID != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// 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 {
|
||||
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 != "" {
|
||||
_ = 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 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
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
logger.Warn("ws auth: no valid session found")
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","error":"invalid_session"}`))
|
||||
var userID, username string
|
||||
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()`,
|
||||
token,
|
||||
).Scan(&userID, &username)
|
||||
if err != nil {
|
||||
logger.Warn("ws auth: invalid session", "error", err)
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
+8
-25
@@ -5,7 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -286,30 +285,15 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
|
||||
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()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.clients {
|
||||
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
||||
if members[cID] {
|
||||
servers, ok := h.userServers[client.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, member := servers[serverID]; member {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
@@ -338,19 +322,18 @@ func (h *Hub) BroadcastToConversation(convID string, event Event) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make(map[string]bool)
|
||||
members := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err == nil {
|
||||
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
||||
members[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for client := range h.clients {
|
||||
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
||||
if members[cID] {
|
||||
if _, ok := members[client.UserID]; ok {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -13,21 +12,16 @@ import (
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
lastUsed map[string]time.Time
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||
l := &ipLimiter{
|
||||
return &ipLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
lastUsed: make(map[string]time.Time),
|
||||
rate: r,
|
||||
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 {
|
||||
@@ -39,27 +33,9 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
lim = rate.NewLimiter(l.rate, l.burst)
|
||||
l.limiters[ip] = lim
|
||||
}
|
||||
l.lastUsed[ip] = time.Now()
|
||||
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.
|
||||
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
||||
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "dumpsterChat",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.9",
|
||||
"identifier": "coffee.dustin.dumpster",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;"
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -117,21 +117,9 @@ export function ChannelList() {
|
||||
}, [fetchNotifSettings, fetchReadStates]);
|
||||
|
||||
const channels = useMemo(() => {
|
||||
return activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
|
||||
return activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||
}, [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(() => {
|
||||
if (!activeServerId) return null;
|
||||
return servers.find((s) => s.id === activeServerId) || null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, memo, Fragment } from "react";
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
|
||||
import { useMessageStore } from "../stores/message.ts";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { useChannelStore } from "../stores/channel.ts";
|
||||
@@ -284,14 +284,14 @@ const MessageItem = memo(({
|
||||
MessageItem.displayName = "MessageItem";
|
||||
|
||||
export function ChatArea() {
|
||||
const rawActiveChannelId = useChannelStore((s) => s.activeChannelId);
|
||||
const activeChannelId = rawActiveChannelId ? rawActiveChannelId.toLowerCase() : null;
|
||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
const messages = useMessageStore((s) => (activeChannelId ? s.messagesByChannel[activeChannelId] || [] : []));
|
||||
const messages = useMessageStore((s) =>
|
||||
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
|
||||
);
|
||||
const isLoading = useMessageStore((s) => s.isLoading);
|
||||
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
||||
const hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
|
||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||
@@ -332,8 +332,8 @@ export function ChatArea() {
|
||||
);
|
||||
const canBulkDelete = true;
|
||||
|
||||
const channels = activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
|
||||
const activeChannel = channels.find((c) => c.id.toLowerCase() === activeChannelId);
|
||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
||||
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
||||
@@ -447,21 +447,19 @@ export function ChatArea() {
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el || !activeChannelId || isLoadingOlder || !hasMore) return;
|
||||
if (!el || !activeChannelId || isLoadingOlder) return;
|
||||
if (el.scrollTop < 100) {
|
||||
const prevHeight = el.scrollHeight;
|
||||
fetchOlderMessages(activeChannelId).then(() => {
|
||||
// ponytail: maintain scroll position after prepending older messages
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight - prevHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [activeChannelId, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [messages]);
|
||||
|
||||
@@ -848,8 +846,9 @@ export function ChatArea() {
|
||||
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
|
||||
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<>
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
memberUsernames={memberUsernames}
|
||||
selectMode={selectMode}
|
||||
@@ -874,7 +873,7 @@ export function ChatArea() {
|
||||
<span className="flex-1 border-t border-gb-red"></span>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
|
||||
@@ -16,10 +16,9 @@ function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
|
||||
|
||||
export function ConversationList() {
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const activeId = useConversationStore((s) => s.activeConversationId?.toLowerCase() ?? null);
|
||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||
const createConversation = useConversationStore((s) => s.createConversation);
|
||||
const setActiveConversation = useConversationStore((s) => s.setActiveConversation);
|
||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
||||
@@ -32,9 +31,8 @@ export function ConversationList() {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
const openConversation = (rawId: string) => {
|
||||
const convId = rawId.toLowerCase();
|
||||
setActiveConversation(convId);
|
||||
const openConversation = (convId: string) => {
|
||||
// mark as read when opening
|
||||
const msgs = messagesByConv[convId] || [];
|
||||
if (msgs.length > 0) {
|
||||
markConvRead(convId, msgs[msgs.length - 1].id);
|
||||
@@ -52,7 +50,7 @@ export function ConversationList() {
|
||||
try {
|
||||
const conv = await createConversation([]);
|
||||
if (conv) {
|
||||
openConversation(conv.id);
|
||||
navigate(`/dm/${conv.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create notes:", err);
|
||||
@@ -61,7 +59,7 @@ export function ConversationList() {
|
||||
};
|
||||
|
||||
const getLatestMessageId = (convId: string): string | undefined => {
|
||||
const msgs = messagesByConv[convId.toLowerCase()] || [];
|
||||
const msgs = messagesByConv[convId] || [];
|
||||
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
||||
};
|
||||
|
||||
@@ -91,20 +89,19 @@ export function ConversationList() {
|
||||
<p className="text-gb-fg-f">[no conversations]</p>
|
||||
)}
|
||||
{conversations.map((conv) => {
|
||||
const convId = conv.id.toLowerCase();
|
||||
const self = isSelfDM(conv, currentUser?.id || "");
|
||||
const name = self
|
||||
? "Notes"
|
||||
: conv.type === "group_dm"
|
||||
? conv.name || conv.members.map((m) => m.username).join(", ")
|
||||
: otherMemberName(conv, currentUser?.id || "");
|
||||
const unread = hasConvUnread(convId, getLatestMessageId(convId));
|
||||
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => openConversation(conv.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
convId === activeId
|
||||
conv.id === activeId
|
||||
? "terminal-active"
|
||||
: "hover:bg-gb-bg-t text-gb-fg-s"
|
||||
}`}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo, Fragment } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
@@ -152,6 +152,7 @@ export function DMChat() {
|
||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||
const setActive = useConversationStore((s) => s.setActiveConversation);
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
||||
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
||||
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
||||
@@ -172,18 +173,8 @@ export function DMChat() {
|
||||
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
||||
const convStates = useReadStatesStore((s) => s.convStates);
|
||||
|
||||
const rawId = conversationId || activeId;
|
||||
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 id = conversationId || activeId;
|
||||
const conversation = conversations.find((c) => c.id === id);
|
||||
|
||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||
if (!id) return;
|
||||
@@ -198,7 +189,7 @@ export function DMChat() {
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el || !id || isLoadingOlder || !hasMore) return;
|
||||
if (!el || !id || isLoadingOlder) return;
|
||||
if (el.scrollTop < 100) {
|
||||
const prevHeight = el.scrollHeight;
|
||||
fetchOlderMessages(id).then(() => {
|
||||
@@ -207,7 +198,8 @@ export function DMChat() {
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||
}, [id, isLoadingOlder, fetchOlderMessages]);
|
||||
const messages = id ? messagesByConv[id] || [] : [];
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
@@ -221,7 +213,9 @@ export function DMChat() {
|
||||
}
|
||||
}, [id, setActive, fetchMessages]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || messages.length === 0 || isLoading) return;
|
||||
@@ -335,8 +329,9 @@ export function DMChat() {
|
||||
const lastReadId = id ? convStates[id] : undefined;
|
||||
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
||||
return (
|
||||
<Fragment key={msg.id}>
|
||||
<>
|
||||
<DMMessageItem
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onAddReaction={handleAddReaction}
|
||||
activeReactionMessageId={activeReactionMessageId}
|
||||
@@ -353,7 +348,7 @@ export function DMChat() {
|
||||
<span className="flex-1 border-t border-gb-red"></span>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
|
||||
@@ -15,7 +15,6 @@ import { MemberList } from './MemberList.tsx';
|
||||
import { VoicePanel } from './VoicePanel.tsx';
|
||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||
import { VersionNotifier } from './VersionNotifier.tsx';
|
||||
|
||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||
function statusColor(status: UserStatus): string {
|
||||
@@ -36,6 +35,8 @@ export function Layout() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
@@ -62,8 +63,9 @@ export function Layout() {
|
||||
}, [currentVoiceRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
useWebSocketStore.getState().connect();
|
||||
}, []);
|
||||
wsConnect();
|
||||
return () => { wsDisconnect(); };
|
||||
}, [wsConnect, wsDisconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||
@@ -100,7 +102,6 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
||||
<VersionNotifier />
|
||||
{/* Outer terminal frame with safe area */}
|
||||
<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)' }}>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function LoginForm() {
|
||||
</button>
|
||||
</form>
|
||||
{!isRegister && (
|
||||
<div className="mt-4 text-center">
|
||||
<div className="mt-3 text-center">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-gb-yellow hover:text-gb-orange text-xs"
|
||||
@@ -127,7 +127,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
{!isRegister && (
|
||||
<div className="mt-4">
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
@@ -157,13 +157,13 @@ export function LoginForm() {
|
||||
console.error("Passkey login failed:", err);
|
||||
}
|
||||
}}
|
||||
className="terminal-button w-full border-gb-aqua text-gb-aqua text-xs"
|
||||
className="terminal-button w-full border-gb-aqua text-gb-aqua"
|
||||
>
|
||||
[SIGN IN WITH PASSKEY]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 text-center">
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inlineMarkdownToHtml(text: string): string {
|
||||
@@ -610,8 +610,8 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
<button type="button" disabled={disabled || uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Upload file"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faPlus} className={`w-4 h-4 ${uploading ? "animate-pulse" : ""}`} />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
|
||||
</button>
|
||||
|
||||
<span className="text-gb-bg-t mx-1">│</span>
|
||||
@@ -619,28 +619,28 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
{/* block formatting */}
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("ul")} title="Unordered list"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListUl} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("ol")} title="Ordered list"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListOl} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("blockquote")} title="Blockquote"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faQuoteRight} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={execLink} title="Insert link"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faLink} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("h2")} title="Heading"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faHeading} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<span className="text-gb-bg-t mx-1">│</span>
|
||||
@@ -648,43 +648,43 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
{/* inline formatting */}
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("**", "**")} title="Bold"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faBold} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("*", "*")} title="Italic"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faItalic} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("~~", "~~")} title="Strikethrough"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faStrikethrough} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("`", "`")} title="Code"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faCode} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("||", "||")} title="Spoiler"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faEyeSlash} className="w-4 h-4" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<span className="text-gb-bg-t mx-1">│</span>
|
||||
|
||||
{/* emoji / kaomoji / gif */}
|
||||
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
|
||||
className={`p-1.5 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
className={`p-1 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
|
||||
className={`p-1.5 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
className={`p-1 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
|
||||
className={`p-1.5 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
className={`p-1 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useConversationStore } from "../stores/conversation.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
@@ -12,7 +11,6 @@ interface NewConversationModalProps {
|
||||
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const createConversation = useConversationStore((s) => s.createConversation);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||
@@ -50,14 +48,7 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (selected.length === 0) return;
|
||||
try {
|
||||
const conv = await createConversation(selected);
|
||||
if (conv?.id) {
|
||||
navigate(`/dm/${conv.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create conversation:", err);
|
||||
}
|
||||
await createConversation(selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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
|
||||
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
||||
if (navigator.userAgent.includes('Android')) {
|
||||
document.documentElement.style.setProperty('--safe-top', '2rem');
|
||||
document.documentElement.style.setProperty('--safe-top', '1.5rem');
|
||||
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
||||
document.documentElement.style.setProperty('--safe-left', '0px');
|
||||
document.documentElement.style.setProperty('--safe-right', '0px');
|
||||
|
||||
@@ -84,7 +84,7 @@ function autoSubscribePush() {
|
||||
});
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
@@ -146,9 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
if (!get().user) {
|
||||
set({ isLoading: true, error: null });
|
||||
}
|
||||
try {
|
||||
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
|
||||
+18
-41
@@ -33,18 +33,11 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
||||
error: null,
|
||||
|
||||
fetchChannels: async (serverId) => {
|
||||
const srvId = serverId.toLowerCase();
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
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(),
|
||||
}));
|
||||
const channels = await api.get<Channel[]>(`/servers/${serverId}/channels`);
|
||||
set((state) => ({
|
||||
channelsByServer: { ...state.channelsByServer, [srvId]: normalized },
|
||||
channelsByServer: { ...state.channelsByServer, [serverId]: channels },
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -56,66 +49,50 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
||||
},
|
||||
|
||||
setActiveChannel: (id) => {
|
||||
const chId = id ? id.toLowerCase() : null;
|
||||
set({ activeChannelId: chId });
|
||||
if (chId) {
|
||||
set({ activeChannelId: id });
|
||||
if (id) {
|
||||
// ponytail: persist last active channel for session restore
|
||||
const state = useChannelStore.getState();
|
||||
for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
|
||||
if (channels.some((c) => c.id.toLowerCase() === chId)) {
|
||||
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: chId }));
|
||||
if (channels.some((c) => c.id === id)) {
|
||||
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: id }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addChannel: (channel) => {
|
||||
const norm = {
|
||||
...channel,
|
||||
id: channel.id.toLowerCase(),
|
||||
server_id: channel.server_id.toLowerCase(),
|
||||
};
|
||||
addChannel: (channel) =>
|
||||
set((state) => {
|
||||
const list = state.channelsByServer[norm.server_id] || [];
|
||||
if (list.some((c) => c.id === norm.id)) return state;
|
||||
const list = state.channelsByServer[channel.server_id] || [];
|
||||
return {
|
||||
channelsByServer: {
|
||||
...state.channelsByServer,
|
||||
[norm.server_id]: [...list, norm],
|
||||
[channel.server_id]: [...list, channel],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
updateChannel: (channel) => {
|
||||
const norm = {
|
||||
...channel,
|
||||
id: channel.id.toLowerCase(),
|
||||
server_id: channel.server_id.toLowerCase(),
|
||||
};
|
||||
updateChannel: (channel) =>
|
||||
set((state) => {
|
||||
const list = state.channelsByServer[norm.server_id] || [];
|
||||
const list = state.channelsByServer[channel.server_id] || [];
|
||||
return {
|
||||
channelsByServer: {
|
||||
...state.channelsByServer,
|
||||
[norm.server_id]: list.map((c) => (c.id === norm.id ? norm : c)),
|
||||
[channel.server_id]: list.map((c) => (c.id === channel.id ? channel : c)),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
removeChannel: (id) => {
|
||||
const chId = id.toLowerCase();
|
||||
removeChannel: (id) =>
|
||||
set((state) => {
|
||||
const next: Record<string, Channel[]> = {};
|
||||
for (const serverId of Object.keys(state.channelsByServer)) {
|
||||
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== chId);
|
||||
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== id);
|
||||
}
|
||||
return {
|
||||
channelsByServer: next,
|
||||
activeChannelId: state.activeChannelId === chId ? null : state.activeChannelId,
|
||||
activeChannelId: state.activeChannelId === id ? null : state.activeChannelId,
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -2,13 +2,6 @@ import { create } from "zustand";
|
||||
import { api } from "../lib/api.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 {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -79,147 +72,121 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||
|
||||
createConversation: async (userIds) => {
|
||||
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
|
||||
const convId = conversation.id.toLowerCase();
|
||||
const normalizedConv = { ...conversation, id: convId };
|
||||
set((state) => ({
|
||||
conversations: [normalizedConv, ...state.conversations],
|
||||
activeConversationId: convId,
|
||||
conversations: [conversation, ...state.conversations],
|
||||
activeConversationId: conversation.id,
|
||||
}));
|
||||
return normalizedConv;
|
||||
return conversation;
|
||||
},
|
||||
|
||||
setActiveConversation: (id) => set({ activeConversationId: id ? id.toLowerCase() : null }),
|
||||
setActiveConversation: (id) => set({ activeConversationId: id }),
|
||||
|
||||
fetchMessages: async (conversationId, before) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const params = before ? "?before=" + encodeURIComponent(before) : "";
|
||||
const messages = await api.get<ConversationMessage[]>(
|
||||
`/conversations/${convId}/messages${params}`,
|
||||
`/conversations/${conversationId}/messages${params}`,
|
||||
);
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
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 {
|
||||
set((state) => ({
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: merged,
|
||||
[conversationId]: list,
|
||||
},
|
||||
hasMoreByConversation: {
|
||||
...state.hasMoreByConversation,
|
||||
[convId]: list.length >= 50,
|
||||
[conversationId]: list.length >= 50,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
||||
}
|
||||
},
|
||||
|
||||
fetchOlderMessages: async (conversationId) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
const state = get();
|
||||
if (state.isLoadingOlder || state.hasMoreByConversation[convId] === false) return;
|
||||
const existing = state.messagesByConversation[convId] || [];
|
||||
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
|
||||
const existing = state.messagesByConversation[conversationId] || [];
|
||||
if (existing.length === 0) return;
|
||||
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
||||
const oldestId = existing[0].id;
|
||||
set({ isLoadingOlder: true });
|
||||
try {
|
||||
const older = await api.get<ConversationMessage[]>(
|
||||
`/conversations/${convId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||
);
|
||||
const list = Array.isArray(older) ? older : [];
|
||||
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 {
|
||||
set((state) => ({
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: merged,
|
||||
[conversationId]: [...list, ...existing],
|
||||
},
|
||||
hasMoreByConversation: {
|
||||
...state.hasMoreByConversation,
|
||||
[convId]: list.length >= 50,
|
||||
[conversationId]: list.length >= 50,
|
||||
},
|
||||
isLoadingOlder: false,
|
||||
};
|
||||
});
|
||||
}));
|
||||
} catch {
|
||||
set({ isLoadingOlder: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (conversationId, content) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
// 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<ConversationMessage>(
|
||||
`/conversations/${convId}/messages`,
|
||||
`/conversations/${conversationId}/messages`,
|
||||
{ content },
|
||||
);
|
||||
const normalizedMessage = { ...message, conversation_id: (message.conversation_id || convId).toLowerCase() };
|
||||
get().addMessage(normalizedMessage);
|
||||
return normalizedMessage;
|
||||
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
||||
get().addMessage(message);
|
||||
return message;
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const convId = (message.conversation_id || "").toLowerCase();
|
||||
const normalizedMessage = { ...message, conversation_id: convId };
|
||||
set((state) => {
|
||||
const existing = state.messagesByConversation[convId] || [];
|
||||
if (existing.some((m) => m.id === normalizedMessage.id)) {
|
||||
const existing = state.messagesByConversation[message.conversation_id] || [];
|
||||
if (existing.some((m) => m.id === message.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: [...existing, normalizedMessage]
|
||||
.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
|
||||
[message.conversation_id]: [...existing, message]
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateMessage: (message) => {
|
||||
const convId = (message.conversation_id || "").toLowerCase();
|
||||
const normalizedMessage = { ...message, conversation_id: convId };
|
||||
set((state) => {
|
||||
const convMsgs = state.messagesByConversation[convId] || [];
|
||||
const convMsgs = state.messagesByConversation[message.conversation_id] || [];
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: convMsgs.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m)),
|
||||
[message.conversation_id]: convMsgs.map((m) => (m.id === message.id ? message : m)),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deleteMessage: (conversationId, messageId) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
set((state) => {
|
||||
const convMsgs = state.messagesByConversation[convId] || [];
|
||||
const convMsgs = state.messagesByConversation[conversationId] || [];
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: convMsgs.filter((m) => m.id !== messageId),
|
||||
[conversationId]: convMsgs.filter((m) => m.id !== messageId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addReaction: (conversationId, messageId, emoji, userId) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
set((state) => {
|
||||
const messages = state.messagesByConversation[convId];
|
||||
const messages = state.messagesByConversation[conversationId];
|
||||
if (!messages) return state;
|
||||
|
||||
const newMessages = messages.map((m) => {
|
||||
@@ -243,16 +210,15 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: newMessages,
|
||||
[conversationId]: newMessages,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeReaction: (conversationId, messageId, emoji, userId) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
set((state) => {
|
||||
const messages = state.messagesByConversation[convId];
|
||||
const messages = state.messagesByConversation[conversationId];
|
||||
if (!messages) return state;
|
||||
|
||||
const newMessages = messages.map((m) => {
|
||||
@@ -275,7 +241,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[convId]: newMessages,
|
||||
[conversationId]: newMessages,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+77
-129
@@ -1,13 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
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 {
|
||||
id?: string;
|
||||
url: string;
|
||||
@@ -66,7 +59,7 @@ export interface MessageState {
|
||||
messagesByChannel: Record<string, Message[]>;
|
||||
pinnedMessagesByChannel: Record<string, Message[]>;
|
||||
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||
selectedMessageIds: Record<string, Set<string>>;
|
||||
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
||||
isLoading: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
hasMoreByChannel: Record<string, boolean>;
|
||||
@@ -102,34 +95,24 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
error: null,
|
||||
|
||||
fetchMessages: async (channelId, before) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const params = before ? `?before=${encodeURIComponent(before)}` : "";
|
||||
const messages = await api.get<Message[]>(
|
||||
`/channels/${chId}/messages${params}`,
|
||||
`/channels/${channelId}/messages${params}`,
|
||||
);
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
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 {
|
||||
set((state) => ({
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: merged,
|
||||
[channelId]: list,
|
||||
},
|
||||
hasMoreByChannel: {
|
||||
...state.hasMoreByChannel,
|
||||
[chId]: list.length >= 50,
|
||||
[channelId]: list.length >= 50,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
@@ -142,136 +125,116 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchOlderMessages: async (channelId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
const state = get();
|
||||
if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return;
|
||||
const existing = state.messagesByChannel[chId] || [];
|
||||
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
|
||||
const existing = state.messagesByChannel[channelId] || [];
|
||||
if (existing.length === 0) return;
|
||||
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
||||
const oldestId = existing[0].id;
|
||||
set({ isLoadingOlder: true });
|
||||
try {
|
||||
const older = await api.get<Message[]>(
|
||||
`/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||
);
|
||||
const list = Array.isArray(older) ? older : [];
|
||||
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 {
|
||||
set((state) => ({
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: merged,
|
||||
[channelId]: [...list, ...existing],
|
||||
},
|
||||
hasMoreByChannel: {
|
||||
...state.hasMoreByChannel,
|
||||
[chId]: list.length >= 50,
|
||||
[channelId]: list.length >= 50,
|
||||
},
|
||||
isLoadingOlder: false,
|
||||
};
|
||||
});
|
||||
}));
|
||||
} catch {
|
||||
set({ isLoadingOlder: false });
|
||||
}
|
||||
},
|
||||
|
||||
searchMessages: async (channelId, query) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
const results = await api.get<SearchResultMessage[]>(
|
||||
`/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`,
|
||||
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
const list = Array.isArray(results) ? results : [];
|
||||
set((state) => ({
|
||||
searchResultsByChannel: {
|
||||
...state.searchResultsByChannel,
|
||||
[chId]: list,
|
||||
[channelId]: Array.isArray(results) ? results : [],
|
||||
},
|
||||
}));
|
||||
return list;
|
||||
return Array.isArray(results) ? results : [];
|
||||
},
|
||||
|
||||
sendMessage: async (channelId, content, replyTo) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
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/${chId}/messages`,
|
||||
`/channels/${channelId}/messages`,
|
||||
body,
|
||||
);
|
||||
const normalizedMessage = { ...message, channel_id: (message.channel_id || chId).toLowerCase() };
|
||||
get().addMessage(normalizedMessage);
|
||||
return normalizedMessage;
|
||||
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
||||
get().addMessage(message);
|
||||
return message;
|
||||
},
|
||||
|
||||
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);
|
||||
const updated = await api.put<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
|
||||
get().updateMessage(updated);
|
||||
},
|
||||
|
||||
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);
|
||||
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
|
||||
get().updateMessage(updated);
|
||||
},
|
||||
|
||||
fetchPinnedMessages: async (channelId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
const pinned = await api.get<Message[]>(`/channels/${chId}/messages/pinned`);
|
||||
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
|
||||
const list = Array.isArray(pinned) ? pinned : [];
|
||||
set((state) => ({
|
||||
pinnedMessagesByChannel: {
|
||||
...state.pinnedMessagesByChannel,
|
||||
[chId]: list,
|
||||
[channelId]: list,
|
||||
},
|
||||
}));
|
||||
return list;
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const chId = (message.channel_id || "").toLowerCase();
|
||||
const normalizedMessage = { ...message, channel_id: chId };
|
||||
addMessage: (message) =>
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[chId] || [];
|
||||
if (list.some((m) => m.id === normalizedMessage.id)) {
|
||||
const list = state.messagesByChannel[message.channel_id] || [];
|
||||
if (list.some((m) => m.id === message.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: [...list, normalizedMessage].sort(
|
||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||
),
|
||||
[message.channel_id]: [...list, message],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
updateMessage: (message) => {
|
||||
const chId = (message.channel_id || "").toLowerCase();
|
||||
const normalizedMessage = { ...message, channel_id: chId };
|
||||
updateMessage: (message) =>
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[chId] || [];
|
||||
const list = state.messagesByChannel[message.channel_id] || [];
|
||||
const updatedList = list.map((m) =>
|
||||
m.id === normalizedMessage.id ? normalizedMessage : m,
|
||||
m.id === message.id ? message : m,
|
||||
);
|
||||
|
||||
const pinnedList = state.pinnedMessagesByChannel[chId] || [];
|
||||
const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || [];
|
||||
let updatedPinned = [...pinnedList];
|
||||
if (normalizedMessage.pinned) {
|
||||
if (!pinnedList.some((m) => m.id === normalizedMessage.id)) {
|
||||
updatedPinned = [normalizedMessage, ...pinnedList].sort(
|
||||
(a, b) => parseDate(b.created_at) - parseDate(a.created_at)
|
||||
if (message.pinned) {
|
||||
if (!pinnedList.some((m) => m.id === message.id)) {
|
||||
updatedPinned = [message, ...pinnedList].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
} else {
|
||||
updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m));
|
||||
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
|
||||
}
|
||||
} else {
|
||||
updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id);
|
||||
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
|
||||
}
|
||||
if (updatedPinned.length > 5) {
|
||||
updatedPinned = updatedPinned.slice(0, 5);
|
||||
@@ -280,38 +243,34 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: updatedList,
|
||||
[message.channel_id]: updatedList,
|
||||
},
|
||||
pinnedMessagesByChannel: {
|
||||
...state.pinnedMessagesByChannel,
|
||||
[chId]: updatedPinned,
|
||||
[message.channel_id]: updatedPinned,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
removeMessage: (channelId, messageId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
removeMessage: (channelId, messageId) =>
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[chId] || [];
|
||||
const pinnedList = state.pinnedMessagesByChannel[chId] || [];
|
||||
const list = state.messagesByChannel[channelId] || [];
|
||||
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: list.filter((m) => m.id !== messageId),
|
||||
[channelId]: list.filter((m) => m.id !== messageId),
|
||||
},
|
||||
pinnedMessagesByChannel: {
|
||||
...state.pinnedMessagesByChannel,
|
||||
[chId]: pinnedList.filter((m) => m.id !== messageId),
|
||||
[channelId]: pinnedList.filter((m) => m.id !== messageId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
addReaction: (channelId, messageId, emoji, userId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
addReaction: (channelId, messageId, emoji, userId) =>
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[chId] || [];
|
||||
const list = state.messagesByChannel[channelId] || [];
|
||||
const updatedList = list.map((m) => {
|
||||
if (m.id !== messageId) return m;
|
||||
const reactions = m.reactions ? [...m.reactions] : [];
|
||||
@@ -329,16 +288,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: updatedList,
|
||||
[channelId]: updatedList,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
removeReaction: (channelId, messageId, emoji, userId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
removeReaction: (channelId, messageId, emoji, userId) =>
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[chId] || [];
|
||||
const list = state.messagesByChannel[channelId] || [];
|
||||
const updatedList = list.map((m) => {
|
||||
if (m.id !== messageId) return m;
|
||||
if (!m.reactions) return m;
|
||||
@@ -354,27 +311,25 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: updatedList,
|
||||
[channelId]: updatedList,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
bulkDeleteMessages: async (channelId, messageIds) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
const res = await api.post<{ deleted: number }>(
|
||||
`/channels/${chId}/messages/bulk-delete`,
|
||||
`/channels/${channelId}/messages/bulk-delete`,
|
||||
{ messages: messageIds },
|
||||
);
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[chId] || [];
|
||||
const list = state.messagesByChannel[channelId] || [];
|
||||
const ids = new Set(messageIds);
|
||||
const selected = { ...state.selectedMessageIds };
|
||||
delete selected[chId];
|
||||
delete selected[channelId];
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: list.filter((m) => !ids.has(m.id)),
|
||||
[channelId]: list.filter((m) => !ids.has(m.id)),
|
||||
},
|
||||
selectedMessageIds: selected,
|
||||
};
|
||||
@@ -382,10 +337,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return res;
|
||||
},
|
||||
|
||||
toggleSelectedMessage: (channelId, messageId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
toggleSelectedMessage: (channelId, messageId) =>
|
||||
set((state) => {
|
||||
const current = state.selectedMessageIds[chId] || new Set<string>();
|
||||
const current = state.selectedMessageIds[channelId] || new Set<string>();
|
||||
const next = new Set(current);
|
||||
if (next.has(messageId)) {
|
||||
next.delete(messageId);
|
||||
@@ -395,25 +349,21 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return {
|
||||
selectedMessageIds: {
|
||||
...state.selectedMessageIds,
|
||||
[chId]: next,
|
||||
[channelId]: next,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
clearSelectedMessages: (channelId) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
clearSelectedMessages: (channelId) =>
|
||||
set((state) => {
|
||||
const next = { ...state.selectedMessageIds };
|
||||
delete next[chId];
|
||||
delete next[channelId];
|
||||
return { selectedMessageIds: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
createPoll: async (channelId, question, options) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
const resp = await api.post<Poll>("/polls", {
|
||||
channel_id: chId,
|
||||
channel_id: channelId,
|
||||
question,
|
||||
options,
|
||||
});
|
||||
@@ -424,10 +374,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
|
||||
},
|
||||
|
||||
updatePoll: (channelId, poll) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
updatePoll: (channelId, poll) =>
|
||||
set((state) => {
|
||||
const messages = state.messagesByChannel[chId];
|
||||
const messages = state.messagesByChannel[channelId];
|
||||
if (!messages) return state;
|
||||
const updated = messages.map((m) =>
|
||||
m.poll?.id === poll.id ? { ...m, poll } : m,
|
||||
@@ -435,9 +384,8 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[chId]: updated,
|
||||
[channelId]: updated,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -27,28 +27,25 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
||||
},
|
||||
|
||||
markRead: async (channelId: string, messageId: string) => {
|
||||
const chId = channelId.toLowerCase();
|
||||
set((state) => ({
|
||||
states: { ...state.states, [chId]: messageId },
|
||||
states: { ...state.states, [channelId]: messageId },
|
||||
}));
|
||||
try {
|
||||
await api.put(`/channels/${chId}/read`, { last_read_message_id: messageId });
|
||||
await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId });
|
||||
} catch {
|
||||
// optimistic update, ignore failure
|
||||
}
|
||||
},
|
||||
|
||||
markConvRead: (conversationId: string, messageId: string) => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
set((state) => ({
|
||||
convStates: { ...state.convStates, [convId]: messageId },
|
||||
convStates: { ...state.convStates, [conversationId]: messageId },
|
||||
}));
|
||||
},
|
||||
|
||||
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
|
||||
const chId = channelId.toLowerCase();
|
||||
const state = get().states;
|
||||
const lastRead = state[chId];
|
||||
const lastRead = state[channelId];
|
||||
// never viewed: unread if there are messages
|
||||
if (!lastRead) return !!latestMessageId;
|
||||
// viewed but newer messages exist
|
||||
@@ -57,9 +54,8 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
||||
},
|
||||
|
||||
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
|
||||
const convId = conversationId.toLowerCase();
|
||||
const state = get().convStates;
|
||||
const lastRead = state[convId];
|
||||
const lastRead = state[conversationId];
|
||||
if (!lastRead) return !!latestMessageId;
|
||||
if (latestMessageId && lastRead !== latestMessageId) return true;
|
||||
return false;
|
||||
|
||||
+14
-26
@@ -61,15 +61,11 @@ function extractIds(payload: UnknownPayload | undefined): { channel_id?: string;
|
||||
? payload.id
|
||||
: null;
|
||||
if (!messageId) return null;
|
||||
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.toLowerCase(), message_id: messageId };
|
||||
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId };
|
||||
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId };
|
||||
return null;
|
||||
}
|
||||
|
||||
let reconnectDelay = 1000;
|
||||
const maxReconnectDelay = 30000;
|
||||
let reconnectTimeout: number | null = null;
|
||||
|
||||
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
socket: null,
|
||||
connected: false,
|
||||
@@ -82,6 +78,10 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
|
||||
const socket = new WebSocket(`${getWsHost()}/ws`);
|
||||
|
||||
let reconnectDelay = 1000;
|
||||
const maxReconnectDelay = 30000;
|
||||
let reconnectTimeout: number | null = null;
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimeout) {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
@@ -95,15 +95,8 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectDelay = 1000; // ponytail: reset backoff on successful connect
|
||||
const token = localStorage.getItem('dumpster_session_token');
|
||||
if (token) {
|
||||
try {
|
||||
socket.send(JSON.stringify({ token }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// Cookie-based auth: browser sends session cookie automatically.
|
||||
// No need to send a token frame.
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
@@ -117,10 +110,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
if (data.type === 'ready') {
|
||||
set({ connected: true });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -135,6 +124,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const addMessage = useMessageStore.getState().addMessage;
|
||||
const updateMessage = useMessageStore.getState().updateMessage;
|
||||
const removeMessage = useMessageStore.getState().removeMessage;
|
||||
const addReaction = useMessageStore.getState().addReaction;
|
||||
@@ -151,17 +141,15 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
if (isRecord(payload)) {
|
||||
if (payload.conversation_id) {
|
||||
const msg = payload as unknown as ConversationMessage;
|
||||
const normalizedMsg = { ...msg, conversation_id: (msg.conversation_id || '').toLowerCase() };
|
||||
useConversationStore.getState().addMessage(normalizedMsg);
|
||||
useConversationStore.getState().addMessage(msg);
|
||||
// auto-mark DM as read if this conversation is active
|
||||
const activeConvId = (useConversationStore.getState().activeConversationId || '').toLowerCase();
|
||||
if (normalizedMsg.conversation_id === activeConvId && document.hasFocus()) {
|
||||
useReadStatesStore.getState().markConvRead(normalizedMsg.conversation_id, normalizedMsg.id);
|
||||
const activeConvId = useConversationStore.getState().activeConversationId;
|
||||
if (msg.conversation_id === activeConvId && document.hasFocus()) {
|
||||
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id);
|
||||
}
|
||||
} else {
|
||||
const msg = payload as unknown as Message;
|
||||
const normalizedMsg = { ...msg, channel_id: (msg.channel_id || '').toLowerCase() };
|
||||
useMessageStore.getState().addMessage(normalizedMsg);
|
||||
addMessage(msg);
|
||||
|
||||
// Desktop notification
|
||||
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/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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user