Compare commits

...

92 Commits

Author SHA1 Message Date
hobokenchicken 4a02d054cc fix: release workflow for Gitea Actions (runner labels + gitea-release-action)
Release Desktop Apps / build-windows (push) Failing after 3h0m44s
Release Desktop Apps / build-linux (push) Has been cancelled
Release Desktop Apps / release (push) Has been cancelled
2026-07-07 14:19:22 -04:00
hobokenchicken dbd4de8e61 bump: desktop app v0.2.0, build all Linux bundles (appimage, deb, rpm) 2026-07-07 10:20:27 -04:00
hobokenchicken a0bb229173 fix: DMs now show full date+time like channels [DD.MM.YYYY @ HH:MM]
Release Desktop Apps / build-windows (push) Failing after 3h0m23s
Release Desktop Apps / build-linux (push) Has been cancelled
Release Desktop Apps / release (push) Has been cancelled
2026-07-07 14:01:27 +00:00
hobokenchicken c86c602294 change upload limit from 25MB to 1GB 2026-07-07 13:43:41 +00:00
hobokenchicken aa39b6a5be feat: read indicator — NEW divider line between read and unread messages 2026-07-06 19:41:19 +00:00
hobokenchicken 8cf1e91b2b ci: Gitea Actions release workflow for Linux + Windows desktop builds
Triggers on v* tags. Builds AppImage/deb/rpm on Linux runner,
MSI/NSIS on Windows runner, then creates a release with all assets.
Requires self-hosted runners registered in Gitea Actions.
2026-07-06 14:48:59 -04:00
hobokenchicken f068e15398 build: add build-tauri target (NO_STRIP=true for Arch) 2026-07-06 14:32:16 -04:00
hobokenchicken 559d17d1f2 feat: Tauri desktop app scaffold
- src-tauri/ with Rust backend, Cargo.toml, main.rs, lib.rs
- tauri.conf.json: Linux deb bundle with webkit2gtk deps
- package.json: added tauri script
- Build on a machine with Rust: cd web && npm run tauri build
2026-07-06 18:07:34 +00:00
hobokenchicken ea72050202 docs: add formatting toolbar, DM kaomoji/GIFs, last-channel restore to features list 2026-07-06 14:04:48 -04:00
hobokenchicken 88194b17c4 feat: unread markers for channels and DMs
- Read states store: smarter hasUnread compares against latest message
- ConversationList: orange dot + bold name for unread DMs
- DMChat: auto-mark-read when viewing conversation
- WS handler: mark DM read on new message while active+focused
2026-07-06 17:56:19 +00:00
hobokenchicken 2b45b11ea8 fix: screenshare rendering, voice panel layout, SW cache bust with git SHA
- VideoGrid: add screen share track display with proper sizing
- participantToVoice: detect ScreenShare + ScreenShareAudio tracks
- VoicePanel: flex layout, no scroll, tiles dynamically fill space
- VoiceChannel: show screen/camera icons in participant list
- vite.config: inject git SHA into SW CACHE_VERSION on build
2026-07-06 17:49:17 +00:00
hobokenchicken b3b5ff495d fix: DM message ordering, consolidate input toolbar, add rich text/WYSIWYG, file upload with drag-drop
- Fix DM backend ListMessages to use DESC + reverse (match channel handler)
- Remove spurious .reverse() from frontend message/conversation stores
- Create shared MessageInput component with Slack-style single toolbar row
- Add file upload via + button with progress bar and drag-and-drop
- Add markdown/rich text toggle with full WYSIWYG block formatting
  (lists, blockquotes, links, headings, code blocks)
- Add frontend+backend security for file uploads (extension + content-type guards)
2026-07-06 17:33:20 +00:00
hobokenchicken 9512dedec1 feat(chat): add native emoji pickers for chat input and reactions 2026-07-06 13:59:20 +00:00
hobokenchicken 8bbe2a0ff0 fix(dm): remove local append on send to prevent double messages 2026-07-06 13:53:11 +00:00
hobokenchicken c2fb3165f4 fix(dm): add image paste support to DM chat and fix realtime DM updates 2026-07-06 13:44:41 +00:00
hobokenchicken 0c26e2402b feat(chat): support pasting images directly into the chat box 2026-07-06 13:38:02 +00:00
hobokenchicken 88e7faaafb fix(webrtc): fix typescript errors for participant list migration 2026-07-06 13:32:10 +00:00
hobokenchicken 1d37284c25 fix(permissions): wire up MUTE_MEMBERS bitmask and fix member permissions in frontend 2026-07-06 13:27:38 +00:00
hobokenchicken b1b41ca5e1 fix(webrtc): add error handling and ui alerts for background processor initialization 2026-07-06 13:16:01 +00:00
hobokenchicken e10cb22811 fix(webrtc): update CSP to allow background processor wasm and blob execution 2026-07-06 13:14:27 +00:00
hobokenchicken 2631e6ce4a fix(webrtc): fix invalid hook call in device settings modal for bgMode 2026-07-06 13:13:05 +00:00
hobokenchicken 239640975f feat(webrtc): add background blur and virtual background options 2026-07-06 13:08:59 +00:00
hobokenchicken ed408c4044 feat(webrtc): add admin option to mute other participants 2026-07-06 13:04:39 +00:00
hobokenchicken 9aeedb1a53 feat(webrtc): add UI to select audio/video input and output devices 2026-07-06 12:56:27 +00:00
hobokenchicken 59846fc1e4 chore(build): automatically embed git commit hash in version string 2026-07-06 12:52:20 +00:00
hobokenchicken 944ae4074e fix(webrtc): sync participants state when local user publishes tracks 2026-07-06 12:49:14 +00:00
hobokenchicken 549bf842d0 feat(ui): tabulate voice and chat panels to prevent overlapping 2026-07-06 12:47:23 +00:00
hobokenchicken 83c05731ed fix(webrtc): correctly subscribe to remote camera tracks 2026-07-06 12:40:26 +00:00
hobokenchicken 5d8f420882 fix(webrtc): fix VideoGrid reactivity and layout 2026-07-06 12:37:35 +00:00
hobokenchicken 074742ddb4 feat(webrtc): render remote audio tracks and allow camera access 2026-07-06 12:25:40 +00:00
hobokenchicken f9b1e16e3a fix(webrtc): configure external TURN server and update CSP to allow inline scripts 2026-07-06 12:20:53 +00:00
hobokenchicken f9edd8c069 fix(docker): update livekit schema, coturn ports, and proxy keys 2026-07-05 23:59:32 +00:00
hobokenchicken 7e1c0b822b feat: inline markdown formatting toolbar
FormatToolbar component: B I S ` || buttons that wrap selected text
with markdown syntax. Added to both channel and DM input areas.
Keyboard shortcuts: Ctrl+B bold, Ctrl+I italic already work via
browser defaults on the rendered markdown.
2026-07-02 15:58:59 -04:00
hobokenchicken f61b33ff81 feat: GIF picker and kaomoji in DMs
- Added GiphyPicker to DM input area (toggle with [GIF] button)
- Added EmojiPicker/kaomoji to DMs (toggle with [☺] or Ctrl+E)
- GiphyPicker shows above input when toggled
- Kaomoji appends to input text
2026-07-02 15:50:28 -04:00
hobokenchicken ed50815902 feat: restore last active channel on login
- Channel store saves {serverId, channelId} to localStorage on select
- ServerBar restores last server+channel after fetching servers
- Fetches channels for the saved server, then sets the channel if valid
- Gracefully handles missing/deleted servers or channels
2026-07-02 15:44:55 -04:00
hobokenchicken b4e2f97dba docs: push notification setup in README, fix VAPID_SUBJECT example
- .env.example: VAPID_SUBJECT without mailto: prefix (library adds it)
- README: VAPID env vars in table, push notification setup section
- README: add dm/ to project structure, keygen instructions
- README: iOS tap-gesture requirement and Android battery optimization notes
2026-07-02 15:40:18 -04:00
hobokenchicken 53f8c6b2ef fix: remove requireInteraction, bump vibrate for Android
requireInteraction can suppress heads-up delivery on some Android
versions when screen is off. Removed it so the OS can auto-dismiss
after showing the notification.
2026-07-02 15:36:51 -04:00
hobokenchicken d1022d7c2a fix: remove Topic header, add push response logging
- Remove Topic header (causes issues with Apple push service)
- Log push response status codes for debugging
- Log endpoint prefix on errors for identification
- Auto-cleanup 410 (gone) subscriptions on success path too
2026-07-02 15:16:33 -04:00
hobokenchicken 15f9f6ae07 fix: push delivery when app is closed
- TTL 30s -> 86400 (24h): push services were dropping undelivered notifs
- Urgency: high: wakes device from doze/sleep
- Topic: per-user to collapse duplicate pending pushes
- SW push handler: try/catch for malformed payloads, always call waitUntil
- Bump SW cache to v6
2026-07-02 15:11:07 -04:00
hobokenchicken c803991cda fix: iOS push notification prompt
iOS requires user gesture for Notification.requestPermission().
- Added NotificationPrompt banner that shows on permission='default'
- autoSubscribePush only fires when already 'granted' (re-subscribe on restore)
- Banner uses tap handler for permission request (works on iOS)
- Gruvbox themed, dismissible, bottom-center toast
2026-07-02 15:04:31 -04:00
hobokenchicken a502cd8fd4 feat: push notifications for DMs
- DM handler now sends push notifications to other conversation members
- Cleared stale push subscriptions (old broken VAPID keys)
- Auto-subscribe will re-create them on next page load
2026-07-02 14:56:58 -04:00
hobokenchicken a593f04d7f feat: auto-subscribe push on login, better push logging
- Auto-subscribe to push on login, register, and session restore
- Guard against nil db in SendChannelNotification
- Log push send count per channel message
- Bump SW cache to v5
2026-07-02 14:34:02 -04:00
hobokenchicken 6b7dc2addd chore: remove committed keygen binary, gitignore 2026-07-02 14:24:30 -04:00
hobokenchicken 8336ca5d87 fix: use webpush-go's GenerateVAPIDKeys
Old implementation just copied random bytes as the public key (not real EC math).
New cmd/keygen calls the library's proper P-256 key generation.
Removed broken local GenerateVAPIDKeys from push/handlers.go.
2026-07-02 14:24:24 -04:00
hobokenchicken 85f9c21f4c fix: center and fill PWA icons
Trim background, cover-crop to square, center. No more letterboxing.
2026-07-02 14:16:14 -04:00
hobokenchicken 92af2e21b1 feat: safe area insets for Dynamic Island / notch / cutout
viewport-fit=cover already set. Added env(safe-area-inset-*) padding
to outer container so content doesn't hide behind Dynamic Island,
notches, or the home indicator bar.
2026-07-02 14:07:19 -04:00
hobokenchicken 1e22daead3 fix: remove unused activeChannelId variable 2026-07-02 14:00:12 -04:00
hobokenchicken 9e70884a16 fix: use ChannelStore for activeChannelId subscription
activeChannelId lives on ChannelStore, not ServerStore.
2026-07-02 13:59:27 -04:00
hobokenchicken b6d4614881 feat: mobile-responsive layout
- Mobile (<md): single-panel view, sidebar/chat/members as overlays
- Hamburger button toggles sidebar overlay (ServerBar + ChannelList)
- Members button toggles MemberList overlay
- ← back button in ChatArea and DMChat headers returns to sidebar
- Auto-switch to chat view when channel or DM is selected
- Tap empty area to dismiss overlays
- Desktop layout unchanged (all panels side by side)
- Status/settings buttons hidden on small screens to save space
2026-07-02 13:58:15 -04:00
hobokenchicken 0b645fe765 feat: PWA overhaul
- Remove debug artifact from login page ('Use Link component: HOME')
- Full icon set: 48/72/96/128/144/152/192/384/512 + maskable variants
- Proper manifest: display_override, shortcuts, categories, scope
- Better service worker: offline fallback page, pre-caching, stale-while-revalidate
- Custom install banner (Android beforeinstallprompt + iOS instructions)
- Connection status indicator (online/offline toast)
- Updated index.html with proper favicon sizes and meta tags
2026-07-02 13:51:42 -04:00
hobokenchicken f6f63ecd2b fix: pass GIT_SHA inline to npm run build
The export-then-chain wasn't propagating into the subshell.
Use Make variable and pass GIT_SHA= directly to the command.
2026-07-02 13:21:17 -04:00
hobokenchicken f30ddb1323 feat: show git commit SHA as version in footer
TERM v1.0 → TERM v5ca5e01 (or whatever SHA is building).
GIT_SHA is injected via vite define from Makefile.
2026-07-02 13:19:43 -04:00
hobokenchicken 5ca5e018ca feat: feature requests board with voting
- [FEATURES] tab next to PINNED in channel header
- Create, vote/unvote, filter by status (open/planned/done/rejected)
- Sort by vote count (most voted first)
- Status selector for moderation
- DB: feature_requests + feature_request_votes tables
- Backend: full CRUD + vote endpoints under /servers/{id}/feature-requests
2026-07-02 13:11:52 -04:00
hobokenchicken 3002626ac4 fix: use window.addEventListener for dropdown keyboard nav
inputRef-based listener never fired (ref null at effect time or
element-level listeners don't catch events in this React setup).
Switched to window.addEventListener matching the pattern every other
keyboard handler in this codebase already uses.
2026-07-02 13:03:41 -04:00
hobokenchicken 4ed2e5bfd0 fix: use addEventListener for dropdown keyboard navigation
Moved keydown handler from inline React prop to useEffect with
addEventListener on the input ref. Uses a ref to track latest
dropdown state, avoiding stale closure issues that prevented
arrow keys from working. Added stopPropagation on Enter to
prevent form submission race. Guard in handleSubmit to bail
when a dropdown is open.
2026-07-02 12:58:40 -04:00
hobokenchicken aa80e354c9 fix: Enter on dropdown executes command immediately
Previously selecting a slash command with Enter just filled the
input, requiring a second Enter to send. Now Enter directly
executes the command transform (or opens /poll modal) and sends.
2026-07-02 12:47:58 -04:00
hobokenchicken cf2f1c96a3 feat: keyboard navigation for mention and slash command dropdowns
Arrow up/down cycles through items with highlight. Enter selects
the highlighted item. Escape dismisses the dropdown. Index resets
when the query changes.
2026-07-02 12:44:27 -04:00
hobokenchicken 40f8d193ff feat: polls with live voting via WebSocket
- /poll command opens creation modal (2-10 options)
- PollDisplay with vote bars, percentages, live WS updates
- Backend: polls/poll_options/poll_votes tables, Create/Get/Vote endpoints
- attachPolls enriches message list responses
- POLL_UPDATE broadcast on vote for real-time sync
2026-07-02 12:35:05 -04:00
hobokenchicken d8b4defaff feat: slash commands with autocomplete dropdown
Type / at start of message to see available commands.
Commands: /shrug, /tableflip, /unflip, /lenny, /bear,
/disapprove, /facepalm, /cry, /dance, /hug, /greet,
/me (action text), /spoiler (hidden text).

Autocomplete dropdown with tab-complete. Commands transform
input text client-side before sending. Unknown /commands
pass through as regular messages.
2026-07-02 12:22:43 -04:00
hobokenchicken 722eab8e94 fix: position kaomoji picker above message input bar
Moved EmojiPicker inside the form element so it anchors to the
input area (form is already position:relative). Picker now pops
up above the input instead of floating in the message scroll area.
2026-07-02 12:16:57 -04:00
hobokenchicken 19ac68d058 feat: Ctrl+E keyboard shortcut for kaomoji picker in message input 2026-07-02 12:13:00 -04:00
hobokenchicken 3a04eff42b feat: kaomoji picker with full kaomojikuma.com library
34 categories, 3300+ kaomoji from kaomojikuma.com.
Data in web/src/lib/kaomojiData.ts (exported KAOMOJI_CATEGORIES).
EmojiPicker refactored to import from external data file.
Categories: Greetings, Joy, Sad, Mad, Stress, Love, Animals,
Action, Shy, OwO, OMG, Neutral, Kawaii, Wave, Bodily, Table,
Fight, Magic, Dance, Sleep, Run, Music, Hugs, Kiss, Wink,
Heart, Rage, Thumbs, Cheer, Drink, Write, Comfort, Flourishes.
Search by emoji text or tags. Tabbed browsing.
2026-07-02 12:04:22 -04:00
hobokenchicken 59f6a40882 fix: server settings groups tab visible to owner
Server interface had 'ownerId' (camelCase) but API returns
'owner_id' (snake_case). isOwner was always false, hiding
groups/roles/audit tabs for the server owner.
2026-07-02 11:47:53 -04:00
hobokenchicken 5a7d4dc57e fix: preserve whitespace around mentions
renderContent: mention span now includes trailing space when the
next text segment doesn't start with one (React collapses whitespace
between sibling inline elements).

handleMentionSelect: avoid double space when the text after cursor
already starts with a space.
2026-07-02 11:41:12 -04:00
hobokenchicken 89f8381e2d added features and fixes 2026-07-02 15:34:00 +00:00
hobokenchicken eb5b7d55a4 fix: TYPING_START events now carry user_id and username
The backend was broadcasting TYPING_START with only channel_id.
Frontend required user_id + username to show who is typing.
Added Username to Client struct, fetched on WS connect via JOIN.
Typing events now enriched with sender identity before broadcast.
2026-07-02 09:39:15 -04:00
hobokenchicken 7d67d6f360 feat: typing indicators in channel and DM chats
Wired existing typing store (backend WS broadcast + frontend
_handleTypingEvent with 3s auto-expiry) into ChatArea and DMChat.
Typing events throttled to once per 3s. Shows 'X is typing...' or
'X and N others are typing...' below the messages, above the input.
2026-07-02 09:33:51 -04:00
hobokenchicken 08dfc4e400 feat: scroll-to-top loads older messages
Both ChatArea and DMChat now detect when you scroll near the top
and fetch the next 50 older messages. Scroll position is preserved
so you don't lose your place. Works for both server channels and
DM conversations.
2026-07-02 09:28:28 -04:00
hobokenchicken cbfbcb2627 feat: thin themed scrollbar replaces hidden scrollbars
6px scrollbar with bg track, bg-t thumb, fg-f hover. Subtle but
visible. Applies globally to all scrollable areas.
2026-07-02 09:22:08 -04:00
hobokenchicken ca7a9e30f1 fix: message list now returns most recent 50, not oldest 50
Query was ORDER BY ASC LIMIT 50 — returning the 50 oldest messages.
On page refresh users saw ancient messages instead of recent ones.
Changed to ORDER BY DESC LIMIT then reverse in Go so the API still
returns chronological order but from the bottom of the history.
2026-07-02 09:19:31 -04:00
hobokenchicken 10a702f732 fix: server owner can now update channel settings
Channel Update handler required MANAGE_CHANNELS role permission.
Server owner was getting 'forbidden' because their roles didn't
include that bit. Added owner bypass (same pattern as Delete).
2026-07-02 09:15:18 -04:00
hobokenchicken af961d805e feat: channel settings modal now has name editing
Added [OVERVIEW] tab to ChannelSettingsModal with an editable
channel name field. The ⚙ button on each channel opens the modal.
Users can rename channels without needing to know about the
right-click context menu.
2026-07-02 09:10:33 -04:00
hobokenchicken 7f2a58599d fix: invisible notification/settings icons were eating channel clicks
The notification bell and settings gear spans had opacity-0 but
pointer-events were still active. Their stopPropagation handlers
prevented the parent button's onClick from firing when clicking
in the icon area. Added pointer-events-none to the container,
re-enabled on group-hover.
2026-07-02 09:06:04 -04:00
hobokenchicken 67ce6bbfd9 refactor: remove redundant far-left [S][@] selector
ServerBar already has [@] at the top and server icons below.
The far-left w-16 div was a duplicate layer. ServerBar now
navigates on server/DM clicks.
2026-07-02 09:02:40 -04:00
hobokenchicken c0686d4ca3 fix: nested button inside button broke channel clicks
Invalid HTML: <button> inside <button> causes the browser to close
the outer button prematurely, so clicking the channel name never
fires setActiveChannel. Changed inner elements to <span role=button>.
2026-07-02 08:52:26 -04:00
hobokenchicken 74a1db8392 fix: nav buttons now navigate, not just toggle store state
The [S] button only called setDM(false) without navigating away from
/chat area. Since the notes-to-self feature now calls navigate('/dm/:id'),
the URL stayed at /dm/... and ChatArea never re-mounted.

[S] now navigates to /, [@] navigates to /dm.
2026-07-02 08:49:47 -04:00
hobokenchicken 5cc9d76394 fix: remove user_ids validation that blocked notes-to-self
The 'at least one user_id is required' check ran before the creator
was added to memberSet, so {user_ids: []} always 400'd. Now empty
user_ids is allowed — the creator is always in memberSet.
2026-07-02 08:45:52 -04:00
hobokenchicken 3aa1ca3ead fix: clicking DM entries actually navigates to conversation
ConversationList was only setting activeConversationId in the store
but never navigating. If the Outlet was still showing ChatArea from
a server channel route, DMChat never mounted. Now every click
navigates to /dm/:conversationId so the router mounts DMChat.
2026-07-02 08:43:57 -04:00
hobokenchicken 3ed66f3d43 debug: add error surfacing for notes-to-self creation 2026-07-02 08:40:07 -04:00
hobokenchicken a5c3b25cc0 feat: notes to self via self-DM
Backend:
- Removed self-DM restriction in conversation Create
- Added duplicate check for self-DMs (returns existing one)

Frontend:
- ConversationList: [📝] button creates/opens notes to self
- Self-DMs show as 'Notes' with 📝 icon in sidebar
- DMChat header shows 'Notes' for self-DMs
2026-07-02 08:33:57 -04:00
hobokenchicken 17c09edc4f fix: channel PATCH was nulling group_id on name-only updates
group_id =  without COALESCE wrote NULL when only name was sent,
orphaning the channel from its group.
2026-07-02 08:30:09 -04:00
hobokenchicken d0da35e816 fix: channel/group rename now works
- Input field no longer nested inside a button/clickable div
- Snapshot state before clearing editing mode to avoid stale closures
- Log errors instead of silently swallowing them
2026-07-02 08:25:11 -04:00
hobokenchicken 31cb295a24 feat: right-click context menus for channels, groups, and servers
ContextMenu: reusable hook-based component that positions a menu at
the cursor and auto-closes on outside click or Escape.

ChannelList:
- Right-click channel -> Edit Name, Permissions, Delete Channel
- Right-click group header -> Edit Name, Create Channel Here, Delete Group
- Inline rename: replacing name text with an input, commits on Enter/blur
- CreateChannelModal now accepts defaultGroupId to pre-select a group

ServerBar:
- Right-click server icon -> Server Settings, Invite People, Leave Server
- Leave calls DELETE /servers/{id}/members/me

Backend:
- Added LeaveServer handler (DELETE /servers/{serverID}/members/me)
- Server owner cannot leave; must transfer or delete
2026-07-02 08:18:12 -04:00
hobokenchicken ecb26cb731 fix: use gif image URL instead of giphy page URL in messages
gif.url is the giphy.com page link, gif.images.fixed_height.url is
the actual media CDN URL. react-markdown was rendering a dead link
instead of an embedded image.
2026-07-02 08:06:31 -04:00
hobokenchicken cb8aeddde6 fix(pwa): generate icons, network-first HTML, bust stale cache 2026-07-01 16:14:29 -04:00
hobokenchicken 33dc63b94c fix: stop double-posting messages (WS + HTTP both appended) 2026-07-01 15:59:31 -04:00
hobokenchicken 48a99d58ee fix: use APP_URL for email links instead of host:port
DUMPSTER_PORT=8080 (internal) was baked into email reset links,
producing http://dumpster.dustin.coffee:8080/reset-password which
doesn't resolve through Caddy. Added Config.AppURL() that reads
APP_URL env var (set to https://dumpster.dustin.coffee on server),
falls back to http://host:port for dev.
2026-06-30 15:59:55 -04:00
hobokenchicken c80a36bacb fix: move DUMPSTER banner back inside login card, centered 2026-06-30 15:56:45 -04:00
hobokenchicken 65de4efb0d fix: span DUMPSTER banner full-width above login card 2026-06-30 15:55:14 -04:00
hobokenchicken b8ae43e250 fix: center DUMPSTER and LOGIN headings on login screen 2026-06-30 15:53:04 -04:00
hobokenchicken 82ddf914e4 feat: add forgot password UI flow
- ForgotPasswordPage: email input, sends reset request, shows success
- ResetPasswordPage: reads token from ?token= query param, password
  + confirm inputs, validates min length, shows success with login link
- LoginForm: added [FORGOT PASSWORD?] link below the form
- App.tsx: /forgot-password and /reset-password routes (public, no auth)
- auth store: requestPasswordReset + resetPassword actions
- Backend routes already existed at POST /auth/request-password-reset
  and POST /auth/reset-password
2026-06-30 15:49:23 -04:00
root f4437c5e1d feat: add SMTP email support and fix profile/permissions bugs 2026-06-30 19:29:08 +00:00
152 changed files with 16678 additions and 1001 deletions
+1 -1
View File
@@ -20,6 +20,6 @@ LIVEKIT_API_SECRET=<gener...n
# Web Push # Web Push
VAPID_PUBLIC_KEY=<generated> VAPID_PUBLIC_KEY=<generated>
VAPID_PRIVATE_KEY=<generated> VAPID_PRIVATE_KEY=<generated>
VAPID_SUBJECT=mailto:admin@your.domain VAPID_SUBJECT=admin@your.domain
# Integrations # Integrations
GIPHY_API_KEY=your_giphy_api_key_here GIPHY_API_KEY=your_giphy_api_key_here
+64
View File
@@ -0,0 +1,64 @@
name: Release Desktop Apps
on:
push:
tags: ['v*']
jobs:
build-linux:
runs-on: ubuntu-latest
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 }
- uses: dtolnay/rust-toolchain@stable
- name: Build frontend
run: cd web; npm ci; npm run build
- name: Build Tauri bundles
run: cd web; npx tauri build
- 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: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- name: Create release
uses: actions/gitea-release-action@v3
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
files: |
linux-bundles/*
windows-bundles/*
+1
View File
@@ -40,3 +40,4 @@ minio_data/
/server /server
/migrate /migrate
/dumpster-server /dumpster-server
keygen
+7 -1
View File
@@ -1,6 +1,8 @@
SHELL := /bin/bash SHELL := /bin/bash
.PHONY: build build-web build-server build-tui run run-tui dev docker-up docker-down clean .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 everything
build: build-web build-server build: build-web build-server
@@ -12,13 +14,17 @@ build-server:
build-tui: build-tui:
CGO_ENABLED=0 go build -o dumpster-tui ./cmd/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 # Generate Swagger docs
docs: docs:
~/go/bin/swag init -g cmd/server/main.go -o docs ~/go/bin/swag init -g cmd/server/main.go -o docs
# Build the web frontend # Build the web frontend
build-web: 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 the server locally (dev mode)
run: build-server run: build-server
+19
View File
@@ -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] WebAuthn backend (full begin/finish flow)
- [x] REST API documentation (Swagger UI at /docs) - [x] REST API documentation (Swagger UI at /docs)
- [x] Push notification mention parsing and dispatch - [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 ## Project Structure
@@ -96,6 +99,7 @@ dumpsterChat/
│ ├── invite/ # Server invite links │ ├── invite/ # Server invite links
│ ├── bot/ # Bot framework, auth, commands │ ├── bot/ # Bot framework, auth, commands
│ ├── webhook/ # Incoming webhooks │ ├── webhook/ # Incoming webhooks
│ ├── dm/ # Direct messages (conversations)
│ ├── push/ # Push notification sender (VAPID) │ ├── push/ # Push notification sender (VAPID)
│ ├── permissions/ # Permission bitflags and checker │ ├── permissions/ # Permission bitflags and checker
│ ├── moderation/ # Mute, ban, kick, slowmode, audit log │ ├── 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 | | `LIVEKIT_*` | Voice/video server | No |
| `GIPHY_API_KEY` | Giphy API key for GIF search | No | | `GIPHY_API_KEY` | Giphy API key for GIF search | No |
| `MINIO_*` | File upload storage | 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 ## Wiki
+81
View File
@@ -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-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggnJOfQM8flEJFULr
8vvHwWRlXlJ1a0sI5Jw/Y6KRQeGhRANCAATB70bxIRjXyxdwR/3k56AkiFRKwApx
q/h7xNXK5/I5xZ+5+HCLMHjEmFOIUtUj87DpcPHsrAfJZcH19gQIt7Xl
-----END PRIVATE KEY-----
+17
View File
@@ -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)
}
+3
View File
@@ -103,6 +103,7 @@ CREATE TABLE IF NOT EXISTS messages (
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL, content TEXT NOT NULL,
edited_at TIMESTAMPTZ, edited_at TIMESTAMPTZ,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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_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_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id); 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 = ` const downSQL = `
+58 -3
View File
@@ -3,8 +3,10 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
@@ -16,6 +18,7 @@ import (
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm" "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/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
@@ -98,8 +101,11 @@ func main() {
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)") logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
} }
// Email mailer
mailer := email.NewMailer(cfg.SMTP)
// Auth handler // Auth handler
authHandler := auth.NewHandler(database.DB, cfg, hub) authHandler := auth.NewHandler(database.DB, cfg, hub, mailer)
// Permissions checker // Permissions checker
permissionsChecker := permissions.NewChecker(database.DB) permissionsChecker := permissions.NewChecker(database.DB)
@@ -206,7 +212,7 @@ func main() {
}) })
// Direct messages // Direct messages
dmHandler := dm.NewHandler(database.DB, hub, logger) dmHandler := dm.NewHandler(database.DB, hub, pushHandler, logger)
r.Route("/conversations", func(r chi.Router) { r.Route("/conversations", func(r chi.Router) {
dmHandler.RegisterRoutes(r) dmHandler.RegisterRoutes(r)
}) })
@@ -220,11 +226,26 @@ func main() {
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r) 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 // Per-channel notification settings
r.Route("/channels/{channelID}/notifications", func(r chi.Router) { r.Route("/channels/{channelID}/notifications", func(r chi.Router) {
notification.NewHandler(database.DB, logger).RegisterRoutes(r) notification.NewHandler(database.DB, logger).RegisterRoutes(r)
}) })
// Push notifications
pushHandler.RegisterRoutes(r)
// Read receipts // Read receipts
rsHandler := readstate.NewHandler(database.DB, logger) rsHandler := readstate.NewHandler(database.DB, logger)
r.Put("/channels/{channelID}/read", rsHandler.MarkRead) r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
@@ -255,6 +276,34 @@ func main() {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gifs) 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 // File upload
@@ -305,6 +354,12 @@ func main() {
// File serving (public, for viewing uploaded files) // File serving (public, for viewing uploaded files)
if uploadHandler != nil { if uploadHandler != nil {
r.Get("/files/*", uploadHandler.Serve) 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) // Public webhook execution (no auth required)
@@ -333,7 +388,7 @@ func main() {
}) })
// Static file serving for production (SPA) // Static file serving for production (SPA)
staticDir := "/srv/web" staticDir := "web/dist"
if _, err := os.Stat(staticDir); err == nil { if _, err := os.Stat(staticDir); err == nil {
fileServer := http.FileServer(http.Dir(staticDir)) fileServer := http.FileServer(http.Dir(staticDir))
r.NotFound(func(w http.ResponseWriter, r *http.Request) { r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+5
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Regular → Executable
View File
+1
View File
@@ -1,5 +1,6 @@
{ {
auto_https off auto_https off
https_port 8443
} }
:80 { :80 {
+2 -1
View File
@@ -1,9 +1,10 @@
FROM golang:1.24-alpine AS go-builder FROM golang:1.26.4-alpine AS go-builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd/ cmd/ COPY cmd/ cmd/
COPY internal/ internal/ COPY internal/ internal/
COPY docs/ docs/
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
FROM node:22-alpine AS web-builder FROM node:22-alpine AS web-builder
+10 -6
View File
@@ -88,31 +88,35 @@ services:
volumes: volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro - ./livekit.yaml:/etc/livekit.yaml:ro
environment: environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
container_name: dumpster-coturn container_name: dumpster-coturn
command: command:
- "-n"
- "--log-file=stdout" - "--log-file=stdout"
- "--min-port=10000" - "--min-port=50101"
- "--max-port=20000" - "--max-port=50200"
- "--realm=${DUMPSTER_HOST:-localhost}" - "--realm=${DUMPSTER_HOST:-localhost}"
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" - "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
- "--cert=/etc/coturn/certs/turn.crt"
- "--pkey=/etc/coturn/certs/turn.key"
- "--tls-listening-port=5349"
ports: ports:
- "3478:3478/tcp" - "3478:3478/tcp"
- "3478:3478/udp" - "3478:3478/udp"
- "5349:5349/tcp" - "5349:5349/tcp"
- "5349:5349/udp" - "5349:5349/udp"
- "10000-20000:10000-20000/udp" - "50101-50200:50101-50200/udp"
volumes:
- ./certs:/etc/coturn/certs:ro
caddy: caddy:
image: caddy:2 image: caddy:2
container_name: dumpster-caddy container_name: dumpster-caddy
ports: ports:
- "80:80" - "80:80"
- "443:443" - "8443:8443"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- web_assets:/srv/web:ro - web_assets:/srv/web:ro
+10 -9
View File
@@ -1,16 +1,17 @@
port: 7880 port: 7880
bind: 0.0.0.0
rtc: rtc:
tcp_port: 7881 tcp_port: 7881
udp_port: 7882 udp_port: 7882
use_external_ip: true use_external_ip: true
relay_range_start: 50000 port_range_start: 50000
relay_range_end: 50100 port_range_end: 50100
turn_servers:
- host: turn.dustin.coffee
port: 443
protocol: tls
username: V8S6EWOU1Lqp
credential: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
turn: turn:
enabled: true enabled: false
domain: livekit.localhost
tls_port: 5349
udp_port: 3478
external_tls: false
keys: keys:
devkey: secret V8S6EWOU1Lqp: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -17,7 +17,7 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: secure, Secure: secure,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteLaxMode,
MaxAge: int(duration.Seconds()), MaxAge: int(duration.Seconds()),
}) })
} }
+216
View File
@@ -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)
}
+103 -26
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "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/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -21,14 +22,16 @@ type Handler struct {
cfg *config.Config cfg *config.Config
sessions *SessionStore sessions *SessionStore
hub *gateway.Hub 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{ return &Handler{
db: db, db: db,
cfg: cfg, cfg: cfg,
sessions: NewSessionStore(db, cfg), sessions: NewSessionStore(db, cfg),
hub: hub, hub: hub,
mailer: mailer,
} }
} }
@@ -36,9 +39,14 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
r.Post("/register", h.Register) r.Post("/register", h.Register)
r.Post("/login/password", h.Login) r.Post("/login/password", h.Login)
r.Post("/logout", h.Logout) 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) { 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.Get("/auth/me", h.Me)
r.Patch("/auth/me", h.UpdateProfile) r.Patch("/auth/me", h.UpdateProfile)
r.Put("/auth/me/password", h.ChangePassword) r.Put("/auth/me/password", h.ChangePassword)
@@ -210,16 +218,16 @@ type loginRequest struct {
} }
type updateProfileRequest struct { type updateProfileRequest struct {
AvatarURL string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
DisplayName string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio string `json:"bio"` Bio *string `json:"bio"`
AccentColor string `json:"accent_color"` AccentColor *string `json:"accent_color"`
StatusText string `json:"status_text"` StatusText *string `json:"status_text"`
Status string `json:"status"` Status *string `json:"status"`
BannerURL string `json:"banner_url"` BannerURL *string `json:"banner_url"`
Tagline string `json:"tagline"` Tagline *string `json:"tagline"`
Pronouns string `json:"pronouns"` Pronouns *string `json:"pronouns"`
SocialLinks []struct { SocialLinks *[]struct {
Platform string `json:"platform"` Platform string `json:"platform"`
URL string `json:"url"` URL string `json:"url"`
} `json:"social_links"` } `json:"social_links"`
@@ -410,8 +418,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
// @Failure 401 {object} map[string]string // @Failure 401 {object} map[string]string
// @Router /auth/me [get] // @Router /auth/me [get]
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { 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()) userID, ok := middleware.UserIDFromContext(r.Context())
if !ok { if !ok {
fmt.Printf("Me Request: NO USER ID IN CONTEXT\n")
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return return
} }
@@ -450,52 +466,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return 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) http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
return return
} }
if len(req.StatusText) > 128 { if len(newStatusText) > 128 {
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest) http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
return return
} }
if len(req.DisplayName) > 32 { if len(newDisplayName) > 32 {
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest) http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
return return
} }
if len(req.Tagline) > 128 { if len(newTagline) > 128 {
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest) http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
return return
} }
if len(req.Pronouns) > 40 { if len(newPronouns) > 40 {
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest) http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
return 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) http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
return return
} }
if req.Status != "" && !isValidStatus(req.Status) { if newStatus != "" && !isValidStatus(newStatus) {
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
return return
} }
socialLinksJSON, _ := json.Marshal(req.SocialLinks) var socialLinksJSON []byte
_, err := h.db.ExecContext(r.Context(), ` if req.SocialLinks != nil {
socialLinksJSON, _ = json.Marshal(*req.SocialLinks)
} else {
socialLinksJSON, _ = json.Marshal(existing.SocialLinks)
}
_, err = h.db.ExecContext(r.Context(), `
UPDATE users UPDATE users
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status), SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6,
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar) banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11
WHERE id = $1 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 { if err != nil {
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return return
} }
if req.Status != "" && h.hub != nil { if req.Status != nil && h.hub != nil {
user, err := h.getProfile(r.Context(), userID) user, err := h.getProfile(r.Context(), userID)
if err == nil { if err == nil {
h.hub.SetUserStatus(userID, req.Status) h.hub.SetUserStatus(userID, *req.Status)
h.hub.BroadcastPresence(userID, user.Username, req.Status) h.hub.BroadcastPresence(userID, user.Username, *req.Status)
} }
} }
+24 -6
View File
@@ -204,7 +204,16 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return 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 { if err := rows.Err(); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
@@ -339,14 +348,23 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return return
} }
if !allowed { if ownerID != userID {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
return 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 var ch channelResponse
@@ -357,7 +375,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
category = COALESCE($3, category), category = COALESCE($3, category),
position = COALESCE($4, position), position = COALESCE($4, position),
slowmode_seconds = COALESCE($5, slowmode_seconds), slowmode_seconds = COALESCE($5, slowmode_seconds),
group_id = $7 group_id = COALESCE($7, group_id)
WHERE id = $6 WHERE id = $6
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at 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( `, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan(
+31
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"bufio" "bufio"
"fmt"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -51,6 +52,14 @@ type Config struct {
WebPush WebPushConfig WebPush WebPushConfig
Session SessionConfig Session SessionConfig
Giphy GiphyConfig Giphy GiphyConfig
SMTP SMTPConfig
}
type SMTPConfig struct {
Host string
Port string
Username string
Password string
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -134,9 +143,31 @@ func Load() *Config {
Giphy: GiphyConfig{ Giphy: GiphyConfig{
APIKey: getEnv("GIPHY_API_KEY", ""), 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 { 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 return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode
} }
+72
View File
@@ -47,8 +47,19 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 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 ( CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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 ( CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(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(), joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id) PRIMARY KEY (user_id, server_id)
); );
ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100);
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, 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, content TEXT NOT NULL,
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL, reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
edited_at TIMESTAMPTZ, edited_at TIMESTAMPTZ,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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) -- 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 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 ( CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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_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 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 -- Moderation
CREATE TABLE IF NOT EXISTS bans ( CREATE TABLE IF NOT EXISTS bans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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); 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; 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);
` `
+251 -20
View File
@@ -5,26 +5,30 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
"strings"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// Handler handles direct-message conversations. // Handler handles direct-message conversations.
type Handler struct { type Handler struct {
db *sql.DB db *sql.DB
hub *gateway.Hub hub *gateway.Hub
logger *slog.Logger pushHandler *push.Handler
logger *slog.Logger
} }
// NewHandler creates a new DM handler. // NewHandler creates a new DM handler.
func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler { func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
return &Handler{db: db, hub: hub, logger: logger} return &Handler{db: db, hub: hub, pushHandler: pushHandler, logger: logger}
} }
// RegisterRoutes registers conversation routes. // RegisterRoutes registers conversation routes.
@@ -35,6 +39,10 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/", h.Get) r.Get("/", h.Get)
r.Get("/messages", h.ListMessages) r.Get("/messages", h.ListMessages)
r.Post("/messages", h.SendMessage) 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) http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return return
} }
if len(req.UserIDs) == 0 { // ponytail: user_ids can be empty for self-DM (notes to self)
http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest)
return
}
// Build unique member set including creator. // Build unique member set including creator.
memberSet := map[string]struct{}{userID: {}} memberSet := map[string]struct{}{userID: {}}
@@ -82,10 +87,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
memberSet[uid] = struct{}{} memberSet[uid] = struct{}{}
} }
} }
if len(memberSet) == 1 { // ponytail: allow self-DM (notes to self) — len(memberSet)==1 is fine
http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest)
return
}
memberIDs := make([]string, 0, len(memberSet)) memberIDs := make([]string, 0, len(memberSet))
for uid := range memberSet { for uid := range memberSet {
@@ -94,7 +96,24 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
sort.Strings(memberIDs) sort.Strings(memberIDs)
// For a 1:1 DM, check if it already exists. // 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 var existingID string
err := h.db.QueryRowContext(r.Context(), ` err := h.db.QueryRowContext(r.Context(), `
SELECT c.id FROM conversations c 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) { func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
var resp conversationResponse var resp conversationResponse
err := h.db.QueryRowContext(ctx, ` 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) `, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
if err != nil { if err != nil {
return resp, err return resp, err
} }
memberRows, err := h.db.QueryContext(ctx, ` 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 FROM conversation_members cm
JOIN users u ON u.id = cm.user_id JOIN users u ON u.id = cm.user_id
WHERE cm.conversation_id = $1 WHERE cm.conversation_id = $1
@@ -262,8 +281,9 @@ type messageResponse struct {
AuthorName string `json:"author_username"` AuthorName string `json:"author_username"`
DisplayName *string `json:"author_display_name"` DisplayName *string `json:"author_display_name"`
Content string `json:"content"` Content string `json:"content"`
EditedAt *string `json:"edited_at"` EditedAt *string `json:"edited_at"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Reactions []emojiGroup `json:"reactions"`
} }
// ListMessages lists messages in a conversation. // ListMessages lists messages in a conversation.
@@ -296,7 +316,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
FROM conversation_messages m FROM conversation_messages m
JOIN users u ON u.id = m.author_id 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) 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 LIMIT $3
`, convID, before, limit) `, convID, before, limit)
} else { } else {
@@ -305,7 +325,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
FROM conversation_messages m FROM conversation_messages m
JOIN users u ON u.id = m.author_id JOIN users u ON u.id = m.author_id
WHERE m.conversation_id = $1 WHERE m.conversation_id = $1
ORDER BY m.created_at ASC ORDER BY m.created_at DESC
LIMIT $2 LIMIT $2
`, convID, limit) `, convID, limit)
} }
@@ -334,6 +354,13 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
messages = append(messages, msg) 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages) json.NewEncoder(w).Encode(messages)
} }
@@ -390,6 +417,8 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
msg.DisplayName = &displayName.String msg.DisplayName = &displayName.String
} }
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
if h.hub != nil { if h.hub != nil {
h.hub.BroadcastToConversation(convID, gateway.Event{ h.hub.BroadcastToConversation(convID, gateway.Event{
Type: gateway.EventMessageCreate, 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(msg) json.NewEncoder(w).Encode(msg)
@@ -424,3 +473,185 @@ func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error
} }
return ids, nil 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)
}
+74
View File
@@ -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)
}
+26 -12
View File
@@ -102,10 +102,11 @@ func extractHostname(s string) string {
// Client is a middleman between the websocket connection and the hub. // Client is a middleman between the websocket connection and the hub.
type Client struct { type Client struct {
Hub *Hub Hub *Hub
Conn *websocket.Conn Conn *websocket.Conn
UserID string UserID string
send chan []byte Username string
send chan []byte
} }
// readPump pumps messages from the websocket connection to the hub. // readPump pumps messages from the websocket connection to the hub.
@@ -140,7 +141,19 @@ func (c *Client) readPump() {
c.Hub.RecordActivity(c.UserID) c.Hub.RecordActivity(c.UserID)
switch event.Type { 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) c.Hub.BroadcastEvent(event)
case EventVoiceWhisper: case EventVoiceWhisper:
// Forward voice whispers only to the target user, not broadcast // 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 token = auth.Token
} }
var userID string var userID, username string
err = db.QueryRowContext(context.Background(), 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, token,
).Scan(&userID) ).Scan(&userID, &username)
if err != nil { if err != nil {
logger.Warn("ws auth: invalid session", "error", err) logger.Warn("ws auth: invalid session", "error", err)
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`)) 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"}`)) conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`))
client := &Client{ client := &Client{
Hub: hub, Hub: hub,
Conn: conn, Conn: conn,
UserID: userID, UserID: userID,
send: make(chan []byte, 256), Username: username,
send: make(chan []byte, 256),
} }
hub.Register(client) hub.Register(client)
+216
View File
@@ -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}`))
}
+440 -11
View File
@@ -47,6 +47,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/bulk-delete", h.BulkDelete) r.Post("/bulk-delete", h.BulkDelete)
r.Patch("/{messageID}", h.Update) r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete) 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 { type bulkDeleteRequest struct {
@@ -135,6 +138,21 @@ type embedResponse struct {
SiteName string `json:"site_name"` 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 { type messageResponse struct {
ID string `json:"id"` ID string `json:"id"`
ChannelID string `json:"channel_id"` ChannelID string `json:"channel_id"`
@@ -144,8 +162,11 @@ type messageResponse struct {
Content string `json:"content"` Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"` ReplyTo *string `json:"reply_to,omitempty"`
EditedAt *string `json:"edited_at"` EditedAt *string `json:"edited_at"`
Pinned bool `json:"pinned"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"` 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. // 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(), ` err = h.db.QueryRowContext(r.Context(), `
INSERT INTO messages (channel_id, author_id, content, reply_to) INSERT INTO messages (channel_id, author_id, content, reply_to)
VALUES ($1, $2, $3, $4) 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( `, 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 { if err != nil {
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError) 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.EditedAt = &editedAt.String
} }
msg.CreatedAt = createdAt.String msg.CreatedAt = createdAt.String
msg.Reactions = make([]emojiGroup, 0)
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server) // Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
h.hub.BroadcastToServer(serverID, gateway.Event{ 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(), ` err := h.db.QueryRowContext(r.Context(), `
UPDATE messages SET content = $1, edited_at = NOW() UPDATE messages SET content = $1, edited_at = NOW()
WHERE id = $2 AND author_id = $3 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( `, 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 err != nil {
if errors.Is(err, sql.ErrNoRows) { 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. // Re-fetch embeds after edit.
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Look up serverID for scoped broadcast // Look up serverID for scoped broadcast
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID) 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 var err error
if before != "" { if before != "" {
rows, err = h.db.QueryContext(r.Context(), ` 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 FROM messages m
JOIN users u ON m.author_id = u.id 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) 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 LIMIT $3
`, channelID, before, limit) `, channelID, before, limit)
} else { } else {
rows, err = h.db.QueryContext(r.Context(), ` 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 FROM messages m
JOIN users u ON m.author_id = u.id JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 WHERE m.channel_id = $1
ORDER BY m.created_at ASC ORDER BY m.created_at DESC
LIMIT $2 LIMIT $2
`, channelID, limit) `, channelID, limit)
} }
@@ -511,7 +534,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var editedAt sql.NullString var editedAt sql.NullString
var createdAt sql.NullString var createdAt sql.NullString
var replyTo 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 { if err != nil {
continue continue
} }
@@ -526,6 +549,13 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
} }
messages = h.attachEmbeds(r.Context(), messages) 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages) json.NewEncoder(w).Encode(messages)
@@ -565,6 +595,189 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
return messages 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. // Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID") 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(), ` 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 ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
FROM messages m FROM messages m
JOIN users u ON m.author_id = u.id 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 createdAt sql.NullString
var replyTo sql.NullString var replyTo sql.NullString
var rank float64 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 { if err != nil {
continue continue
} }
@@ -624,6 +837,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
} }
messages = h.attachEmbeds(r.Context(), messages) messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages) json.NewEncoder(w).Encode(messages)
@@ -668,3 +882,218 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
return userID, serverID, true 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)
}
+324
View File
@@ -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
}
+9 -3
View File
@@ -11,9 +11,15 @@ func SecurityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ "default-src 'self'; "+
"img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';") "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://unpkg.com; "+
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()") "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) next.ServeHTTP(w, r)
}) })
} }
+18 -3
View File
@@ -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 // CheckPermission reports whether the user has all the required permission bits
// in the given server. ADMINISTRATOR bypasses all checks. // in the given server. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) { 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) perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil { if err != nil {
return false, err 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 // for a specific channel. It layers channel-level overrides on top of server role
// permissions. ADMINISTRATOR bypasses all checks. // permissions. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) { 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) perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil { if err != nil {
return false, err 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). // 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, ` rows, err := c.db.QueryContext(ctx, `
SELECT co.allow_bitflags, co.deny_bitflags SELECT co.allow_bitflags, co.deny_bitflags
FROM channel_overrides co FROM channel_overrides co
WHERE co.channel_id = $1 AND co.target_type = 'role' WHERE co.channel_id = $1 AND co.target_type = 'role'
AND co.target_id IN ( AND (
SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3 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) `, channelID, userID, serverID)
if err != nil { if err != nil {
+23 -26
View File
@@ -2,12 +2,11 @@ package push
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/base64"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/go-chi/chi/v5"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
webpush "github.com/SherClockHolmes/webpush-go" 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) { func (h *Handler) RegisterRoutes(r chi.Router) {
r.HandleFunc("POST /push/subscribe", h.Subscribe) r.Post("/push/subscribe", h.Subscribe)
r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe) r.Post("/push/unsubscribe", h.Unsubscribe)
r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey) r.Get("/push/vapid-public-key", h.GetPublicKey)
} }
// @Summary Get VAPID public key // @Summary Get VAPID public key
@@ -168,17 +167,22 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
Subscriber: h.vapidSubj, Subscriber: h.vapidSubj,
VAPIDPublicKey: h.vapidPub, VAPIDPublicKey: h.vapidPub,
VAPIDPrivateKey: h.vapidPriv, VAPIDPrivateKey: h.vapidPriv,
TTL: 30, TTL: 86400,
Urgency: webpush.UrgencyHigh,
}) })
if err != nil { 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 { if resp != nil && resp.StatusCode == 410 {
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
} }
continue continue
} }
if resp != nil { if resp != nil {
h.logger.Info("push sent", "user_id", userID, "status", resp.StatusCode, "endpoint", endpoint[:min(60, len(endpoint))])
resp.Body.Close() 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) // SendChannelNotification sends a push notification to all server members (except the author)
// when a new message is posted in a channel. // when a new message is posted in a channel.
func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) { 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 return
} }
@@ -197,6 +201,8 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
return return
} }
h.logger.Info("sending push notifications", "channel", channelName, "author", authorID)
body := content body := content
if len(body) > 200 { if len(body) > 200 {
body = body[:200] + "..." body = body[:200] + "..."
@@ -222,6 +228,7 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
} }
defer rows.Close() defer rows.Close()
sent := 0
for rows.Next() { for rows.Next() {
var userID, endpoint, p256dh, auth string var userID, endpoint, p256dh, auth string
if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil { 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, Subscriber: h.vapidSubj,
VAPIDPublicKey: h.vapidPub, VAPIDPublicKey: h.vapidPub,
VAPIDPrivateKey: h.vapidPriv, VAPIDPrivateKey: h.vapidPriv,
TTL: 30, TTL: 86400,
Urgency: webpush.UrgencyHigh,
}) })
if err != nil { 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)
@@ -251,23 +259,12 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
} }
if resp != nil { if resp != nil {
resp.Body.Close() 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. // GenerateVAPIDKeys is in cmd/keygen. Use github.com/SherClockHolmes/webpush-go directly.
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
}
+3 -3
View File
@@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
} }
func (h *Handler) RegisterRoutes(r chi.Router) { func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/{messageID}/reactions", h.Add) r.Post("/", h.Add)
r.Delete("/{messageID}/reactions/{emoji}", h.Remove) r.Delete("/{emoji}", h.Remove)
r.Get("/{messageID}/reactions", h.List) r.Get("/", h.List)
} }
type addReactionRequest struct { type addReactionRequest struct {
+36
View File
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/{serverID}", h.Get) r.Get("/{serverID}", h.Get)
r.Patch("/{serverID}", h.Update) r.Patch("/{serverID}", h.Update)
r.Delete("/{serverID}", h.Delete) r.Delete("/{serverID}", h.Delete)
r.Delete("/{serverID}/members/me", h.LeaveServer)
} }
type createServerRequest struct { type createServerRequest struct {
@@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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)
}
+87 -10
View File
@@ -6,28 +6,35 @@ import (
"net/http" "net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type MemberHandler struct { type MemberHandler struct {
db *sql.DB db *sql.DB
checker *permissions.Checker
} }
func NewMemberHandler(db *sql.DB) *MemberHandler { 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) { func (h *MemberHandler) RegisterRoutes(r chi.Router) {
r.Get("/members", h.ListMembers) r.Get("/members", h.ListMembers)
r.Patch("/members/{userID}", h.UpdateMember)
} }
type memberResponse struct { type memberResponse struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Avatar string `json:"avatar"` Nickname *string `json:"nickname"`
Status string `json:"status"` Avatar string `json:"avatar"`
StatusText string `json:"status_text"` Status string `json:"status"`
StatusText string `json:"status_text"`
} }
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) { 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(), ` 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 FROM members m
JOIN users u ON u.id = m.user_id JOIN users u ON u.id = m.user_id
WHERE m.server_id = $1 WHERE m.server_id = $1
@@ -65,12 +72,82 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members := make([]memberResponse, 0) members := make([]memberResponse, 0)
for rows.Next() { for rows.Next() {
var m memberResponse 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 continue
} }
if nick.Valid {
m.Nickname = &nick.String
}
members = append(members, m) members = append(members, m)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members) 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)
}
+34 -5
View File
@@ -80,7 +80,7 @@ type setMemberRolesRequest struct {
// Helpers // 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. // Returns false (and writes the error response) if denied.
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool { func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
userID, ok := middleware.UserIDFromContext(r.Context()) userID, ok := middleware.UserIDFromContext(r.Context())
@@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request
return false 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 { if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return false 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 { if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return false return false
@@ -358,12 +365,34 @@ func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(roles) 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). // Call this after creating a server (e.g. from the server creation handler).
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error { func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
// @everyone
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
INSERT INTO roles (server_id, name, permissions, position, is_default) INSERT INTO roles (server_id, name, permissions, position, is_default, color)
VALUES ($1, '@everyone', $2, 0, TRUE) VALUES ($1, '@everyone', $2, 0, TRUE, NULL)
`, serverID, permissions.DefaultEveryonePermissions) `, 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 return err
} }
+7 -25
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
if ownerID != userID { if ownerID != userID {
// Check permission checker := permissions.NewChecker(h.db)
var hasPerm bool hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
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)
if err != nil || !hasPerm { if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
@@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return return
} }
if ownerID != userID { if ownerID != userID {
var hasPerm bool checker := permissions.NewChecker(h.db)
err = h.db.QueryRowContext(r.Context(), ` hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
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)
if err != nil || !hasPerm { if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
@@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
if ownerID != userID { if ownerID != userID {
var hasPerm bool checker := permissions.NewChecker(h.db)
err = h.db.QueryRowContext(r.Context(), ` hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
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)
if err != nil || !hasPerm { if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
+8 -3
View File
@@ -14,7 +14,7 @@ import (
"github.com/minio/minio-go/v7/pkg/credentials" "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{ var allowedExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".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") 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) { 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-Type", stat.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) 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) io.Copy(w, obj)
} }
+27
View File
@@ -99,6 +99,33 @@ func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo,
return resp.Participants, nil 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 { func boolPtr(b bool) *bool {
return &b return &b
} }
+61 -6
View File
@@ -7,27 +7,32 @@ import (
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/livekit/protocol/livekit" "github.com/livekit/protocol/livekit"
) )
type Handler struct { type Handler struct {
db *sql.DB db *sql.DB
client *Client client *Client
hub *gateway.Hub hub *gateway.Hub
checker *permissions.Checker
} }
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler { func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
return &Handler{ return &Handler{
db: db, db: db,
client: client, client: client,
hub: hub, hub: hub,
checker: permissions.NewChecker(db),
} }
} }
func (h *Handler) RegisterRoutes(r chi.Router) { func (h *Handler) RegisterRoutes(r chi.Router) {
r.Use(middleware.RequireAuth)
r.Post("/token", h.GetToken) r.Post("/token", h.GetToken)
r.Get("/rooms/{roomID}/participants", h.GetParticipants) r.Get("/rooms/{roomID}/participants", h.GetParticipants)
r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant)
} }
type tokenRequest struct { type tokenRequest struct {
@@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result) 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"})
}
+19 -2
View File
@@ -2,14 +2,31 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <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="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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Dumpster" /> <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" /> <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> <title>Dumpster</title>
</head> </head>
<body> <body>
+377 -19
View File
@@ -8,7 +8,14 @@
"name": "dumpster-web", "name": "dumpster-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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/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", "livekit-client": "^2.20.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -18,6 +25,7 @@
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
@@ -745,6 +753,52 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -856,6 +910,25 @@
"@bufbuild/protobuf": "^1.10.0" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1299,6 +1372,236 @@
"win32" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1360,6 +1663,23 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1399,6 +1719,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1534,9 +1864,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.42",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1573,9 +1903,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.4", "version": "4.28.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1593,10 +1923,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.38", "baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001800",
"electron-to-chromium": "^1.5.376", "electron-to-chromium": "^1.5.387",
"node-releases": "^2.0.48", "node-releases": "^2.0.50",
"update-browserslist-db": "^1.2.3" "update-browserslist-db": "^1.2.3"
}, },
"bin": { "bin": {
@@ -1617,9 +1947,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001799", "version": "1.0.30001802",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1847,12 +2177,27 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.380", "version": "1.5.387",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
"integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -2002,6 +2347,12 @@
"node": ">=8" "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": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -4129,9 +4480,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4210,6 +4561,13 @@
"node": ">=14.17" "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": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+10 -1
View File
@@ -7,10 +7,18 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview",
"tauri": "tauri"
}, },
"dependencies": { "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/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", "livekit-client": "^2.20.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -20,6 +28,7 @@
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+70 -3
View File
@@ -1,22 +1,89 @@
{ {
"name": "Dumpster", "name": "Dumpster Chat",
"short_name": "Dumpster", "short_name": "Dumpster",
"description": "A chaotic, self-hosted Discord-like platform", "description": "A chaotic, self-hosted Discord-like platform",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"orientation": "any",
"background_color": "#282828", "background_color": "#282828",
"theme_color": "#282828", "theme_color": "#282828",
"orientation": "any", "categories": ["social", "communication"],
"lang": "en",
"dir": "ltr",
"icons": [ "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", "src": "/icons/icon-192.png",
"sizes": "192x192", "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" "type": "image/png"
}, },
{ {
"src": "/icons/icon-512.png", "src": "/icons/icon-512.png",
"sizes": "512x512", "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"
}
]
} }
] ]
} }
+103
View File
@@ -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>
+70 -54
View File
@@ -1,95 +1,111 @@
const CACHE_NAME = 'dumpster-v1'; // Dumpster PWA Service Worker
const STATIC_ASSETS = [ // 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', '/offline.html',
'/manifest.json', '/favicon.png',
'/icons/icon-192.png',
'/icons/icon-512.png',
]; ];
// Install: pre-cache shell and skip waiting
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
return cache.addAll(STATIC_ASSETS);
})
); );
self.skipWaiting();
}); });
// Activate: clean old caches and claim clients
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { caches
return Promise.all( .keys()
cacheNames .then((names) => Promise.all(names.filter((n) => n !== CACHE_VERSION).map((n) => caches.delete(n))))
.filter((name) => name !== CACHE_NAME) .then(() => self.clients.claim())
.map((name) => caches.delete(name))
);
})
); );
self.clients.claim();
}); });
// Fetch handler
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Skip API requests // Skip API, WebSocket, and cross-origin requests
if (event.request.url.includes('/api/')) return; if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
if (url.origin !== self.location.origin) return;
// Skip WebSocket // Navigation requests: network-first, offline fallback
if (event.request.url.includes('/ws')) return; 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( event.respondWith(
caches.match(event.request).then((cached) => { caches.match(event.request).then((cached) => {
// Return cached version, fetch new one in background const fetchPromise = fetch(event.request)
const fetchPromise = fetch(event.request).then((response) => { .then((response) => {
// Only cache successful responses if (response.ok) {
if (response.ok) { const clone = response.clone();
const clone = response.clone(); caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
caches.open(CACHE_NAME).then((cache) => { }
cache.put(event.request, clone); return response;
}); })
} .catch(() => cached);
return response;
}).catch(() => {
// Return cached if fetch fails
return cached;
});
return cached || fetchPromise; return cached || fetchPromise;
}) })
); );
}); });
// Push notifications
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
if (!event.data) return; let data = { title: 'Dumpster', body: '', url: '/', tag: 'dumpster-notification' };
try {
const data = event.data.json(); if (event.data) {
const options = { data = { ...data, ...event.data.json() };
body: data.body, }
icon: '/icons/icon-192.png', } catch (e) {
badge: '/icons/badge.png', data.body = event.data ? event.data.text() : '';
vibrate: [100, 50, 100], }
data: {
url: data.url || '/',
},
};
event.waitUntil( 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) => { self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
event.waitUntil( event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
// Focus existing window if available for (const client of list) {
for (const client of clientList) {
if (client.url.includes(event.notification.data.url) && 'focus' in client) { if (client.url.includes(event.notification.data.url) && 'focus' in client) {
return client.focus(); return client.focus();
} }
} }
// Otherwise open new window
return clients.openWindow(event.notification.data.url); return clients.openWindow(event.notification.data.url);
}) })
); );
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4755
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+16
View File
@@ -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");
}
+6
View File
@@ -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();
}
+45
View File
@@ -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"]
}
}
}
}
+29
View File
@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { LoginForm } from './components/LoginForm.tsx'; import { LoginForm } from './components/LoginForm.tsx';
import { Layout } from './components/Layout.tsx'; import { Layout } from './components/Layout.tsx';
import { ChatArea } from './components/ChatArea.tsx'; import { ChatArea } from './components/ChatArea.tsx';
@@ -8,7 +9,12 @@ import { CommandManager } from './components/CommandManager.tsx';
import { RoleManager } from './components/RoleManager.tsx'; import { RoleManager } from './components/RoleManager.tsx';
import { JoinServer } from './components/JoinServer.tsx'; import { JoinServer } from './components/JoinServer.tsx';
import { DMChat } from './components/DMChat.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 { 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 }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -16,10 +22,33 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
} }
function App() { 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 ( return (
<BrowserRouter> <BrowserRouter>
<ConnectionStatus />
<InstallBanner />
<Routes> <Routes>
<Route path="/login" element={<LoginForm />} /> <Route path="/login" element={<LoginForm />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route <Route
path="/settings" path="/settings"
element={ element={
+73
View File
@@ -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>
);
}
+201 -102
View File
@@ -7,6 +7,8 @@ import { InviteModal } from './InviteModal.tsx';
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx'; import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts'; import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
import { useReadStatesStore } from '../stores/readStates.ts'; import { useReadStatesStore } from '../stores/readStates.ts';
import { useMessageStore } from '../stores/message.ts';
import { useContextMenu } from './ContextMenu.tsx';
import { api } from '../lib/api.ts'; import { api } from '../lib/api.ts';
type NotifLevel = 'all' | 'mentions' | 'none'; type NotifLevel = 'all' | 'mentions' | 'none';
@@ -59,28 +61,54 @@ export function ChannelList() {
const fetchChannels = useChannelStore((state) => state.fetchChannels); const fetchChannels = useChannelStore((state) => state.fetchChannels);
const activeChannelId = useChannelStore((state) => state.activeChannelId); const activeChannelId = useChannelStore((state) => state.activeChannelId);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel); const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const removeChannel = useChannelStore((state) => state.removeChannel);
const updateChannel = useChannelStore((state) => state.updateChannel);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [createInGroup, setCreateInGroup] = useState<string | null>(null);
const [showInvite, setShowInvite] = useState(false); const [showInvite, setShowInvite] = useState(false);
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null); const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
const [groups, setGroups] = useState<ServerGroup[]>([]); const [groups, setGroups] = useState<ServerGroup[]>([]);
const [toggleTick, setToggleTick] = useState(0); 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 notifSettings = useNotificationSettingsStore((state) => state.settings);
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings); const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel); const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
const readStates = useReadStatesStore((state) => state.states);
const fetchReadStates = useReadStatesStore((state) => state.fetchStates); 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(() => { useEffect(() => {
if (activeServerId) { if (activeServerId) {
fetchChannels(activeServerId); fetchChannels(activeServerId);
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups') refreshGroups();
.then(setGroups)
.catch(() => setGroups([]));
} else { } else {
setGroups([]); setGroups([]);
} }
const handleRefresh = () => {
refreshGroups();
};
window.addEventListener('refresh-server-groups', handleRefresh);
return () => {
window.removeEventListener('refresh-server-groups', handleRefresh);
};
}, [activeServerId, fetchChannels]); }, [activeServerId, fetchChannels]);
useEffect(() => { useEffect(() => {
@@ -153,8 +181,143 @@ export function ChannelList() {
return level === 'mentions' ? '🔔@' : '🔔'; return level === 'mentions' ? '🔔@' : '🔔';
}; };
const hasUnread = (channelId: string): boolean => { const getLatestMessageId = (channelId: string): string | undefined => {
return !(channelId in readStates); 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 ( return (
@@ -171,7 +334,7 @@ export function ChannelList() {
[INV] [INV]
</button> </button>
<button <button
onClick={() => setShowCreate(true)} onClick={() => { setCreateInGroup(null); setShowCreate(true); }}
className="terminal-button text-xs" className="terminal-button text-xs"
title="Create channel" title="Create channel"
> >
@@ -188,61 +351,35 @@ export function ChannelList() {
if (section.type === 'group') { if (section.type === 'group') {
const grp = section.group; const grp = section.group;
const collapsed = isCollapsed(activeServerId || '', grp.id); const collapsed = isCollapsed(activeServerId || '', grp.id);
const isEditingGroup = editingGroupId === grp.id;
return ( return (
<div key={'grp:' + grp.id} className="mb-3"> <div key={'grp:' + grp.id} className="mb-3">
<div {isEditingGroup ? (
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg" <div className="text-gb-fg-t text-xs uppercase mb-1 flex items-center">
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }} <span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
> <input
{collapsed ? '[+]' : '[-]'} {grp.name} autoFocus
</div> 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 && ( {!collapsed && (
<> <>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{section.channels.map((channel) => {section.channels.map((channel) => renderChannel(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>
)
)}
</> </>
)} )}
</div> </div>
@@ -253,56 +390,17 @@ export function ChannelList() {
<div key={'cat:' + section.name} className="mb-3"> <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-t text-xs uppercase mb-1">{section.name}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{section.channels.map((channel) => {section.channels.map((channel) => renderChannel(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>
)
)}
</div> </div>
); );
})} })}
</div> </div>
{showCreate && activeServerId && ( {showCreate && activeServerId && (
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} /> <CreateChannelModal
serverId={activeServerId}
defaultGroupId={createInGroup}
onClose={() => { setShowCreate(false); setCreateInGroup(null); }}
/>
)} )}
{showInvite && activeServerId && activeServer && ( {showInvite && activeServerId && activeServer && (
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} /> <InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
@@ -315,6 +413,7 @@ export function ChannelList() {
onClose={() => setSettingsChannel(null)} onClose={() => setSettingsChannel(null)}
/> />
)} )}
{MenuPortal}
</div> </div>
); );
} }
+52 -7
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from '../stores/permissions.ts'; import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from "../stores/permissions.ts";
import { useRoleStore } from '../stores/role.ts'; import { useRoleStore } from "../stores/role.ts";
import { useMemberStore } from '../stores/member.ts'; import { useMemberStore } from "../stores/member.ts";
import { useChannelStore } from "../stores/channel.ts";
import { api } from "../lib/api.ts";
interface ChannelSettingsModalProps { interface ChannelSettingsModalProps {
serverId: string; serverId: string;
@@ -10,7 +12,7 @@ interface ChannelSettingsModalProps {
onClose: () => void; onClose: () => void;
} }
type Tab = 'permissions'; type Tab = 'overview' | 'permissions';
type TargetMode = 'role' | 'user'; type TargetMode = 'role' | 'user';
interface OverrideFormData { interface OverrideFormData {
@@ -76,10 +78,13 @@ function OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number
} }
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) { 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 [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); 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 overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides); 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' const targets = form.targetType === 'role'
? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name })) ? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
: members.map((m) => ({ id: m.id, label: m.username })); : 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> <button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div> </div>
<div className="flex border-b border-gb-bg-t"> <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>
<div className="flex-1 overflow-y-auto p-4"> <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' && ( {tab === 'permissions' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs text-gb-fg-f">Existing overrides</div> <div className="text-xs text-gb-fg-f">Existing overrides</div>
+666 -131
View File
@@ -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 { useMessageStore } from "../stores/message.ts";
import { api } from "../lib/api.ts";
import { useChannelStore } from "../stores/channel.ts"; import { useChannelStore } from "../stores/channel.ts";
import { useServerStore } from "../stores/server.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 { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.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 { MentionDropdown } from "./MentionDropdown";
import { useReadStatesStore } from "../stores/readStates.ts"; import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch"; import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.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 { ForumView } from "./ForumView.tsx";
import { CalendarView } from "./CalendarView.tsx"; import { CalendarView } from "./CalendarView.tsx";
import { DocsView } from "./DocsView.tsx"; import { DocsView } from "./DocsView.tsx";
@@ -51,29 +67,53 @@ function renderContent(content: string, memberUsernames: Set<string>) {
return segments.map((seg, idx) => { return segments.map((seg, idx) => {
if (seg.type === "mention") { 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 ( return (
<span key={idx} className="text-gb-aqua"> <span key={idx} className="text-gb-aqua">
@{seg.value} @{seg.value}{needsSpace ? " " : ""}
</span> </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 ( return (
<ReactMarkdown <span key={idx}>
key={idx} {leadingSpace}
remarkPlugins={[remarkGfm]} {trimmed ? (
components={{ <ReactMarkdown
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />, remarkPlugins={[remarkGfm]}
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />, components={{
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />, a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />, code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />, pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />, blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />, table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
p: ({ ...props }) => <span {...props} className="inline" />, 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" />,
{seg.value} h2: ({ ...props }) => <h2 {...props} className="text-base font-bold my-1" />,
</ReactMarkdown> 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);
}}>
&lt;{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}&gt;
</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() { export function ChatArea() {
const activeChannelId = useChannelStore((s) => s.activeChannelId); const activeChannelId = useChannelStore((s) => s.activeChannelId);
const channelsByServer = useChannelStore((s) => s.channelsByServer); const channelsByServer = useChannelStore((s) => s.channelsByServer);
@@ -115,22 +280,38 @@ export function ChatArea() {
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [], activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
); );
const isLoading = useMessageStore((s) => s.isLoading); const isLoading = useMessageStore((s) => s.isLoading);
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
const fetchMessages = useMessageStore((s) => s.fetchMessages); const fetchMessages = useMessageStore((s) => s.fetchMessages);
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
const sendMessage = useMessageStore((s) => s.sendMessage); 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 membersByServer = useMemberStore((s) => s.membersByServer);
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []); const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); 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 [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null); const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
const [profileUserId, setProfileUserId] = useState<string | null>(null); const [profileUserId, setProfileUserId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(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 toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages); const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
@@ -145,6 +326,95 @@ export function ChatArea() {
const members = activeServerId ? membersByServer[activeServerId] || [] : []; const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]); const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
const markRead = useReadStatesStore((s) => s.markRead); 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(() => { useEffect(() => {
if (activeChannelId) { if (activeChannelId) {
@@ -161,6 +431,20 @@ export function ChatArea() {
} }
}, [activeChannelId, messages.length, isLoading]); }, [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(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" }); bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]); }, [messages]);
@@ -179,22 +463,56 @@ export function ChatArea() {
return () => clearInterval(t); return () => clearInterval(t);
}, [slowmodeRemaining]); }, [slowmodeRemaining]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const value = e.target.value; const items = e.clipboardData.items;
const cursor = e.target.selectionStart ?? value.length; let imageFile: File | null = null;
setInput(value);
const beforeCursor = value.slice(0, cursor); for (let i = 0; i < items.length; i++) {
const atIndex = beforeCursor.lastIndexOf("@"); if (items[i].type.startsWith('image/')) {
if (atIndex === -1) { imageFile = items[i].getAsFile();
setMentionQuery(null); break;
return; }
} }
const between = beforeCursor.slice(atIndex + 1);
if (between.includes(" ") || between.includes("\n")) { if (imageFile) {
setMentionQuery(null); e.preventDefault();
return;
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 = `![image](${imageUrl})`;
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) => { const handleMentionSelect = (username: string) => {
@@ -204,29 +522,131 @@ export function ChatArea() {
setMentionQuery(null); setMentionQuery(null);
return; return;
} }
const cursor = inputEl.selectionStart ?? input.length; // Read directly from DOM to avoid stale closure
const beforeCursor = input.slice(0, cursor); const currentValue = inputEl.value;
const cursor = inputEl.selectionStart ?? currentValue.length;
const beforeCursor = currentValue.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@"); const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) return; if (atIndex === -1) return;
const before = input.slice(0, atIndex); const before = currentValue.slice(0, atIndex);
const after = input.slice(cursor); const after = currentValue.slice(cursor);
const next = `${before}@${username} ${after}`; const separator = after.startsWith(" ") || after === "" ? "" : " ";
const next = `${before}@${username}${separator}${after}`;
setInput(next); setInput(next);
setMentionQuery(null); setMentionQuery(null);
requestAnimationFrame(() => { setDropdownIndex(0);
const pos = atIndex + username.length + 2; // Also set the DOM value immediately so cursor positioning works
inputEl.focus(); inputEl.value = next;
inputEl.setSelectionRange(pos, pos); const pos = atIndex + username.length + 2;
}); inputEl.focus();
inputEl.setSelectionRange(pos, pos);
}; };
const handleSubmit = async (event: React.FormEvent) => { useEffect(() => {
event.preventDefault(); 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; if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null); setError(null);
try { 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(""); setInput("");
setReplyToMessage(null);
setMentionQuery(null); setMentionQuery(null);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Failed to send"; const msg = err instanceof Error ? err.message : "Failed to send";
@@ -241,14 +661,13 @@ export function ChatArea() {
} }
setError(msg); setError(msg);
} }
}; }, [mentionQuery, commandQuery, activeChannelId, input, slowmodeRemaining, sendMessage, replyToMessage, currentUser]);
const handleGifSelect = async (gif: Gif) => { const handleGifSelect = async (gif: Gif) => {
if (!activeChannelId) return; if (!activeChannelId) return;
const content = `![${gif.title || "GIF"}](${gif.url})`; const content = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`;
try { try {
await sendMessage(activeChannelId, content); await sendMessage(activeChannelId, content);
setShowGifPicker(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to send"); setError(err instanceof Error ? err.message : "Failed to send");
} }
@@ -256,22 +675,31 @@ export function ChatArea() {
return ( return (
<div className="flex flex-col h-full bg-gb-bg"> <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"> <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">
<span> <div className="flex items-center gap-2 min-w-0">
{activeChannel <button
? activeChannel.type === 'forum' type="button"
? `${activeChannel.name}` onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
: activeChannel.type === 'calendar' className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
? `${activeChannel.name}` >
: activeChannel.type === 'docs'
? `${activeChannel.name}` </button>
: activeChannel.type === 'list' <span className="truncate">
? `${activeChannel.name}` {activeChannel
: `# ${activeChannel.name}` ? activeChannel.type === 'forum'
: activeChannelId ? `${activeChannel.name}`
? `#${activeChannelId}` : activeChannel.type === 'calendar'
: "[NO CHANNEL SELECTED]"} ? `${activeChannel.name}`
</span> : activeChannel.type === 'docs'
? `${activeChannel.name}`
: activeChannel.type === 'list'
? `${activeChannel.name}`
: `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
</span>
</div>
{activeChannelId && activeChannel?.type === 'text' && ( {activeChannelId && activeChannel?.type === 'text' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -289,29 +717,42 @@ export function ChatArea() {
[SELECT] [SELECT]
</button> </button>
<button <button
onClick={() => setShowSearch((prev) => !prev)} onClick={() => {
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono" 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" title="Search messages"
> >
[SEARCH] [SEARCH]
</button> </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>
)} )}
</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' && ( {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"> <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> <span>{selectedIds.size} selected</span>
@@ -351,36 +792,100 @@ export function ChatArea() {
<ListView channelId={activeChannelId} channelName={activeChannel.name} /> <ListView channelId={activeChannelId} channelName={activeChannel.name} />
) : ( ) : (
<> <>
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0 relative">
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm"> {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 && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && ( {!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p> <p className="text-gb-fg-f">[no messages in this channel]</p>
)} )}
{messages.map((message) => ( {messages.map((message, i) => {
<div // ponytail: last-read divider — show after the message matching the user's read state
key={message.id} const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)} const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`} return (
> <>
{selectMode && activeChannelId && ( <MessageItem
<span className="text-gb-fg-f mr-2"> key={message.id}
{selectedIds.has(message.id) ? '[x]' : '[ ]'} message={message}
</span> memberUsernames={memberUsernames}
)} selectMode={selectMode}
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '} isSelected={selectedIds.has(message.id)}
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={() => setProfileUserId(message.author_id)}> onAuthorClick={handleAuthorClick}
&lt;{message.author_username}&gt; onToggleSelect={handleToggleSelect}
</span>{" "} onContextMenu={handleMessageContextMenu}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span> onAddReaction={handleAddReaction}
{renderEmbeds(message.embeds)} currentUserId={currentUser?.id}
</div> 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 ref={bottomRef} />
</div> </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"> <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> </div>
)} )}
{error && ( {error && (
@@ -393,32 +898,55 @@ export function ChatArea() {
SLOWMODE: wait {slowmodeRemaining}s SLOWMODE: wait {slowmodeRemaining}s
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative"> <div className="p-3 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span> {mentionQuery !== null && (
<div className="flex-1 min-w-0 relative"> <MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
<input )}
ref={inputRef} {commandQuery !== null && (
type="text" <CommandDropdown
value={input} query={commandQuery}
onChange={handleInputChange} selectedIndex={dropdownIndex}
placeholder="type a message..." onSelect={(name) => {
className="terminal-input w-full" setInput('/' + name + ' ');
disabled={!activeChannelId || slowmodeRemaining > 0} setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus();
}}
/> />
{mentionQuery !== null && ( )}
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} /> <MessageInput
)} ref={inputRef}
</div> value={input}
<button onChange={(v) => {
type="button" setInput(v);
onClick={() => setShowGifPicker((prev) => !prev)} const cursor = inputRef.current?.selectionStart ?? v.length;
disabled={!activeChannelId} const beforeCursor = v.slice(0, cursor);
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0" if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
title="Toggle GIF picker" setCommandQuery(beforeCursor.slice(1));
> setMentionQuery(null);
[GIF] } else {
</button> setCommandQuery(null);
</form> 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> </div>
{activeThread && ( {activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} /> <ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
@@ -429,6 +957,13 @@ export function ChatArea() {
{profileUserId && ( {profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} /> <UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)} )}
{MenuPortal}
{showPollModal && activeChannelId && (
<CreatePollModal
channelId={activeChannelId}
onClose={() => setShowPollModal(false)}
/>
)}
</div> </div>
); );
} }
+44
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+87
View File
@@ -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 };
}
+74 -13
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useConversationStore } from "../stores/conversation.ts"; import { useConversationStore } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts"; import { useAuthStore } from "../stores/auth.ts";
import { useReadStatesStore } from "../stores/readStates.ts";
import { NewConversationModal } from "./NewConversationModal.tsx"; import { NewConversationModal } from "./NewConversationModal.tsx";
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) { 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"; 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() { export function ConversationList() {
const conversations = useConversationStore((s) => s.conversations); const conversations = useConversationStore((s) => s.conversations);
const activeId = useConversationStore((s) => s.activeConversationId); const activeId = useConversationStore((s) => s.activeConversationId);
const fetchConversations = useConversationStore((s) => s.fetchConversations); 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 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 [showNew, setShowNew] = useState(false);
const [notesError, setNotesError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchConversations(); fetchConversations();
}, [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 ( return (
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col"> <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"> <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> <span>[DIRECT MESSAGES]</span>
<button <div className="flex gap-1">
onClick={() => setShowNew(true)} <button
className="terminal-button text-xs" onClick={openNotes}
title="New conversation" className="terminal-button text-xs"
> title="Notes to self"
[+] >
</button> [📝]
</button>
<button
onClick={() => setShowNew(true)}
className="terminal-button text-xs"
title="New conversation"
>
[+]
</button>
</div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm"> <div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{conversations.length === 0 && ( {conversations.length === 0 && (
<p className="text-gb-fg-f">[no conversations]</p> <p className="text-gb-fg-f">[no conversations]</p>
)} )}
{conversations.map((conv) => { {conversations.map((conv) => {
const name = const self = isSelfDM(conv, currentUser?.id || "");
conv.type === "group_dm" const name = self
? "Notes"
: conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ") ? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || ""); : otherMemberName(conv, currentUser?.id || "");
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
return ( return (
<button <button
key={conv.id} 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 ${ className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
conv.id === activeId conv.id === activeId
? "terminal-active" ? "terminal-active"
: "hover:bg-gb-bg-t text-gb-fg-s" : "hover:bg-gb-bg-t text-gb-fg-s"
}`} }`}
> >
<span className="text-gb-fg-f">@</span> <span className="text-gb-fg-f">{self ? "📝" : "@"}</span>
<span className="truncate">{name}</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> </button>
); );
})} })}
</div> </div>
{notesError && (
<div className="px-3 py-1 text-gb-red text-xs font-mono">ERR: {notesError}</div>
)}
{showNew && <NewConversationModal onClose={() => setShowNew(false)} />} {showNew && <NewConversationModal onClose={() => setShowNew(false)} />}
</div> </div>
); );
+4 -3
View File
@@ -4,6 +4,7 @@ import { useChannelStore } from '../stores/channel.ts';
interface CreateChannelModalProps { interface CreateChannelModalProps {
serverId: string; serverId: string;
defaultGroupId?: string | null;
onClose: () => void; onClose: () => void;
} }
@@ -36,13 +37,13 @@ const SLOWMODE_OPTIONS = [
{ label: '5 minutes', value: 300 }, { label: '5 minutes', value: 300 },
]; ];
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) { export function CreateChannelModal({ serverId, defaultGroupId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text'); const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
const [category, setCategory] = useState('general'); const [category, setCategory] = useState('general');
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0); const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
const [groups, setGroups] = useState<ServerGroup[]>([]); const [groups, setGroups] = useState<ServerGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState(''); const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || '');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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 result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
const newChannel = { const newChannel = {
id: result.id, id: result.id,
serverId: result.server_id, server_id: result.server_id,
name: result.name, name: result.name,
type: result.type, type: result.type,
category: result.category || null, category: result.category || null,
+1 -1
View File
@@ -45,7 +45,7 @@ export function CreateServerModal({ onClose }: CreateServerModalProps) {
id: result.id, id: result.id,
name: result.name, name: result.name,
icon: result.icon, icon: result.icon,
ownerId: result.owner_id, owner_id: result.owner_id,
}; };
useServerStore.getState().addServer(newServer); useServerStore.getState().addServer(newServer);
useServerStore.getState().setActiveServer(newServer.id); useServerStore.getState().setActiveServer(newServer.id);

Some files were not shown because too many files have changed in this diff Show More