Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa8cb1b0ca | |||
| 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 |
@@ -20,6 +20,6 @@ LIVEKIT_API_SECRET=<gener...n
|
||||
# Web Push
|
||||
VAPID_PUBLIC_KEY=<generated>
|
||||
VAPID_PRIVATE_KEY=<generated>
|
||||
VAPID_SUBJECT=mailto:admin@your.domain
|
||||
VAPID_SUBJECT=admin@your.domain
|
||||
# Integrations
|
||||
GIPHY_API_KEY=your_giphy_api_key_here
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Release Desktop Apps
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install system deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Build frontend
|
||||
run: cd web && npm ci && npm run build
|
||||
- name: Build Tauri bundles
|
||||
env:
|
||||
NO_STRIP: "true"
|
||||
run: cd web && npx tauri build
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-bundles
|
||||
path: |
|
||||
web/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
web/src-tauri/target/release/bundle/deb/*.deb
|
||||
web/src-tauri/target/release/bundle/rpm/*.rpm
|
||||
|
||||
build-windows:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- name: Install Rust
|
||||
run: |
|
||||
if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
|
||||
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
$env:Path += ";$env:USERPROFILE\.cargo\bin"
|
||||
}
|
||||
rustc --version
|
||||
shell: pwsh
|
||||
- name: Build frontend
|
||||
run: cd web; npm ci; npm run build
|
||||
- name: Build Tauri bundles
|
||||
run: cd web; $env:Path += ";$env:USERPROFILE\.cargo\bin"; npx tauri build
|
||||
shell: pwsh
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-bundles
|
||||
path: |
|
||||
web/src-tauri/target/release/bundle/msi/*.msi
|
||||
web/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
linux-bundles/*
|
||||
windows-bundles/*
|
||||
generate_release_notes: true
|
||||
@@ -40,3 +40,4 @@ minio_data/
|
||||
/server
|
||||
/migrate
|
||||
/dumpster-server
|
||||
keygen
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
SHELL := /bin/bash
|
||||
.PHONY: build build-web build-server build-tui run run-tui dev docker-up docker-down clean
|
||||
|
||||
GIT_SHA := $(shell git rev-parse --short HEAD)
|
||||
|
||||
# Build everything
|
||||
build: build-web build-server
|
||||
|
||||
@@ -12,13 +14,17 @@ build-server:
|
||||
build-tui:
|
||||
CGO_ENABLED=0 go build -o dumpster-tui ./cmd/tui
|
||||
|
||||
# Build the Tauri desktop app (Linux AppImage)
|
||||
build-tauri:
|
||||
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
|
||||
|
||||
# Generate Swagger docs
|
||||
docs:
|
||||
~/go/bin/swag init -g cmd/server/main.go -o docs
|
||||
|
||||
# Build the web frontend
|
||||
build-web:
|
||||
cd web && ([ -s ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh && npm run build || npm run build)
|
||||
cd web && ([ -s ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh && GIT_SHA=$(GIT_SHA) npm run build || GIT_SHA=$(GIT_SHA) npm run build)
|
||||
|
||||
# Run the server locally (dev mode)
|
||||
run: build-server
|
||||
|
||||
@@ -71,6 +71,9 @@ For optional features, copy `.env.example` to `.env` and set Giphy, MinIO, LiveK
|
||||
- [x] WebAuthn backend (full begin/finish flow)
|
||||
- [x] REST API documentation (Swagger UI at /docs)
|
||||
- [x] Push notification mention parsing and dispatch
|
||||
- [x] Inline markdown formatting toolbar (B/I/S/code/spoiler)
|
||||
- [x] GIF picker and kaomoji in DMs
|
||||
- [x] Restore last active channel on login
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -96,6 +99,7 @@ dumpsterChat/
|
||||
│ ├── invite/ # Server invite links
|
||||
│ ├── bot/ # Bot framework, auth, commands
|
||||
│ ├── webhook/ # Incoming webhooks
|
||||
│ ├── dm/ # Direct messages (conversations)
|
||||
│ ├── push/ # Push notification sender (VAPID)
|
||||
│ ├── permissions/ # Permission bitflags and checker
|
||||
│ ├── moderation/ # Mute, ban, kick, slowmode, audit log
|
||||
@@ -133,6 +137,21 @@ See `.env.example` for the full list. Key variables:
|
||||
| `LIVEKIT_*` | Voice/video server | No |
|
||||
| `GIPHY_API_KEY` | Giphy API key for GIF search | No |
|
||||
| `MINIO_*` | File upload storage | No |
|
||||
| `VAPID_PUBLIC_KEY` | Web push public key | No (generate with `go run ./cmd/keygen`) |
|
||||
| `VAPID_PRIVATE_KEY` | Web push private key | No |
|
||||
| `VAPID_SUBJECT` | Contact for VAPID JWT (e.g. `admin@your.domain`) | No |
|
||||
|
||||
### Push Notifications
|
||||
|
||||
Web push uses VAPID keys. Generate a keypair:
|
||||
|
||||
```bash
|
||||
go run ./cmd/keygen
|
||||
```
|
||||
|
||||
Copy the output into your `.env`. Set `VAPID_SUBJECT` to your email address **without** the `mailto:` prefix (the library adds it automatically).
|
||||
|
||||
Users are prompted to enable notifications on login. On iOS, the prompt requires a tap gesture, so a banner appears instead of a silent auto-subscribe. On Android, notifications deliver through FCM when Chrome is running (even in background). Ensure Chrome battery optimization is set to Unrestricted for reliable delivery when the PWA is closed.
|
||||
|
||||
## Wiki
|
||||
|
||||
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("VAPID_PRIVATE_KEY=" + priv)
|
||||
fmt.Println("VAPID_PUBLIC_KEY=" + pub)
|
||||
}
|
||||
@@ -103,6 +103,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -126,6 +127,8 @@ CREATE TABLE IF NOT EXISTS member_roles (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
`
|
||||
|
||||
const downSQL = `
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
||||
@@ -98,8 +101,11 @@ func main() {
|
||||
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
||||
}
|
||||
|
||||
// Email mailer
|
||||
mailer := email.NewMailer(cfg.SMTP)
|
||||
|
||||
// Auth handler
|
||||
authHandler := auth.NewHandler(database.DB, cfg, hub)
|
||||
authHandler := auth.NewHandler(database.DB, cfg, hub, mailer)
|
||||
|
||||
// Permissions checker
|
||||
permissionsChecker := permissions.NewChecker(database.DB)
|
||||
@@ -206,7 +212,7 @@ func main() {
|
||||
})
|
||||
|
||||
// Direct messages
|
||||
dmHandler := dm.NewHandler(database.DB, hub, logger)
|
||||
dmHandler := dm.NewHandler(database.DB, hub, pushHandler, logger)
|
||||
r.Route("/conversations", func(r chi.Router) {
|
||||
dmHandler.RegisterRoutes(r)
|
||||
})
|
||||
@@ -220,11 +226,26 @@ func main() {
|
||||
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Polls
|
||||
pollHandler := message.NewPollHandler(database.DB, hub, permissionsChecker)
|
||||
r.Route("/polls", func(r chi.Router) {
|
||||
pollHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Feature requests
|
||||
frHandler := message.NewFeatureRequestHandler(database.DB)
|
||||
r.Route("/servers/{serverID}/feature-requests", func(r chi.Router) {
|
||||
frHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Per-channel notification settings
|
||||
r.Route("/channels/{channelID}/notifications", func(r chi.Router) {
|
||||
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Push notifications
|
||||
pushHandler.RegisterRoutes(r)
|
||||
|
||||
// Read receipts
|
||||
rsHandler := readstate.NewHandler(database.DB, logger)
|
||||
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
||||
@@ -255,6 +276,34 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(gifs)
|
||||
})
|
||||
r.Get("/gifs/proxy", func(w http.ResponseWriter, r *http.Request) {
|
||||
targetURL := r.URL.Query().Get("url")
|
||||
if targetURL == "" {
|
||||
http.Error(w, `{"error":"missing url"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's a Giphy domain to prevent general proxy abuse
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil || parsed.Scheme != "https" || !strings.HasSuffix(parsed.Host, ".giphy.com") {
|
||||
http.Error(w, `{"error":"invalid proxy target"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Get(targetURL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch image"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Stream response
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.Header().Set("Content-Length", resp.Header.Get("Content-Length"))
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800") // Cache for 7 days
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// File upload
|
||||
@@ -305,6 +354,12 @@ func main() {
|
||||
// File serving (public, for viewing uploaded files)
|
||||
if uploadHandler != nil {
|
||||
r.Get("/files/*", uploadHandler.Serve)
|
||||
|
||||
// Legacy redirect: old uploads returned /{bucket}/objectName
|
||||
r.Get("/dumpster-files/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
objectName := strings.TrimPrefix(r.URL.Path, "/dumpster-files/")
|
||||
http.Redirect(w, r, "/files/"+objectName, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
// Public webhook execution (no auth required)
|
||||
@@ -333,7 +388,7 @@ func main() {
|
||||
})
|
||||
|
||||
// Static file serving for production (SPA)
|
||||
staticDir := "/srv/web"
|
||||
staticDir := "web/dist"
|
||||
if _, err := os.Stat(staticDir); err == nil {
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
auto_https off
|
||||
https_port 8443
|
||||
}
|
||||
|
||||
:80 {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM golang:1.24-alpine AS go-builder
|
||||
FROM golang:1.26.4-alpine AS go-builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY docs/ docs/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
|
||||
|
||||
FROM node:22-alpine AS web-builder
|
||||
|
||||
@@ -88,31 +88,35 @@ services:
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:latest
|
||||
container_name: dumpster-coturn
|
||||
command:
|
||||
- "-n"
|
||||
- "--log-file=stdout"
|
||||
- "--min-port=10000"
|
||||
- "--max-port=20000"
|
||||
- "--min-port=50101"
|
||||
- "--max-port=50200"
|
||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||
- "--cert=/etc/coturn/certs/turn.crt"
|
||||
- "--pkey=/etc/coturn/certs/turn.key"
|
||||
- "--tls-listening-port=5349"
|
||||
ports:
|
||||
- "3478:3478/tcp"
|
||||
- "3478:3478/udp"
|
||||
- "5349:5349/tcp"
|
||||
- "5349:5349/udp"
|
||||
- "10000-20000:10000-20000/udp"
|
||||
- "50101-50200:50101-50200/udp"
|
||||
volumes:
|
||||
- ./certs:/etc/coturn/certs:ro
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
container_name: dumpster-caddy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- web_assets:/srv/web:ro
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
port: 7880
|
||||
bind: 0.0.0.0
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: true
|
||||
relay_range_start: 50000
|
||||
relay_range_end: 50100
|
||||
port_range_start: 50000
|
||||
port_range_end: 50100
|
||||
turn_servers:
|
||||
- host: turn.dustin.coffee
|
||||
port: 443
|
||||
protocol: tls
|
||||
username: V8S6EWOU1Lqp
|
||||
credential: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||
turn:
|
||||
enabled: true
|
||||
domain: livekit.localhost
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
external_tls: false
|
||||
enabled: false
|
||||
keys:
|
||||
devkey: secret
|
||||
V8S6EWOU1Lqp: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||
|
||||
@@ -17,7 +17,7 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(duration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
)
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appURL := h.cfg.AppURL()
|
||||
|
||||
if err := h.mailer.SendVerificationEmail(user.Email, user.Username, token, appURL); err != nil {
|
||||
http.Error(w, `{"error":"failed to send email"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, req.Token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users SET email_verified = TRUE WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, username FROM users WHERE email = $1
|
||||
`, req.Email).Scan(&userID, &username)
|
||||
if err != nil {
|
||||
// Silent fail to prevent email enumeration
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appURL := h.cfg.AppURL()
|
||||
|
||||
h.mailer.SendPasswordResetEmail(req.Email, username, token, appURL)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
http.Error(w, `{"error":"password too short"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, req.Token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to hash password"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users SET password_hash = $2 WHERE id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all sessions to force re-login
|
||||
h.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = $1`, userID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is an admin across the whole system.
|
||||
// For now, let's assume if they have ADMINISTRATOR on ANY server, they can announce.
|
||||
// Or maybe there is a global admin flag? Let's check permissions.
|
||||
// Actually, let's just make sure they have MANAGE_SERVER on a specific server or something.
|
||||
// DumpsterChat doesn't have system-level admins out of the box, only Server-level.
|
||||
// Let's require them to be the owner of at least one server, or have ADMINISTRATOR on a server?
|
||||
// The prompt just said "admin sent announcements". Let's allow it for users who are in any server with ADMINISTRATOR.
|
||||
var isAdmin bool
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM member_roles mr
|
||||
JOIN roles r ON mr.role_id = r.id
|
||||
WHERE mr.user_id = $1 AND (r.permissions & $2) = $2
|
||||
)
|
||||
`, userID, permissions.ADMINISTRATOR).Scan(&isAdmin)
|
||||
|
||||
if err != nil || !isAdmin {
|
||||
http.Error(w, `{"error":"must be an administrator"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `SELECT email FROM users`)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch users"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err == nil && email != "" {
|
||||
// This is synchronous and could block, ideally use a queue, but this is fine for now
|
||||
h.mailer.SendAnnouncement(email, req.Subject, req.Body)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -21,14 +22,16 @@ type Handler struct {
|
||||
cfg *config.Config
|
||||
sessions *SessionStore
|
||||
hub *gateway.Hub
|
||||
mailer *email.Mailer
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler {
|
||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub, mailer *email.Mailer) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
sessions: NewSessionStore(db, cfg),
|
||||
hub: hub,
|
||||
mailer: mailer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +39,14 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/login/password", h.Login)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Post("/verify-email", h.VerifyEmail)
|
||||
r.Post("/request-password-reset", h.RequestPasswordReset)
|
||||
r.Post("/reset-password", h.ResetPassword)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
||||
r.Post("/auth/request-verification", h.RequestVerification)
|
||||
r.Post("/auth/admin/announce", h.AdminAnnounce)
|
||||
r.Get("/auth/me", h.Me)
|
||||
r.Patch("/auth/me", h.UpdateProfile)
|
||||
r.Put("/auth/me/password", h.ChangePassword)
|
||||
@@ -210,16 +218,16 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
StatusText string `json:"status_text"`
|
||||
Status string `json:"status"`
|
||||
BannerURL string `json:"banner_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
SocialLinks []struct {
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Bio *string `json:"bio"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
StatusText *string `json:"status_text"`
|
||||
Status *string `json:"status"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
Tagline *string `json:"tagline"`
|
||||
Pronouns *string `json:"pronouns"`
|
||||
SocialLinks *[]struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
} `json:"social_links"`
|
||||
@@ -410,8 +418,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /auth/me [get]
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
cookie, _ := r.Cookie(h.cfg.Session.CookieName)
|
||||
if cookie != nil {
|
||||
fmt.Printf("Me Request: Cookie present! value=%s\n", cookie.Value)
|
||||
} else {
|
||||
fmt.Printf("Me Request: NO COOKIE!\n")
|
||||
}
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
fmt.Printf("Me Request: NO USER ID IN CONTEXT\n")
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -450,52 +466,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Bio) > 250 {
|
||||
// Fetch existing user to merge fields
|
||||
var existing struct {
|
||||
DisplayName string
|
||||
Bio string
|
||||
AccentColor string
|
||||
StatusText string
|
||||
Status string
|
||||
BannerURL string
|
||||
Tagline string
|
||||
Pronouns string
|
||||
AvatarURL string
|
||||
SocialLinks []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
}
|
||||
var socialLinksBytes []byte
|
||||
var bannerURL, tagline, pronouns, avatar sql.NullString
|
||||
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT display_name, bio, accent_color, status_text, status, banner_url, tagline, pronouns, avatar, social_links
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&existing.DisplayName, &existing.Bio, &existing.AccentColor, &existing.StatusText, &existing.Status, &bannerURL, &tagline, &pronouns, &avatar, &socialLinksBytes)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
existing.BannerURL = bannerURL.String
|
||||
existing.Tagline = tagline.String
|
||||
existing.Pronouns = pronouns.String
|
||||
existing.AvatarURL = avatar.String
|
||||
if len(socialLinksBytes) > 0 {
|
||||
json.Unmarshal(socialLinksBytes, &existing.SocialLinks)
|
||||
}
|
||||
|
||||
// Helper to merge strings
|
||||
mergeStr := func(ptr *string, old string) string {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return old
|
||||
}
|
||||
|
||||
newBio := mergeStr(req.Bio, existing.Bio)
|
||||
newStatusText := mergeStr(req.StatusText, existing.StatusText)
|
||||
newDisplayName := mergeStr(req.DisplayName, existing.DisplayName)
|
||||
newTagline := mergeStr(req.Tagline, existing.Tagline)
|
||||
newPronouns := mergeStr(req.Pronouns, existing.Pronouns)
|
||||
newAccentColor := mergeStr(req.AccentColor, existing.AccentColor)
|
||||
newStatus := mergeStr(req.Status, existing.Status)
|
||||
newBannerURL := mergeStr(req.BannerURL, existing.BannerURL)
|
||||
newAvatarURL := mergeStr(req.AvatarURL, existing.AvatarURL)
|
||||
|
||||
if len(newBio) > 250 {
|
||||
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.StatusText) > 128 {
|
||||
if len(newStatusText) > 128 {
|
||||
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.DisplayName) > 32 {
|
||||
if len(newDisplayName) > 32 {
|
||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Tagline) > 128 {
|
||||
if len(newTagline) > 128 {
|
||||
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Pronouns) > 40 {
|
||||
if len(newPronouns) > 40 {
|
||||
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
||||
if newAccentColor != "" && len(newAccentColor) != 7 {
|
||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Status != "" && !isValidStatus(req.Status) {
|
||||
if newStatus != "" && !isValidStatus(newStatus) {
|
||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
var socialLinksJSON []byte
|
||||
if req.SocialLinks != nil {
|
||||
socialLinksJSON, _ = json.Marshal(*req.SocialLinks)
|
||||
} else {
|
||||
socialLinksJSON, _ = json.Marshal(existing.SocialLinks)
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
||||
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar)
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6,
|
||||
banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11
|
||||
WHERE id = $1
|
||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON, req.AvatarURL)
|
||||
`, userID, newDisplayName, newBio, newAccentColor, newStatusText, newStatus, newBannerURL, newTagline, newPronouns, socialLinksJSON, newAvatarURL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "" && h.hub != nil {
|
||||
if req.Status != nil && h.hub != nil {
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err == nil {
|
||||
h.hub.SetUserStatus(userID, req.Status)
|
||||
h.hub.BroadcastPresence(userID, user.Username, req.Status)
|
||||
h.hub.SetUserStatus(userID, *req.Status)
|
||||
h.hub.BroadcastPresence(userID, user.Username, *req.Status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,16 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
channels = append(channels, ch)
|
||||
|
||||
// Check VIEW_CHANNEL permission
|
||||
allowed, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, ch.ID, permissions.VIEW_CHANNEL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if allowed {
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
@@ -339,14 +348,23 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
// Server owner bypasses permission check
|
||||
var ownerID string
|
||||
err = h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
if ownerID != userID {
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var ch channelResponse
|
||||
@@ -357,7 +375,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
category = COALESCE($3, category),
|
||||
position = COALESCE($4, position),
|
||||
slowmode_seconds = COALESCE($5, slowmode_seconds),
|
||||
group_id = $7
|
||||
group_id = COALESCE($7, group_id)
|
||||
WHERE id = $6
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
||||
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan(
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -51,6 +52,14 @@ type Config struct {
|
||||
WebPush WebPushConfig
|
||||
Session SessionConfig
|
||||
Giphy GiphyConfig
|
||||
SMTP SMTPConfig
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -134,9 +143,31 @@ func Load() *Config {
|
||||
Giphy: GiphyConfig{
|
||||
APIKey: getEnv("GIPHY_API_KEY", ""),
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Host: getEnv("SMTP_HOST", ""),
|
||||
Port: getEnv("SMTP_PORT", ""),
|
||||
Username: getEnv("SMTP_USERNAME", ""),
|
||||
Password: getEnv("SMTP_PASSWORD", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) AppURL() string {
|
||||
if v := getEnv("APP_URL", ""); v != "" {
|
||||
return v
|
||||
}
|
||||
scheme := "http"
|
||||
port := c.Port
|
||||
if c.Database.SSLMode == "require" {
|
||||
// ponytail: using SSLMode as a rough https hint; add explicit APP_URL for real setups
|
||||
scheme = "https"
|
||||
}
|
||||
if port == "80" || port == "443" {
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Host)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%s", scheme, c.Host, port)
|
||||
}
|
||||
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode
|
||||
}
|
||||
|
||||
@@ -47,8 +47,19 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -79,10 +90,13 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
nickname VARCHAR(100),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, server_id)
|
||||
);
|
||||
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
@@ -90,6 +104,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
content TEXT NOT NULL,
|
||||
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -193,6 +208,7 @@ ALTER TABLE webhooks DROP COLUMN IF EXISTS token;
|
||||
|
||||
-- Ensure reply_to column exists on messages (added after initial table creation)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -261,6 +277,15 @@ CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES conversation_messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
-- Moderation
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -493,5 +518,52 @@ CREATE TABLE IF NOT EXISTS server_groups (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id);
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS polls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_polls_message ON polls(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
poll_id UUID NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_options_poll ON poll_options(poll_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
option_id UUID NOT NULL REFERENCES poll_options(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (option_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_votes_option ON poll_votes(option_id);
|
||||
|
||||
-- Feature requests
|
||||
CREATE TABLE IF NOT EXISTS feature_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'planned', 'done', 'rejected')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_server ON feature_requests(server_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feature_request_votes (
|
||||
feature_request_id UUID NOT NULL REFERENCES feature_requests(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (feature_request_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
||||
`
|
||||
|
||||
|
||||
@@ -5,26 +5,30 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles direct-message conversations.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
logger *slog.Logger
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
pushHandler *push.Handler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new DM handler.
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler {
|
||||
return &Handler{db: db, hub: hub, logger: logger}
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
||||
return &Handler{db: db, hub: hub, pushHandler: pushHandler, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers conversation routes.
|
||||
@@ -35,6 +39,10 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.Get)
|
||||
r.Get("/messages", h.ListMessages)
|
||||
r.Post("/messages", h.SendMessage)
|
||||
r.Route("/messages/{messageID}/reactions", func(r chi.Router) {
|
||||
r.Post("/", h.AddReaction)
|
||||
r.Delete("/{emoji}", h.RemoveReaction)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,10 +78,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.UserIDs) == 0 {
|
||||
http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ponytail: user_ids can be empty for self-DM (notes to self)
|
||||
|
||||
// Build unique member set including creator.
|
||||
memberSet := map[string]struct{}{userID: {}}
|
||||
@@ -82,10 +87,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
memberSet[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(memberSet) == 1 {
|
||||
http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ponytail: allow self-DM (notes to self) — len(memberSet)==1 is fine
|
||||
|
||||
memberIDs := make([]string, 0, len(memberSet))
|
||||
for uid := range memberSet {
|
||||
@@ -94,7 +96,24 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Strings(memberIDs)
|
||||
|
||||
// For a 1:1 DM, check if it already exists.
|
||||
if len(memberIDs) == 2 {
|
||||
if len(memberIDs) == 1 {
|
||||
var existingID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT c.id FROM conversations c
|
||||
WHERE c.type = 'dm'
|
||||
AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversation_id = c.id) = 1
|
||||
AND EXISTS (SELECT 1 FROM conversation_members cm2 WHERE cm2.conversation_id = c.id AND cm2.user_id = $1)
|
||||
LIMIT 1
|
||||
`, memberIDs[0]).Scan(&existingID)
|
||||
if err == nil {
|
||||
h.getByID(w, r, existingID)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if len(memberIDs) == 2 {
|
||||
var existingID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT c.id FROM conversations c
|
||||
@@ -228,14 +247,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
|
||||
var resp conversationResponse
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT id, type, name, created_at::text FROM conversations WHERE id = $1
|
||||
SELECT id, type, COALESCE(name, ''), created_at::text FROM conversations WHERE id = $1
|
||||
`, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
memberRows, err := h.db.QueryContext(ctx, `
|
||||
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, '')
|
||||
SELECT u.id, u.username, COALESCE(u.display_name, ''), COALESCE(u.avatar, '')
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = $1
|
||||
@@ -262,8 +281,9 @@ type messageResponse struct {
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
Content string `json:"content"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Reactions []emojiGroup `json:"reactions"`
|
||||
}
|
||||
|
||||
// ListMessages lists messages in a conversation.
|
||||
@@ -296,7 +316,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
FROM conversation_messages m
|
||||
JOIN users u ON u.id = m.author_id
|
||||
WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2)
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, convID, before, limit)
|
||||
} else {
|
||||
@@ -305,7 +325,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
FROM conversation_messages m
|
||||
JOIN users u ON u.id = m.author_id
|
||||
WHERE m.conversation_id = $1
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
`, convID, limit)
|
||||
}
|
||||
@@ -334,6 +354,13 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
|
||||
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
@@ -390,6 +417,8 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
msg.DisplayName = &displayName.String
|
||||
}
|
||||
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
@@ -397,6 +426,26 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Push notifications for DM participants
|
||||
if h.pushHandler != nil {
|
||||
go func() {
|
||||
members, err := h.MemberIDs(context.Background(), convID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": msg.AuthorName,
|
||||
"body": req.Content,
|
||||
"url": "/conversations/" + convID,
|
||||
}
|
||||
for _, memberID := range members {
|
||||
if memberID != userID {
|
||||
h.pushHandler.SendPush(context.Background(), memberID, payload)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
@@ -424,3 +473,185 @@ func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
type emojiGroup struct {
|
||||
Emoji string `json:"emoji"`
|
||||
Count int `json:"count"`
|
||||
Users []string `json:"users"`
|
||||
Details []reactionResponse `json:"details"`
|
||||
}
|
||||
|
||||
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
msgIDs := make([]string, len(messages))
|
||||
msgMap := make(map[string]int)
|
||||
for i, msg := range messages {
|
||||
msgIDs[i] = msg.ID
|
||||
msgMap[msg.ID] = i
|
||||
messages[i].Reactions = make([]emojiGroup, 0)
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(msgIDs))
|
||||
args := make([]interface{}, len(msgIDs))
|
||||
for i, id := range msgIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, message_id, user_id, emoji, created_at::text
|
||||
FROM conversation_reactions
|
||||
WHERE message_id IN (%s)
|
||||
ORDER BY created_at ASC
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reactionsMap := make(map[string]map[string]*emojiGroup)
|
||||
emojiOrderMap := make(map[string][]string)
|
||||
|
||||
for rows.Next() {
|
||||
var reaction reactionResponse
|
||||
var createdAt sql.NullString
|
||||
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
msgID := reaction.MessageID
|
||||
if _, ok := reactionsMap[msgID]; !ok {
|
||||
reactionsMap[msgID] = make(map[string]*emojiGroup)
|
||||
emojiOrderMap[msgID] = make([]string, 0)
|
||||
}
|
||||
|
||||
group, ok := reactionsMap[msgID][reaction.Emoji]
|
||||
if !ok {
|
||||
group = &emojiGroup{
|
||||
Emoji: reaction.Emoji,
|
||||
Users: []string{},
|
||||
Details: []reactionResponse{},
|
||||
}
|
||||
reactionsMap[msgID][reaction.Emoji] = group
|
||||
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
|
||||
}
|
||||
group.Count++
|
||||
group.Users = append(group.Users, reaction.UserID)
|
||||
group.Details = append(group.Details, reaction)
|
||||
}
|
||||
|
||||
for msgID, emojisMap := range reactionsMap {
|
||||
idx, ok := msgMap[msgID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
order := emojiOrderMap[msgID]
|
||||
for _, emoji := range order {
|
||||
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
type reactionResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Emoji string `json:"emoji"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// @Summary Add a reaction to a DM
|
||||
// @Router /conversations/{conversationID}/messages/{messageID}/reactions [post]
|
||||
func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) {
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok || !h.isMember(r.Context(), convID, userID) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Emoji == "" {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var reaction reactionResponse
|
||||
reaction.ConversationID = convID
|
||||
var createdAt sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO conversation_reactions (message_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (message_id, user_id, emoji) DO UPDATE SET emoji = EXCLUDED.emoji
|
||||
RETURNING id, message_id, user_id, emoji, created_at::text
|
||||
`, messageID, userID, req.Emoji).Scan(
|
||||
&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to add dm reaction", "error", err)
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventReactionAdd,
|
||||
Data: map[string]interface{}{
|
||||
"reaction": reaction,
|
||||
"conversation_id": convID,
|
||||
"message_id": messageID,
|
||||
},
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(reaction)
|
||||
}
|
||||
|
||||
// @Summary Remove a reaction from a DM
|
||||
// @Router /conversations/{conversationID}/messages/{messageID}/reactions/{emoji} [delete]
|
||||
func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
emoji := chi.URLParam(r, "emoji")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok || !h.isMember(r.Context(), convID, userID) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.ExecContext(r.Context(), `
|
||||
DELETE FROM conversation_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3
|
||||
`, messageID, userID, emoji)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to remove dm reaction", "error", err)
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventReactionRemove,
|
||||
Data: map[string]interface{}{
|
||||
"conversation_id": convID,
|
||||
"message_id": messageID,
|
||||
"user_id": userID,
|
||||
"emoji": emoji,
|
||||
},
|
||||
})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
)
|
||||
|
||||
type Mailer struct {
|
||||
cfg config.SMTPConfig
|
||||
}
|
||||
|
||||
func NewMailer(cfg config.SMTPConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
if m.cfg.Host == "" || m.cfg.Port == "" {
|
||||
return fmt.Errorf("SMTP is not configured")
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
|
||||
msg := []byte("To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"Content-Type: text/html; charset=UTF-8\r\n" +
|
||||
"\r\n" +
|
||||
body + "\r\n")
|
||||
|
||||
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||
|
||||
// Some servers (like STARTTLS on 587) require standard Dial, then StartTLS
|
||||
// We'll use smtp.SendMail which does STARTTLS automatically if the server supports it
|
||||
err := smtp.SendMail(addr, auth, m.cfg.Username, []string{to}, msg)
|
||||
if err != nil {
|
||||
// If standard SendMail fails due to TLS issues, fallback to manual TLS/STARTTLS if needed
|
||||
// But smtp.SendMail is usually sufficient for standard STARTTLS on port 587.
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailer) SendVerificationEmail(to, username, token, appURL string) error {
|
||||
subject := "Verify your dumpsterChat email"
|
||||
link := fmt.Sprintf("%s/verify-email?token=%s", appURL, token)
|
||||
body := fmt.Sprintf(`
|
||||
<h1>Welcome to dumpsterChat, %s!</h1>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<p><a href="%s">%s</a></p>
|
||||
<p>If you did not sign up for this account, you can safely ignore this email.</p>
|
||||
`, username, link, link)
|
||||
|
||||
return m.Send(to, subject, body)
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPasswordResetEmail(to, username, token, appURL string) error {
|
||||
subject := "Reset your dumpsterChat password"
|
||||
link := fmt.Sprintf("%s/reset-password?token=%s", appURL, token)
|
||||
body := fmt.Sprintf(`
|
||||
<h1>Password Reset Request</h1>
|
||||
<p>Hi %s,</p>
|
||||
<p>We received a request to reset your password. Click the link below to set a new password:</p>
|
||||
<p><a href="%s">%s</a></p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
`, username, link, link)
|
||||
|
||||
return m.Send(to, subject, body)
|
||||
}
|
||||
|
||||
func (m *Mailer) SendAnnouncement(to, subject, body string) error {
|
||||
return m.Send(to, subject, body)
|
||||
}
|
||||
@@ -102,10 +102,11 @@ func extractHostname(s string) string {
|
||||
|
||||
// Client is a middleman between the websocket connection and the hub.
|
||||
type Client struct {
|
||||
Hub *Hub
|
||||
Conn *websocket.Conn
|
||||
UserID string
|
||||
send chan []byte
|
||||
Hub *Hub
|
||||
Conn *websocket.Conn
|
||||
UserID string
|
||||
Username string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// readPump pumps messages from the websocket connection to the hub.
|
||||
@@ -140,7 +141,19 @@ func (c *Client) readPump() {
|
||||
c.Hub.RecordActivity(c.UserID)
|
||||
|
||||
switch event.Type {
|
||||
case EventTypingStart, EventPresenceUpdate:
|
||||
case EventTypingStart:
|
||||
// Enrich with sender info so receivers know who is typing
|
||||
var data map[string]interface{}
|
||||
if raw, ok := event.Data.(json.RawMessage); ok {
|
||||
json.Unmarshal(raw, &data)
|
||||
}
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
data["user_id"] = c.UserID
|
||||
data["username"] = c.Username
|
||||
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
||||
case EventPresenceUpdate:
|
||||
c.Hub.BroadcastEvent(event)
|
||||
case EventVoiceWhisper:
|
||||
// Forward voice whispers only to the target user, not broadcast
|
||||
@@ -247,11 +260,11 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
token = auth.Token
|
||||
}
|
||||
|
||||
var userID string
|
||||
var userID, username string
|
||||
err = db.QueryRowContext(context.Background(),
|
||||
`SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`,
|
||||
`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)
|
||||
).Scan(&userID, &username)
|
||||
if err != nil {
|
||||
logger.Warn("ws auth: invalid session", "error", err)
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
||||
@@ -263,10 +276,11 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`))
|
||||
|
||||
client := &Client{
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: userID,
|
||||
send: make(chan []byte, 256),
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type FeatureRequestHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewFeatureRequestHandler(db *sql.DB) *FeatureRequestHandler {
|
||||
return &FeatureRequestHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Create)
|
||||
r.Post("/{id}/vote", h.Vote)
|
||||
r.Delete("/{id}/vote", h.Unvote)
|
||||
r.Patch("/{id}/status", h.UpdateStatus)
|
||||
}
|
||||
|
||||
type featureRequestResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Voters []string `json:"voters"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
if serverID == "" {
|
||||
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT fr.id, fr.server_id, fr.channel_id, fr.author_id, fr.title, fr.description, fr.status,
|
||||
fr.created_at, fr.updated_at,
|
||||
COALESCE((SELECT count(*) FROM feature_request_votes frv WHERE frv.feature_request_id = fr.id), 0) as vote_count
|
||||
FROM feature_requests fr
|
||||
WHERE fr.server_id = $1
|
||||
ORDER BY vote_count DESC, fr.created_at DESC
|
||||
`, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []featureRequestResponse
|
||||
for rows.Next() {
|
||||
var fr featureRequestResponse
|
||||
if err := rows.Scan(&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt, &fr.VoteCount); err != nil {
|
||||
continue
|
||||
}
|
||||
// Fetch voters
|
||||
voterRows, _ := h.db.QueryContext(r.Context(), `
|
||||
SELECT user_id FROM feature_request_votes WHERE feature_request_id = $1
|
||||
`, fr.ID)
|
||||
if voterRows != nil {
|
||||
for voterRows.Next() {
|
||||
var vid string
|
||||
if voterRows.Scan(&vid) == nil {
|
||||
fr.Voters = append(fr.Voters, vid)
|
||||
}
|
||||
}
|
||||
voterRows.Close()
|
||||
}
|
||||
if fr.Voters == nil {
|
||||
fr.Voters = []string{}
|
||||
}
|
||||
results = append(results, fr)
|
||||
}
|
||||
if results == nil {
|
||||
results = []featureRequestResponse{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
|
||||
type createFeatureRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
if serverID == "" {
|
||||
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req createFeatureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
http.Error(w, `{"error":"title required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ChannelID == "" {
|
||||
// Default to the first text channel in the server
|
||||
h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id FROM channels WHERE server_id = $1 AND type = 'text' ORDER BY position LIMIT 1
|
||||
`, serverID).Scan(&req.ChannelID)
|
||||
}
|
||||
|
||||
var fr featureRequestResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO feature_requests (server_id, channel_id, author_id, title, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, server_id, channel_id, author_id, title, description, status, created_at, updated_at
|
||||
`, serverID, req.ChannelID, userID, req.Title, req.Description).Scan(
|
||||
&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create feature request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fr.VoteCount = 0
|
||||
fr.Voters = []string{}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(fr)
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) Vote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO feature_request_votes (feature_request_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||
`, id, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"vote failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) Unvote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
h.db.ExecContext(r.Context(), `
|
||||
DELETE FROM feature_request_votes WHERE feature_request_id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := map[string]bool{"open": true, "planned": true, "done": true, "rejected": true}
|
||||
if !validStatuses[req.Status] {
|
||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
UPDATE feature_requests SET status = $1, updated_at = NOW() WHERE id = $2
|
||||
`, req.Status, id)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
@@ -47,6 +47,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/bulk-delete", h.BulkDelete)
|
||||
r.Patch("/{messageID}", h.Update)
|
||||
r.Delete("/{messageID}", h.Delete)
|
||||
r.Get("/pinned", h.ListPinned)
|
||||
r.Put("/{messageID}/pin", h.Pin)
|
||||
r.Delete("/{messageID}/pin", h.Unpin)
|
||||
}
|
||||
|
||||
type bulkDeleteRequest struct {
|
||||
@@ -135,6 +138,21 @@ type embedResponse struct {
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
type reactionResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Emoji string `json:"emoji"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type emojiGroup struct {
|
||||
Emoji string `json:"emoji"`
|
||||
Count int `json:"count"`
|
||||
Users []string `json:"users"`
|
||||
Details []reactionResponse `json:"details"`
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
@@ -144,8 +162,11 @@ type messageResponse struct {
|
||||
Content string `json:"content"`
|
||||
ReplyTo *string `json:"reply_to,omitempty"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Embeds []embedResponse `json:"embeds"`
|
||||
Reactions []emojiGroup `json:"reactions"`
|
||||
Poll *pollResponse `json:"poll,omitempty"`
|
||||
}
|
||||
|
||||
// isMember checks whether the given user is a member of the given server.
|
||||
@@ -229,9 +250,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, channelID, userID, req.Content, replyTo).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||
@@ -254,6 +275,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
msg.Reactions = make([]emojiGroup, 0)
|
||||
|
||||
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
@@ -355,9 +377,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET content = $1, edited_at = NOW()
|
||||
WHERE id = $2 AND author_id = $3
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, req.Content, messageID, userID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -386,6 +408,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Re-fetch embeds after edit.
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
// Look up serverID for scoped broadcast
|
||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
||||
@@ -482,20 +505,20 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if before != "" {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, channelID, before, limit)
|
||||
} else {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.channel_id = $1
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
`, channelID, limit)
|
||||
}
|
||||
@@ -511,7 +534,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -526,6 +549,13 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
messages = h.attachPolls(r.Context(), messages)
|
||||
|
||||
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
@@ -565,6 +595,189 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
|
||||
return messages
|
||||
}
|
||||
|
||||
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
msgIDs := make([]string, len(messages))
|
||||
msgMap := make(map[string]int)
|
||||
for i, msg := range messages {
|
||||
msgIDs[i] = msg.ID
|
||||
msgMap[msg.ID] = i
|
||||
messages[i].Reactions = make([]emojiGroup, 0)
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(msgIDs))
|
||||
args := make([]interface{}, len(msgIDs))
|
||||
for i, id := range msgIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, message_id, user_id, emoji, created_at::text
|
||||
FROM reactions
|
||||
WHERE message_id IN (%s)
|
||||
ORDER BY created_at ASC
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reactionsMap := make(map[string]map[string]*emojiGroup)
|
||||
emojiOrderMap := make(map[string][]string)
|
||||
|
||||
for rows.Next() {
|
||||
var reaction reactionResponse
|
||||
var createdAt sql.NullString
|
||||
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
msgID := reaction.MessageID
|
||||
if _, ok := reactionsMap[msgID]; !ok {
|
||||
reactionsMap[msgID] = make(map[string]*emojiGroup)
|
||||
emojiOrderMap[msgID] = make([]string, 0)
|
||||
}
|
||||
|
||||
group, ok := reactionsMap[msgID][reaction.Emoji]
|
||||
if !ok {
|
||||
group = &emojiGroup{
|
||||
Emoji: reaction.Emoji,
|
||||
Users: []string{},
|
||||
Details: []reactionResponse{},
|
||||
}
|
||||
reactionsMap[msgID][reaction.Emoji] = group
|
||||
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
|
||||
}
|
||||
group.Count++
|
||||
group.Users = append(group.Users, reaction.UserID)
|
||||
group.Details = append(group.Details, reaction)
|
||||
}
|
||||
|
||||
for msgID, emojisMap := range reactionsMap {
|
||||
idx, ok := msgMap[msgID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
order := emojiOrderMap[msgID]
|
||||
for _, emoji := range order {
|
||||
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// attachPolls loads poll data for messages that have polls.
|
||||
func (h *Handler) attachPolls(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
msgIDs := make([]string, len(messages))
|
||||
for i, m := range messages {
|
||||
msgIDs[i] = m.ID
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(msgIDs))
|
||||
args := make([]interface{}, len(msgIDs))
|
||||
for i, id := range msgIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT p.id, p.message_id, p.question, p.created_at::text
|
||||
FROM polls p
|
||||
WHERE p.message_id IN (%s)
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
msgMap := make(map[string]int)
|
||||
for i, m := range messages {
|
||||
msgMap[m.ID] = i
|
||||
}
|
||||
|
||||
type pollRow struct {
|
||||
ID string
|
||||
MessageID string
|
||||
Question string
|
||||
CreatedAt string
|
||||
}
|
||||
pollRows := make([]pollRow, 0)
|
||||
for rows.Next() {
|
||||
var p pollRow
|
||||
if err := rows.Scan(&p.ID, &p.MessageID, &p.Question, &p.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
pollRows = append(pollRows, p)
|
||||
}
|
||||
|
||||
for _, pr := range pollRows {
|
||||
idx, ok := msgMap[pr.MessageID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
poll := &pollResponse{
|
||||
ID: pr.ID,
|
||||
MessageID: pr.MessageID,
|
||||
Question: pr.Question,
|
||||
CreatedAt: pr.CreatedAt,
|
||||
Options: make([]pollOptionResponse, 0),
|
||||
}
|
||||
|
||||
optRows, err := h.db.QueryContext(ctx, `
|
||||
SELECT po.id, po.text, po.position, COUNT(pv.id) as votes
|
||||
FROM poll_options po
|
||||
LEFT JOIN poll_votes pv ON pv.option_id = po.id
|
||||
WHERE po.poll_id = $1
|
||||
GROUP BY po.id, po.text, po.position
|
||||
ORDER BY po.position
|
||||
`, pr.ID)
|
||||
if err == nil {
|
||||
for optRows.Next() {
|
||||
var opt pollOptionResponse
|
||||
if err := optRows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
|
||||
continue
|
||||
}
|
||||
opt.Voters = []string{}
|
||||
poll.Options = append(poll.Options, opt)
|
||||
}
|
||||
optRows.Close()
|
||||
|
||||
for i, opt := range poll.Options {
|
||||
voterRows, err := h.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for voterRows.Next() {
|
||||
var voterID string
|
||||
if voterRows.Scan(&voterID) == nil {
|
||||
poll.Options[i].Voters = append(poll.Options[i].Voters, voterID)
|
||||
}
|
||||
}
|
||||
voterRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
messages[idx].Poll = poll
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// Search searches messages in a channel using PostgreSQL full-text search.
|
||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
@@ -588,7 +801,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
|
||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
@@ -609,7 +822,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
var rank float64
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -624,6 +837,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
@@ -668,3 +882,218 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
|
||||
|
||||
return userID, serverID, true
|
||||
}
|
||||
|
||||
func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message exists and belongs to this channel
|
||||
var msgChannelID string
|
||||
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if msgChannelID != channelID {
|
||||
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
var isPinned bool
|
||||
err = h.db.QueryRowContext(r.Context(), "SELECT pinned FROM messages WHERE id = $1", messageID).Scan(&isPinned)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isPinned {
|
||||
http.Error(w, `{"error":"message is already pinned"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Count pinned messages in this channel
|
||||
var count int
|
||||
err = h.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND pinned = TRUE", channelID).Scan(&count)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= 5 {
|
||||
http.Error(w, `{"error":"limit reached (max 5 pinned messages per channel)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update message
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET pinned = TRUE
|
||||
WHERE id = $1
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, messageID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to pin message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch author details
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Attach embeds
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
// Broadcast MESSAGE_UPDATE
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message exists and belongs to this channel
|
||||
var msgChannelID string
|
||||
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if msgChannelID != channelID {
|
||||
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update message
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET pinned = FALSE
|
||||
WHERE id = $1
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, messageID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to unpin message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch author details
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Attach embeds
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
// Broadcast MESSAGE_UPDATE
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
||||
ORDER BY m.created_at DESC
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to list pinned messages"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]messageResponse, 0)
|
||||
for rows.Next() {
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PollHandler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewPollHandler(db *sql.DB, hub *gateway.Hub, checker *permissions.Checker) *PollHandler {
|
||||
return &PollHandler{db: db, hub: hub, checker: checker}
|
||||
}
|
||||
|
||||
func (ph *PollHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/", ph.Create)
|
||||
r.Get("/{pollID}", ph.Get)
|
||||
r.Post("/{pollID}/vote", ph.Vote)
|
||||
}
|
||||
|
||||
type createPollRequest struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type pollOptionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Position int `json:"position"`
|
||||
Votes int `json:"votes"`
|
||||
Voters []string `json:"voters"`
|
||||
}
|
||||
|
||||
type pollResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
Question string `json:"question"`
|
||||
Options []pollOptionResponse `json:"options"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req createPollRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Question == "" || len(req.Options) < 2 || len(req.Options) > 10 {
|
||||
http.Error(w, `{"error":"question required, 2-10 options"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var serverID string
|
||||
err := ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT server_id FROM channels WHERE id = $1
|
||||
`, req.ChannelID).Scan(&serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := ph.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var msgID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.ChannelID, userID, req.Question).Scan(&msgID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var pollID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO polls (message_id, question)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, msgID, req.Question).Scan(&pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create poll"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
options := make([]pollOptionResponse, len(req.Options))
|
||||
for i, text := range req.Options {
|
||||
var optID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO poll_options (poll_id, text, position)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, pollID, text, i).Scan(&optID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create option"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
options[i] = pollOptionResponse{
|
||||
ID: optID,
|
||||
Text: text,
|
||||
Position: i,
|
||||
Votes: 0,
|
||||
Voters: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var authorName string
|
||||
var displayName sql.NullString
|
||||
_ = ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, userID).Scan(&authorName, &displayName)
|
||||
|
||||
var createdAt string
|
||||
_ = ph.db.QueryRowContext(r.Context(), `SELECT created_at::text FROM messages WHERE id = $1`, msgID).Scan(&createdAt)
|
||||
|
||||
poll := pollResponse{
|
||||
ID: pollID,
|
||||
MessageID: msgID,
|
||||
Question: req.Question,
|
||||
Options: options,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
|
||||
broadcastMsg := map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": req.ChannelID,
|
||||
"author_id": userID,
|
||||
"author_username": authorName,
|
||||
"author_display_name": displayName.String,
|
||||
"content": req.Question,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"reactions": []interface{}{},
|
||||
"embeds": []interface{}{},
|
||||
"poll": poll,
|
||||
}
|
||||
ph.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: broadcastMsg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(poll)
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
pollID := chi.URLParam(r, "pollID")
|
||||
|
||||
poll, err := ph.fetchPoll(r.Context(), pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"poll not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(poll)
|
||||
}
|
||||
|
||||
type voteRequest struct {
|
||||
OptionID string `json:"option_id"`
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Vote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pollID := chi.URLParam(r, "pollID")
|
||||
|
||||
var req voteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var optPollID string
|
||||
err := ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT poll_id FROM poll_options WHERE id = $1
|
||||
`, req.OptionID).Scan(&optPollID)
|
||||
if err != nil || optPollID != pollID {
|
||||
http.Error(w, `{"error":"invalid option"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := ph.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
DELETE FROM poll_votes
|
||||
WHERE user_id = $1
|
||||
AND option_id IN (SELECT id FROM poll_options WHERE poll_id = $2)
|
||||
`, userID, pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO poll_votes (option_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
`, req.OptionID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get server for broadcast
|
||||
var msgID, channelID, serverID string
|
||||
_ = ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT p.message_id, m.channel_id, c.server_id
|
||||
FROM polls p
|
||||
JOIN messages m ON m.id = p.message_id
|
||||
JOIN channels c ON c.id = m.channel_id
|
||||
WHERE p.id = $1
|
||||
`, pollID).Scan(&msgID, &channelID, &serverID)
|
||||
|
||||
pollResp, _ := ph.fetchPoll(r.Context(), pollID)
|
||||
if pollResp != nil {
|
||||
ph.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: "POLL_UPDATE",
|
||||
Data: map[string]interface{}{
|
||||
"poll": pollResp,
|
||||
"message_id": msgID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (ph *PollHandler) fetchPoll(ctx context.Context, pollID string) (*pollResponse, error) {
|
||||
var poll pollResponse
|
||||
var createdAt sql.NullString
|
||||
err := ph.db.QueryRowContext(ctx, `
|
||||
SELECT id, message_id, question, created_at::text FROM polls WHERE id = $1
|
||||
`, pollID).Scan(&poll.ID, &poll.MessageID, &poll.Question, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createdAt.Valid {
|
||||
poll.CreatedAt = createdAt.String
|
||||
}
|
||||
|
||||
rows, err := ph.db.QueryContext(ctx, `
|
||||
SELECT po.id, po.text, po.position, COUNT(pv.id) as votes
|
||||
FROM poll_options po
|
||||
LEFT JOIN poll_votes pv ON pv.option_id = po.id
|
||||
WHERE po.poll_id = $1
|
||||
GROUP BY po.id, po.text, po.position
|
||||
ORDER BY po.position
|
||||
`, pollID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
poll.Options = make([]pollOptionResponse, 0)
|
||||
for rows.Next() {
|
||||
var opt pollOptionResponse
|
||||
if err := rows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
|
||||
continue
|
||||
}
|
||||
opt.Voters = []string{}
|
||||
poll.Options = append(poll.Options, opt)
|
||||
}
|
||||
|
||||
for i, opt := range poll.Options {
|
||||
voterRows, err := ph.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for voterRows.Next() {
|
||||
var voterID string
|
||||
if voterRows.Scan(&voterID) == nil {
|
||||
poll.Options[i].Voters = append(poll.Options[i].Voters, voterID)
|
||||
}
|
||||
}
|
||||
voterRows.Close()
|
||||
}
|
||||
|
||||
return &poll, nil
|
||||
}
|
||||
@@ -11,9 +11,15 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://unpkg.com; "+
|
||||
"worker-src 'self' blob:; "+
|
||||
"child-src 'self' blob:; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' https: data: blob:; "+
|
||||
"connect-src 'self' wss: ws: https:; "+
|
||||
"font-src 'self';")
|
||||
w.Header().Set("Permissions-Policy", "camera=(self), microphone=(self), geolocation=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ func (c *Checker) GetUserPermissions(ctx context.Context, serverID string, userI
|
||||
// CheckPermission reports whether the user has all the required permission bits
|
||||
// in the given server. ADMINISTRATOR bypasses all checks.
|
||||
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) {
|
||||
// Check if the user is the server owner.
|
||||
var ownerID string
|
||||
err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err == nil && ownerID == userID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
perms, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -85,6 +92,13 @@ func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID s
|
||||
// for a specific channel. It layers channel-level overrides on top of server role
|
||||
// permissions. ADMINISTRATOR bypasses all checks.
|
||||
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) {
|
||||
// Check if the user is the server owner.
|
||||
var ownerID string
|
||||
err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err == nil && ownerID == userID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
perms, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -96,13 +110,14 @@ func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID,
|
||||
}
|
||||
|
||||
// Layer channel overrides: apply deny first (subtract), then allow (add).
|
||||
// Role-based overrides from all user roles, then user-specific override.
|
||||
// Role-based overrides from all user roles, plus the @everyone role, then user-specific override.
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT co.allow_bitflags, co.deny_bitflags
|
||||
FROM channel_overrides co
|
||||
WHERE co.channel_id = $1 AND co.target_type = 'role'
|
||||
AND co.target_id IN (
|
||||
SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3
|
||||
AND (
|
||||
co.target_id IN (SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3)
|
||||
OR co.target_id = (SELECT id FROM roles WHERE server_id = $3 AND is_default = TRUE LIMIT 1)
|
||||
)
|
||||
`, channelID, userID, serverID)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,12 +2,11 @@ package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
@@ -31,10 +30,10 @@ func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog.
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r *http.ServeMux) {
|
||||
r.HandleFunc("POST /push/subscribe", h.Subscribe)
|
||||
r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe)
|
||||
r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey)
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/push/subscribe", h.Subscribe)
|
||||
r.Post("/push/unsubscribe", h.Unsubscribe)
|
||||
r.Get("/push/vapid-public-key", h.GetPublicKey)
|
||||
}
|
||||
|
||||
// @Summary Get VAPID public key
|
||||
@@ -168,17 +167,22 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
|
||||
Subscriber: h.vapidSubj,
|
||||
VAPIDPublicKey: h.vapidPub,
|
||||
VAPIDPrivateKey: h.vapidPriv,
|
||||
TTL: 30,
|
||||
TTL: 86400,
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to send push", "error", err, "user_id", userID)
|
||||
h.logger.Error("failed to send push", "error", err, "user_id", userID, "endpoint", endpoint[:min(60, len(endpoint))])
|
||||
if resp != nil && resp.StatusCode == 410 {
|
||||
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp != nil {
|
||||
h.logger.Info("push sent", "user_id", userID, "status", resp.StatusCode, "endpoint", endpoint[:min(60, len(endpoint))])
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 410 {
|
||||
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +190,7 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
|
||||
// SendChannelNotification sends a push notification to all server members (except the author)
|
||||
// when a new message is posted in a channel.
|
||||
func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) {
|
||||
if h.vapidPub == "" || h.vapidPriv == "" {
|
||||
if h.vapidPub == "" || h.vapidPriv == "" || h.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -197,6 +201,8 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("sending push notifications", "channel", channelName, "author", authorID)
|
||||
|
||||
body := content
|
||||
if len(body) > 200 {
|
||||
body = body[:200] + "..."
|
||||
@@ -222,6 +228,7 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sent := 0
|
||||
for rows.Next() {
|
||||
var userID, endpoint, p256dh, auth string
|
||||
if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil {
|
||||
@@ -240,7 +247,8 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
Subscriber: h.vapidSubj,
|
||||
VAPIDPublicKey: h.vapidPub,
|
||||
VAPIDPrivateKey: h.vapidPriv,
|
||||
TTL: 30,
|
||||
TTL: 86400,
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to send push", "error", err, "user_id", userID)
|
||||
@@ -251,23 +259,12 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 300 {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
}
|
||||
h.logger.Info("push notifications sent", "count", sent, "channel", channelName)
|
||||
}
|
||||
|
||||
// GenerateVAPIDKeys generates a new VAPID key pair.
|
||||
func GenerateVAPIDKeys() (publicKey, privateKey string, err error) {
|
||||
privateKeyBytes := make([]byte, 32)
|
||||
_, err = rand.Read(privateKeyBytes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pubBytes := make([]byte, 65)
|
||||
copy(pubBytes, privateKeyBytes)
|
||||
|
||||
publicKey = base64.RawURLEncoding.EncodeToString(pubBytes)
|
||||
privateKey = base64.RawURLEncoding.EncodeToString(privateKeyBytes)
|
||||
|
||||
return publicKey, privateKey, nil
|
||||
}
|
||||
// GenerateVAPIDKeys is in cmd/keygen. Use github.com/SherClockHolmes/webpush-go directly.
|
||||
|
||||
@@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/{messageID}/reactions", h.Add)
|
||||
r.Delete("/{messageID}/reactions/{emoji}", h.Remove)
|
||||
r.Get("/{messageID}/reactions", h.List)
|
||||
r.Post("/", h.Add)
|
||||
r.Delete("/{emoji}", h.Remove)
|
||||
r.Get("/", h.List)
|
||||
}
|
||||
|
||||
type addReactionRequest struct {
|
||||
|
||||
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/{serverID}", h.Get)
|
||||
r.Patch("/{serverID}", h.Update)
|
||||
r.Delete("/{serverID}", h.Delete)
|
||||
r.Delete("/{serverID}/members/me", h.LeaveServer)
|
||||
}
|
||||
|
||||
type createServerRequest struct {
|
||||
@@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) LeaveServer(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
|
||||
// Cannot leave a server you own
|
||||
var ownerID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID == userID {
|
||||
http.Error(w, `{"error":"server owner cannot leave; transfer ownership or delete the server"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE user_id = $1 AND server_id = $2`, userID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to leave server"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
http.Error(w, `{"error":"not a member"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -6,28 +6,35 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type MemberHandler struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewMemberHandler(db *sql.DB) *MemberHandler {
|
||||
return &MemberHandler{db: db}
|
||||
return &MemberHandler{
|
||||
db: db,
|
||||
checker: permissions.NewChecker(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MemberHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/members", h.ListMembers)
|
||||
r.Patch("/members/{userID}", h.UpdateMember)
|
||||
}
|
||||
|
||||
type memberResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
}
|
||||
|
||||
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -50,7 +57,7 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '')
|
||||
SELECT u.id, u.username, u.display_name, m.nickname, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '')
|
||||
FROM members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.server_id = $1
|
||||
@@ -65,12 +72,82 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members := make([]memberResponse, 0)
|
||||
for rows.Next() {
|
||||
var m memberResponse
|
||||
if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar, &m.Status, &m.StatusText); err != nil {
|
||||
var nick sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &nick, &m.Avatar, &m.Status, &m.StatusText); err != nil {
|
||||
continue
|
||||
}
|
||||
if nick.Valid {
|
||||
m.Nickname = &nick.String
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(members)
|
||||
}
|
||||
|
||||
type updateMemberRequest struct {
|
||||
Nickname *string `json:"nickname"`
|
||||
}
|
||||
|
||||
func (h *MemberHandler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetUserID := chi.URLParam(r, "userID")
|
||||
|
||||
var req updateMemberRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Nickname == nil {
|
||||
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions:
|
||||
// - If self, need CHANGE_NICKNAME
|
||||
// - If other, need MANAGE_NICKNAMES
|
||||
var requiredPerm int64
|
||||
if userID == targetUserID {
|
||||
requiredPerm = permissions.CHANGE_NICKNAME
|
||||
} else {
|
||||
requiredPerm = permissions.MANAGE_NICKNAMES
|
||||
}
|
||||
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, requiredPerm)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Update nickname in the database
|
||||
var nickVal sql.NullString
|
||||
if *req.Nickname != "" {
|
||||
nickVal = sql.NullString{String: *req.Nickname, Valid: true}
|
||||
} else {
|
||||
nickVal = sql.NullString{Valid: false} // NULL value clears the nickname
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE members
|
||||
SET nickname = $1
|
||||
WHERE user_id = $2 AND server_id = $3
|
||||
`, nickVal, targetUserID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update nickname"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ type setMemberRolesRequest struct {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// requireManageServer checks that the caller has MANAGE_SERVER permission.
|
||||
// requireManageServer checks that the caller has MANAGE_SERVER or MANAGE_ROLES permission.
|
||||
// Returns false (and writes the error response) if denied.
|
||||
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
@@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request
|
||||
return false
|
||||
}
|
||||
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_ROLES)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if !allowed {
|
||||
allowed, err = h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return false
|
||||
@@ -358,12 +365,34 @@ func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(roles)
|
||||
}
|
||||
|
||||
// SeedDefaultRole inserts the @everyone role for a newly created server.
|
||||
// SeedDefaultRole inserts the @everyone, Admin, and Moderator roles for a newly created server.
|
||||
// Call this after creating a server (e.g. from the server creation handler).
|
||||
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
||||
// @everyone
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default)
|
||||
VALUES ($1, '@everyone', $2, 0, TRUE)
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||
VALUES ($1, '@everyone', $2, 0, TRUE, NULL)
|
||||
`, serverID, permissions.DefaultEveryonePermissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin
|
||||
adminPerms := permissions.ADMINISTRATOR
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||
VALUES ($1, 'Admin', $2, 99, FALSE, '#e06c75')
|
||||
`, serverID, adminPerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Moderator
|
||||
modPerms := permissions.VIEW_CHANNEL | permissions.SEND_MESSAGES | permissions.MANAGE_MESSAGES | permissions.KICK_MEMBERS | permissions.BAN_MEMBERS | permissions.MUTE_MEMBERS | permissions.CONNECT_VOICE | permissions.SPEAK_VOICE
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||
VALUES ($1, 'Moderator', $2, 50, FALSE, '#61afef')
|
||||
`, serverID, modPerms)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
// Check permission
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
checker := permissions.NewChecker(h.db)
|
||||
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
@@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
checker := permissions.NewChecker(h.db)
|
||||
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
@@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
checker := permissions.NewChecker(h.db)
|
||||
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
const maxUploadSize = 25 << 20 // 25 MB per file
|
||||
const maxUploadSize = 1 << 30 // 1 GB per file
|
||||
|
||||
var allowedExtensions = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
@@ -119,7 +119,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"url":"/%s/%s","size":%d}`, h.bucket, objectName, info.Size)
|
||||
fmt.Fprintf(w, `{"url":"/files/%s","size":%d}`, objectName, info.Size)
|
||||
}
|
||||
|
||||
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -153,6 +153,11 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", stat.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
// Inline display for images; attachment for everything else
|
||||
if strings.HasPrefix(stat.ContentType, "image/") {
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
} else {
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
}
|
||||
io.Copy(w, obj)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,33 @@ func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo,
|
||||
return resp.Participants, nil
|
||||
}
|
||||
|
||||
func (c *Client) MuteParticipant(roomName string, identity string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("voice client not configured")
|
||||
}
|
||||
|
||||
participant, err := c.roomClient.GetParticipant(context.Background(), &livekit.RoomParticipantIdentity{
|
||||
Room: roomName,
|
||||
Identity: identity,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get participant: %w", err)
|
||||
}
|
||||
|
||||
for _, track := range participant.Tracks {
|
||||
_, err := c.roomClient.MutePublishedTrack(context.Background(), &livekit.MuteRoomTrackRequest{
|
||||
Room: roomName,
|
||||
Identity: identity,
|
||||
TrackSid: track.Sid,
|
||||
Muted: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mute track %s: %w", track.Sid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -7,27 +7,32 @@ import (
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
client *Client
|
||||
hub *gateway.Hub
|
||||
db *sql.DB
|
||||
client *Client
|
||||
hub *gateway.Hub
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
client: client,
|
||||
hub: hub,
|
||||
db: db,
|
||||
client: client,
|
||||
hub: hub,
|
||||
checker: permissions.NewChecker(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth)
|
||||
r.Post("/token", h.GetToken)
|
||||
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
||||
r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant)
|
||||
}
|
||||
|
||||
type tokenRequest struct {
|
||||
@@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// @Summary Mute a participant
|
||||
// @Description Mute a participant's tracks in a voice room (requires MUTE_MEMBERS permission)
|
||||
// @Tags voice
|
||||
// @Security SessionAuth
|
||||
// @Param roomID path string true "Room ID"
|
||||
// @Param userID path string true "User ID (Identity)"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /voice/rooms/{roomID}/participants/{userID}/mute [post]
|
||||
func (h *Handler) MuteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := chi.URLParam(r, "roomID")
|
||||
targetIdentity := chi.URLParam(r, "userID")
|
||||
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the server_id for this channel to check permissions.
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, roomID).Scan(&serverID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, `{"error":"room not found or not a server channel"}`, http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hasPerm, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, roomID, permissions.MUTE_MEMBERS)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to check permissions"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden: missing MUTE_MEMBERS permission"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.MuteParticipant(roomID, targetIdentity); err != nil {
|
||||
http.Error(w, `{"error":"failed to mute participant"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,31 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
||||
|
||||
<!-- Apple -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dumpster" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Splash screens for iOS (apple-touch-startup-image) -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<title>Dumpster</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
"name": "dumpster-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@livekit/components-react": "^2.9.21",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"livekit-client": "^2.20.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -18,6 +25,7 @@
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -745,6 +753,52 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.0.tgz",
|
||||
"integrity": "sha512-X/vND0Y1l9fVJ9O79UgtZnXSpz4aNF3bXlDxiJAEAm6kgeSftp9wjjBPgqzazJV8YlmxfRoeXNfSCJ48sf/Hhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.0.tgz",
|
||||
"integrity": "sha512-MFbTNLDWkLJwbozDvHOZ7hwyDjQcBMBattlcOQ6ZmV5YD9bBrqdl1rNtmVjQ/lzqveXXX3sMz2Ew6fAgXoxmkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.0.tgz",
|
||||
"integrity": "sha512-YxI/CuwWeI3nPIoYU//vkDS+3ige/67DPZ6XwMATpYEFESzO9L8zfJOKllGRgIlpT/uebrZCcvAzp3peD7GmTw==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.1.tgz",
|
||||
"integrity": "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -856,6 +910,25 @@
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
|
||||
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "0.10.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1299,6 +1372,236 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1360,6 +1663,23 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
|
||||
"integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
|
||||
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1399,6 +1719,16 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
||||
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1534,9 +1864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"version": "2.10.42",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1573,9 +1903,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||
"version": "4.28.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||
"integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1593,10 +1923,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"electron-to-chromium": "^1.5.376",
|
||||
"node-releases": "^2.0.48",
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
"electron-to-chromium": "^1.5.387",
|
||||
"node-releases": "^2.0.50",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1617,9 +1947,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||
"version": "1.0.30001802",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz",
|
||||
"integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1847,12 +2177,27 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.380",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
||||
"integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
|
||||
"version": "1.5.387",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
||||
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.19.1",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.19.1.tgz",
|
||||
"integrity": "sha512-BmDdqInKFVYJpv7qS9WI6L9656cDAC+FkDvUjJds56nKHbaVTBNeDmLwKBytRnzu37zWHs9Isg7gt5PT43y6xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
@@ -2002,6 +2347,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -4129,9 +4480,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4210,6 +4561,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -7,10 +7,18 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@livekit/components-react": "^2.9.21",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"livekit-client": "^2.20.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -20,6 +28,7 @@
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 791 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,22 +1,89 @@
|
||||
{
|
||||
"name": "Dumpster",
|
||||
"name": "Dumpster Chat",
|
||||
"short_name": "Dumpster",
|
||||
"description": "A chaotic, self-hosted Discord-like platform",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"orientation": "any",
|
||||
"background_color": "#282828",
|
||||
"theme_color": "#282828",
|
||||
"orientation": "any",
|
||||
"categories": ["social", "communication"],
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Create Account",
|
||||
"short_name": "Register",
|
||||
"url": "/?register=true",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<title>Dumpster - Offline</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #282828;
|
||||
color: #ebdbb2;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
color: #fe8019;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: #a89984;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid #928374;
|
||||
color: #928374;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.status.online {
|
||||
border-color: #b8bb26;
|
||||
color: #b8bb26;
|
||||
}
|
||||
button {
|
||||
background: transparent;
|
||||
border: 1px solid #fe8019;
|
||||
color: #fe8019;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button:hover {
|
||||
background: #fe8019;
|
||||
color: #282828;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">📡</div>
|
||||
<h1>YOU ARE OFFLINE</h1>
|
||||
<p>
|
||||
Dumpster can't reach the server. Check your connection and try again.
|
||||
Messages will sync when you're back online.
|
||||
</p>
|
||||
<div class="status" id="status">OFFLINE</div>
|
||||
<br />
|
||||
<button onclick="location.reload()">[RETRY]</button>
|
||||
</div>
|
||||
<script>
|
||||
function updateStatus() {
|
||||
const el = document.getElementById('status');
|
||||
if (navigator.onLine) {
|
||||
el.textContent = 'ONLINE';
|
||||
el.className = 'status online';
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
el.textContent = 'OFFLINE';
|
||||
el.className = 'status';
|
||||
}
|
||||
}
|
||||
window.addEventListener('online', updateStatus);
|
||||
window.addEventListener('offline', updateStatus);
|
||||
updateStatus();
|
||||
// Poll connectivity every 10s
|
||||
setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
fetch('/', { method: 'HEAD', cache: 'no-cache' })
|
||||
.then(() => location.reload())
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,95 +1,111 @@
|
||||
const CACHE_NAME = 'dumpster-v1';
|
||||
const STATIC_ASSETS = [
|
||||
// Dumpster PWA Service Worker
|
||||
// Bump CACHE_VERSION on each deploy to force re-cache of hashed assets.
|
||||
const CACHE_VERSION = 'dumpster-v6';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
|
||||
// Assets to pre-cache on install (shell files)
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/offline.html',
|
||||
'/favicon.png',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
];
|
||||
|
||||
// Install: pre-cache shell and skip waiting
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean old caches and claim clients
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
caches
|
||||
.keys()
|
||||
.then((names) => Promise.all(names.filter((n) => n !== CACHE_VERSION).map((n) => caches.delete(n))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch handler
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip API requests
|
||||
if (event.request.url.includes('/api/')) return;
|
||||
// Skip API, WebSocket, and cross-origin requests
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Skip WebSocket
|
||||
if (event.request.url.includes('/ws')) return;
|
||||
// Navigation requests: network-first, offline fallback
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.match(event.request).then((cached) => cached || caches.match(OFFLINE_URL))
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets (JS, CSS, images, fonts): stale-while-revalidate
|
||||
// Serve cached immediately, fetch fresh in background
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
// Return cached version, fetch new one in background
|
||||
const fetchPromise = fetch(event.request).then((response) => {
|
||||
// Only cache successful responses
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, clone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {
|
||||
// Return cached if fetch fails
|
||||
return cached;
|
||||
});
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
},
|
||||
};
|
||||
|
||||
let data = { title: 'Dumpster', body: '', url: '/', tag: 'dumpster-notification' };
|
||||
try {
|
||||
if (event.data) {
|
||||
data = { ...data, ...event.data.json() };
|
||||
}
|
||||
} catch (e) {
|
||||
data.body = event.data ? event.data.text() : '';
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Dumpster', options)
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: data.tag,
|
||||
renotify: true,
|
||||
data: { url: data.url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click: focus or open window
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Focus existing window if available
|
||||
for (const client of clientList) {
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
|
||||
for (const client of list) {
|
||||
if (client.url.includes(event.notification.data.url) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise open new window
|
||||
return clients.openWindow(event.notification.data.url);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.2.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.3", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,16 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "dumpsterChat",
|
||||
"version": "0.2.0",
|
||||
"identifier": "coffee.dustin.dumpsterchat",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "dumpsterChat",
|
||||
"width": 1100,
|
||||
"height": 750,
|
||||
"minWidth": 680,
|
||||
"minHeight": 480,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["libwebkit2gtk-4.1-0", "libgtk-3-0", "libappindicator3-1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoginForm } from './components/LoginForm.tsx';
|
||||
import { Layout } from './components/Layout.tsx';
|
||||
import { ChatArea } from './components/ChatArea.tsx';
|
||||
@@ -8,7 +9,12 @@ import { CommandManager } from './components/CommandManager.tsx';
|
||||
import { RoleManager } from './components/RoleManager.tsx';
|
||||
import { JoinServer } from './components/JoinServer.tsx';
|
||||
import { DMChat } from './components/DMChat.tsx';
|
||||
import { ForgotPasswordPage } from './components/ForgotPasswordPage.tsx';
|
||||
import { ResetPasswordPage } from './components/ResetPasswordPage.tsx';
|
||||
import { useAuthStore } from './stores/auth.ts';
|
||||
import { useWebSocketStore } from './stores/ws.ts';
|
||||
import { InstallBanner } from './components/InstallBanner.tsx';
|
||||
import { ConnectionStatus } from './components/ConnectionStatus.tsx';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
@@ -16,10 +22,33 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const fetchMe = useAuthStore((state) => state.fetchMe);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const [init, setInit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMe().finally(() => setInit(true));
|
||||
}, [fetchMe]);
|
||||
|
||||
// Connect WebSocket for real-time messages once authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
useWebSocketStore.getState().connect();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!init) {
|
||||
return <div className="h-screen w-screen bg-gb-bg flex items-center justify-center text-gb-fg-s font-mono text-xs">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ConnectionStatus />
|
||||
<InstallBanner />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginForm />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../stores/voice.ts';
|
||||
import { Track } from 'livekit-client';
|
||||
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
||||
|
||||
function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let pub: TrackPublication | undefined;
|
||||
|
||||
const attachTrack = () => {
|
||||
pub = participant.getTrackPublication(Track.Source.Microphone);
|
||||
if (pub?.track && el) {
|
||||
pub.track.attach(el);
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const detachTrack = () => {
|
||||
if (pub?.track && el) {
|
||||
pub.track.detach(el);
|
||||
}
|
||||
};
|
||||
|
||||
attachTrack();
|
||||
|
||||
const handlePublished = (publication: TrackPublication) => {
|
||||
if (publication.source === Track.Source.Microphone && publication.track) {
|
||||
publication.track.attach(el);
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
room.on('trackPublished' as any, handlePublished);
|
||||
|
||||
// If a track is subscribed later
|
||||
const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => {
|
||||
if (trackParticipant.identity === participant.identity && publication.source === Track.Source.Microphone) {
|
||||
track.attach(el);
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
room.on('trackSubscribed' as any, handleSubscribed);
|
||||
|
||||
return () => {
|
||||
detachTrack();
|
||||
room.off('trackPublished' as any, handlePublished);
|
||||
room.off('trackSubscribed' as any, handleSubscribed);
|
||||
};
|
||||
}, [participant, room]);
|
||||
|
||||
return <audio ref={audioRef} autoPlay />;
|
||||
}
|
||||
|
||||
export function AudioRenderers() {
|
||||
const room = useVoiceStore((s) => s._room);
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const remoteParticipants = Array.from(room.remoteParticipants.values());
|
||||
|
||||
return (
|
||||
<div className="hidden">
|
||||
{remoteParticipants.map((p) => (
|
||||
<RemoteAudioTrack key={p.identity} participant={p} room={room} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { InviteModal } from './InviteModal.tsx';
|
||||
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
||||
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
||||
import { useReadStatesStore } from '../stores/readStates.ts';
|
||||
import { useMessageStore } from '../stores/message.ts';
|
||||
import { useContextMenu } from './ContextMenu.tsx';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
type NotifLevel = 'all' | 'mentions' | 'none';
|
||||
@@ -59,28 +61,54 @@ export function ChannelList() {
|
||||
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
||||
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||
const removeChannel = useChannelStore((state) => state.removeChannel);
|
||||
const updateChannel = useChannelStore((state) => state.updateChannel);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createInGroup, setCreateInGroup] = useState<string | null>(null);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||
const [toggleTick, setToggleTick] = useState(0);
|
||||
|
||||
// Inline editing state
|
||||
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
||||
const [editingChannelName, setEditingChannelName] = useState('');
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editingGroupName, setEditingGroupName] = useState('');
|
||||
|
||||
const notifSettings = useNotificationSettingsStore((state) => state.settings);
|
||||
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
||||
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
|
||||
|
||||
const readStates = useReadStatesStore((state) => state.states);
|
||||
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
||||
const hasUnread = useReadStatesStore((state) => state.hasUnread);
|
||||
const messagesByChannel = useMessageStore((state) => state.messagesByChannel);
|
||||
|
||||
const { showMenu, MenuPortal } = useContextMenu();
|
||||
|
||||
const refreshGroups = () => {
|
||||
if (activeServerId) {
|
||||
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
|
||||
.then(setGroups)
|
||||
.catch(() => setGroups([]));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
fetchChannels(activeServerId);
|
||||
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
|
||||
.then(setGroups)
|
||||
.catch(() => setGroups([]));
|
||||
refreshGroups();
|
||||
} else {
|
||||
setGroups([]);
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
refreshGroups();
|
||||
};
|
||||
window.addEventListener('refresh-server-groups', handleRefresh);
|
||||
return () => {
|
||||
window.removeEventListener('refresh-server-groups', handleRefresh);
|
||||
};
|
||||
}, [activeServerId, fetchChannels]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,8 +181,143 @@ export function ChannelList() {
|
||||
return level === 'mentions' ? '🔔@' : '🔔';
|
||||
};
|
||||
|
||||
const hasUnread = (channelId: string): boolean => {
|
||||
return !(channelId in readStates);
|
||||
const getLatestMessageId = (channelId: string): string | undefined => {
|
||||
const msgs = messagesByChannel[channelId] || [];
|
||||
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
||||
};
|
||||
|
||||
// --- Channel context menu ---
|
||||
const handleChannelContextMenu = (e: React.MouseEvent, channel: { id: string; name: string }) => {
|
||||
showMenu(e, [
|
||||
{ label: 'Edit Name', icon: '✏️', onClick: () => startEditChannel(channel) },
|
||||
{ label: 'Permissions', icon: '🔒', onClick: () => setSettingsChannel({ id: channel.id, name: channel.name }) },
|
||||
{ label: 'Delete Channel', icon: '🗑️', danger: true, onClick: () => deleteChannel(channel.id, channel.name) },
|
||||
]);
|
||||
};
|
||||
|
||||
const startEditChannel = (channel: { id: string; name: string }) => {
|
||||
setEditingChannelId(channel.id);
|
||||
setEditingChannelName(channel.name);
|
||||
};
|
||||
|
||||
const commitEditChannel = async () => {
|
||||
const id = editingChannelId;
|
||||
const name = editingChannelName.trim();
|
||||
setEditingChannelId(null);
|
||||
if (!id || !name) return;
|
||||
try {
|
||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
||||
`/servers/${activeServerId}/channels/${id}`,
|
||||
{ name }
|
||||
);
|
||||
updateChannel(updated as any);
|
||||
} catch (err) {
|
||||
console.error('Rename channel failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteChannel = async (channelId: string, channelName: string) => {
|
||||
if (!confirm(`Delete #${channelName}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api.delete(`/servers/${activeServerId}/channels/${channelId}`);
|
||||
removeChannel(channelId);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// --- Group context menu ---
|
||||
const handleGroupContextMenu = (e: React.MouseEvent, group: ServerGroup) => {
|
||||
showMenu(e, [
|
||||
{ label: 'Edit Name', icon: '✏️', onClick: () => startEditGroup(group) },
|
||||
{ label: 'Create Channel Here', icon: '➕', onClick: () => { setCreateInGroup(group.id); setShowCreate(true); } },
|
||||
{ label: 'Delete Group', icon: '🗑️', danger: true, onClick: () => deleteGroup(group.id, group.name) },
|
||||
]);
|
||||
};
|
||||
|
||||
const startEditGroup = (group: ServerGroup) => {
|
||||
setEditingGroupId(group.id);
|
||||
setEditingGroupName(group.name);
|
||||
};
|
||||
|
||||
const commitEditGroup = async () => {
|
||||
const id = editingGroupId;
|
||||
const name = editingGroupName.trim();
|
||||
setEditingGroupId(null);
|
||||
if (!id || !name) return;
|
||||
try {
|
||||
await api.patch(`/servers/${activeServerId}/groups/${id}`, { name });
|
||||
refreshGroups();
|
||||
} catch (err) {
|
||||
console.error('Rename group failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (groupId: string, groupName: string) => {
|
||||
if (!confirm(`Delete group "${groupName}"? Channels will be moved to ungrouped.`)) return;
|
||||
try {
|
||||
await api.delete(`/servers/${activeServerId}/groups/${groupId}`);
|
||||
refreshGroups();
|
||||
if (activeServerId) fetchChannels(activeServerId);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// --- Channel row renderer ---
|
||||
const renderChannel = (channel: typeof channels[0]) => {
|
||||
if (channel.type === 'voice') {
|
||||
return (
|
||||
<VoiceChannel key={channel.id} channelId={channel.id} channelName={channel.name} />
|
||||
);
|
||||
}
|
||||
|
||||
const isEditing = editingChannelId === channel.id;
|
||||
|
||||
return (
|
||||
<div key={channel.id} className="group" onContextMenu={(e) => handleChannelContextMenu(e, channel)}>
|
||||
{isEditing ? (
|
||||
<div className="w-full text-left px-2 py-1 rounded-sm flex items-center gap-2">
|
||||
<span className="text-gb-fg-f">#</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={editingChannelName}
|
||||
onChange={(e) => setEditingChannelName(e.target.value)}
|
||||
onBlur={commitEditChannel}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEditChannel(); if (e.key === 'Escape') setEditingChannelId(null); }}
|
||||
className="flex-1 bg-gb-bg-t text-gb-fg px-1 py-0.5 text-sm outline-none border border-gb-orange"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id, getLatestMessageId(channel.id)) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs pointer-events-none group-hover:pointer-events-auto">
|
||||
<span
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Channel settings"
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -171,7 +334,7 @@ export function ChannelList() {
|
||||
[INV]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
onClick={() => { setCreateInGroup(null); setShowCreate(true); }}
|
||||
className="terminal-button text-xs"
|
||||
title="Create channel"
|
||||
>
|
||||
@@ -188,61 +351,35 @@ export function ChannelList() {
|
||||
if (section.type === 'group') {
|
||||
const grp = section.group;
|
||||
const collapsed = isCollapsed(activeServerId || '', grp.id);
|
||||
const isEditingGroup = editingGroupId === grp.id;
|
||||
return (
|
||||
<div key={'grp:' + grp.id} className="mb-3">
|
||||
<div
|
||||
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg"
|
||||
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }}
|
||||
>
|
||||
{collapsed ? '[+]' : '[-]'} {grp.name}
|
||||
</div>
|
||||
{isEditingGroup ? (
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1 flex items-center">
|
||||
<span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={editingGroupName}
|
||||
onChange={(e) => setEditingGroupName(e.target.value)}
|
||||
onBlur={commitEditGroup}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEditGroup(); if (e.key === 'Escape') setEditingGroupId(null); }}
|
||||
className="flex-1 bg-gb-bg-t text-gb-fg px-1 py-0.5 text-xs outline-none border border-gb-orange"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg flex items-center"
|
||||
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }}
|
||||
onContextMenu={(e) => handleGroupContextMenu(e, grp)}
|
||||
>
|
||||
<span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
|
||||
<span>{grp.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{section.channels.map((channel) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{section.channels.map((channel) => renderChannel(channel))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -253,56 +390,17 @@ export function ChannelList() {
|
||||
<div key={'cat:' + section.name} className="mb-3">
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">{section.name}</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{section.channels.map((channel) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{section.channels.map((channel) => renderChannel(channel))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showCreate && activeServerId && (
|
||||
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
|
||||
<CreateChannelModal
|
||||
serverId={activeServerId}
|
||||
defaultGroupId={createInGroup}
|
||||
onClose={() => { setShowCreate(false); setCreateInGroup(null); }}
|
||||
/>
|
||||
)}
|
||||
{showInvite && activeServerId && activeServer && (
|
||||
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
||||
@@ -315,6 +413,7 @@ export function ChannelList() {
|
||||
onClose={() => setSettingsChannel(null)}
|
||||
/>
|
||||
)}
|
||||
{MenuPortal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from '../stores/permissions.ts';
|
||||
import { useRoleStore } from '../stores/role.ts';
|
||||
import { useMemberStore } from '../stores/member.ts';
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from "../stores/permissions.ts";
|
||||
import { useRoleStore } from "../stores/role.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useChannelStore } from "../stores/channel.ts";
|
||||
import { api } from "../lib/api.ts";
|
||||
|
||||
interface ChannelSettingsModalProps {
|
||||
serverId: string;
|
||||
@@ -10,7 +12,7 @@ interface ChannelSettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'permissions';
|
||||
type Tab = 'overview' | 'permissions';
|
||||
type TargetMode = 'role' | 'user';
|
||||
|
||||
interface OverrideFormData {
|
||||
@@ -76,10 +78,13 @@ function OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number
|
||||
}
|
||||
|
||||
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
|
||||
const [tab] = useState<Tab>('permissions');
|
||||
const [tab, setTab] = useState<Tab>('overview');
|
||||
const [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState(channelName);
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const updateChannel = useChannelStore((s) => s.updateChannel);
|
||||
|
||||
const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
|
||||
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
||||
@@ -122,6 +127,24 @@ export function ChannelSettingsModal({ serverId, channelId, channelName, onClose
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
const name = editName.trim();
|
||||
if (!name || name === channelName) return;
|
||||
setSavingName(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
||||
`/servers/${serverId}/channels/${channelId}`,
|
||||
{ name }
|
||||
);
|
||||
updateChannel(updated as any);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename channel');
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const targets = form.targetType === 'role'
|
||||
? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
|
||||
: members.map((m) => ({ id: m.id, label: m.username }));
|
||||
@@ -134,9 +157,31 @@ export function ChannelSettingsModal({ serverId, channelId, channelName, onClose
|
||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||
</div>
|
||||
<div className="flex border-b border-gb-bg-t">
|
||||
<button className="px-4 py-2 text-xs text-gb-orange bg-gb-bg-s">[PERMISSIONS]</button>
|
||||
<button onClick={() => setTab('overview')} className={`px-4 py-2 text-xs ${tab === 'overview' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[OVERVIEW]</button>
|
||||
<button onClick={() => setTab('permissions')} className={`px-4 py-2 text-xs ${tab === 'permissions' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[PERMISSIONS]</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gb-fg-f">Channel name</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveName(); }}
|
||||
className="terminal-input text-sm flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={savingName || !editName.trim() || editName.trim() === channelName}
|
||||
className="px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
||||
>
|
||||
{savingName ? 'SAVING...' : '[SAVE]'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-gb-red">ERR: {error}</div>}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'permissions' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gb-fg-f">Existing overrides</div>
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
|
||||
import { useMessageStore } from "../stores/message.ts";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { useChannelStore } from "../stores/channel.ts";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
import { useTypingStore } from "../stores/typing.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useThreadStore } from "../stores/thread.ts";
|
||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||
import { type Gif } from "./GiphyPicker";
|
||||
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker";
|
||||
import Picker, { Theme } from 'emoji-picker-react';
|
||||
import { CommandDropdown } from "./CommandDropdown";
|
||||
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
|
||||
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
||||
import { MentionDropdown } from "./MentionDropdown";
|
||||
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||
import { MessageSearch } from "./MessageSearch";
|
||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||
import { ThreadPanel } from "./ThreadPanel.tsx";
|
||||
import { useContextMenu } from "./ContextMenu.tsx";
|
||||
import { PinnedMessages } from "./PinnedMessages.tsx";
|
||||
import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx";
|
||||
import { ReactionBar } from "./ReactionBar.tsx";
|
||||
import { MessageInput } from "./MessageInput.tsx";
|
||||
import { ReplyBar } from "./ReplyBar.tsx";
|
||||
import { ExpandableImage } from "./ExpandableImage.tsx";
|
||||
import { useLayoutStore } from "../stores/layout.ts";
|
||||
import { ForumView } from "./ForumView.tsx";
|
||||
import { CalendarView } from "./CalendarView.tsx";
|
||||
import { DocsView } from "./DocsView.tsx";
|
||||
@@ -51,29 +67,53 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
|
||||
return segments.map((seg, idx) => {
|
||||
if (seg.type === "mention") {
|
||||
// ponytail: add trailing space because React collapses whitespace between sibling spans
|
||||
const nextSeg = segments[idx + 1];
|
||||
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
|
||||
return (
|
||||
<span key={idx} className="text-gb-aqua">
|
||||
@{seg.value}
|
||||
@{seg.value}{needsSpace ? " " : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// For plain text segments, render through ReactMarkdown but preserve
|
||||
// leading/trailing spaces that ReactMarkdown would otherwise trim
|
||||
const leadingSpace = seg.value.match(/^\s+/)?.[0] || "";
|
||||
const trailingSpace = seg.value.match(/\s+$/)?.[0] || "";
|
||||
const trimmed = seg.value.slice(leadingSpace.length, seg.value.length - trailingSpace.length);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={idx}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
|
||||
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
|
||||
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
|
||||
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
|
||||
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
|
||||
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
|
||||
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
|
||||
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||
}}
|
||||
>
|
||||
{seg.value}
|
||||
</ReactMarkdown>
|
||||
<span key={idx}>
|
||||
{leadingSpace}
|
||||
{trimmed ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
|
||||
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
|
||||
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
|
||||
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
|
||||
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
|
||||
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
|
||||
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
|
||||
h1: ({ ...props }) => <h1 {...props} className="text-lg font-bold my-1" />,
|
||||
h2: ({ ...props }) => <h2 {...props} className="text-base font-bold my-1" />,
|
||||
h3: ({ ...props }) => <h3 {...props} className="text-sm font-bold my-0.5" />,
|
||||
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<ExpandableImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{trimmed}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
{trailingSpace}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -107,6 +147,131 @@ function renderEmbeds(embeds?: { url: string; title?: string; description?: stri
|
||||
);
|
||||
}
|
||||
|
||||
const MessageItem = memo(({
|
||||
message,
|
||||
memberUsernames,
|
||||
selectMode,
|
||||
isSelected,
|
||||
onAuthorClick,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
onAddReaction,
|
||||
currentUserId,
|
||||
activeReactionMessageId,
|
||||
setActiveReactionMessageId,
|
||||
activeNativeReactionMessageId,
|
||||
setActiveNativeReactionMessageId,
|
||||
members,
|
||||
parentMessage,
|
||||
onJumpToParent,
|
||||
}: {
|
||||
message: any;
|
||||
memberUsernames: Set<string>;
|
||||
selectMode: boolean;
|
||||
isSelected: boolean;
|
||||
onAuthorClick: (authorId: string) => void;
|
||||
onToggleSelect: (messageId: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: any) => void;
|
||||
onAddReaction: (messageId: string, emoji: string) => void;
|
||||
currentUserId?: string;
|
||||
activeReactionMessageId: string | null;
|
||||
setActiveReactionMessageId: (id: string | null) => void;
|
||||
activeNativeReactionMessageId: string | null;
|
||||
setActiveNativeReactionMessageId: (id: string | null) => void;
|
||||
members: any[];
|
||||
parentMessage: any | null;
|
||||
onJumpToParent: (id: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
id={`msg-${message.id}`}
|
||||
onClick={() => selectMode && onToggleSelect(message.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, message)}
|
||||
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${isSelected ? 'bg-gb-bg-s' : ''} relative`}
|
||||
>
|
||||
{parentMessage && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onJumpToParent(parentMessage.id);
|
||||
}}
|
||||
className="cursor-pointer hover:opacity-80 mb-0.5"
|
||||
>
|
||||
<ReplyBar
|
||||
replyTo={{
|
||||
id: parentMessage.id,
|
||||
content: parentMessage.content,
|
||||
author: {
|
||||
username: parentMessage.author_username,
|
||||
}
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectMode && (
|
||||
<span className="text-gb-fg-f mr-2">
|
||||
{isSelected ? '[x]' : '[ ]'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAuthorClick(message.author_id);
|
||||
}}>
|
||||
<{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}>
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
||||
|
||||
{/* Message reactions */}
|
||||
<ReactionBar
|
||||
reactions={(message.reactions || []).map((r: any) => {
|
||||
const usernames = r.users.map((uid: string) => {
|
||||
const m = members.find((member: any) => member.id === uid);
|
||||
return m ? (m.display_name || m.username) : "unknown user";
|
||||
});
|
||||
return {
|
||||
emoji: r.emoji,
|
||||
count: r.count,
|
||||
users: usernames,
|
||||
reacted: r.users.includes(currentUserId),
|
||||
};
|
||||
})}
|
||||
onToggle={async (emoji, isReacted) => {
|
||||
if (isReacted) {
|
||||
await api.delete(`/messages/${message.id}/reactions/${encodeURIComponent(emoji)}`);
|
||||
} else {
|
||||
await api.post(`/messages/${message.id}/reactions`, { emoji });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Kaomoji picker popover */}
|
||||
{activeReactionMessageId === message.id && (
|
||||
<KaomojiPicker
|
||||
onSelect={(emoji) => onAddReaction(message.id, emoji)}
|
||||
onClose={() => setActiveReactionMessageId(null)}
|
||||
/>
|
||||
)}
|
||||
{activeNativeReactionMessageId === message.id && (
|
||||
<div className="absolute z-50">
|
||||
<Picker
|
||||
theme={Theme.DARK}
|
||||
onEmojiClick={(emoji) => {
|
||||
onAddReaction(message.id, emoji.emoji);
|
||||
setActiveNativeReactionMessageId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MessageItem.displayName = "MessageItem";
|
||||
|
||||
export function ChatArea() {
|
||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||
@@ -115,22 +280,38 @@ export function ChatArea() {
|
||||
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
|
||||
);
|
||||
const isLoading = useMessageStore((s) => s.isLoading);
|
||||
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||
const typingUsers = useTypingStore((s) => s.typingUsers);
|
||||
const sendTypingStart = useTypingStore((s) => s.sendTypingStart);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [commandQuery, setCommandQuery] = useState<string | null>(null);
|
||||
const [dropdownIndex, setDropdownIndex] = useState(0);
|
||||
const [showPollModal, setShowPollModal] = useState(false);
|
||||
|
||||
// Reset dropdown index when query changes
|
||||
useEffect(() => { setDropdownIndex(0); }, [mentionQuery, commandQuery]);
|
||||
|
||||
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
|
||||
const [profileUserId, setProfileUserId] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lastTypingRef = useRef<number>(0);
|
||||
const dropdownRef = useRef({ mentionQuery: null as string | null, commandQuery: null as string | null, dropdownIndex: 0 });
|
||||
// ponytail: keep ref in sync with state so the stable keydown listener reads current values
|
||||
dropdownRef.current = { mentionQuery, commandQuery, dropdownIndex };
|
||||
|
||||
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
||||
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
|
||||
@@ -145,6 +326,95 @@ export function ChatArea() {
|
||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
||||
const markRead = useReadStatesStore((s) => s.markRead);
|
||||
const readStates = useReadStatesStore((s) => s.states);
|
||||
|
||||
const [showPinned, setShowPinned] = useState(false);
|
||||
const [showFeatureRequests, setShowFeatureRequests] = useState(false);
|
||||
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
|
||||
const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = useState<string | null>(null);
|
||||
const [replyToMessage, setReplyToMessage] = useState<any | null>(null);
|
||||
const pinMessage = useMessageStore((s) => s.pinMessage);
|
||||
const unpinMessage = useMessageStore((s) => s.unpinMessage);
|
||||
const fetchPinnedMessages = useMessageStore((s) => s.fetchPinnedMessages);
|
||||
const pinnedMessages = useMessageStore((s) => activeChannelId ? s.pinnedMessagesByChannel[activeChannelId] || [] : []);
|
||||
const pinnedCount = pinnedMessages.length;
|
||||
|
||||
const { showMenu, MenuPortal } = useContextMenu();
|
||||
|
||||
const handleJumpToMessage = useCallback((messageId: string) => {
|
||||
const el = document.getElementById(`msg-${messageId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("terminal-mention");
|
||||
setTimeout(() => {
|
||||
el.classList.remove("terminal-mention");
|
||||
}, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||
try {
|
||||
await api.post(`/messages/${messageId}/reactions`, { emoji });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add reaction");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMessageContextMenu = useCallback((e: React.MouseEvent, msg: any) => {
|
||||
if (selectMode) return;
|
||||
showMenu(e, [
|
||||
{
|
||||
label: "[REPLY]",
|
||||
onClick: () => {
|
||||
setReplyToMessage(msg);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "[ADD KAOMOJI REACTION]",
|
||||
onClick: () => {
|
||||
setActiveReactionMessageId(msg.id);
|
||||
setActiveNativeReactionMessageId(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "[ADD EMOJI REACTION]",
|
||||
onClick: () => {
|
||||
setActiveNativeReactionMessageId(msg.id);
|
||||
setActiveReactionMessageId(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: msg.pinned ? "[UNPIN MESSAGE]" : "[PIN MESSAGE]",
|
||||
onClick: async () => {
|
||||
try {
|
||||
if (msg.pinned) {
|
||||
await unpinMessage(msg.channel_id, msg.id);
|
||||
} else {
|
||||
await pinMessage(msg.channel_id, msg.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pin operation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
}, [selectMode, showMenu, pinMessage, unpinMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannelId) {
|
||||
fetchPinnedMessages(activeChannelId);
|
||||
}
|
||||
}, [activeChannelId, fetchPinnedMessages]);
|
||||
|
||||
const handleToggleSelect = useCallback((messageId: string) => {
|
||||
if (activeChannelId) {
|
||||
toggleSelectedMessage(activeChannelId, messageId);
|
||||
}
|
||||
}, [activeChannelId, toggleSelectedMessage]);
|
||||
|
||||
const handleAuthorClick = useCallback((authorId: string) => {
|
||||
setProfileUserId(authorId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannelId) {
|
||||
@@ -161,6 +431,20 @@ export function ChatArea() {
|
||||
}
|
||||
}, [activeChannelId, messages.length, isLoading]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el || !activeChannelId || isLoadingOlder) return;
|
||||
if (el.scrollTop < 100) {
|
||||
const prevHeight = el.scrollHeight;
|
||||
fetchOlderMessages(activeChannelId).then(() => {
|
||||
// ponytail: maintain scroll position after prepending older messages
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight - prevHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [messages]);
|
||||
@@ -179,22 +463,56 @@ export function ChatArea() {
|
||||
return () => clearInterval(t);
|
||||
}, [slowmodeRemaining]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart ?? value.length;
|
||||
setInput(value);
|
||||
const beforeCursor = value.slice(0, cursor);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
if (atIndex === -1) {
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData.items;
|
||||
let imageFile: File | null = null;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
imageFile = items[i].getAsFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const between = beforeCursor.slice(atIndex + 1);
|
||||
if (between.includes(" ") || between.includes("\n")) {
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
|
||||
if (imageFile) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', imageFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
|
||||
const data = await res.json();
|
||||
const imageUrl = data.url;
|
||||
|
||||
// Insert into input
|
||||
const imgMarkdown = ``;
|
||||
|
||||
const inputEl = inputRef.current;
|
||||
if (inputEl) {
|
||||
const start = inputEl.selectionStart || 0;
|
||||
const end = inputEl.selectionEnd || 0;
|
||||
const newValue = input.slice(0, start) + imgMarkdown + input.slice(end);
|
||||
setInput(newValue);
|
||||
|
||||
setTimeout(() => {
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(start + imgMarkdown.length, start + imgMarkdown.length);
|
||||
}, 0);
|
||||
} else {
|
||||
setInput(prev => prev + (prev.length > 0 && !prev.endsWith(' ') ? ' ' : '') + imgMarkdown);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to upload image:', err);
|
||||
}
|
||||
}
|
||||
setMentionQuery(between);
|
||||
};
|
||||
|
||||
const handleMentionSelect = (username: string) => {
|
||||
@@ -204,29 +522,131 @@ export function ChatArea() {
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
}
|
||||
const cursor = inputEl.selectionStart ?? input.length;
|
||||
const beforeCursor = input.slice(0, cursor);
|
||||
// Read directly from DOM to avoid stale closure
|
||||
const currentValue = inputEl.value;
|
||||
const cursor = inputEl.selectionStart ?? currentValue.length;
|
||||
const beforeCursor = currentValue.slice(0, cursor);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
if (atIndex === -1) return;
|
||||
const before = input.slice(0, atIndex);
|
||||
const after = input.slice(cursor);
|
||||
const next = `${before}@${username} ${after}`;
|
||||
const before = currentValue.slice(0, atIndex);
|
||||
const after = currentValue.slice(cursor);
|
||||
const separator = after.startsWith(" ") || after === "" ? "" : " ";
|
||||
const next = `${before}@${username}${separator}${after}`;
|
||||
setInput(next);
|
||||
setMentionQuery(null);
|
||||
requestAnimationFrame(() => {
|
||||
const pos = atIndex + username.length + 2;
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(pos, pos);
|
||||
});
|
||||
setDropdownIndex(0);
|
||||
// Also set the DOM value immediately so cursor positioning works
|
||||
inputEl.value = next;
|
||||
const pos = atIndex + username.length + 2;
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(pos, pos);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'e') return; // handled by MessageInput
|
||||
|
||||
const { mentionQuery: mq, commandQuery: cq, dropdownIndex: di } = dropdownRef.current;
|
||||
const isDropdownOpen = mq !== null || cq !== null;
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const itemCount = mq !== null
|
||||
? members.filter((m) =>
|
||||
m.username.toLowerCase().includes(mq.toLowerCase()) ||
|
||||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
|
||||
).slice(0, 6).length
|
||||
: cq !== null
|
||||
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
|
||||
: 0;
|
||||
|
||||
if (itemCount === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setDropdownIndex((prev) => (prev + 1) % itemCount);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setDropdownIndex((prev) => (prev - 1 + itemCount) % itemCount);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (mq !== null) {
|
||||
const q = mq.toLowerCase();
|
||||
const filtered = members.filter((m) =>
|
||||
m.username.toLowerCase().includes(q) ||
|
||||
m.display_name?.toLowerCase().includes(q)
|
||||
).slice(0, 6);
|
||||
if (filtered[di]) {
|
||||
handleMentionSelect(filtered[di].username);
|
||||
}
|
||||
} else if (cq !== null) {
|
||||
const q = cq.toLowerCase();
|
||||
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
|
||||
if (filtered[di]) {
|
||||
const cmd = filtered[di];
|
||||
setCommandQuery(null);
|
||||
setDropdownIndex(0);
|
||||
if (cmd.name === 'poll') {
|
||||
setShowPollModal(true);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (cmd.name === 'emoji') {
|
||||
setInput('');
|
||||
setCommandQuery(null);
|
||||
setDropdownIndex(0);
|
||||
return;
|
||||
}
|
||||
const inputEl = inputRef.current;
|
||||
const curVal = inputEl?.value || '';
|
||||
const args = curVal.startsWith('/' + cmd.name + ' ')
|
||||
? curVal.slice(cmd.name.length + 2).trim()
|
||||
: '';
|
||||
const text = cmd.transform(args, currentUser?.username || 'user');
|
||||
sendMessage(activeChannelId!, text, replyToMessage?.id);
|
||||
setInput('');
|
||||
setReplyToMessage(null);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setMentionQuery(null);
|
||||
setCommandQuery(null);
|
||||
setDropdownIndex(0);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (mentionQuery !== null || commandQuery !== null) return;
|
||||
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
await sendMessage(activeChannelId, input.trim());
|
||||
let messageText = input.trim();
|
||||
if (messageText.startsWith('/')) {
|
||||
const spaceIdx = messageText.indexOf(' ');
|
||||
const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx);
|
||||
const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim();
|
||||
if (cmdName === 'poll') {
|
||||
setShowPollModal(true);
|
||||
setInput('');
|
||||
setCommandQuery(null);
|
||||
return;
|
||||
}
|
||||
if (cmdName === 'emoji') {
|
||||
setInput('');
|
||||
setCommandQuery(null);
|
||||
return;
|
||||
}
|
||||
const cmd = findCommand(cmdName);
|
||||
if (cmd) {
|
||||
messageText = cmd.transform(args, currentUser?.username || 'user');
|
||||
}
|
||||
}
|
||||
await sendMessage(activeChannelId, messageText, replyToMessage?.id);
|
||||
setInput("");
|
||||
setReplyToMessage(null);
|
||||
setMentionQuery(null);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to send";
|
||||
@@ -241,14 +661,13 @@ export function ChatArea() {
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
}, [mentionQuery, commandQuery, activeChannelId, input, slowmodeRemaining, sendMessage, replyToMessage, currentUser]);
|
||||
|
||||
const handleGifSelect = async (gif: Gif) => {
|
||||
if (!activeChannelId) return;
|
||||
const content = ``;
|
||||
const content = ``;
|
||||
try {
|
||||
await sendMessage(activeChannelId, content);
|
||||
setShowGifPicker(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send");
|
||||
}
|
||||
@@ -256,22 +675,31 @@ export function ChatArea() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gb-bg">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||
<span>
|
||||
{activeChannel
|
||||
? activeChannel.type === 'forum'
|
||||
? `■ ${activeChannel.name}`
|
||||
: activeChannel.type === 'calendar'
|
||||
? `○ ${activeChannel.name}`
|
||||
: activeChannel.type === 'docs'
|
||||
? `☰ ${activeChannel.name}`
|
||||
: activeChannel.type === 'list'
|
||||
? `☑ ${activeChannel.name}`
|
||||
: `# ${activeChannel.name}`
|
||||
: activeChannelId
|
||||
? `#${activeChannelId}`
|
||||
: "[NO CHANNEL SELECTED]"}
|
||||
</span>
|
||||
<div className="terminal-border border-t-0 border-x-0 px-2 md:px-3 py-2 text-gb-fg-s flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
|
||||
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="truncate">
|
||||
{activeChannel
|
||||
? activeChannel.type === 'forum'
|
||||
? `■ ${activeChannel.name}`
|
||||
: activeChannel.type === 'calendar'
|
||||
? `○ ${activeChannel.name}`
|
||||
: activeChannel.type === 'docs'
|
||||
? `☰ ${activeChannel.name}`
|
||||
: activeChannel.type === 'list'
|
||||
? `☑ ${activeChannel.name}`
|
||||
: `# ${activeChannel.name}`
|
||||
: activeChannelId
|
||||
? `#${activeChannelId}`
|
||||
: "[NO CHANNEL SELECTED]"}
|
||||
</span>
|
||||
</div>
|
||||
{activeChannelId && activeChannel?.type === 'text' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -289,29 +717,42 @@ export function ChatArea() {
|
||||
[SELECT]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
onClick={() => {
|
||||
setShowPinned(false);
|
||||
setShowFeatureRequests(false);
|
||||
setShowSearch((prev) => !prev);
|
||||
}}
|
||||
className={`text-xs font-mono ${showSearch ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSearch(false);
|
||||
setShowFeatureRequests(false);
|
||||
setShowPinned((prev) => !prev);
|
||||
}}
|
||||
className={`text-xs font-mono ${showPinned ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Show pinned messages"
|
||||
>
|
||||
[PINNED{pinnedCount > 0 ? `:${pinnedCount}` : ''}]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSearch(false);
|
||||
setShowPinned(false);
|
||||
setShowFeatureRequests((prev) => !prev);
|
||||
}}
|
||||
className={`text-xs font-mono ${showFeatureRequests ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Feature requests"
|
||||
>
|
||||
[FEATURES]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
<MessageSearch
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||
<ThreadListPanel
|
||||
channelId={activeChannelId}
|
||||
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
|
||||
onClose={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
|
||||
<span>{selectedIds.size} selected</span>
|
||||
@@ -351,36 +792,100 @@ export function ChatArea() {
|
||||
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
<MessageSearch
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowSearch(false)}
|
||||
onJump={handleJumpToMessage}
|
||||
/>
|
||||
)}
|
||||
{showPinned && activeChannelId && activeChannel && (
|
||||
<PinnedMessages
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowPinned(false)}
|
||||
onJump={handleJumpToMessage}
|
||||
/>
|
||||
)}
|
||||
{showFeatureRequests && activeServerId && (
|
||||
<FeatureRequestsPanel
|
||||
serverId={activeServerId}
|
||||
channelId={activeChannelId || undefined}
|
||||
onClose={() => setShowFeatureRequests(false)}
|
||||
/>
|
||||
)}
|
||||
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||
<ThreadListPanel
|
||||
channelId={activeChannelId}
|
||||
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
|
||||
onClose={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
|
||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
|
||||
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
|
||||
>
|
||||
{selectMode && activeChannelId && (
|
||||
<span className="text-gb-fg-f mr-2">
|
||||
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={() => setProfileUserId(message.author_id)}>
|
||||
<{message.author_username}>
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
</div>
|
||||
))}
|
||||
{messages.map((message, i) => {
|
||||
// ponytail: last-read divider — show after the message matching the user's read state
|
||||
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
|
||||
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
|
||||
return (
|
||||
<>
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
memberUsernames={memberUsernames}
|
||||
selectMode={selectMode}
|
||||
isSelected={selectedIds.has(message.id)}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onContextMenu={handleMessageContextMenu}
|
||||
onAddReaction={handleAddReaction}
|
||||
currentUserId={currentUser?.id}
|
||||
activeReactionMessageId={activeReactionMessageId}
|
||||
setActiveReactionMessageId={setActiveReactionMessageId}
|
||||
activeNativeReactionMessageId={activeNativeReactionMessageId}
|
||||
setActiveNativeReactionMessageId={setActiveNativeReactionMessageId}
|
||||
members={members}
|
||||
parentMessage={message.reply_to ? messages.find((m) => m.id === message.reply_to) : null}
|
||||
onJumpToParent={handleJumpToMessage}
|
||||
/>
|
||||
{showDivider && (
|
||||
<div className="flex items-center gap-2 my-1">
|
||||
<span className="flex-1 border-t border-gb-red"></span>
|
||||
<span className="text-gb-red text-xs font-bold shrink-0">── NEW ──</span>
|
||||
<span className="flex-1 border-t border-gb-red"></span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{showGifPicker && (
|
||||
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
|
||||
{(() => {
|
||||
const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
|
||||
if (chTyping.length === 0) return "\u00A0";
|
||||
const names = chTyping.map(u => u.username);
|
||||
return names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`;
|
||||
})()}
|
||||
</div>
|
||||
{replyToMessage && (
|
||||
<div className="px-3 pb-1">
|
||||
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
||||
<ReplyBar
|
||||
replyTo={{
|
||||
id: replyToMessage.id,
|
||||
content: replyToMessage.content,
|
||||
author: {
|
||||
username: replyToMessage.author_username,
|
||||
}
|
||||
}}
|
||||
onCancel={() => setReplyToMessage(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -393,32 +898,55 @@ export function ChatArea() {
|
||||
SLOWMODE: wait {slowmodeRemaining}s
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
|
||||
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="type a message..."
|
||||
className="terminal-input w-full"
|
||||
disabled={!activeChannelId || slowmodeRemaining > 0}
|
||||
<div className="p-3 relative">
|
||||
{mentionQuery !== null && (
|
||||
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
|
||||
)}
|
||||
{commandQuery !== null && (
|
||||
<CommandDropdown
|
||||
query={commandQuery}
|
||||
selectedIndex={dropdownIndex}
|
||||
onSelect={(name) => {
|
||||
setInput('/' + name + ' ');
|
||||
setCommandQuery(null);
|
||||
setDropdownIndex(0);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
{mentionQuery !== null && (
|
||||
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGifPicker((prev) => !prev)}
|
||||
disabled={!activeChannelId}
|
||||
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
title="Toggle GIF picker"
|
||||
>
|
||||
[GIF]
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<MessageInput
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(v) => {
|
||||
setInput(v);
|
||||
const cursor = inputRef.current?.selectionStart ?? v.length;
|
||||
const beforeCursor = v.slice(0, cursor);
|
||||
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
|
||||
setCommandQuery(beforeCursor.slice(1));
|
||||
setMentionQuery(null);
|
||||
} else {
|
||||
setCommandQuery(null);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
if (atIndex === -1 || beforeCursor.slice(atIndex + 1).includes(" ") || beforeCursor.slice(atIndex + 1).includes("\n")) {
|
||||
setMentionQuery(null);
|
||||
} else {
|
||||
setMentionQuery(beforeCursor.slice(atIndex + 1));
|
||||
}
|
||||
}
|
||||
if (activeChannelId && v.length > 0) {
|
||||
const now = Date.now();
|
||||
if (now - lastTypingRef.current > 3000) {
|
||||
sendTypingStart(activeChannelId);
|
||||
lastTypingRef.current = now;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onPaste={handlePaste}
|
||||
onGifSelect={handleGifSelect}
|
||||
disabled={!activeChannelId || slowmodeRemaining > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{activeThread && (
|
||||
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
|
||||
@@ -429,6 +957,13 @@ export function ChatArea() {
|
||||
{profileUserId && (
|
||||
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||
)}
|
||||
{MenuPortal}
|
||||
{showPollModal && activeChannelId && (
|
||||
<CreatePollModal
|
||||
channelId={activeChannelId}
|
||||
onClose={() => setShowPollModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SLASH_COMMANDS } from "../lib/slashCommands";
|
||||
|
||||
interface CommandDropdownProps {
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (command: string) => void;
|
||||
}
|
||||
|
||||
export function CommandDropdown({ query, selectedIndex, onSelect }: CommandDropdownProps) {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 w-72 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
|
||||
COMMANDS
|
||||
</div>
|
||||
{filtered.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(cmd.name);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 text-sm font-mono flex items-center gap-2 transition-colors ${
|
||||
i === selectedIndex
|
||||
? "bg-gb-orange text-gb-bg"
|
||||
: "hover:bg-gb-bg-t text-gb-fg"
|
||||
}`}
|
||||
>
|
||||
<span className={i === selectedIndex ? "text-gb-bg" : "text-gb-orange"}>
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className={`text-xs truncate ${i === selectedIndex ? "text-gb-bg" : "text-gb-fg-f"}`}>
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function ConnectionStatus() {
|
||||
const [online, setOnline] = useState(navigator.onLine);
|
||||
const [visible, setVisible] = useState(!navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setOnline(true);
|
||||
// Show "back online" briefly then hide
|
||||
setVisible(true);
|
||||
setTimeout(() => setVisible(false), 3000);
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setOnline(false);
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 z-[100] text-center py-1 text-xs font-mono transition-all duration-300 ${
|
||||
online
|
||||
? "bg-gb-green/20 text-gb-green border-b border-gb-green/30"
|
||||
: "bg-gb-red/20 text-gb-red border-b border-gb-red/30"
|
||||
}`}
|
||||
>
|
||||
{online ? "● CONNECTION RESTORED" : "○ YOU ARE OFFLINE — messages will send when reconnected"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showMenu = useCallback((e: React.MouseEvent, items: ContextMenuItem[]) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Position: use cursor, but keep within viewport
|
||||
const x = Math.min(e.clientX, window.innerWidth - 200);
|
||||
const y = Math.min(e.clientY, window.innerHeight - (items.length * 32 + 16));
|
||||
|
||||
setMenu({ x, y, items });
|
||||
}, []);
|
||||
|
||||
const hideMenu = useCallback(() => setMenu(null), []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hideMenu();
|
||||
}
|
||||
};
|
||||
// Use mousedown so it fires before the click that opened a new menu
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [menu, hideMenu]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') hideMenu();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [menu, hideMenu]);
|
||||
|
||||
const MenuPortal = menu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999] bg-gb-bg border border-gb-bg-t font-mono text-xs min-w-[160px] shadow-lg"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
>
|
||||
{menu.items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
hideMenu();
|
||||
if (!item.disabled) item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 ${
|
||||
item.disabled
|
||||
? 'text-gb-fg-f cursor-not-allowed'
|
||||
: item.danger
|
||||
? 'text-gb-red hover:bg-gb-red hover:text-gb-bg'
|
||||
: 'text-gb-fg hover:bg-gb-bg-t hover:text-gb-orange'
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="w-4 text-center">{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { showMenu, hideMenu, MenuPortal };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useConversationStore } from "../stores/conversation.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||
import { NewConversationModal } from "./NewConversationModal.tsx";
|
||||
|
||||
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
|
||||
@@ -8,55 +10,114 @@ function otherMemberName(conv: { members: { id: string; username: string }[] },
|
||||
return other?.username || "unknown";
|
||||
}
|
||||
|
||||
function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
|
||||
return conv.members.length === 1 && conv.members[0].id === currentUserId;
|
||||
}
|
||||
|
||||
export function ConversationList() {
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||
const setActive = useConversationStore((s) => s.setActiveConversation);
|
||||
const createConversation = useConversationStore((s) => s.createConversation);
|
||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
||||
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
||||
const navigate = useNavigate();
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [notesError, setNotesError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
const openConversation = (convId: string) => {
|
||||
// mark as read when opening
|
||||
const msgs = messagesByConv[convId] || [];
|
||||
if (msgs.length > 0) {
|
||||
markConvRead(convId, msgs[msgs.length - 1].id);
|
||||
}
|
||||
navigate(`/dm/${convId}`);
|
||||
};
|
||||
|
||||
const openNotes = async () => {
|
||||
setNotesError(null);
|
||||
const existing = conversations.find((c) => isSelfDM(c, currentUser?.id || ""));
|
||||
if (existing) {
|
||||
openConversation(existing.id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conv = await createConversation([]);
|
||||
if (conv) {
|
||||
navigate(`/dm/${conv.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create notes:", err);
|
||||
setNotesError(err instanceof Error ? err.message : "Failed to create notes");
|
||||
}
|
||||
};
|
||||
|
||||
const getLatestMessageId = (convId: string): string | undefined => {
|
||||
const msgs = messagesByConv[convId] || [];
|
||||
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
|
||||
<span>[DIRECT MESSAGES]</span>
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
className="terminal-button text-xs"
|
||||
title="New conversation"
|
||||
>
|
||||
[+]
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={openNotes}
|
||||
className="terminal-button text-xs"
|
||||
title="Notes to self"
|
||||
>
|
||||
[📝]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
className="terminal-button text-xs"
|
||||
title="New conversation"
|
||||
>
|
||||
[+]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
||||
{conversations.length === 0 && (
|
||||
<p className="text-gb-fg-f">[no conversations]</p>
|
||||
)}
|
||||
{conversations.map((conv) => {
|
||||
const name =
|
||||
conv.type === "group_dm"
|
||||
const self = isSelfDM(conv, currentUser?.id || "");
|
||||
const name = self
|
||||
? "Notes"
|
||||
: conv.type === "group_dm"
|
||||
? conv.name || conv.members.map((m) => m.username).join(", ")
|
||||
: otherMemberName(conv, currentUser?.id || "");
|
||||
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => setActive(conv.id)}
|
||||
onClick={() => openConversation(conv.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
conv.id === activeId
|
||||
? "terminal-active"
|
||||
: "hover:bg-gb-bg-t text-gb-fg-s"
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">@</span>
|
||||
<span className="truncate">{name}</span>
|
||||
<span className="text-gb-fg-f">{self ? "📝" : "@"}</span>
|
||||
<span className={`truncate flex-1 ${unread ? "text-gb-fg font-bold" : ""}`}>{name}</span>
|
||||
{unread && (
|
||||
<span className="text-gb-orange text-xs font-bold shrink-0" title="Unread messages">●</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notesError && (
|
||||
<div className="px-3 py-1 text-gb-red text-xs font-mono">ERR: {notesError}</div>
|
||||
)}
|
||||
{showNew && <NewConversationModal onClose={() => setShowNew(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useChannelStore } from '../stores/channel.ts';
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
serverId: string;
|
||||
defaultGroupId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -36,13 +37,13 @@ const SLOWMODE_OPTIONS = [
|
||||
{ label: '5 minutes', value: 300 },
|
||||
];
|
||||
|
||||
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
||||
export function CreateChannelModal({ serverId, defaultGroupId, onClose }: CreateChannelModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
|
||||
const [category, setCategory] = useState('general');
|
||||
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('');
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -82,7 +83,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
|
||||
const newChannel = {
|
||||
id: result.id,
|
||||
serverId: result.server_id,
|
||||
server_id: result.server_id,
|
||||
name: result.name,
|
||||
type: result.type,
|
||||
category: result.category || null,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CreateServerModal({ onClose }: CreateServerModalProps) {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
icon: result.icon,
|
||||
ownerId: result.owner_id,
|
||||
owner_id: result.owner_id,
|
||||
};
|
||||
useServerStore.getState().addServer(newServer);
|
||||
useServerStore.getState().setActiveServer(newServer.id);
|
||||
|
||||