Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
# App
|
||||||
# Copy this file to .env and fill in your values.
|
DUMPSTER_HOST=localhost
|
||||||
|
DUMPSTER_PORT=8080
|
||||||
# Server
|
DUMPSTER_SECRET=*** Database
|
||||||
DUMPSTER_HOST=dumpster.dustin.coffee
|
POSTGRES_HOST=localhost
|
||||||
DUMPSTER_SECRET=generate-a-random-secret-here
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
# Database
|
|
||||||
POSTGRES_USER=dumpster
|
POSTGRES_USER=dumpster
|
||||||
POSTGRES_PASSWORD=generate-a-random-password-here
|
POSTGRES_PASSWORD=dumpster
|
||||||
POSTGRES_DB=dumpster
|
POSTGRES_SSLMODE=disable
|
||||||
|
# Valkey
|
||||||
# MinIO (optional — omit for local filesystem)
|
VALKEY_URL=redis://localhost:***@example.com
|
||||||
MINIO_ENDPOINT=
|
# MinIO
|
||||||
MINIO_ACCESS_KEY=
|
MINIO_ENDPOINT=minio:9000
|
||||||
MINIO_SECRET_KEY=
|
MINIO_ACCESS_KEY=<generated>
|
||||||
|
MINIO_SECRET_KEY=<gener...n
|
||||||
# LiveKit (WebRTC / voice)
|
# LiveKit
|
||||||
LIVEKIT_API_KEY=generate-a-random-key-here
|
LIVEKIT_URL=wss://livekit.your.domain
|
||||||
LIVEKIT_API_SECRET=generate-a-random-secret-here
|
LIVEKIT_API_KEY=<generated>
|
||||||
|
LIVEKIT_API_SECRET=<gener...n
|
||||||
# Giphy (optional — for GIF search)
|
# Web Push
|
||||||
GIPHY_API_KEY=
|
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)
|
# Tauri Android keystore (contains signing passwords)
|
||||||
web/src-tauri/gen/android/keystore.properties
|
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 the Go server
|
||||||
build-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 the TUI client
|
||||||
build-tui:
|
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
|
// @host localhost:8080
|
||||||
// @BasePath /api/v1
|
// @BasePath /api/v1
|
||||||
// @schemes http https
|
// @schemes http https
|
||||||
var GitSHA = "dev"
|
// @securityDefinitions.apikey SessionAuth
|
||||||
|
// @in cookie
|
||||||
|
// @name dumpster_session
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||||
|
|
||||||
@@ -152,14 +153,6 @@ func main() {
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"version": GitSHA,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auth (public: register, login, logout) with strict rate limiting
|
// Auth (public: register, login, logout) with strict rate limiting
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
||||||
@@ -453,13 +446,9 @@ func main() {
|
|||||||
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
||||||
path := staticDir + r.URL.Path
|
path := staticDir + r.URL.Path
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
http.ServeFile(w, r, staticDir+"/index.html")
|
http.ServeFile(w, r, staticDir+"/index.html")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
}
|
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
nvm use 22
|
nvm use 22
|
||||||
|
|
||||||
# Snapshot the current binary before pulling
|
|
||||||
if [ -f dumpster-server ]; then
|
|
||||||
cp dumpster-server dumpster-server.bak
|
|
||||||
echo "Backed up existing binary to dumpster-server.bak"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git pull
|
git pull
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# 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
|
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..."
|
echo "Deploy complete — $(date)"
|
||||||
systemctl stop dumpster
|
|
||||||
cp dumpster-server.bak dumpster-server
|
|
||||||
systemctl start dumpster
|
|
||||||
echo "Rolled back to previous binary — $(date)"
|
|
||||||
exit 1
|
|
||||||
|
|||||||
+4
-4
@@ -4,14 +4,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
# API routes — use handle (not handle_path) to preserve the /api/v1 prefix
|
# API routes
|
||||||
handle /api/* {
|
handle_path /api/* {
|
||||||
reverse_proxy 172.18.0.1:8080
|
reverse_proxy app:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket gateway
|
# WebSocket gateway
|
||||||
handle /ws {
|
handle /ws {
|
||||||
reverse_proxy 172.18.0.1:8080 {
|
reverse_proxy app:8080 {
|
||||||
header_up Connection {>Connection}
|
header_up Connection {>Connection}
|
||||||
header_up Upgrade {>Upgrade}
|
header_up Upgrade {>Upgrade}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -16,10 +16,8 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk --no-cache add ca-certificates
|
RUN apk --no-cache add ca-certificates
|
||||||
RUN adduser -D -g '' appuser
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
||||||
COPY --from=web-builder /app/dist /srv/web
|
COPY --from=web-builder /app/dist /srv/web
|
||||||
USER appuser
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD sh -c "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"
|
CMD ["sh", "-c", "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"]
|
||||||
|
|||||||
+3
-22
@@ -1,9 +1,5 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
networks:
|
|
||||||
frontend:
|
|
||||||
backend:
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -36,9 +32,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
valkey:
|
valkey:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
|
||||||
- frontend
|
|
||||||
- backend
|
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -56,8 +49,6 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
valkey:
|
valkey:
|
||||||
image: valkey/valkey:8
|
image: valkey/valkey:8
|
||||||
@@ -71,8 +62,6 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
@@ -86,8 +75,6 @@ services:
|
|||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:latest
|
||||||
@@ -102,8 +89,6 @@ services:
|
|||||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
environment:
|
environment:
|
||||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
@@ -114,8 +99,8 @@ services:
|
|||||||
- "--max-port=50200"
|
- "--max-port=50200"
|
||||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||||
- "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
|
- "--cert=/etc/coturn/certs/turn.crt"
|
||||||
- "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
|
- "--pkey=/etc/coturn/certs/turn.key"
|
||||||
- "--tls-listening-port=5349"
|
- "--tls-listening-port=5349"
|
||||||
ports:
|
ports:
|
||||||
- "3478:3478/tcp"
|
- "3478:3478/tcp"
|
||||||
@@ -124,9 +109,7 @@ services:
|
|||||||
- "5349:5349/udp"
|
- "5349:5349/udp"
|
||||||
- "50101-50200:50101-50200/udp"
|
- "50101-50200:50101-50200/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
- ./certs:/etc/coturn/certs:ro
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: caddy:2
|
||||||
@@ -141,8 +124,6 @@ services:
|
|||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
networks:
|
|
||||||
- frontend
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
+3
-3
@@ -9,9 +9,9 @@ rtc:
|
|||||||
- host: turn.dustin.coffee
|
- host: turn.dustin.coffee
|
||||||
port: 443
|
port: 443
|
||||||
protocol: tls
|
protocol: tls
|
||||||
username: "${LIVEKIT_API_KEY}"
|
username: V8S6EWOU1Lqp
|
||||||
credential: "${LIVEKIT_API_SECRET}"
|
credential: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||||
turn:
|
turn:
|
||||||
enabled: false
|
enabled: false
|
||||||
keys:
|
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()
|
token := generateToken()
|
||||||
tokenHash := hashToken(token)
|
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||||
`, userID, tokenHash)
|
`, userID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -64,7 +63,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
DELETE FROM user_tokens
|
DELETE FROM user_tokens
|
||||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
`, hashToken(req.Token)).Scan(&userID)
|
`, req.Token).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -101,11 +100,10 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
token := generateToken()
|
token := generateToken()
|
||||||
tokenHash := hashToken(token)
|
|
||||||
_, err = h.db.ExecContext(r.Context(), `
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||||
`, userID, tokenHash)
|
`, userID, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -136,7 +134,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
DELETE FROM user_tokens
|
DELETE FROM user_tokens
|
||||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
`, hashToken(req.Token)).Scan(&userID)
|
`, req.Token).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,25 +20,17 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
|
|||||||
return &SessionStore{db: db, cfg: cfg}
|
return &SessionStore{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashToken computes a SHA-256 hash of a token string.
|
|
||||||
// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions.
|
|
||||||
func hashToken(token string) string {
|
|
||||||
h := sha256.Sum256([]byte(token))
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", fmt.Errorf("generate token: %w", err)
|
return "", fmt.Errorf("generate token: %w", err)
|
||||||
}
|
}
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO sessions (user_id, token, expires_at)
|
INSERT INTO sessions (user_id, token, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
`, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
|
`, userID, token, time.Now().Add(s.cfg.Session.Duration))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert session: %w", err)
|
return "", fmt.Errorf("insert session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +43,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
|||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT user_id FROM sessions
|
SELECT user_id FROM sessions
|
||||||
WHERE token = $1 AND expires_at > NOW()
|
WHERE token = $1 AND expires_at > NOW()
|
||||||
`, hashToken(token)).Scan(&userID)
|
`, token).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"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)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: prevent unbounded connection growth under burst load
|
|
||||||
db.SetMaxOpenConns(25)
|
|
||||||
db.SetMaxIdleConns(5)
|
|
||||||
db.SetConnMaxLifetime(15 * time.Minute)
|
|
||||||
|
|
||||||
return &DB{db}, nil
|
return &DB{db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-40
@@ -263,52 +263,40 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userID, username string
|
// Try cookie-based auth first (browser clients).
|
||||||
|
var token string
|
||||||
// Collect candidate tokens from Query param, Authorization header, and Cookies.
|
if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" {
|
||||||
var candidateTokens []string
|
token = cookie.Value
|
||||||
if qToken := r.URL.Query().Get("token"); qToken != "" {
|
|
||||||
candidateTokens = append(candidateTokens, qToken)
|
|
||||||
}
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
|
||||||
candidateTokens = append(candidateTokens, authHeader[7:])
|
|
||||||
}
|
|
||||||
for _, c := range r.Cookies() {
|
|
||||||
if c.Name == cookieName && c.Value != "" {
|
|
||||||
candidateTokens = append(candidateTokens, c.Value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test candidates against Postgres sessions (tokens are stored hashed)
|
// Fall back to message-frame auth (TUI, bots).
|
||||||
for _, token := range candidateTokens {
|
if token == "" {
|
||||||
err := db.QueryRowContext(context.Background(),
|
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
`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))
|
|
||||||
_, raw, readErr := conn.ReadMessage()
|
_, raw, readErr := conn.ReadMessage()
|
||||||
if readErr == nil {
|
if readErr != nil {
|
||||||
var auth authMessage
|
logger.Warn("ws auth: failed to read auth message", "error", readErr)
|
||||||
if jsonErr := json.Unmarshal(raw, &auth); jsonErr == nil && auth.Token != "" {
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`))
|
||||||
_ = db.QueryRowContext(context.Background(),
|
conn.Close()
|
||||||
`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()`,
|
return
|
||||||
hashToken(auth.Token),
|
|
||||||
).Scan(&userID, &username)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
var auth authMessage
|
||||||
|
if jsonErr := json.Unmarshal(raw, &auth); jsonErr != nil || auth.Token == "" {
|
||||||
|
logger.Warn("ws auth: invalid auth message")
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`))
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token = auth.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID == "" {
|
var userID, username string
|
||||||
logger.Warn("ws auth: no valid session found")
|
err = db.QueryRowContext(context.Background(),
|
||||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","error":"invalid_session"}`))
|
`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()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-25
@@ -5,7 +5,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -286,30 +285,15 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM members WHERE server_id = $1`, serverID)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("failed to load server members", "server_id", serverID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
members := make(map[string]bool)
|
|
||||||
for rows.Next() {
|
|
||||||
var uid string
|
|
||||||
if err := rows.Scan(&uid); err == nil {
|
|
||||||
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
servers, ok := h.userServers[client.UserID]
|
||||||
if members[cID] {
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, member := servers[serverID]; member {
|
||||||
select {
|
select {
|
||||||
case client.send <- data:
|
case client.send <- data:
|
||||||
default:
|
default:
|
||||||
@@ -338,19 +322,18 @@ func (h *Hub) BroadcastToConversation(convID string, event Event) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
members := make(map[string]bool)
|
members := make(map[string]struct{})
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var uid string
|
var uid string
|
||||||
if err := rows.Scan(&uid); err == nil {
|
if err := rows.Scan(&uid); err == nil {
|
||||||
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
members[uid] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
if _, ok := members[client.UserID]; ok {
|
||||||
if members[cID] {
|
|
||||||
select {
|
select {
|
||||||
case client.send <- data:
|
case client.send <- data:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
@@ -13,21 +12,16 @@ import (
|
|||||||
type ipLimiter struct {
|
type ipLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
limiters map[string]*rate.Limiter
|
limiters map[string]*rate.Limiter
|
||||||
lastUsed map[string]time.Time
|
|
||||||
rate rate.Limit
|
rate rate.Limit
|
||||||
burst int
|
burst int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||||
l := &ipLimiter{
|
return &ipLimiter{
|
||||||
limiters: make(map[string]*rate.Limiter),
|
limiters: make(map[string]*rate.Limiter),
|
||||||
lastUsed: make(map[string]time.Time),
|
|
||||||
rate: r,
|
rate: r,
|
||||||
burst: burst,
|
burst: burst,
|
||||||
}
|
}
|
||||||
// ponytail: evict inactive limiters every 5 minutes to prevent unbounded map growth
|
|
||||||
go l.cleanup()
|
|
||||||
return l
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||||
@@ -39,27 +33,9 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
|||||||
lim = rate.NewLimiter(l.rate, l.burst)
|
lim = rate.NewLimiter(l.rate, l.burst)
|
||||||
l.limiters[ip] = lim
|
l.limiters[ip] = lim
|
||||||
}
|
}
|
||||||
l.lastUsed[ip] = time.Now()
|
|
||||||
return lim
|
return lim
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup runs in a background goroutine and removes limiters that haven't
|
|
||||||
// been accessed in 10 minutes.
|
|
||||||
func (l *ipLimiter) cleanup() {
|
|
||||||
for {
|
|
||||||
time.Sleep(5 * time.Minute)
|
|
||||||
l.mu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
for ip, last := range l.lastUsed {
|
|
||||||
if now.Sub(last) > 10*time.Minute {
|
|
||||||
delete(l.limiters, ip)
|
|
||||||
delete(l.lastUsed, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RateLimit returns middleware that limits requests per IP.
|
// RateLimit returns middleware that limits requests per IP.
|
||||||
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
||||||
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "dumpsterChat",
|
"productName": "dumpsterChat",
|
||||||
"version": "0.2.10",
|
"version": "0.2.9",
|
||||||
"identifier": "coffee.dustin.dumpster",
|
"identifier": "coffee.dustin.dumpster",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|||||||
@@ -117,21 +117,9 @@ export function ChannelList() {
|
|||||||
}, [fetchNotifSettings, fetchReadStates]);
|
}, [fetchNotifSettings, fetchReadStates]);
|
||||||
|
|
||||||
const channels = useMemo(() => {
|
const channels = useMemo(() => {
|
||||||
return activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
|
return activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
}, [activeServerId, channelsByServer]);
|
}, [activeServerId, channelsByServer]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (channels.length > 0) {
|
|
||||||
const activeExists = activeChannelId && channels.some((c) => c.id === activeChannelId);
|
|
||||||
if (!activeExists) {
|
|
||||||
const defaultChannel = channels.find((c) => c.type === 'text') || channels[0];
|
|
||||||
if (defaultChannel) {
|
|
||||||
setActiveChannel(defaultChannel.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [channels, activeChannelId, setActiveChannel]);
|
|
||||||
|
|
||||||
const activeServer = useMemo(() => {
|
const activeServer = useMemo(() => {
|
||||||
if (!activeServerId) return null;
|
if (!activeServerId) return null;
|
||||||
return servers.find((s) => s.id === activeServerId) || null;
|
return servers.find((s) => s.id === activeServerId) || null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback, memo, Fragment } from "react";
|
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
|
||||||
import { useMessageStore } from "../stores/message.ts";
|
import { useMessageStore } from "../stores/message.ts";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
@@ -284,14 +284,14 @@ const MessageItem = memo(({
|
|||||||
MessageItem.displayName = "MessageItem";
|
MessageItem.displayName = "MessageItem";
|
||||||
|
|
||||||
export function ChatArea() {
|
export function ChatArea() {
|
||||||
const rawActiveChannelId = useChannelStore((s) => s.activeChannelId);
|
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||||
const activeChannelId = rawActiveChannelId ? rawActiveChannelId.toLowerCase() : null;
|
|
||||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
const messages = useMessageStore((s) => (activeChannelId ? s.messagesByChannel[activeChannelId] || [] : []));
|
const messages = useMessageStore((s) =>
|
||||||
|
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
|
||||||
|
);
|
||||||
const isLoading = useMessageStore((s) => s.isLoading);
|
const isLoading = useMessageStore((s) => s.isLoading);
|
||||||
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
||||||
const hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
|
|
||||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||||
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||||
@@ -332,8 +332,8 @@ export function ChatArea() {
|
|||||||
);
|
);
|
||||||
const canBulkDelete = true;
|
const canBulkDelete = true;
|
||||||
|
|
||||||
const channels = activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
|
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
const activeChannel = channels.find((c) => c.id.toLowerCase() === activeChannelId);
|
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
||||||
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
||||||
@@ -447,21 +447,19 @@ export function ChatArea() {
|
|||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollContainerRef.current;
|
const el = scrollContainerRef.current;
|
||||||
if (!el || !activeChannelId || isLoadingOlder || !hasMore) return;
|
if (!el || !activeChannelId || isLoadingOlder) return;
|
||||||
if (el.scrollTop < 100) {
|
if (el.scrollTop < 100) {
|
||||||
const prevHeight = el.scrollHeight;
|
const prevHeight = el.scrollHeight;
|
||||||
fetchOlderMessages(activeChannelId).then(() => {
|
fetchOlderMessages(activeChannelId).then(() => {
|
||||||
|
// ponytail: maintain scroll position after prepending older messages
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
el.scrollTop = el.scrollHeight - prevHeight;
|
el.scrollTop = el.scrollHeight - prevHeight;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activeChannelId, isLoadingOlder, hasMore, fetchOlderMessages]);
|
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
@@ -848,8 +846,9 @@ export function ChatArea() {
|
|||||||
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
|
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
|
||||||
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
|
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
|
||||||
return (
|
return (
|
||||||
<Fragment key={message.id}>
|
<>
|
||||||
<MessageItem
|
<MessageItem
|
||||||
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
memberUsernames={memberUsernames}
|
memberUsernames={memberUsernames}
|
||||||
selectMode={selectMode}
|
selectMode={selectMode}
|
||||||
@@ -874,7 +873,7 @@ export function ChatArea() {
|
|||||||
<span className="flex-1 border-t border-gb-red"></span>
|
<span className="flex-1 border-t border-gb-red"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
|
|||||||
|
|
||||||
export function ConversationList() {
|
export function ConversationList() {
|
||||||
const conversations = useConversationStore((s) => s.conversations);
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
const activeId = useConversationStore((s) => s.activeConversationId?.toLowerCase() ?? null);
|
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||||
const createConversation = useConversationStore((s) => s.createConversation);
|
const createConversation = useConversationStore((s) => s.createConversation);
|
||||||
const setActiveConversation = useConversationStore((s) => s.setActiveConversation);
|
|
||||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
||||||
@@ -32,9 +31,8 @@ export function ConversationList() {
|
|||||||
fetchConversations();
|
fetchConversations();
|
||||||
}, [fetchConversations]);
|
}, [fetchConversations]);
|
||||||
|
|
||||||
const openConversation = (rawId: string) => {
|
const openConversation = (convId: string) => {
|
||||||
const convId = rawId.toLowerCase();
|
// mark as read when opening
|
||||||
setActiveConversation(convId);
|
|
||||||
const msgs = messagesByConv[convId] || [];
|
const msgs = messagesByConv[convId] || [];
|
||||||
if (msgs.length > 0) {
|
if (msgs.length > 0) {
|
||||||
markConvRead(convId, msgs[msgs.length - 1].id);
|
markConvRead(convId, msgs[msgs.length - 1].id);
|
||||||
@@ -52,7 +50,7 @@ export function ConversationList() {
|
|||||||
try {
|
try {
|
||||||
const conv = await createConversation([]);
|
const conv = await createConversation([]);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
openConversation(conv.id);
|
navigate(`/dm/${conv.id}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create notes:", err);
|
console.error("Failed to create notes:", err);
|
||||||
@@ -61,7 +59,7 @@ export function ConversationList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getLatestMessageId = (convId: string): string | undefined => {
|
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;
|
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>
|
<p className="text-gb-fg-f">[no conversations]</p>
|
||||||
)}
|
)}
|
||||||
{conversations.map((conv) => {
|
{conversations.map((conv) => {
|
||||||
const convId = conv.id.toLowerCase();
|
|
||||||
const self = isSelfDM(conv, currentUser?.id || "");
|
const self = isSelfDM(conv, currentUser?.id || "");
|
||||||
const name = self
|
const name = self
|
||||||
? "Notes"
|
? "Notes"
|
||||||
: conv.type === "group_dm"
|
: conv.type === "group_dm"
|
||||||
? conv.name || conv.members.map((m) => m.username).join(", ")
|
? conv.name || conv.members.map((m) => m.username).join(", ")
|
||||||
: otherMemberName(conv, currentUser?.id || "");
|
: otherMemberName(conv, currentUser?.id || "");
|
||||||
const unread = hasConvUnread(convId, getLatestMessageId(convId));
|
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
onClick={() => openConversation(conv.id)}
|
onClick={() => openConversation(conv.id)}
|
||||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||||
convId === activeId
|
conv.id === activeId
|
||||||
? "terminal-active"
|
? "terminal-active"
|
||||||
: "hover:bg-gb-bg-t text-gb-fg-s"
|
: "hover:bg-gb-bg-t text-gb-fg-s"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback, memo, Fragment } from "react";
|
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
||||||
import { useAuthStore } from "../stores/auth.ts";
|
import { useAuthStore } from "../stores/auth.ts";
|
||||||
@@ -152,6 +152,7 @@ export function DMChat() {
|
|||||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||||
const setActive = useConversationStore((s) => s.setActiveConversation);
|
const setActive = useConversationStore((s) => s.setActiveConversation);
|
||||||
const conversations = useConversationStore((s) => s.conversations);
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||||
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
||||||
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
||||||
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
||||||
@@ -172,18 +173,8 @@ export function DMChat() {
|
|||||||
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
||||||
const convStates = useReadStatesStore((s) => s.convStates);
|
const convStates = useReadStatesStore((s) => s.convStates);
|
||||||
|
|
||||||
const rawId = conversationId || activeId;
|
const id = conversationId || activeId;
|
||||||
const id = rawId ? rawId.toLowerCase() : undefined;
|
const conversation = conversations.find((c) => c.id === id);
|
||||||
const conversation = conversations.find((c) => c.id.toLowerCase() === id);
|
|
||||||
const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true);
|
|
||||||
const messages = useConversationStore((s) => (id ? s.messagesByConversation[id] || [] : []));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -198,7 +189,7 @@ export function DMChat() {
|
|||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollContainerRef.current;
|
const el = scrollContainerRef.current;
|
||||||
if (!el || !id || isLoadingOlder || !hasMore) return;
|
if (!el || !id || isLoadingOlder) return;
|
||||||
if (el.scrollTop < 100) {
|
if (el.scrollTop < 100) {
|
||||||
const prevHeight = el.scrollHeight;
|
const prevHeight = el.scrollHeight;
|
||||||
fetchOlderMessages(id).then(() => {
|
fetchOlderMessages(id).then(() => {
|
||||||
@@ -207,7 +198,8 @@ export function DMChat() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
|
}, [id, isLoadingOlder, fetchOlderMessages]);
|
||||||
|
const messages = id ? messagesByConv[id] || [] : [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConversations();
|
fetchConversations();
|
||||||
@@ -221,7 +213,9 @@ export function DMChat() {
|
|||||||
}
|
}
|
||||||
}, [id, setActive, fetchMessages]);
|
}, [id, setActive, fetchMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || messages.length === 0 || isLoading) return;
|
if (!id || messages.length === 0 || isLoading) return;
|
||||||
@@ -335,8 +329,9 @@ export function DMChat() {
|
|||||||
const lastReadId = id ? convStates[id] : undefined;
|
const lastReadId = id ? convStates[id] : undefined;
|
||||||
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
||||||
return (
|
return (
|
||||||
<Fragment key={msg.id}>
|
<>
|
||||||
<DMMessageItem
|
<DMMessageItem
|
||||||
|
key={msg.id}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
onAddReaction={handleAddReaction}
|
onAddReaction={handleAddReaction}
|
||||||
activeReactionMessageId={activeReactionMessageId}
|
activeReactionMessageId={activeReactionMessageId}
|
||||||
@@ -353,7 +348,7 @@ export function DMChat() {
|
|||||||
<span className="flex-1 border-t border-gb-red"></span>
|
<span className="flex-1 border-t border-gb-red"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { MemberList } from './MemberList.tsx';
|
|||||||
import { VoicePanel } from './VoicePanel.tsx';
|
import { VoicePanel } from './VoicePanel.tsx';
|
||||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||||
import { VersionNotifier } from './VersionNotifier.tsx';
|
|
||||||
|
|
||||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||||
function statusColor(status: UserStatus): string {
|
function statusColor(status: UserStatus): string {
|
||||||
@@ -36,6 +35,8 @@ export function Layout() {
|
|||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||||
|
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||||
|
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
@@ -62,8 +63,9 @@ export function Layout() {
|
|||||||
}, [currentVoiceRoom]);
|
}, [currentVoiceRoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useWebSocketStore.getState().connect();
|
wsConnect();
|
||||||
}, []);
|
return () => { wsDisconnect(); };
|
||||||
|
}, [wsConnect, wsDisconnect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||||
@@ -100,7 +102,6 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
||||||
<VersionNotifier />
|
|
||||||
{/* Outer terminal frame with safe area */}
|
{/* Outer terminal frame with safe area */}
|
||||||
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
||||||
style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}>
|
style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function LoginForm() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{!isRegister && (
|
{!isRegister && (
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-3 text-center">
|
||||||
<Link
|
<Link
|
||||||
to="/forgot-password"
|
to="/forgot-password"
|
||||||
className="text-gb-yellow hover:text-gb-orange text-xs"
|
className="text-gb-yellow hover:text-gb-orange text-xs"
|
||||||
@@ -127,7 +127,7 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRegister && (
|
{!isRegister && (
|
||||||
<div className="mt-4">
|
<div className="mt-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -157,13 +157,13 @@ export function LoginForm() {
|
|||||||
console.error("Passkey login failed:", err);
|
console.error("Passkey login failed:", err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="terminal-button w-full border-gb-aqua text-gb-aqua text-xs"
|
className="terminal-button w-full border-gb-aqua text-gb-aqua"
|
||||||
>
|
>
|
||||||
[SIGN IN WITH PASSKEY]
|
[SIGN IN WITH PASSKEY]
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-5 text-center">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineMarkdownToHtml(text: string): string {
|
function inlineMarkdownToHtml(text: string): string {
|
||||||
@@ -610,8 +610,8 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
<button type="button" disabled={disabled || uploading}
|
<button type="button" disabled={disabled || uploading}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Upload file"
|
title="Upload file"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faPlus} className={`w-4 h-4 ${uploading ? "animate-pulse" : ""}`} />
|
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-gb-bg-t mx-1">│</span>
|
<span className="text-gb-bg-t mx-1">│</span>
|
||||||
@@ -619,28 +619,28 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
{/* block formatting */}
|
{/* block formatting */}
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("ul")} title="Unordered list"
|
onClick={() => execBlock("ul")} title="Unordered list"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faListUl} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("ol")} title="Ordered list"
|
onClick={() => execBlock("ol")} title="Ordered list"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faListOl} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("blockquote")} title="Blockquote"
|
onClick={() => execBlock("blockquote")} title="Blockquote"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faQuoteRight} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={execLink} title="Insert link"
|
onClick={execLink} title="Insert link"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faLink} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execBlock("h2")} title="Heading"
|
onClick={() => execBlock("h2")} title="Heading"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faHeading} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-gb-bg-t mx-1">│</span>
|
<span className="text-gb-bg-t mx-1">│</span>
|
||||||
@@ -648,43 +648,43 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
{/* inline formatting */}
|
{/* inline formatting */}
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("**", "**")} title="Bold"
|
onClick={() => execInline("**", "**")} title="Bold"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faBold} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("*", "*")} title="Italic"
|
onClick={() => execInline("*", "*")} title="Italic"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faItalic} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("~~", "~~")} title="Strikethrough"
|
onClick={() => execInline("~~", "~~")} title="Strikethrough"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faStrikethrough} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("`", "`")} title="Code"
|
onClick={() => execInline("`", "`")} title="Code"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faCode} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled}
|
<button type="button" disabled={disabled}
|
||||||
onClick={() => execInline("||", "||")} title="Spoiler"
|
onClick={() => execInline("||", "||")} title="Spoiler"
|
||||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||||
<FontAwesomeIcon icon={faEyeSlash} className="w-4 h-4" />
|
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-gb-bg-t mx-1">│</span>
|
<span className="text-gb-bg-t mx-1">│</span>
|
||||||
|
|
||||||
{/* emoji / kaomoji / gif */}
|
{/* emoji / kaomoji / gif */}
|
||||||
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
|
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
|
||||||
className={`p-1.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" />
|
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
|
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
|
||||||
className={`p-1.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" />
|
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
|
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
|
||||||
className={`p-1.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" />
|
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useConversationStore } from "../stores/conversation.ts";
|
import { useConversationStore } from "../stores/conversation.ts";
|
||||||
import { useMemberStore } from "../stores/member.ts";
|
import { useMemberStore } from "../stores/member.ts";
|
||||||
import { useServerStore } from "../stores/server.ts";
|
import { useServerStore } from "../stores/server.ts";
|
||||||
@@ -12,7 +11,6 @@ interface NewConversationModalProps {
|
|||||||
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const navigate = useNavigate();
|
|
||||||
const createConversation = useConversationStore((s) => s.createConversation);
|
const createConversation = useConversationStore((s) => s.createConversation);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
@@ -50,14 +48,7 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
try {
|
await createConversation(selected);
|
||||||
const conv = await createConversation(selected);
|
|
||||||
if (conv?.id) {
|
|
||||||
navigate(`/dm/${conv.id}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create conversation:", err);
|
|
||||||
}
|
|
||||||
onClose();
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+2
-3
@@ -8,7 +8,7 @@ if (isTauri) {
|
|||||||
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
|
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
|
||||||
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
||||||
if (navigator.userAgent.includes('Android')) {
|
if (navigator.userAgent.includes('Android')) {
|
||||||
document.documentElement.style.setProperty('--safe-top', '2rem');
|
document.documentElement.style.setProperty('--safe-top', '1.5rem');
|
||||||
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
||||||
document.documentElement.style.setProperty('--safe-left', '0px');
|
document.documentElement.style.setProperty('--safe-left', '0px');
|
||||||
document.documentElement.style.setProperty('--safe-right', '0px');
|
document.documentElement.style.setProperty('--safe-right', '0px');
|
||||||
@@ -79,8 +79,7 @@ if (isTauri) {
|
|||||||
|
|
||||||
return ws;
|
return ws;
|
||||||
} as unknown as typeof WebSocket;
|
} as unknown as typeof WebSocket;
|
||||||
// ponytail: WebKitGTK may have read-only prototype — guard against throw
|
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||||
try { window.WebSocket.prototype = OriginalWebSocket.prototype; } catch { /* readonly, fine */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function autoSubscribePush() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -146,9 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchMe: async () => {
|
fetchMe: async () => {
|
||||||
if (!get().user) {
|
set({ isLoading: true, error: null });
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
|
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
|
|||||||
+18
-41
@@ -33,18 +33,11 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchChannels: async (serverId) => {
|
fetchChannels: async (serverId) => {
|
||||||
const srvId = serverId.toLowerCase();
|
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const channels = await api.get<Channel[]>(`/servers/${srvId}/channels`);
|
const channels = await api.get<Channel[]>(`/servers/${serverId}/channels`);
|
||||||
const list = Array.isArray(channels) ? channels : [];
|
|
||||||
const normalized = list.map((c) => ({
|
|
||||||
...c,
|
|
||||||
id: c.id.toLowerCase(),
|
|
||||||
server_id: c.server_id.toLowerCase(),
|
|
||||||
}));
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
channelsByServer: { ...state.channelsByServer, [srvId]: normalized },
|
channelsByServer: { ...state.channelsByServer, [serverId]: channels },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,66 +49,50 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setActiveChannel: (id) => {
|
setActiveChannel: (id) => {
|
||||||
const chId = id ? id.toLowerCase() : null;
|
set({ activeChannelId: id });
|
||||||
set({ activeChannelId: chId });
|
if (id) {
|
||||||
if (chId) {
|
|
||||||
// ponytail: persist last active channel for session restore
|
// ponytail: persist last active channel for session restore
|
||||||
const state = useChannelStore.getState();
|
const state = useChannelStore.getState();
|
||||||
for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
|
for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
|
||||||
if (channels.some((c) => c.id.toLowerCase() === chId)) {
|
if (channels.some((c) => c.id === id)) {
|
||||||
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: chId }));
|
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: id }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addChannel: (channel) => {
|
addChannel: (channel) =>
|
||||||
const norm = {
|
|
||||||
...channel,
|
|
||||||
id: channel.id.toLowerCase(),
|
|
||||||
server_id: channel.server_id.toLowerCase(),
|
|
||||||
};
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.channelsByServer[norm.server_id] || [];
|
const list = state.channelsByServer[channel.server_id] || [];
|
||||||
if (list.some((c) => c.id === norm.id)) return state;
|
|
||||||
return {
|
return {
|
||||||
channelsByServer: {
|
channelsByServer: {
|
||||||
...state.channelsByServer,
|
...state.channelsByServer,
|
||||||
[norm.server_id]: [...list, norm],
|
[channel.server_id]: [...list, channel],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
updateChannel: (channel) => {
|
updateChannel: (channel) =>
|
||||||
const norm = {
|
|
||||||
...channel,
|
|
||||||
id: channel.id.toLowerCase(),
|
|
||||||
server_id: channel.server_id.toLowerCase(),
|
|
||||||
};
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.channelsByServer[norm.server_id] || [];
|
const list = state.channelsByServer[channel.server_id] || [];
|
||||||
return {
|
return {
|
||||||
channelsByServer: {
|
channelsByServer: {
|
||||||
...state.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) => {
|
removeChannel: (id) =>
|
||||||
const chId = id.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next: Record<string, Channel[]> = {};
|
const next: Record<string, Channel[]> = {};
|
||||||
for (const serverId of Object.keys(state.channelsByServer)) {
|
for (const serverId of Object.keys(state.channelsByServer)) {
|
||||||
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== chId);
|
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== id);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
channelsByServer: next,
|
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 { api } from "../lib/api.ts";
|
||||||
import { type Reaction } from "./message.ts";
|
import { type Reaction } from "./message.ts";
|
||||||
|
|
||||||
function parseDate(iso: string): number {
|
|
||||||
if (!iso) return Date.now();
|
|
||||||
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
|
|
||||||
const t = new Date(normalized).getTime();
|
|
||||||
return isNaN(t) ? Date.now() : t;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationMember {
|
export interface ConversationMember {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -79,147 +72,121 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
|
|
||||||
createConversation: async (userIds) => {
|
createConversation: async (userIds) => {
|
||||||
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
|
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
|
||||||
const convId = conversation.id.toLowerCase();
|
|
||||||
const normalizedConv = { ...conversation, id: convId };
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
conversations: [normalizedConv, ...state.conversations],
|
conversations: [conversation, ...state.conversations],
|
||||||
activeConversationId: convId,
|
activeConversationId: conversation.id,
|
||||||
}));
|
}));
|
||||||
return normalizedConv;
|
return conversation;
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveConversation: (id) => set({ activeConversationId: id ? id.toLowerCase() : null }),
|
setActiveConversation: (id) => set({ activeConversationId: id }),
|
||||||
|
|
||||||
fetchMessages: async (conversationId, before) => {
|
fetchMessages: async (conversationId, before) => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const params = before ? "?before=" + encodeURIComponent(before) : "";
|
const params = before ? "?before=" + encodeURIComponent(before) : "";
|
||||||
const messages = await api.get<ConversationMessage[]>(
|
const messages = await api.get<ConversationMessage[]>(
|
||||||
`/conversations/${convId}/messages${params}`,
|
`/conversations/${conversationId}/messages${params}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => {
|
set((state) => ({
|
||||||
const existing = state.messagesByConversation[convId] || [];
|
messagesByConversation: {
|
||||||
const map = new Map<string, ConversationMessage>();
|
...state.messagesByConversation,
|
||||||
existing.forEach((m) => map.set(m.id, m));
|
[conversationId]: list,
|
||||||
list.forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
|
},
|
||||||
const merged = Array.from(map.values()).sort(
|
hasMoreByConversation: {
|
||||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
...state.hasMoreByConversation,
|
||||||
);
|
[conversationId]: list.length >= 50,
|
||||||
return {
|
},
|
||||||
messagesByConversation: {
|
isLoading: false,
|
||||||
...state.messagesByConversation,
|
}));
|
||||||
[convId]: merged,
|
|
||||||
},
|
|
||||||
hasMoreByConversation: {
|
|
||||||
...state.hasMoreByConversation,
|
|
||||||
[convId]: list.length >= 50,
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchOlderMessages: async (conversationId) => {
|
fetchOlderMessages: async (conversationId) => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.isLoadingOlder || state.hasMoreByConversation[convId] === false) return;
|
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
|
||||||
const existing = state.messagesByConversation[convId] || [];
|
const existing = state.messagesByConversation[conversationId] || [];
|
||||||
if (existing.length === 0) return;
|
if (existing.length === 0) return;
|
||||||
|
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
||||||
const oldestId = existing[0].id;
|
const oldestId = existing[0].id;
|
||||||
set({ isLoadingOlder: true });
|
set({ isLoadingOlder: true });
|
||||||
try {
|
try {
|
||||||
const older = await api.get<ConversationMessage[]>(
|
const older = await api.get<ConversationMessage[]>(
|
||||||
`/conversations/${convId}/messages?before=${encodeURIComponent(oldestId)}`,
|
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(older) ? older : [];
|
const list = Array.isArray(older) ? older : [];
|
||||||
set((state) => {
|
set((state) => ({
|
||||||
const map = new Map<string, ConversationMessage>();
|
messagesByConversation: {
|
||||||
[...list, ...existing].forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
|
...state.messagesByConversation,
|
||||||
const merged = Array.from(map.values()).sort(
|
[conversationId]: [...list, ...existing],
|
||||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
},
|
||||||
);
|
hasMoreByConversation: {
|
||||||
return {
|
...state.hasMoreByConversation,
|
||||||
messagesByConversation: {
|
[conversationId]: list.length >= 50,
|
||||||
...state.messagesByConversation,
|
},
|
||||||
[convId]: merged,
|
isLoadingOlder: false,
|
||||||
},
|
}));
|
||||||
hasMoreByConversation: {
|
|
||||||
...state.hasMoreByConversation,
|
|
||||||
[convId]: list.length >= 50,
|
|
||||||
},
|
|
||||||
isLoadingOlder: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
set({ isLoadingOlder: false });
|
set({ isLoadingOlder: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (conversationId, content) => {
|
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>(
|
const message = await api.post<ConversationMessage>(
|
||||||
`/conversations/${convId}/messages`,
|
`/conversations/${conversationId}/messages`,
|
||||||
{ content },
|
{ content },
|
||||||
);
|
);
|
||||||
const normalizedMessage = { ...message, conversation_id: (message.conversation_id || convId).toLowerCase() };
|
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
||||||
get().addMessage(normalizedMessage);
|
get().addMessage(message);
|
||||||
return normalizedMessage;
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (message) => {
|
addMessage: (message) => {
|
||||||
const convId = (message.conversation_id || "").toLowerCase();
|
|
||||||
const normalizedMessage = { ...message, conversation_id: convId };
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const existing = state.messagesByConversation[convId] || [];
|
const existing = state.messagesByConversation[message.conversation_id] || [];
|
||||||
if (existing.some((m) => m.id === normalizedMessage.id)) {
|
if (existing.some((m) => m.id === message.id)) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[convId]: [...existing, normalizedMessage]
|
[message.conversation_id]: [...existing, message]
|
||||||
.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMessage: (message) => {
|
updateMessage: (message) => {
|
||||||
const convId = (message.conversation_id || "").toLowerCase();
|
|
||||||
const normalizedMessage = { ...message, conversation_id: convId };
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const convMsgs = state.messagesByConversation[convId] || [];
|
const convMsgs = state.messagesByConversation[message.conversation_id] || [];
|
||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.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) => {
|
deleteMessage: (conversationId, messageId) => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const convMsgs = state.messagesByConversation[convId] || [];
|
const convMsgs = state.messagesByConversation[conversationId] || [];
|
||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[convId]: convMsgs.filter((m) => m.id !== messageId),
|
[conversationId]: convMsgs.filter((m) => m.id !== messageId),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addReaction: (conversationId, messageId, emoji, userId) => {
|
addReaction: (conversationId, messageId, emoji, userId) => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const messages = state.messagesByConversation[convId];
|
const messages = state.messagesByConversation[conversationId];
|
||||||
if (!messages) return state;
|
if (!messages) return state;
|
||||||
|
|
||||||
const newMessages = messages.map((m) => {
|
const newMessages = messages.map((m) => {
|
||||||
@@ -243,16 +210,15 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[convId]: newMessages,
|
[conversationId]: newMessages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeReaction: (conversationId, messageId, emoji, userId) => {
|
removeReaction: (conversationId, messageId, emoji, userId) => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const messages = state.messagesByConversation[convId];
|
const messages = state.messagesByConversation[conversationId];
|
||||||
if (!messages) return state;
|
if (!messages) return state;
|
||||||
|
|
||||||
const newMessages = messages.map((m) => {
|
const newMessages = messages.map((m) => {
|
||||||
@@ -275,7 +241,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[convId]: newMessages,
|
[conversationId]: newMessages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+91
-143
@@ -1,13 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
|
|
||||||
function parseDate(iso: string): number {
|
|
||||||
if (!iso) return Date.now();
|
|
||||||
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
|
|
||||||
const t = new Date(normalized).getTime();
|
|
||||||
return isNaN(t) ? Date.now() : t;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageEmbed {
|
export interface MessageEmbed {
|
||||||
id?: string;
|
id?: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -66,7 +59,7 @@ export interface MessageState {
|
|||||||
messagesByChannel: Record<string, Message[]>;
|
messagesByChannel: Record<string, Message[]>;
|
||||||
pinnedMessagesByChannel: Record<string, Message[]>;
|
pinnedMessagesByChannel: Record<string, Message[]>;
|
||||||
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||||
selectedMessageIds: Record<string, Set<string>>;
|
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isLoadingOlder: boolean;
|
isLoadingOlder: boolean;
|
||||||
hasMoreByChannel: Record<string, boolean>;
|
hasMoreByChannel: Record<string, boolean>;
|
||||||
@@ -102,34 +95,24 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchMessages: async (channelId, before) => {
|
fetchMessages: async (channelId, before) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const params = before ? `?before=${encodeURIComponent(before)}` : "";
|
const params = before ? `?before=${encodeURIComponent(before)}` : "";
|
||||||
const messages = await api.get<Message[]>(
|
const messages = await api.get<Message[]>(
|
||||||
`/channels/${chId}/messages${params}`,
|
`/channels/${channelId}/messages${params}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => {
|
set((state) => ({
|
||||||
const existing = state.messagesByChannel[chId] || [];
|
messagesByChannel: {
|
||||||
const map = new Map<string, Message>();
|
...state.messagesByChannel,
|
||||||
existing.forEach((m) => map.set(m.id, m));
|
[channelId]: list,
|
||||||
list.forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
|
},
|
||||||
const merged = Array.from(map.values()).sort(
|
hasMoreByChannel: {
|
||||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
...state.hasMoreByChannel,
|
||||||
);
|
[channelId]: list.length >= 50,
|
||||||
return {
|
},
|
||||||
messagesByChannel: {
|
isLoading: false,
|
||||||
...state.messagesByChannel,
|
}));
|
||||||
[chId]: merged,
|
|
||||||
},
|
|
||||||
hasMoreByChannel: {
|
|
||||||
...state.hasMoreByChannel,
|
|
||||||
[chId]: list.length >= 50,
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -142,136 +125,116 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchOlderMessages: async (channelId) => {
|
fetchOlderMessages: async (channelId) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return;
|
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
|
||||||
const existing = state.messagesByChannel[chId] || [];
|
const existing = state.messagesByChannel[channelId] || [];
|
||||||
if (existing.length === 0) return;
|
if (existing.length === 0) return;
|
||||||
|
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
||||||
const oldestId = existing[0].id;
|
const oldestId = existing[0].id;
|
||||||
set({ isLoadingOlder: true });
|
set({ isLoadingOlder: true });
|
||||||
try {
|
try {
|
||||||
const older = await api.get<Message[]>(
|
const older = await api.get<Message[]>(
|
||||||
`/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`,
|
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(older) ? older : [];
|
const list = Array.isArray(older) ? older : [];
|
||||||
set((state) => {
|
set((state) => ({
|
||||||
const map = new Map<string, Message>();
|
messagesByChannel: {
|
||||||
[...list, ...existing].forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
|
...state.messagesByChannel,
|
||||||
const merged = Array.from(map.values()).sort(
|
[channelId]: [...list, ...existing],
|
||||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
},
|
||||||
);
|
hasMoreByChannel: {
|
||||||
return {
|
...state.hasMoreByChannel,
|
||||||
messagesByChannel: {
|
[channelId]: list.length >= 50,
|
||||||
...state.messagesByChannel,
|
},
|
||||||
[chId]: merged,
|
isLoadingOlder: false,
|
||||||
},
|
}));
|
||||||
hasMoreByChannel: {
|
|
||||||
...state.hasMoreByChannel,
|
|
||||||
[chId]: list.length >= 50,
|
|
||||||
},
|
|
||||||
isLoadingOlder: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
set({ isLoadingOlder: false });
|
set({ isLoadingOlder: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
searchMessages: async (channelId, query) => {
|
searchMessages: async (channelId, query) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
const results = await api.get<SearchResultMessage[]>(
|
const results = await api.get<SearchResultMessage[]>(
|
||||||
`/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`,
|
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
|
||||||
);
|
);
|
||||||
const list = Array.isArray(results) ? results : [];
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
searchResultsByChannel: {
|
searchResultsByChannel: {
|
||||||
...state.searchResultsByChannel,
|
...state.searchResultsByChannel,
|
||||||
[chId]: list,
|
[channelId]: Array.isArray(results) ? results : [],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return list;
|
return Array.isArray(results) ? results : [];
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (channelId, content, replyTo) => {
|
sendMessage: async (channelId, content, replyTo) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
const body: { content: string; reply_to?: string } = { content };
|
const body: { content: string; reply_to?: string } = { content };
|
||||||
if (replyTo) body.reply_to = replyTo;
|
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>(
|
const message = await api.post<Message>(
|
||||||
`/channels/${chId}/messages`,
|
`/channels/${channelId}/messages`,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
const normalizedMessage = { ...message, channel_id: (message.channel_id || chId).toLowerCase() };
|
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
||||||
get().addMessage(normalizedMessage);
|
get().addMessage(message);
|
||||||
return normalizedMessage;
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
pinMessage: async (channelId, messageId) => {
|
pinMessage: async (channelId, messageId) => {
|
||||||
const chId = channelId.toLowerCase();
|
const updated = await api.put<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
|
||||||
const updated = await api.put<Message>(`/channels/${chId}/messages/${messageId}/pin`, {});
|
get().updateMessage(updated);
|
||||||
const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() };
|
|
||||||
get().updateMessage(normalizedMessage);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
unpinMessage: async (channelId, messageId) => {
|
unpinMessage: async (channelId, messageId) => {
|
||||||
const chId = channelId.toLowerCase();
|
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
|
||||||
const updated = await api.delete<Message>(`/channels/${chId}/messages/${messageId}/pin`);
|
get().updateMessage(updated);
|
||||||
const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() };
|
|
||||||
get().updateMessage(normalizedMessage);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchPinnedMessages: async (channelId) => {
|
fetchPinnedMessages: async (channelId) => {
|
||||||
const chId = channelId.toLowerCase();
|
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
|
||||||
const pinned = await api.get<Message[]>(`/channels/${chId}/messages/pinned`);
|
|
||||||
const list = Array.isArray(pinned) ? pinned : [];
|
const list = Array.isArray(pinned) ? pinned : [];
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
pinnedMessagesByChannel: {
|
pinnedMessagesByChannel: {
|
||||||
...state.pinnedMessagesByChannel,
|
...state.pinnedMessagesByChannel,
|
||||||
[chId]: list,
|
[channelId]: list,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return list;
|
return list;
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (message) => {
|
addMessage: (message) =>
|
||||||
const chId = (message.channel_id || "").toLowerCase();
|
|
||||||
const normalizedMessage = { ...message, channel_id: chId };
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[chId] || [];
|
const list = state.messagesByChannel[message.channel_id] || [];
|
||||||
if (list.some((m) => m.id === normalizedMessage.id)) {
|
if (list.some((m) => m.id === message.id)) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: [...list, normalizedMessage].sort(
|
[message.channel_id]: [...list, message],
|
||||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
updateMessage: (message) => {
|
updateMessage: (message) =>
|
||||||
const chId = (message.channel_id || "").toLowerCase();
|
|
||||||
const normalizedMessage = { ...message, channel_id: chId };
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[chId] || [];
|
const list = state.messagesByChannel[message.channel_id] || [];
|
||||||
const updatedList = list.map((m) =>
|
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];
|
let updatedPinned = [...pinnedList];
|
||||||
if (normalizedMessage.pinned) {
|
if (message.pinned) {
|
||||||
if (!pinnedList.some((m) => m.id === normalizedMessage.id)) {
|
if (!pinnedList.some((m) => m.id === message.id)) {
|
||||||
updatedPinned = [normalizedMessage, ...pinnedList].sort(
|
updatedPinned = [message, ...pinnedList].sort(
|
||||||
(a, b) => parseDate(b.created_at) - parseDate(a.created_at)
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m));
|
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id);
|
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
|
||||||
}
|
}
|
||||||
if (updatedPinned.length > 5) {
|
if (updatedPinned.length > 5) {
|
||||||
updatedPinned = updatedPinned.slice(0, 5);
|
updatedPinned = updatedPinned.slice(0, 5);
|
||||||
@@ -280,38 +243,34 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: updatedList,
|
[message.channel_id]: updatedList,
|
||||||
},
|
},
|
||||||
pinnedMessagesByChannel: {
|
pinnedMessagesByChannel: {
|
||||||
...state.pinnedMessagesByChannel,
|
...state.pinnedMessagesByChannel,
|
||||||
[chId]: updatedPinned,
|
[message.channel_id]: updatedPinned,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
removeMessage: (channelId, messageId) => {
|
removeMessage: (channelId, messageId) =>
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[chId] || [];
|
const list = state.messagesByChannel[channelId] || [];
|
||||||
const pinnedList = state.pinnedMessagesByChannel[chId] || [];
|
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
|
||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: list.filter((m) => m.id !== messageId),
|
[channelId]: list.filter((m) => m.id !== messageId),
|
||||||
},
|
},
|
||||||
pinnedMessagesByChannel: {
|
pinnedMessagesByChannel: {
|
||||||
...state.pinnedMessagesByChannel,
|
...state.pinnedMessagesByChannel,
|
||||||
[chId]: pinnedList.filter((m) => m.id !== messageId),
|
[channelId]: pinnedList.filter((m) => m.id !== messageId),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
addReaction: (channelId, messageId, emoji, userId) => {
|
addReaction: (channelId, messageId, emoji, userId) =>
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[chId] || [];
|
const list = state.messagesByChannel[channelId] || [];
|
||||||
const updatedList = list.map((m) => {
|
const updatedList = list.map((m) => {
|
||||||
if (m.id !== messageId) return m;
|
if (m.id !== messageId) return m;
|
||||||
const reactions = m.reactions ? [...m.reactions] : [];
|
const reactions = m.reactions ? [...m.reactions] : [];
|
||||||
@@ -329,16 +288,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: updatedList,
|
[channelId]: updatedList,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
removeReaction: (channelId, messageId, emoji, userId) => {
|
removeReaction: (channelId, messageId, emoji, userId) =>
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[chId] || [];
|
const list = state.messagesByChannel[channelId] || [];
|
||||||
const updatedList = list.map((m) => {
|
const updatedList = list.map((m) => {
|
||||||
if (m.id !== messageId) return m;
|
if (m.id !== messageId) return m;
|
||||||
if (!m.reactions) return m;
|
if (!m.reactions) return m;
|
||||||
@@ -354,27 +311,25 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: updatedList,
|
[channelId]: updatedList,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
bulkDeleteMessages: async (channelId, messageIds) => {
|
bulkDeleteMessages: async (channelId, messageIds) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
const res = await api.post<{ deleted: number }>(
|
const res = await api.post<{ deleted: number }>(
|
||||||
`/channels/${chId}/messages/bulk-delete`,
|
`/channels/${channelId}/messages/bulk-delete`,
|
||||||
{ messages: messageIds },
|
{ messages: messageIds },
|
||||||
);
|
);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.messagesByChannel[chId] || [];
|
const list = state.messagesByChannel[channelId] || [];
|
||||||
const ids = new Set(messageIds);
|
const ids = new Set(messageIds);
|
||||||
const selected = { ...state.selectedMessageIds };
|
const selected = { ...state.selectedMessageIds };
|
||||||
delete selected[chId];
|
delete selected[channelId];
|
||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: list.filter((m) => !ids.has(m.id)),
|
[channelId]: list.filter((m) => !ids.has(m.id)),
|
||||||
},
|
},
|
||||||
selectedMessageIds: selected,
|
selectedMessageIds: selected,
|
||||||
};
|
};
|
||||||
@@ -382,10 +337,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSelectedMessage: (channelId, messageId) => {
|
toggleSelectedMessage: (channelId, messageId) =>
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.selectedMessageIds[chId] || new Set<string>();
|
const current = state.selectedMessageIds[channelId] || new Set<string>();
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (next.has(messageId)) {
|
if (next.has(messageId)) {
|
||||||
next.delete(messageId);
|
next.delete(messageId);
|
||||||
@@ -395,25 +349,21 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
selectedMessageIds: {
|
selectedMessageIds: {
|
||||||
...state.selectedMessageIds,
|
...state.selectedMessageIds,
|
||||||
[chId]: next,
|
[channelId]: next,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
clearSelectedMessages: (channelId) => {
|
clearSelectedMessages: (channelId) =>
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = { ...state.selectedMessageIds };
|
const next = { ...state.selectedMessageIds };
|
||||||
delete next[chId];
|
delete next[channelId];
|
||||||
return { selectedMessageIds: next };
|
return { selectedMessageIds: next };
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
|
|
||||||
createPoll: async (channelId, question, options) => {
|
createPoll: async (channelId, question, options) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
const resp = await api.post<Poll>("/polls", {
|
const resp = await api.post<Poll>("/polls", {
|
||||||
channel_id: chId,
|
channel_id: channelId,
|
||||||
question,
|
question,
|
||||||
options,
|
options,
|
||||||
});
|
});
|
||||||
@@ -424,10 +374,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
|
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePoll: (channelId, poll) => {
|
updatePoll: (channelId, poll) =>
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const messages = state.messagesByChannel[chId];
|
const messages = state.messagesByChannel[channelId];
|
||||||
if (!messages) return state;
|
if (!messages) return state;
|
||||||
const updated = messages.map((m) =>
|
const updated = messages.map((m) =>
|
||||||
m.poll?.id === poll.id ? { ...m, poll } : m,
|
m.poll?.id === poll.id ? { ...m, poll } : m,
|
||||||
@@ -435,9 +384,8 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[chId]: updated,
|
[channelId]: updated,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -27,28 +27,25 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
markRead: async (channelId: string, messageId: string) => {
|
markRead: async (channelId: string, messageId: string) => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
states: { ...state.states, [chId]: messageId },
|
states: { ...state.states, [channelId]: messageId },
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await api.put(`/channels/${chId}/read`, { last_read_message_id: messageId });
|
await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId });
|
||||||
} catch {
|
} catch {
|
||||||
// optimistic update, ignore failure
|
// optimistic update, ignore failure
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markConvRead: (conversationId: string, messageId: string) => {
|
markConvRead: (conversationId: string, messageId: string) => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
convStates: { ...state.convStates, [convId]: messageId },
|
convStates: { ...state.convStates, [conversationId]: messageId },
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
|
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
|
||||||
const chId = channelId.toLowerCase();
|
|
||||||
const state = get().states;
|
const state = get().states;
|
||||||
const lastRead = state[chId];
|
const lastRead = state[channelId];
|
||||||
// never viewed: unread if there are messages
|
// never viewed: unread if there are messages
|
||||||
if (!lastRead) return !!latestMessageId;
|
if (!lastRead) return !!latestMessageId;
|
||||||
// viewed but newer messages exist
|
// viewed but newer messages exist
|
||||||
@@ -57,9 +54,8 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
|
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
|
||||||
const convId = conversationId.toLowerCase();
|
|
||||||
const state = get().convStates;
|
const state = get().convStates;
|
||||||
const lastRead = state[convId];
|
const lastRead = state[conversationId];
|
||||||
if (!lastRead) return !!latestMessageId;
|
if (!lastRead) return !!latestMessageId;
|
||||||
if (latestMessageId && lastRead !== latestMessageId) return true;
|
if (latestMessageId && lastRead !== latestMessageId) return true;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+14
-26
@@ -61,15 +61,11 @@ function extractIds(payload: UnknownPayload | undefined): { channel_id?: string;
|
|||||||
? payload.id
|
? payload.id
|
||||||
: null;
|
: null;
|
||||||
if (!messageId) return null;
|
if (!messageId) return null;
|
||||||
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id.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.toLowerCase(), message_id: messageId };
|
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let reconnectDelay = 1000;
|
|
||||||
const maxReconnectDelay = 30000;
|
|
||||||
let reconnectTimeout: number | null = null;
|
|
||||||
|
|
||||||
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||||
socket: null,
|
socket: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -82,6 +78,10 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
const socket = new WebSocket(`${getWsHost()}/ws`);
|
const socket = new WebSocket(`${getWsHost()}/ws`);
|
||||||
|
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
const maxReconnectDelay = 30000;
|
||||||
|
let reconnectTimeout: number | null = null;
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
const scheduleReconnect = () => {
|
||||||
if (reconnectTimeout) {
|
if (reconnectTimeout) {
|
||||||
window.clearTimeout(reconnectTimeout);
|
window.clearTimeout(reconnectTimeout);
|
||||||
@@ -95,15 +95,8 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
reconnectDelay = 1000; // ponytail: reset backoff on successful connect
|
// Cookie-based auth: browser sends session cookie automatically.
|
||||||
const token = localStorage.getItem('dumpster_session_token');
|
// No need to send a token frame.
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
socket.send(JSON.stringify({ token }));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
@@ -117,10 +110,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
if (data.type === 'ready') {
|
if (data.type === 'ready') {
|
||||||
set({ connected: true });
|
set({ connected: true });
|
||||||
reconnectDelay = 1000;
|
reconnectDelay = 1000;
|
||||||
const activeChan = useChannelStore.getState().activeChannelId;
|
|
||||||
if (activeChan) useMessageStore.getState().fetchMessages(activeChan);
|
|
||||||
const activeConv = useConversationStore.getState().activeConversationId;
|
|
||||||
if (activeConv) useConversationStore.getState().fetchMessages(activeConv);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +124,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMessage = useMessageStore.getState().addMessage;
|
||||||
const updateMessage = useMessageStore.getState().updateMessage;
|
const updateMessage = useMessageStore.getState().updateMessage;
|
||||||
const removeMessage = useMessageStore.getState().removeMessage;
|
const removeMessage = useMessageStore.getState().removeMessage;
|
||||||
const addReaction = useMessageStore.getState().addReaction;
|
const addReaction = useMessageStore.getState().addReaction;
|
||||||
@@ -151,17 +141,15 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
if (isRecord(payload)) {
|
if (isRecord(payload)) {
|
||||||
if (payload.conversation_id) {
|
if (payload.conversation_id) {
|
||||||
const msg = payload as unknown as ConversationMessage;
|
const msg = payload as unknown as ConversationMessage;
|
||||||
const normalizedMsg = { ...msg, conversation_id: (msg.conversation_id || '').toLowerCase() };
|
useConversationStore.getState().addMessage(msg);
|
||||||
useConversationStore.getState().addMessage(normalizedMsg);
|
|
||||||
// auto-mark DM as read if this conversation is active
|
// auto-mark DM as read if this conversation is active
|
||||||
const activeConvId = (useConversationStore.getState().activeConversationId || '').toLowerCase();
|
const activeConvId = useConversationStore.getState().activeConversationId;
|
||||||
if (normalizedMsg.conversation_id === activeConvId && document.hasFocus()) {
|
if (msg.conversation_id === activeConvId && document.hasFocus()) {
|
||||||
useReadStatesStore.getState().markConvRead(normalizedMsg.conversation_id, normalizedMsg.id);
|
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = payload as unknown as Message;
|
const msg = payload as unknown as Message;
|
||||||
const normalizedMsg = { ...msg, channel_id: (msg.channel_id || '').toLowerCase() };
|
addMessage(msg);
|
||||||
useMessageStore.getState().addMessage(normalizedMsg);
|
|
||||||
|
|
||||||
// Desktop notification
|
// Desktop notification
|
||||||
const currentUserId = useAuthStore.getState().user?.id;
|
const currentUserId = useAuthStore.getState().user?.id;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/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