Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -42,11 +42,3 @@ minio_data/
|
|||||||
/dumpster-server
|
/dumpster-server
|
||||||
keygen
|
keygen
|
||||||
steamfree
|
steamfree
|
||||||
|
|
||||||
# Tauri Android keystore (contains signing passwords)
|
|
||||||
web/src-tauri/gen/android/keystore.properties
|
|
||||||
|
|
||||||
# Secrets and certificates
|
|
||||||
/certs/
|
|
||||||
cookie.txt
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ build: build-web build-server
|
|||||||
|
|
||||||
# Build the Go server
|
# Build 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:
|
||||||
@@ -18,16 +18,6 @@ build-tui:
|
|||||||
build-tauri:
|
build-tauri:
|
||||||
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
|
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
|
||||||
|
|
||||||
# Build signed Android APK
|
|
||||||
build-android:
|
|
||||||
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --apk
|
|
||||||
@echo "APK: web/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
|
||||||
|
|
||||||
# Build signed Android AAB (Google Play bundle)
|
|
||||||
build-android-aab:
|
|
||||||
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --aab
|
|
||||||
@echo "AAB: web/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab"
|
|
||||||
|
|
||||||
# Generate Swagger docs
|
# Generate Swagger docs
|
||||||
docs:
|
docs:
|
||||||
~/go/bin/swag init -g cmd/server/main.go -o docs
|
~/go/bin/swag init -g cmd/server/main.go -o docs
|
||||||
|
|||||||
@@ -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-----
|
||||||
@@ -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
|
||||||
@@ -170,10 +163,7 @@ func main() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Session(sessionStore, cfg))
|
r.Use(middleware.Session(sessionStore, cfg))
|
||||||
r.Use(middleware.RequireAuth)
|
r.Use(middleware.RequireAuth)
|
||||||
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, append(
|
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ",")))
|
||||||
strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","),
|
|
||||||
"tauri://localhost", "http://tauri.localhost", "https://tauri.localhost",
|
|
||||||
)))
|
|
||||||
|
|
||||||
// Auth (protected: me, update profile)
|
// Auth (protected: me, update profile)
|
||||||
authHandler.RegisterProtectedRoutes(r)
|
authHandler.RegisterProtectedRoutes(r)
|
||||||
@@ -438,13 +428,6 @@ func main() {
|
|||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Privacy policy page (served before SPA catch-all)
|
|
||||||
r.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
||||||
w.Write([]byte(privacyPage))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Static file serving for production (SPA)
|
// Static file serving for production (SPA)
|
||||||
staticDir := "web/dist"
|
staticDir := "web/dist"
|
||||||
if _, err := os.Stat(staticDir); err == nil {
|
if _, err := os.Stat(staticDir); err == nil {
|
||||||
@@ -453,13 +436,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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -471,104 +450,3 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const privacyPage = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Privacy Policy — dumpsterChat</title>
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background: #1d2021; color: #ebdbb2; line-height: 1.7; padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
main { max-width: 720px; margin: 0 auto; }
|
|
||||||
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; color: #fabd2f; }
|
|
||||||
.subtitle { color: #a89984; font-size: 0.85rem; margin-bottom: 2rem; }
|
|
||||||
h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; color: #83a598; }
|
|
||||||
p, li { margin-bottom: 0.6rem; }
|
|
||||||
ul { padding-left: 1.25rem; }
|
|
||||||
li { margin-bottom: 0.3rem; }
|
|
||||||
a { color: #8ec07c; }
|
|
||||||
.footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #3c3836; font-size: 0.8rem; color: #928374; }
|
|
||||||
.update { color: #928374; font-size: 0.8rem; margin-top: 1.5rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>Privacy Policy</h1>
|
|
||||||
<p class="subtitle"><strong>dumpsterChat</strong> — Last updated: July 17, 2026</p>
|
|
||||||
|
|
||||||
<h2>Overview</h2>
|
|
||||||
<p>dumpsterChat is a self-hosted messaging platform. This privacy policy describes how your data is handled when you use the app. Because dumpsterChat is <strong>self-hosted</strong>, your data is stored on the server instance you connect to, which is operated by the server owner — not by us.</p>
|
|
||||||
|
|
||||||
<h2>Data We Collect</h2>
|
|
||||||
<p>When you use dumpsterChat, the following data is stored on the server:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Account information:</strong> username, email address, avatar, and password hash (Argon2id, not reversible).</li>
|
|
||||||
<li><strong>Messages and content:</strong> text messages, reactions, uploaded files, voice activity metadata, and poll votes.</li>
|
|
||||||
<li><strong>Session data:</strong> login sessions stored in encrypted cookies.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>How We Use Your Data</h2>
|
|
||||||
<p>Your data is used solely to operate the chat platform:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Deliver messages and notifications to the intended recipients.</li>
|
|
||||||
<li>Sync read states and presence (online/offline) across your devices.</li>
|
|
||||||
<li>Provide moderation tools (kicks, bans, mutes) per server rules.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>No Third-Party Analytics</h2>
|
|
||||||
<p>dumpsterChat does <strong>not</strong> include any analytics SDKs, tracking pixels, or telemetry. No usage data is sent to us or to any third party for advertising, profiling, or analytics purposes.</p>
|
|
||||||
|
|
||||||
<h2>Third-Party Integrations</h2>
|
|
||||||
<p>If enabled by the server owner, optional integrations may be used:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Giphy:</strong> GIF search queries are proxied through the server to Giphy's API. No user data is shared with Giphy.</li>
|
|
||||||
<li><strong>LiveKit:</strong> Voice and video calls use a self-hosted LiveKit server. Media streams are processed in real time and are not recorded or stored by default.</li>
|
|
||||||
</ul>
|
|
||||||
<p>These integrations are optional and controlled entirely by the server owner.</p>
|
|
||||||
|
|
||||||
<h2>Data Retention</h2>
|
|
||||||
<p>Data is retained for as long as the server owner maintains the database. You can delete your messages or account at any time through the app. Server owners may also set message retention limits. Uploaded files persist until explicitly removed.</p>
|
|
||||||
|
|
||||||
<h2>Your Rights</h2>
|
|
||||||
<p>Depending on your jurisdiction, you may have the right to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Request a copy of your stored data.</li>
|
|
||||||
<li>Delete your account and associated data.</li>
|
|
||||||
<li>Correct inaccurate personal information.</li>
|
|
||||||
</ul>
|
|
||||||
<p>To exercise these rights, contact the operator of the dumpsterChat instance you use, or use the account management tools available within the app.</p>
|
|
||||||
|
|
||||||
<h2>Account Deletion Requests</h2>
|
|
||||||
<p>To request deletion of your account and all associated data:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Use the <strong>Delete Account</strong> option in your account settings within the app.</li>
|
|
||||||
<li>Or email <a href="mailto:account-deletion@dustin.coffee">account-deletion@dustin.coffee</a> from the email address associated with your account.</li>
|
|
||||||
</ul>
|
|
||||||
<p>We will process your request within 30 days. Deletion removes your account, messages, uploaded files, and all personal data from the server.</p>
|
|
||||||
|
|
||||||
<h2>Security</h2>
|
|
||||||
<p>We take reasonable measures to protect your data:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Passwords are hashed with Argon2id.</li>
|
|
||||||
<li>Session tokens use httpOnly cookies.</li>
|
|
||||||
<li>All communications are encrypted over HTTPS and WSS where available.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Children's Privacy</h2>
|
|
||||||
<p>dumpsterChat is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
|
||||||
|
|
||||||
<h2>Changes to This Policy</h2>
|
|
||||||
<p>We may update this privacy policy from time to time. Changes will be posted at this URL. Continued use of the app after changes constitutes acceptance of the updated policy.</p>
|
|
||||||
|
|
||||||
<h2>Contact</h2>
|
|
||||||
<p>If you have questions about this privacy policy, contact the operator of the dumpsterChat instance you use, or open an issue on our project repository.</p>
|
|
||||||
|
|
||||||
<div class="update">This privacy policy applies to the dumpsterChat mobile app and web app. The specific data practices of each instance may vary based on the server operator's configuration.</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|||||||
@@ -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,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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
|||||||
r.Get("/auth/me", h.Me)
|
r.Get("/auth/me", h.Me)
|
||||||
r.Patch("/auth/me", h.UpdateProfile)
|
r.Patch("/auth/me", h.UpdateProfile)
|
||||||
r.Put("/auth/me/password", h.ChangePassword)
|
r.Put("/auth/me/password", h.ChangePassword)
|
||||||
r.Delete("/auth/me", h.DeleteAccount)
|
|
||||||
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
||||||
r.Get("/users/me/blocks", h.ListBlocks)
|
r.Get("/users/me/blocks", h.ListBlocks)
|
||||||
r.Post("/users/me/blocks", h.BlockUser)
|
r.Post("/users/me/blocks", h.BlockUser)
|
||||||
@@ -663,32 +662,3 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
|
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAccount deletes the authenticated user and all associated data.
|
|
||||||
// ponytail: cascading FK handles all DB cleanup — push subscriptions cleaned manually.
|
|
||||||
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up push subscriptions
|
|
||||||
h.db.ExecContext(r.Context(), `DELETE FROM push_subscriptions WHERE user_id = $1`, userID)
|
|
||||||
|
|
||||||
// ON DELETE CASCADE handles everything else: sessions, tokens, messages,
|
|
||||||
// reactions, memberships, invites, bots, etc.
|
|
||||||
result, err := h.db.ExecContext(r.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"failed to delete account"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, _ := result.RowsAffected()
|
|
||||||
if rows == 0 {
|
|
||||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "account deleted"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -1,12 +0,0 @@
|
|||||||
# EditorConfig is awesome: https://EditorConfig.org
|
|
||||||
|
|
||||||
# top-most EditorConfig file
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
insert_final_newline = false
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
*.iml
|
|
||||||
.gradle
|
|
||||||
/local.properties
|
|
||||||
/.idea/caches
|
|
||||||
/.idea/libraries
|
|
||||||
/.idea/modules.xml
|
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
|
||||||
build
|
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
|
||||||
.cxx
|
|
||||||
local.properties
|
|
||||||
key.properties
|
|
||||||
keystore.properties
|
|
||||||
|
|
||||||
/.tauri
|
|
||||||
/tauri.settings.gradle
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/src/main/**/generated
|
|
||||||
/src/main/jniLibs/**/*.so
|
|
||||||
/src/main/assets/tauri.conf.json
|
|
||||||
/tauri.build.gradle.kts
|
|
||||||
/proguard-tauri.pro
|
|
||||||
/tauri.properties
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("rust")
|
|
||||||
}
|
|
||||||
|
|
||||||
val tauriProperties = Properties().apply {
|
|
||||||
val propFile = file("tauri.properties")
|
|
||||||
if (propFile.exists()) {
|
|
||||||
propFile.inputStream().use { load(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
|
||||||
val keystoreProperties = Properties()
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdk = 36
|
|
||||||
namespace = "coffee.dustin.dumpster"
|
|
||||||
defaultConfig {
|
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
|
||||||
applicationId = "coffee.dustin.dumpster"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
|
||||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
|
||||||
}
|
|
||||||
signingConfigs {
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
getByName("debug") {
|
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
|
||||||
isDebuggable = true
|
|
||||||
isJniDebuggable = true
|
|
||||||
isMinifyEnabled = false
|
|
||||||
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
|
||||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
|
||||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
|
||||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getByName("release") {
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
isMinifyEnabled = true
|
|
||||||
proguardFiles(
|
|
||||||
*fileTree(".") { include("**/*.pro") }
|
|
||||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
|
||||||
.toList().toTypedArray()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rust {
|
|
||||||
rootDirRel = "../../../"
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.webkit:webkit:1.14.0")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(from = "tauri.build.gradle.kts")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.app"
|
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
|
||||||
<activity
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:label="@string/main_activity_title"
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
<!-- AndroidTV support -->
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package coffee.dustin.dumpster
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="85.84757"
|
|
||||||
android:endY="92.4963"
|
|
||||||
android:startX="42.9492"
|
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="#00000000" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#3DDC84"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
tools:context=".MainActivity">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Hello World!"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintLeft_toLeftOf="parent"
|
|
||||||
app:layout_constraintRight_toRightOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,10 +0,0 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<!-- Base application theme. Forces dark mode since our CSS themes are always dark. -->
|
|
||||||
<style name="Theme.app" parent="Theme.MaterialComponents.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
|
||||||
<!-- Force light status bar icons (white) against our dark background -->
|
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
|
||||||
<item name="android:windowLightNavigationBar">false</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_200">#FFBB86FC</color>
|
|
||||||
<color name="purple_500">#FF6200EE</color>
|
|
||||||
<color name="purple_700">#FF3700B3</color>
|
|
||||||
<color name="teal_200">#FF03DAC5</color>
|
|
||||||
<color name="teal_700">#FF018786</color>
|
|
||||||
<color name="black">#FF000000</color>
|
|
||||||
<color name="white">#FFFFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">"dumpsterChat"</string>
|
|
||||||
<string name="main_activity_title">"dumpsterChat"</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<!-- Base application theme. Forces dark mode since our CSS themes are always dark. -->
|
|
||||||
<style name="Theme.app" parent="Theme.MaterialComponents.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
|
||||||
<!-- Force light status bar icons (white) against our dark background -->
|
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
|
||||||
<item name="android:windowLightNavigationBar">false</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<external-path name="my_images" path="." />
|
|
||||||
<cache-path name="my_cache_images" path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
buildscript {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath("com.android.tools.build:gradle:8.11.0")
|
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register("clean").configure {
|
|
||||||
delete("build")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
plugins {
|
|
||||||
`kotlin-dsl`
|
|
||||||
}
|
|
||||||
|
|
||||||
gradlePlugin {
|
|
||||||
plugins {
|
|
||||||
create("pluginsForCoolKids") {
|
|
||||||
id = "rust"
|
|
||||||
implementationClass = "RustPlugin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
compileOnly(gradleApi())
|
|
||||||
implementation("com.android.tools.build:gradle:8.11.0")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import java.io.File
|
|
||||||
import org.apache.tools.ant.taskdefs.condition.Os
|
|
||||||
import org.gradle.api.DefaultTask
|
|
||||||
import org.gradle.api.GradleException
|
|
||||||
import org.gradle.api.logging.LogLevel
|
|
||||||
import org.gradle.api.tasks.Input
|
|
||||||
import org.gradle.api.tasks.TaskAction
|
|
||||||
|
|
||||||
open class BuildTask : DefaultTask() {
|
|
||||||
@Input
|
|
||||||
var rootDirRel: String? = null
|
|
||||||
@Input
|
|
||||||
var target: String? = null
|
|
||||||
@Input
|
|
||||||
var release: Boolean? = null
|
|
||||||
|
|
||||||
@TaskAction
|
|
||||||
fun assemble() {
|
|
||||||
val executable = """npm""";
|
|
||||||
try {
|
|
||||||
runTauriCli(executable)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
|
||||||
// Try different Windows-specific extensions
|
|
||||||
val fallbacks = listOf(
|
|
||||||
"$executable.exe",
|
|
||||||
"$executable.cmd",
|
|
||||||
"$executable.bat",
|
|
||||||
)
|
|
||||||
|
|
||||||
var lastException: Exception = e
|
|
||||||
for (fallback in fallbacks) {
|
|
||||||
try {
|
|
||||||
runTauriCli(fallback)
|
|
||||||
return
|
|
||||||
} catch (fallbackException: Exception) {
|
|
||||||
lastException = fallbackException
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastException
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun runTauriCli(executable: String) {
|
|
||||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
|
||||||
val target = target ?: throw GradleException("target cannot be null")
|
|
||||||
val release = release ?: throw GradleException("release cannot be null")
|
|
||||||
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
|
|
||||||
|
|
||||||
project.exec {
|
|
||||||
workingDir(File(project.projectDir, rootDirRel))
|
|
||||||
executable(executable)
|
|
||||||
args(args)
|
|
||||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
|
||||||
args("-vv")
|
|
||||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
|
||||||
args("-v")
|
|
||||||
}
|
|
||||||
if (release) {
|
|
||||||
args("--release")
|
|
||||||
}
|
|
||||||
args(listOf("--target", target))
|
|
||||||
}.assertNormalExitValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import com.android.build.api.dsl.ApplicationExtension
|
|
||||||
import org.gradle.api.DefaultTask
|
|
||||||
import org.gradle.api.Plugin
|
|
||||||
import org.gradle.api.Project
|
|
||||||
import org.gradle.kotlin.dsl.configure
|
|
||||||
import org.gradle.kotlin.dsl.get
|
|
||||||
|
|
||||||
const val TASK_GROUP = "rust"
|
|
||||||
|
|
||||||
open class Config {
|
|
||||||
lateinit var rootDirRel: String
|
|
||||||
}
|
|
||||||
|
|
||||||
open class RustPlugin : Plugin<Project> {
|
|
||||||
private lateinit var config: Config
|
|
||||||
|
|
||||||
override fun apply(project: Project) = with(project) {
|
|
||||||
config = extensions.create("rust", Config::class.java)
|
|
||||||
|
|
||||||
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
|
||||||
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
|
||||||
|
|
||||||
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
|
||||||
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
|
||||||
|
|
||||||
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
|
||||||
|
|
||||||
extensions.configure<ApplicationExtension> {
|
|
||||||
@Suppress("UnstableApiUsage")
|
|
||||||
flavorDimensions.add("abi")
|
|
||||||
productFlavors {
|
|
||||||
create("universal") {
|
|
||||||
dimension = "abi"
|
|
||||||
ndk {
|
|
||||||
abiFilters += abiList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defaultArchList.forEachIndexed { index, arch ->
|
|
||||||
create(arch) {
|
|
||||||
dimension = "abi"
|
|
||||||
ndk {
|
|
||||||
abiFilters.add(defaultAbiList[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEvaluate {
|
|
||||||
for (profile in listOf("debug", "release")) {
|
|
||||||
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
|
||||||
val buildTask = tasks.maybeCreate(
|
|
||||||
"rustBuildUniversal$profileCapitalized",
|
|
||||||
DefaultTask::class.java
|
|
||||||
).apply {
|
|
||||||
group = TASK_GROUP
|
|
||||||
description = "Build dynamic library in $profile mode for all targets"
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
|
||||||
|
|
||||||
for (targetPair in targetsList.withIndex()) {
|
|
||||||
val targetName = targetPair.value
|
|
||||||
val targetArch = archList[targetPair.index]
|
|
||||||
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
|
||||||
val targetBuildTask = project.tasks.maybeCreate(
|
|
||||||
"rustBuild$targetArchCapitalized$profileCapitalized",
|
|
||||||
BuildTask::class.java
|
|
||||||
).apply {
|
|
||||||
group = TASK_GROUP
|
|
||||||
description = "Build dynamic library in $profile mode for $targetArch"
|
|
||||||
rootDirRel = config.rootDirRel
|
|
||||||
target = targetName
|
|
||||||
release = profile == "release"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTask.dependsOn(targetBuildTask)
|
|
||||||
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
|
||||||
targetBuildTask
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Project-wide Gradle settings.
|
|
||||||
# IDE (e.g. Android Studio) users:
|
|
||||||
# Gradle settings configured through the IDE *will override*
|
|
||||||
# any settings specified in this file.
|
|
||||||
# For more details on how to configure your build environment visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
|
||||||
# Specifies the JVM arguments used for the daemon process.
|
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
|
||||||
# org.gradle.parallel=true
|
|
||||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
|
||||||
# Android operating system, and which are packaged with your app"s APK
|
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
|
||||||
android.useAndroidX=true
|
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
|
||||||
kotlin.code.style=official
|
|
||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
|
||||||
# resources declared in the library itself and none from the library's dependencies,
|
|
||||||
# thereby reducing the size of the R class for that library
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
android.nonFinalResIds=false
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#Tue May 10 19:22:52 CST 2022
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright 2015 the original author or authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
##
|
|
||||||
## Gradle start up script for UN*X
|
|
||||||
##
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
PRG="$0"
|
|
||||||
# Need this for relative symlinks.
|
|
||||||
while [ -h "$PRG" ] ; do
|
|
||||||
ls=`ls -ld "$PRG"`
|
|
||||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
|
||||||
if expr "$link" : '/.*' > /dev/null; then
|
|
||||||
PRG="$link"
|
|
||||||
else
|
|
||||||
PRG=`dirname "$PRG"`"/$link"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
SAVED="`pwd`"
|
|
||||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
|
||||||
APP_HOME="`pwd -P`"
|
|
||||||
cd "$SAVED" >/dev/null
|
|
||||||
|
|
||||||
APP_NAME="Gradle"
|
|
||||||
APP_BASE_NAME=`basename "$0"`
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD="maximum"
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "`uname`" in
|
|
||||||
CYGWIN* )
|
|
||||||
cygwin=true
|
|
||||||
;;
|
|
||||||
Darwin* )
|
|
||||||
darwin=true
|
|
||||||
;;
|
|
||||||
MINGW* )
|
|
||||||
msys=true
|
|
||||||
;;
|
|
||||||
NONSTOP* )
|
|
||||||
nonstop=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
|
||||||
else
|
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD="java"
|
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
|
||||||
MAX_FD_LIMIT=`ulimit -H -n`
|
|
||||||
if [ $? -eq 0 ] ; then
|
|
||||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
|
||||||
MAX_FD="$MAX_FD_LIMIT"
|
|
||||||
fi
|
|
||||||
ulimit -n $MAX_FD
|
|
||||||
if [ $? -ne 0 ] ; then
|
|
||||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Darwin, add options to specify how the application appears in the dock
|
|
||||||
if $darwin; then
|
|
||||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
|
||||||
|
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
|
||||||
|
|
||||||
# We build the pattern for arguments to be converted via cygpath
|
|
||||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
|
||||||
SEP=""
|
|
||||||
for dir in $ROOTDIRSRAW ; do
|
|
||||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
|
||||||
SEP="|"
|
|
||||||
done
|
|
||||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
|
||||||
# Add a user-defined pattern to the cygpath arguments
|
|
||||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
|
||||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
|
||||||
fi
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
i=0
|
|
||||||
for arg in "$@" ; do
|
|
||||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
|
||||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
|
||||||
|
|
||||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
|
||||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
|
||||||
else
|
|
||||||
eval `echo args$i`="\"$arg\""
|
|
||||||
fi
|
|
||||||
i=`expr $i + 1`
|
|
||||||
done
|
|
||||||
case $i in
|
|
||||||
0) set -- ;;
|
|
||||||
1) set -- "$args0" ;;
|
|
||||||
2) set -- "$args0" "$args1" ;;
|
|
||||||
3) set -- "$args0" "$args1" "$args2" ;;
|
|
||||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
|
||||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
|
||||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
|
||||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
|
||||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
|
||||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Escape application args
|
|
||||||
save () {
|
|
||||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
|
||||||
echo " "
|
|
||||||
}
|
|
||||||
APP_ARGS=`save "$@"`
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
|
||||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%" == "" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%" == "" set DIRNAME=.
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if "%ERRORLEVEL%" == "0" goto execute
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
echo location of your Java installation.
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
echo location of your Java installation.
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
|
||||||
exit /b 1
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
include ':app'
|
|
||||||
|
|
||||||
apply from: 'tauri.settings.gradle'
|
|
||||||
@@ -3,12 +3,8 @@
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
|
||||||
std::env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
app_lib::run();
|
app_lib::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$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.0",
|
||||||
"identifier": "coffee.dustin.dumpster",
|
"identifier": "coffee.dustin.dumpsterchat",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
@@ -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,10 +102,9 @@ 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: 'max(env(safe-area-inset-top), 0.25rem) max(env(safe-area-inset-right), 0) max(env(safe-area-inset-bottom), 0) max(env(safe-area-inset-left), 0)' }}>
|
||||||
|
|
||||||
{/* Top bar — minimal on mobile */}
|
{/* Top bar — minimal on mobile */}
|
||||||
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1">
|
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1">
|
||||||
@@ -236,7 +237,7 @@ function MobileBottomNav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
|
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
|
||||||
style={{ paddingBottom: 'var(--safe-bottom)' }}>
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
|
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
|
||||||
[SERVERS]
|
[SERVERS]
|
||||||
</NavTab>
|
</NavTab>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -598,7 +598,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* toolbar row */}
|
{/* toolbar row */}
|
||||||
<div className="flex items-center gap-x-1 md:gap-x-0.5 pt-0.5 border-t border-gb-bg-t">
|
<div className="flex items-center gap-0.5 pt-0.5 border-t border-gb-bg-t">
|
||||||
{/* + file upload */}
|
{/* + file upload */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function MobileNav({ activeTab, onTabChange, onToggleDrawer }: MobileNavP
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
{/* Safe area for phones with bottom notch */}
|
{/* Safe area for phones with bottom notch */}
|
||||||
<div className="h-[var(--safe-bottom)]" />
|
<div className="h-[env(safe-area-inset-bottom)]" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { usePushStore } from '../stores/push.ts';
|
|||||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||||
|
|
||||||
export function UserSettings() {
|
export function UserSettings() {
|
||||||
const { user, updateProfile, changePassword, deleteAccount, isLoading, error, clearError } = useAuthStore();
|
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
|
||||||
const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
|
const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -440,31 +440,6 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Account Deletion */}
|
|
||||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
|
||||||
<details className="group">
|
|
||||||
<summary className="text-gb-red font-mono text-xs cursor-pointer hover:text-gb-bright-red select-none">
|
|
||||||
[DELETE ACCOUNT]
|
|
||||||
</summary>
|
|
||||||
<div className="mt-3 p-3 border border-gb-red/40 bg-gb-bg-t/30">
|
|
||||||
<p className="text-gb-fg-f text-xs font-mono mb-3">
|
|
||||||
This action is permanent and cannot be undone. All your messages, servers you own, and uploaded files will be deleted.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm('Are you sure you want to permanently delete your account? This cannot be undone.')) return;
|
|
||||||
await deleteAccount();
|
|
||||||
navigate('/login');
|
|
||||||
}}
|
|
||||||
className="terminal-button text-xs border-gb-red text-gb-red hover:bg-gb-red/20"
|
|
||||||
>
|
|
||||||
[CONFIRM DELETE ACCOUNT]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* Footer info */}
|
||||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||||
<p className="text-gb-fg-f text-xs font-mono">
|
<p className="text-gb-fg-f text-xs font-mono">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,6 @@ import App from './App.tsx';
|
|||||||
|
|
||||||
const isTauri = 'window' in globalThis && '__TAURI_INTERNALS__' in window;
|
const isTauri = 'window' in globalThis && '__TAURI_INTERNALS__' in window;
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
|
|
||||||
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
|
||||||
if (navigator.userAgent.includes('Android')) {
|
|
||||||
document.documentElement.style.setProperty('--safe-top', '2rem');
|
|
||||||
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
|
||||||
document.documentElement.style.setProperty('--safe-left', '0px');
|
|
||||||
document.documentElement.style.setProperty('--safe-right', '0px');
|
|
||||||
} else {
|
|
||||||
// Linux / macOS Tauri — no notch, but body has default padding already
|
|
||||||
document.documentElement.style.setProperty('--safe-top', '0px');
|
|
||||||
document.documentElement.style.setProperty('--safe-bottom', '0px');
|
|
||||||
document.documentElement.style.setProperty('--safe-left', '0px');
|
|
||||||
document.documentElement.style.setProperty('--safe-right', '0px');
|
|
||||||
}
|
|
||||||
const TARGET = 'https://dumpster.dustin.coffee';
|
const TARGET = 'https://dumpster.dustin.coffee';
|
||||||
const WS_TARGET = 'wss://dumpster.dustin.coffee';
|
const WS_TARGET = 'wss://dumpster.dustin.coffee';
|
||||||
|
|
||||||
@@ -79,8 +65,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(
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ interface AuthState {
|
|||||||
blockUser: (userId: string) => Promise<void>;
|
blockUser: (userId: string) => Promise<void>;
|
||||||
unblockUser: (userId: string) => Promise<void>;
|
unblockUser: (userId: string) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
deleteAccount: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: auto-subscribe to push notifications on auth
|
// ponytail: auto-subscribe to push notifications on auth
|
||||||
@@ -84,7 +83,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 +145,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 });
|
||||||
@@ -252,8 +249,4 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
unblockUser: async (userId) => {
|
unblockUser: async (userId) => {
|
||||||
await api.delete(`/users/me/blocks/${userId}`);
|
await api.delete(`/users/me/blocks/${userId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteAccount: async () => {
|
|
||||||
await api.delete("/auth/me");
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,119 @@ 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();
|
// don't append here — WS MESSAGE_CREATE broadcast is the single source of truth.
|
||||||
|
// Appending in both places caused double messages when the WS event arrived before the HTTP response.
|
||||||
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() };
|
return message;
|
||||||
get().addMessage(normalizedMessage);
|
|
||||||
return normalizedMessage;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (message) => {
|
addMessage: (message) => {
|
||||||
const convId = (message.conversation_id || "").toLowerCase();
|
|
||||||
const normalizedMessage = { ...message, conversation_id: convId };
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const existing = state.messagesByConversation[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 +208,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 +239,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[convId]: newMessages,
|
[conversationId]: newMessages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,114 @@ 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;
|
||||||
|
// ponytail: don't append here — WS MESSAGE_CREATE broadcast is the single source of truth.
|
||||||
|
// Appending in both places caused double messages when the WS event arrived before the HTTP response.
|
||||||
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() };
|
return message;
|
||||||
get().addMessage(normalizedMessage);
|
|
||||||
return normalizedMessage;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
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 +241,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 +286,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 +309,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 +335,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 +347,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 +372,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 +382,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;
|
||||||
|
|||||||
@@ -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"}
|
||||||