mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
Release v1.2.0-alpha.1 -> main (#1309)
* fix(admin): accept same-origin first-run setup requests A freshly generated config.yaml leaves allowed_origins commented out, so the list is empty. The setup handler's CSRF guard assumed "no Origin header means same-origin", but browsers send Origin on same-origin POSTs too — Chrome and Edge always, Firefox since 70. The admin panel's own setup call is one of those POSTs, so every new install hit "cross-origin setup request blocked" and could never create an owner account. The guard now accepts a request whose Origin names the same host:port as the request's own Host header, falling back to the allowlist otherwise. That is what the original comment intended. CSRF protection is unaffected: a cross-site attacker cannot set Origin, the browser does, and a foreign origin still needs an explicit allowlist entry. Scheme is not compared. Nothing in this server derives the external scheme (no r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would reject legitimate requests behind a TLS-terminating proxy. Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin cases, plus two handler-level tests pinning both halves — same-origin succeeds against an empty allowlist, a foreign origin still 403s and creates no user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(identity): implement identity keypair caching and error handling * fix(client): use the real OS credential store, not keyring's mock (#1281) The `keyring` crate declares no `default` feature. Every platform arm in its lib.rs selects a backend only when that platform's feature is on and otherwise falls through to `pub use mock as default`, so the client's bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS and Linux alike. The mock keeps its secret in the `Entry` object itself, and each command built its own `Entry`: save_identity_key -> Entry::new(..) -> set_password -> Ok(()) load_identity_key -> Entry::new(..) -> get_password -> NoEntry So a save reported success, the very next read in the same process returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side logged anything, and no entry was ever written to Credential Manager on any machine. Downstream, the voice-E2EE identity keypair was regenerated on reconnect, the published identity key stopped matching the key that signed the announce, and peers correctly rejected it as a possible MITM. Name the platform backends explicitly, and stop trusting a store that reports a write it did not keep: - secret_store: read every write back and compare before reporting success. If the store returns a value we did not write, purge it so it cannot shadow the fallback on the next read. - On Windows only, fall back to a DPAPI-protected file in the app data dir, engaged solely after a proven round-trip failure and cleared as soon as the real store works again. The account name is mixed into the DPAPI entropy so a blob cannot be moved between entries and decrypt. macOS/Linux report an error instead of writing secrets to plaintext. - Log the compiled backend at startup and add `probe_credential_store` so an affected machine can be diagnosed from its own log file. - Guard the regression: `compiled_keyring_backend_is_persistent` fails the build if the features are ever dropped again. Verified to fail against `keyring = "3"`. The E2EE fail-closed posture is unchanged: a peer whose announce signature does not verify is still rejected. Linux builds now need `libdbus-1-dev` for the Secret Service backend. Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58 Co-authored-by: Claude <noreply@anthropic.com> * fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282) * fix(client): make the settings panel do what it says Functional review of every control in the settings overlay. Each fix below closes a gap between what a control promised and what it did. - Appearance: picking a theme no longer drops a saved accent colour. applyThemeByName strips every inline custom property from <body>, which includes the accent override; under neon-glow (whose body class sets --accent) the user's colour silently reverted until restart. - Overlay: reopening the panel rebuilds the active tab. The Voice & Audio mic meter and camera preview are torn down on close, so a reopened panel showed a dead meter and a black preview; tabs also now re-read prefs. The Logs tab's live listener is released when you switch away from it. - Status: the UserBar picker always started at "online" and never persisted, while the Account tab read a pref nobody else wrote — the two surfaces disagreed. Both now go through lib/userStatus, sync live via the pref-change event, and the saved status is re-asserted on connect. - Notifications: Do Not Disturb now suppresses the desktop notification and the chime, as its description in the panel claims. The taskbar flash, a passive cue, stays. - Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but unimplemented. They are wired now (voice ones only while in voice, all of them suspended while the settings panel is open). "Mark as Read" had no feature behind it at all and is replaced by the Escape behaviour that actually exists. - Account: backup codes now carry a "you won't see them again" warning and a copy button; the change-password form requires the current password before spending a server attempt and disables itself while in flight. - Advanced: removed the Hardware Acceleration toggle. Nothing read the preference it wrote — the webview decides GPU compositing before any JS runs, so honouring it needs a Rust startup change. - The settings sidebar name/avatar follow a rename instead of going stale, and settings/helpers no longer keeps a drifted copy of lib/preferences (the copy lacked the write guard, so a failed save could throw). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): close silent-failure gaps in the inline admin surface Continuation of the settings-panel review into the rest of the client. - Member context menu had no styling at all: AdminActions renders BEM class names (context-menu__item and friends) that appear nowhere in the CSS, so the menu had no hover, no danger colour, and the "Change Role" submenu pushed the menu open instead of flying out. Added the missing rules. - The submenu offered a hardcoded admin/moderator/member list. On a server with custom roles those roles were unreachable, and picking a name that didn't resolve to a role id silently did nothing. Roles now come from the server's ready payload (owner excluded), and an unresolvable role reports an error instead of dead-ending. - Kick / ban / delete-channel now show an in-flight state, and the two-click confirm disarms after a few seconds so a menu left open can't turn a stray click into a ban (docs/architecture/ux/settings-and-admin.md §3). - Ban collects a reason, which the server already stores and displays (adminBanMember has always accepted one; the menu never passed it). - Copying an invite code was silent: no confirmation, and a clipboard rejection looked identical to success. It now toasts either way. - Creating an invite double-click-minted two of them, and revoking — which kills a live link — had neither a confirm nor an in-flight guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): restore moderator message deletion and formatting - The delete affordance was offered only on your own messages, so a moderator could not moderate anything from the client. It now also appears when the signed-in user's role carries MANAGE_MESSAGES, derived from the role bitmasks the server already sends in `ready` (this is what docs/architecture/ux/messaging.md §4 specifies as "Delete (own / moderator)"). lib/permissions.ts existed for exactly this and had no callers at all. - Developer-mode "Copy ID" was silent on success and swallowed clipboard failures; it toasts either way now. - prettier --write on AdminActions.ts (Client Static Checks). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): stop the panel reporting success it didn't have Functional review of the server admin web panel. - An expired admin session left the panel on screen toasting "invalid or expired session" for every action, with no way back to the login form — only the log-stream code handled it. api() now handles 401 centrally: clear the token, return to login, and say why. - Deleting a backup called fetch() without looking at the response, so a failed delete reported "Backup deleted" and left the file in place. It now goes through api(), and — like every other destructive action here — asks for confirmation first. - A failed update check rendered as "Up to date. You're running the latest version", which is a lie that hides a broken update path. It now says the check failed and why. A failed apply no longer leaves the button stuck on "Applying...". - The Edit Channel modal could only rename. PATCH /channels/{id} accepts topic, slow_mode, position and archived, and the channel table has an Archived column — which was read-only state with no control behind it. All four are editable now. - Banned users showed "Yes" with no reason, even though the ban reason is collected on ban and returned by the API. It's now displayed. - Login and first-run setup had no in-flight guard, so a double-click spent two attempts against the login lockout / setup rate limit. Settings' Save stayed enabled after a successful save, implying unsaved changes. - Clipboard copies (invite code, new API token) had no rejection path: a refused clipboard looked exactly like a successful copy. - Backup names in inline onclick handlers go through jsq() like every other interpolated string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(admin): add the plugin management UI the backend already had /api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since Phase C Step 9 — its own header says it "exposes plugin lifecycle operations to the admin panel", and docs/architecture/ux/settings-and-admin.md tells operators plugin management lives in the web panel. The panel had no Plugins section at all, so installing a plugin meant hand-crafting a multipart POST. Panel: - Plugins section: installed table (name, manifest description and requested permissions, version, enabled state, install date), zip upload with the 16 MB server cap stated up front, enable/disable, and uninstall behind a confirm. One lifecycle call at a time. - The lifecycle API sits under a different prefix than the rest of the panel and answers errors as plain text (http.Error), not JSON, so it gets its own fetch helper — sharing api() would have surfaced "unexpected token" instead of the server's reason. 401 still routes back to login. Server: - PluginRow had no JSON tags, so the list marshalled Go field names and every column would have rendered empty. Now snake_case like the rest of the API. - GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means "nothing installed" on a live runtime and "you can't install anything" on a disabled one; the body can't tell them apart, so the panel's empty state had no way to be honest about it. The plugin-store test helper now hands back the database the registry writes to — the existing happy-path test wired a *different* in-memory DB into the handler, which is why nothing noticed the list was always empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(client): gate the composer on slow mode instead of failing the send Verified the optimistic message lifecycle against docs/architecture/ux — pending → chat_send_ok → sent, failed rows with mapped reasons, retry and delete-draft all behave as documented. One thing did not: slow mode. The UX spec (§5) says slow mode should "disable send with a live countdown in the composer; do not drop the drafted message". In practice the composer knew nothing about it: you typed, sent, and got a red failed row back — the exact enabled-then-rejected pattern §6.2 forbids. The client never even received the channel's slow_mode value. - Server: channel payloads (ready, channel_create, channel_update) now carry slow_mode alongside can_send, for the same reason can_send is there — the client can express the limit as affordance. The server still enforces. - Client: after an accepted send the composer disables itself for the channel's cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the full window (the server's limiter is the authority on when the next send is allowed). The draft stays in the textarea. Moderators, who bypass slow mode server-side, are not gated. - The MANAGE_MESSAGES lookup added for moderator deletes moves into lib/permissions as currentUserPermissions/currentUserHasPermission/ canManageMessages, so the composer and the message renderer share one definition instead of two. - WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT, BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and ALREADY_JOINED were missing, so code switching on it could not name cases the server actually sends. Now mirrors Server/ws/errors.go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): make backup restore actually restart, and fail closed without a safety copy Verification pass over the remaining review items. Two real defects in restore, one duplicate resolved; cert TOFU and the replay path checked out as-is. Restore: - The handler closed the database, swapped the file underneath it, told the admin "database restored — server restarting", broadcast a 5-second restart countdown to every client... and then kept running. Nothing restarted it, so the server answered every subsequent request against a closed DB until an operator noticed. It now respawns for real, reusing the update-apply pattern (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam. - A failed pre-restore backup was a warning, and the irreversible overwrite went ahead anyway — removing the safety net the panel explicitly promises ("A pre-restore backup will be created"), precisely when it matters. It now aborts with the database untouched. - The safety copy was written to a cwd-relative "data/backups" while every other backup handler uses the absolute backupBaseDir, so a server started from another directory filed it somewhere the operator would never find. Both new tests were confirmed to fail against the previous behaviour. Client: - SidebarArea kept a private 140-line copy of the member-list wiring that SidebarMemberSection already provides (the extracted, tested one was never imported). Fixing the silent role-change failure earlier meant patching both; now there is one copy. Verified without changes: the optimistic send lifecycle (pending → chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft), reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression of unread/notifications), and cert TOFU (first-use and mismatch modals, accept re-pins and reconnects, reject disconnects back to connect). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): remove the data race in the restart test hook CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_ Success polled a plain bool that the restore handler's goroutine wrote, and swapped the restartSelf package var from the test goroutine while that handler read it. The hook is now behind a mutex with an atomic flag in StubRestart. Production behaviour is unchanged — the race was entirely in the test seam I added. Verified with `go test -race -count=2 ./admin/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor + perf: split largest source files into modules; optimize hot paths (#1283) * refactor(updater): split updater.go into cohesive files Split the 1070-line updater.go into four files within the same package: updater.go (core types, release checking), download.go (download and tarball extraction), verify.go (signatures, checksums, staged binary), and assets.go (client assets, text-asset cache, HTTP fetching). Pure mechanical move — no behavior or API changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(ws): split hub.go into cohesive files Split the 1289-line hub.go into five files within the same package: hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go (broadcast fan-out and per-user sends), hub_events.go (sequencing, replay, persistence), hub_sweep.go (stale client/session/voice sweepers), and hub_livekit.go (LiveKit accessors). Also optimizes wrapWithSeq on the hot broadcast path: build the seq prefix with a single preallocated append + strconv.AppendUint instead of fmt.Sprintf, halving allocations per broadcast message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(client): extract E2EEManager from livekitSession Move all client-side E2EE key-exchange logic (~550 lines) out of LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH keypair management, identity signing and TOFU pin verification, announce/offer handling, key-holder election, membership rekeying, and periodic key rotation. Dependencies are injected following the existing roomEventHandlers pattern. LiveKitSession keeps thin public delegates (handleE2EEAnnounce, handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the module-level bound exports and the public API are unchanged. livekitSession.ts shrinks from 1955 to 1409 lines. Adds focused unit tests for E2EEManager (key-holder setup, pending announce queue, offer resolution, clearState, rotation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(server): hot-path and query optimizations Logging (biggest win): rewrite the admin log RingBuffer as a true ring (fixed array + head/count) instead of allocating a fresh 2000-entry slice + full copy per log line; gate the ring handler on a configurable level instead of unconditional DEBUG capture; move the broadcast debug log out of the seqMu critical section; drop the per-message slog.With clone in the WS handler. Database: new migration 019 adds idx_attachments_message (message pages no longer scan the attachments table), a covering role-leading index on channel_overrides (replacing a duplicate of the UNIQUE auto-index), a partial index for pinned messages, and narrows the FTS trigger to content changes only; ANALYZE runs after migrations. Rewrite GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries that range-scan idx_messages_channel — O(unread) instead of O(all messages) per WS connect. New GetUserDMChannelIDs replaces the full DM query where only IDs are needed. CreateMessage/EditMessageContent use RETURNING, removing the re-read after every send/edit. Write-path contention: TouchSession throttled to once per minute per session (was one UPDATE per authenticated request); EventPersister flushes its batch in a single transaction with per-row fallback; revoked-session and stale-voice sweeps run off the hub dispatch goroutine with an in-flight guard, and session checks are batched into one IN query; the rate limiter is sharded into 32 buckets with allocation-free strconv key building (auth.Key). WS structural: voice E2EE channel fan-out goes through the existing pubsub voice topic instead of scanning every connected client under h.mu; channelReadAudience memoizes role lookups per call; hasChannelAccess drops its redundant duplicate permission check; voice_join batches SPEAK/VIDEO/SCREENSHARE checks via HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics stop allocating via Sprintf/global mutex. Verified with go test -race across all packages, go vet, gofmt, and sqlc generate idempotency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): render-path, logging, and bundle optimizations Logging: the logger no longer runs permanently at debug — level is set from the environment at startup (debug in dev, info in prod), so every hot-path debug entry stops being serialized, buffered, consoled, and persisted to disk; per-URL debug logs in embed rendering removed. Render path: MessageList's store selector is scoped to the mounted channel, so messages in other channels no longer trigger re-renders, and a new incremental tail-append fast path appends rows instead of tearing down the whole window; Intl.DateTimeFormat instances are cached at module level; parseTimestamp memoizes epoch millis; media prefs (showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with pref-change invalidation; members store gains a roleRevision counter so MessageList stops rebuilding a role map on every presence/typing event. MemberList patches presence changes in place (status dot + offline class) via a row map instead of rebuilding every row, with single-pass role grouping. ChannelSidebar splits its voice subscription into a structural selector (excluding speaking) and a speaking-only patcher using a cached element map instead of per-user querySelector on every speaker event. Memory: GIF/media elements are unobserved before the message window discards them, fixing unbounded IntersectionObserver retention of detached DOM (including frozen-frame data URLs). Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic imports and manualChunks; the READY handler's stale-voice check reads the voice store instead of requiring the module synchronously. Adds 11 focused tests (different-channel no-rerender, append fast path, media release, presence patch, speaking patch). Full unit suite: 3606/3606 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284) The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate: under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock allocates, so the steady-state ring write measures 1 alloc/call. Extend the build constraint to !race && !deadlock — the test's guarantee is about the ring buffer itself, which the -race-less default pass covers. Make bcryptCost a var with an exported SetCostForTesting hook that also resets the dummy timing pad, and call it with bcrypt.MinCost from the api, auth, and admin TestMains. Password hashing at production cost 12 dominated those suites (~264 hashes): with the race detector the api package alone took ~860s; it now runs in ~33s. Nothing under test depends on hash strength, and no test asserts the cost. Hygiene in the same pass: migration 020 drops idx_sessions_token and idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure write overhead) with updated db_test assertions; remove the dead tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg since Go 1.11); gofmt storage/storage.go comment alignment. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf(ws): route hot-path permission checks through the cached PermissionService (#1285) The ws package was the only major subsystem still doing live per-check permission queries (GetRoleForUser + GetChannelPermissions per check): a V2 voice join cost 9+ DB reads across its four gates, and every channel broadcast resolved one role query per connected client. Hub now holds svc.Permissions and the voice deps carry it (nil-safe: bare test fixtures fall back to the existing live path, fail-closed semantics preserved everywhere). Converted sites: the voice join and token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls, requireChannelAccess, channelReadAudience, and RefreshChannelVisibility. Caching these is revocation-correct: every permission-changing mutation already invalidates synchronously before hub fan-out (InvalidateUser on role change, InvalidateAll on override change), the 30s TTL is only a backstop, and the service's gen-counter guard prevents a populate that races an invalidation from caching stale data — the audience-resolution comments now document that invariant. The stale-voice sweeper's check deliberately stays live: it is the last-line backstop for revocations that might bypass an invalidation hook, runs once a minute for only in-voice clients, and its eviction test pins exactly that guarantee. requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the cache only for positive verdicts and falling through to the live path on denial. Adds perm_cache_test.go: role-change invalidation is immediate (no TTL wait), and a counting-store test proving the second check is served from cache. All pinning tests (authz, voice_perm_stale, channel visibility agreement, sweep eviction) pass unmodified. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286) * perf(db): batch audit writes through an async writer Audit inserts ran synchronously on the request path — including one INSERT per WebSocket connect — each an implicit transaction on the single SQLite connection. WriteAudit keeps its exact signature and D8 policy (never fail the caller, never silently discard): it now upgrades to an async path when the passed Auditor also implements AsyncAuditor. *DB implements that via an atomic pointer that main.go populates at server startup with an AuditWriter modeled on the event persister (bounded queue, batched single-transaction flush with per-row fallback, drain-on-stop, atomic counters, non-blocking enqueue that error-logs drops without leaking the detail field). The token CLI and tests never install a writer, so they keep today's synchronous behavior with zero call-site changes. The writer's Stop defer registers after database.Close's so the LIFO unwind drains the queue before the DB shuts. Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop, flush-failure accounting, poison-row fallback, concurrent enqueue, and seam tests pinning sync-without-writer vs async-with-writer behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): actually defer livekit-client; honor saved log level at startup The manualChunks split was cosmetic: index.html modulepreloaded the 531 kB livekit chunk and the entry statically imported it. All four import chains from startup are now cut — auth.store's logout leaveVoice and ptt's setMuted go through dynamic imports, applyStoredAppearance moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the settings tree (whose overlay now loads on first open), and MainPage itself is a dynamic import in renderPage, guarded against the destroy-before-mount race by a navigation-generation helper and pre-warmed once the socket connects. Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession load as lazy chunks. The logger now honors the Logs tab's saved minimum level at startup (applyStoredLogLevel with the legacy-key migration moved into lib/preferences.ts) and re-applies it live on pref changes. Dead code: remove unreachable VoiceChannel.ts (superseded by ChannelSidebar's renderer) and its test, plus all knip-flagged unused re-exports in message-list/renderers.ts and ConnectPage's unused form types — knip is now clean apart from pre-existing config hints. Tests: +12 (navigation guard incl. stale-mount discard; logger startup pref, migration, and live re-apply); ptt/stored-appearance updated for dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(db): split SQLite into single-writer + multi-reader connection pools The entire server serialized on one SQLite connection: every read queued behind every other read and every write, throwing away WAL's concurrent-reader capability. File-backed databases now open two pools from a DSN that carries all seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA only configures one arbitrary pooled connection — moving them into the DSN is what makes >1 connection safe, foreign_keys included): a single-connection writer with _txlock=immediate, and a reader pool sized max(4, NumCPU). In-memory databases keep the exact historical single-connection behavior, which preserves every :memory: test site and the connection-scoped PRAGMA-toggle tests untouched. Routing lives in a dbtx router implementing sqlc's DBTX: statements go to the reader only when provably read-only (leading SELECT/PRAGMA after skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE ... RETURNING through QueryRowContext/QueryContext, which must stay on the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and the SQLDb() escape hatch all pin to the writer. Every former sqlDB reference across the package was re-pointed deliberately. New pool_test.go pins the properties the split must preserve on a file-backed DB: foreign_keys=1 across many reader connections, WAL journal mode, FK enforcement through both write paths, 8x8 concurrent reader/writer hammering with exact row counts, and a read completing against the pre-tx snapshot while a write transaction is open — the property this change exists to unlock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(client)+chore: split the two largest test files; eslint 10; audit clean Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect / ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module, and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain / vad-worklet / vad-fallback files. Test bodies moved verbatim; the suite count is unchanged at 3593 passing. Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer range already covers v10, flat config unchanged, zero new findings) and pin test-exclude ^8 via the existing overrides block so the coverage chain picks up patched glob/minimatch/brace-expansion. npm audit: 8 high -> 0 vulnerabilities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(server): split remaining large files; dependency hygiene notes Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers / chat / voice / voice_lifecycle / misc test files — bodies verbatim, 746 passing tests before and after. Split service/message.go (781) into message_crud / message_reactions / message_query / message_perms with types and the constructor staying put, and ws/serve.go (754) into serve / serve_pumps / serve_auth / serve_ready. Dependency findings (no changes needed): coraza-coreruleset's stale Feb-2024 pseudo-version is unreachable from our code — it enters the module graph only through coraza's own internal tests, and our WAF uses inline directives, never the CRS (fresher rules would require adopting the /v4 module and rewiring the WAF config — deliberate follow-up, not hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and never built into our binaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * style: satisfy golangci-lint modernize/staticcheck in new pool and audit code CI's golangci-lint pass (not run locally until now) flagged the Phase 3/4 additions: range-over-int loops, interface{} -> any on the dbtx router, WaitGroup.Go in the pool tests, and a De Morgan simplification in isReadOnlySQL's identifier-boundary check. Pure style — verified against the same golangci-lint v2.11.3 binary CI uses (0 issues) and re-ran db/ws race + deadlock suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287) * fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile cargo-audit identified the two Dependabot alerts on the default branch: quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded namespace allocation DoS), fixed in >=0.41. Both were transitive: plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml entirely. cargo-audit is now clean of vulnerabilities; the remaining 20 informational notices are the unmaintained GTK3-binding crates inherent to Tauri v2 on Linux. Verified plist compiles against quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI installs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * feat(waf): layer the maintained OWASP Core Rule Set onto the WAF The WAF previously ran six inline directives only — the CRS never loaded (the old coreruleset dep was a stale graph-only pseudo-version). A second Coraza engine now loads the embedded CRS from coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules, which stay byte-identical and keep blocking exactly as before. CRS ships in a new server.waf_crs_mode knob (off|detect|block), defaulting to detect: chat traffic is CRS-false-positive-prone (a new test pins that block mode rejects benign SQL-ish chat prose at the default threshold), so operators get rule-match visibility via structured logs first and opt into blocking after tuning. Setup mirrors the official connector: Host/Transfer-Encoding restored to the transaction (else 920280 fires on everything), phase 2 always runs so query-string attacks are scored, PUT/PATCH/DELETE added to the CRS method policy for this REST API, body limits matched to the app's 1 MiB cap with uploads excluded from body access and the content-type policy. Also fixes a latent middleware bug: the body was previously swapped for the buffered reader even when nothing was buffered, which would have handed body-access-off routes an empty body; now pinned by a test across all modes. Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection without blocking, block-mode blocking + benign passthrough, upload body preservation); waf_test.go passes unmodified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(ws): replace fixed sleeps with condition-based waits The ws suite paced async hub effects with 537 fixed time.Sleep calls — slow at best, flaky under load at worst. They are now condition-based: a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set (waitRegistered exploits the hub's in-order client-event processing), plus blocking decode-scans for the DM tests. The bulk deletion is grounded in verified production facts, unchanged by this commit: sendMsg is a synchronous buffered send (error replies are already buffered when the handler returns), the voice control / rollback / cleanup / sweep paths are synchronous, and serve.go registers the client before writing the ready frame. Absence assertions were deliberately NOT inverted into polling — they keep bounded windows, each commented. 20 sleeps remain, all justified in place: poll intervals inside condition loops, absence windows, clock-granularity pacing, and the event-pruner's inherently time-based no-prune-after-cancel assertion. Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the remainder is GracefulStop's hard-coded production 5s drain, out of scope here); race flake check passes 3 consecutive iterations; deadlock pass and golangci-lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288) * fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit WAF detect mode wired logCRSMatch as the engine-level error callback, which fires one slog.Warn per matched rule on the request goroutine. In the default detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly scoring, so each request logged a burst of Warn lines in the hot path. Aggregate per request from per-transaction state instead of the shared global callback: in the default detect path leave the engine error callback nil and, in the existing crsTx defer, emit at most one Warn per request that had matches (count + highest-severity rule), demoting the full rule-id list to Debug. Block mode keeps per-rule logging (blocked requests are rare and their detail is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery so existing tests stay unmodified. Detection, interruption, and body handling are unchanged — only the detect-path logging shape. The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow flush the 5s ctx could win, returning while run() was still flushing. main.go's LIFO defers then closed the DB pool under a live flusher, losing audits. Stop now always waits on done (the goroutine has stopped touching the store) while ctx bounds only the drain inside run() via a published stopCtxDone channel, so a slow store delays shutdown by at most one in-flight flush and the pool is never closed under a live writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * fix(client): plug listener leaks, guard lazy livekit load, honor saved log level Follow-up audit of the recently-landed lazy-livekit and session wiring found three real issues: - clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice on every logout, pulling the ~531 kB livekit chunk into the logout path even when no voice session was ever active. Guard the import on an active voice session (currentChannelId set and status not idle) and add a .catch so a failed teardown import can't reject unhandled. - The onStateChange handler unsubscribed session listeners only on the ready transition, not on disconnected; user_update and ready listeners registered per session were never collected for cleanup. Collect them into a sessionUnsubs array cleaned up on both ready and disconnected, preventing duplicate handlers accumulating across reconnects. - The Logs tab min-level select ignored the persisted log level when no explicit dropdown preference was saved. Add logger.getLogLevel() and default the select to it so the UI reflects the level actually in effect. Also add .catch to the ptt setMuted dynamic import. New unit tests cover the clearAuth guard, getLogLevel, and the LogsTab default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289) The CRS WAF engine failed to initialize on Windows, taking the whole api package's test suite red there. coraza's seclang parser resolves Include globs through path/filepath: for every match of `Include @owasp_crs/*.conf` it calls filepath.Join(currentDir, match), which on Windows rewrites the forward slashes to backslashes. It then feeds names like `@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS is the ruleset's embed.FS, which is always forward-slash and rejects a backslash name, so newCRSWAF returned "file does not exist" and no CRS rule under a subdirectory was ever loaded. Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/ Glob) that converts backslashes to forward slashes before delegating. This fixes CRS loading on Windows without patching coraza or the ruleset module and is a no-op where the separator is already "/". The Linux-only local verification for the CRS work missed this because coraza never emits backslashes there. The new test reproduces the failure mode on any OS by constructing the exact backslash name coraza produces on Windows: the raw ruleset FS fails to read it, the wrapper resolves it, and a forward-slash path still works. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291) The Client E2E CI job never completed: every run hit its 25-minute cap and was cancelled. ~229 of the 255 web tests were failing, all cascading from the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout burn on 1 worker deterministically exceeds the cap. Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts now awaits invoke("start_http_proxy") and builds REST URLs as http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for the unstubbed command, so every URL got a literal "null" port and Request construction threw before the mocked plugin:http transport was consulted. Login rejected, [data-testid='app-layout'] never mounted, and every logged-in test burned its full timeout. Stubbing start_http_proxy with any numeric port fixes the cascade because route matching is substring-based. The tail of failures after that fix were tests asserting behavior the app intentionally changed: - The ready payload can no longer pre-connect the local user to voice: the dispatcher treats "self in ready.voice_states while idle" as stale state from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote users only (2, 3), and widget tests join through the real click path via a new joinVoiceChannelByName helper. - The mock's voice_join reply no longer includes a voice_token: a token starts a real LiveKit session that deterministically self-destructs in the browser mock (E2EE key exchange timeout ~15s / connect-refused retries), tearing the widget down mid-test. These web tests validate the WS/UI layer only; real LiveKit is covered by the native suite. The reply also gained the full VoiceStatePayload shape — the sidebar renders user.username directly, and the omitted field broke the whole voice-user list render. - Message-load failure now renders an inline region error + Retry instead of a toast (UX spec 2), so the toast specs assert the inline UI and get their auto-dismiss vehicle from the delete-confirmation toast. CI hardening so a future systemic breakage can never burn the full cap again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now self-terminates with a usable report instead of being SIGKILLed), with the workflow's timeout-minutes 25 as the outer backstop. The job stays continue-on-error until it has proven stably green across a few pushes; the ci.yml comment documents that flip trigger. Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2). Unit tests (3598), typecheck, and prettier all clean. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290) * feat(admin): first-run setup wizard with config.yaml write-back Turn the single-screen owner-account setup into a guided multi-step wizard so non-technical operators never have to hand-edit YAML: - config: new comment-preserving config.Save (yaml.Node round-trip, atomic temp+rename write, verified loadable before replacing the file) plus a shared config.DefaultPath. Persists the runtime-generated LiveKit credentials so voice tokens survive restarts. - admin: POST /admin/api/setup accepts an optional "wizard" object (server name, MOTD, registration, port, TLS mode/domain, upload limit, voice quality). Values are validated before the account is created; DB settings and config.yaml are written after; failures downgrade to warnings so the created owner is never orphaned behind a 5xx. When a startup-only value changed the server restarts itself (reusing the backup/update restart machinery) and returns the new admin URL. - admin: GET /admin/api/setup/status now returns secret-free prefill defaults while setup is pending. - admin panel: six-step wizard UI (welcome, account, server basics, uploads & voice, access, review) with plain-language explanations, a restart/reconnect screen, and a "skip" path that keeps the legacy account-only flow byte-for-byte. - legacy payload {username,password} and all existing call sites keep working (SetupOptions is a trailing variadic parameter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * feat(voice): auto-download the LiveKit server binary Voice now works with zero manual setup: when voice.auto_download_livekit is enabled and no voice.livekit_binary is configured, the server fetches the pinned livekit-server release (v1.13.5, overridable via voice.livekit_version) from the official LiveKit GitHub releases in the background at startup, verifies it against the release's checksums.txt, extracts it into data/livekit/, and manages it as the existing companion process (crash recovery, health checks, graceful shutdown). - ws: new livekit_download.go — pinned version, per-platform asset mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's goreleaser config), size-capped downloads, hash verification and extraction through one open handle (TOCTOU-safe), O_EXCL staging, atomic rename, stale-version cleanup. LiveKitProcess.Start resolves the binary asynchronously with retries so boot is never blocked. - config: voice.auto_download_livekit + voice.livekit_version; enabled in the generated default config so fresh installs get working voice out of the box, while the compiled-in default stays off for existing configs. config.Load now loads the default file it just wrote, so the first boot runs with exactly the configuration the file documents. - wizard: "Voice chat" toggle (on by default) in the Uploads & voice step; the choice is written to config.yaml and factored into the restart decision. - docs: livekit-setup, server-configuration, deployment, README. Verified end-to-end against the real v1.13.5 release: download, checksum match, extraction, and process spawn all succeed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore: remove stray server.log, ignore local run logs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(voice): desktop-client origins on LiveKit proxy + client version bump to alpha.5 + release version guard (#1293) * fix(voice): accept the desktop client's webview origins on the LiveKit proxy The desktop client's chat connection goes through its Rust proxy, which sends no Origin header, so the safe-default empty allowed_origins never blocked it. The LiveKit JS SDK's signal requests and validate probes, however, are issued directly from the webview and carry its fixed origin (http(s)://tauri.localhost on WebView2, tauri://localhost on WKWebView/WebKitGTK). isOriginAllowed treated those as cross-origin and returned 403, so on every default install voice failed for any desktop client that wasn't on the server machine — chat worked, voice didn't, with /livekit/rtc/v1 403s in the server log. Treat these fixed first-party origins as always allowed. This is the same trust already extended to absent-Origin requests: web content can never present them (browsers resolve *.localhost to loopback and cannot reach the tauri:// scheme), so the CSRF surface is unchanged. Exact, case-insensitive matching only — lookalikes (tauri.localhost.evil.com, tauri.localhost:8080) still require an explicit allowlist entry. Operators no longer need to hand-add these origins to server.allowed_origins for voice to work; that list is now only for web/browser clients. Docs and the generated config comment updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore(client): bump version to 1.1.0-alpha.5 The v1.1.0-alpha.4 release shipped client artifacts still versioned 1.1.0-alpha.3 because the client manifests were never bumped — deployed desktop clients therefore consider themselves up to date and never auto-update. Bump package.json, package-lock.json, tauri.conf.json, Cargo.toml and Cargo.lock to 1.1.0-alpha.5 so the next release's clients update normally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * ci(release): fail the release when client version does not match the tag Guards against the v1.1.0-alpha.4 mistake recurring: a new verify-versions job compares the pushed tag against tauri.conf.json, package.json and Cargo.toml and fails before any build starts; every build job now depends on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(admin): allow API-token principals to use the SSE log stream (#1294) The log stream was session-only: POST /admin/api/logs/ticket required a *db.Session in the request context (deliberately nil for API-token principals), and the stream handler re-validated the ticket hash against the sessions table alone. API tokens could reach every other /admin/api/* route but not the log stream, breaking the mcp-introspect server_logs tool that docs/mcp-introspect.md documents as working. Bind tickets to the hash of whichever bearer credential authenticated the request, and resolve it in the stream handler via auth.ResolveTokenHash — the same session-first, API-token-fallback path the admin middleware uses. Ban, role demotion, and mid-stream revocation of either credential kind cut the stream exactly as before. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(voice): treat the server's own origin as same-origin on the LiveKit proxy (#1295) A page served by the server itself (e.g. a browser client at https://<server>:8443) chats fine but cannot join voice: browsers attach the page origin to every WebSocket handshake, and the LiveKit proxy's hand-rolled isOriginAllowed only recognized "no Origin" as same-origin, so the RTC upgrade 403'd while same-origin fetches (which omit Origin) succeeded — /livekit/rtc/v1 403s with validate flipping 403/200 in the server log. Allow an Origin whose host equals the request Host, mirroring websocket.Accept's default same-origin policy that the chat WS endpoint already applies — which is exactly why chat worked and voice didn't. Web content on another origin can never present this origin (the browser pins it), so the CSRF surface is unchanged. Same host on a different port remains cross-origin and denied. Also log rejected origins on the 403 path (origin, path, remote) — this failure was previously undiagnosable from the server log, which recorded the 403 but not the offending origin. Existing allowlist tests used origins colliding with httptest's default request host (example.com), which the new semantics correctly treat as same-origin; their fixtures now use distinct hosts so they keep exercising the allowlist path. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(release): strip bundled libwayland from Linux AppImages (white screen on Arch) (#1297) linuxdeploy bundles the Ubuntu 22.04 runner's libwayland-{client,cursor, egl,server} into the AppImage, and AppRun forces them onto LD_LIBRARY_PATH. On hosts with newer Mesa (Arch, Fedora), EGL init dlopens libwayland-client, hits the stale bundled copy, and fails with "Could not create default EGL display: EGL_BAD_PARAMETER. Aborting..." - WebKit's web process dies and the window stays white. Reproduced in an Arch container with the published alpha.5 aarch64 AppImage (identical stderr to the field report); the same image renders normally on Ubuntu 24.04, and removing the four bundled libwayland libs makes it render on both. WEBKIT_DISABLE_COMPOSITING_MODE=1 does NOT help (tested). Add scripts/strip-appimage-bundled-libs.sh and run it in both Linux release jobs after the Tauri build: strip the libs, repack with appimagetool, regenerate the updater tar.gz, and re-sign both artifacts with the Tauri updater key. Every supported distro ships libwayland at or above the 1.20 the client links against, so the host copy is always the right one. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(client): send the session bearer token when fetching attachments (#1298) Uploaded images rendered only as loading placeholders: the server's /api/v1/files/{id} endpoint requires a Bearer token (it enforces per-channel ACLs), but the client's attachment image fetch and file download never attached one, so every request came back 401 and the placeholder was never replaced. Server-hosted attachment fetches now go through fetchServerFile, which routes through the cert-pinned TOFU proxy with the session token from the auth store. The token is only ever sent to the configured server host — external image URLs keep a plain, credential-free fetch. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(client): enable microphone/camera detection on Linux (WebKitGTK) (#1299) On Linux no audio or video devices were ever detected: WebKitGTK ships with enable-media-stream and enable-webrtc off, and wry installs no permission-request handler on its webkitgtk backend (unlike macOS, where it auto-grants media capture), so WebKit's default denies every getUserMedia/enumerateDevices request. Add a Linux-only setup hook that turns both settings on for the main window's webview and grants WebKitUserMediaPermissionRequest and WebKitDeviceInfoPermissionRequest. All other permission request types still fall through to WebKit's default deny. The webkit2gtk crate becomes a direct dependency, pinned to the exact version wry already links (=2.0.2, v2_38 for enable-webrtc), so the binary's native library footprint is unchanged — the AppImage bundle set stays identical and the libwayland strip step from #1297 is unaffected. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * feat(client): kick to login and reset call state on server shutdown (#1300) When the server shut down, connected clients stayed on the main page in an endless "Reconnecting..." loop, and a live call's webcam/screenshare toggles kept whatever state they had. The server already broadcasts server_restart with reason "shutdown" from hub.GracefulStop before closing connections — the client just ignored the reason. The dispatcher now treats reason "shutdown" as terminal: it signs the user out (clearAuth), which navigates back to the login screen, leaves the voice session — stopping any live camera/screenshare tracks — and resets all call settings (camera, screenshare, mute, deafen, channel) to their normal state. Other restart reasons (update, setup, backup_restore) keep the existing countdown-banner + auto-reconnect behavior. clearAuth gains a LogoutReason so the logout wiring can tell a server-initiated kick from a user logout or invalid-token path: on "server_shutdown" the saved credential is kept (the token is still valid), so profiles with auto-login reconnect on their own once the server comes back, instead of losing their stored login on every server restart. The main page also skips the restart countdown banner for shutdown notices since the page unmounts immediately. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(client): credential fallback store on every OS, not just Windows (#1301) Credential saves still failed outright on machines where the OS keychain does not round-trip — most commonly a Linux desktop with no Secret Service provider (no gnome-keyring / KWallet, e.g. a bare window manager) and a locked macOS Keychain. The verified-write fallback introduced for the 2026-07 keyring regression existed on Windows only; on macOS and Linux secret_store::set returned an error and nothing was persisted, so logins and the voice-E2EE identity key vanished on every restart. The fallback now engages on every desktop platform, under the same rule as before: only after a keychain write has provably failed to round-trip, with the OS credential store taking over again the moment it recovers. Windows keeps DPAPI. macOS/Linux entries are sealed with ChaCha20-Poly1305 (via ring, already in the tree) under a per-install random key file written owner-only (0600) to the app data dir; the account name is bound in as AEAD associated data, mirroring the DPAPI entropy, so a blob cannot be moved between entries. Secrets at rest are never plaintext, and a copied fallback store is useless without the key file beside it. The shared set/get fallback path is now platform-neutral with only the sealing primitive per-OS, Backend gains an EncryptedFile variant, and fallback_crypto ships round-trip, AAD-mismatch, tamper, nonce uniqueness, and key-file permission tests that run in CI. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(voice): keep stream audio playing when the user mutes/deafens (#1302) Muting yourself in a call (which the deafen control also engages — deafen forces mute) silenced the audio of any screen-share stream being watched: the deafen path unsubscribed every remote audio publication, including ScreenShareAudio tracks, and the subscribe-time guard blocked new stream-audio tracks the same way. Muting/deafening yourself gates voices, not the content someone is streaming. Both paths now exempt ScreenShareAudio: the stream's audio keeps playing while the user is muted or deafened, and remains controllable through its own per-tile mute button and volume slider. Microphone (voice) audio is still fully unsubscribed on deafen exactly as before. The mic-mute path itself never touched incoming stream audio (verified against livekit-client: setMicrophoneEnabled, RemoteParticipant.setVolume and the audio pipeline are all scoped to the Microphone source) — the coupling was only ever the deafen subscription sweep. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * feat: Discord-parity quick wins (blocks UI, topics, role colors, profile popup, temp bans, archived filtering) (#1303) * feat: Discord-parity quick wins — blocks UI, topics, role colors, profile popup, temp bans, archived filtering Adds docs/plans/discord-parity.md (full gap analysis vs Discord free/Nitro, phased plan) and lands phase 1 — the six features where one side already existed and the other was never finished: - Block/unblock from the client: PUT/DELETE /blocks/{userId} were server-only; the member context menu now offers Block (with confirm) / Unblock to every user, admin actions stay role-gated. New setUserBlockedByMe store helper. - Channel topics end-to-end: topic now ships in the WS ready payload (protocol.md updated), renders live in the chat header, and is editable in the client's Edit Channel modal (PATCH already supported it). - Role colors from server data: member list groups and message username colors now use roles.color from ready (with theme-var fallbacks) instead of a hardcoded 4-name switch; custom roles render their own groups, and members with an unknown role render in a gray group instead of vanishing. - Profile popup mounted: left-clicking a member opens the existing UserProfilePopup (previously dead code); its Message button starts a DM. Action buttons without handlers are no longer rendered. - Temp bans: PATCH /admin/api/users/{id} accepts ban_duration_hours (1..8760) feeding the existing BanUser expiry plumbing; ban menu gains a duration selector (Forever/1h/1d/7d/30d). - Archived channels actually hide: VisibleChannelIDs now skips archived refs, so REST list, ready payload, and replay filtering all exclude them; archiving live-syncs connected clients via RefreshChannelVisibility. The admin panel still lists archived channels for unarchiving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): rewrite visibility if-else chain as switch (gocritic ifElseChain) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * feat: Discord-parity phases 2–6 (moderation, mentions, markdown, roles, social) (#1304) * feat: parity phase 2 — moderation depth (live permission bits, voice moderation, purge) - Admin perimeter now admits any role holding a moderation-capable bit (AdminPerimeter mask); each route group re-checks its own bit: channels/overrides -> MANAGE_CHANNELS, audit log -> VIEW_AUDIT_LOG, settings -> MANAGE_SERVER, force-logout -> KICK_MEMBERS. Ban and role assignment authorize inside ModerationService (BAN_MEMBERS / MANAGE_ROLES). New GET /admin/api/me lets the panel hide tabs and row actions the caller cannot use; the desktop member-list menu gates on permission bits from the ready role list instead of role names. - Hierarchy beyond ban: ChangeUserRole requires the actor to strictly outrank the target and refuses to assign a role at or above the actor's own position (closes "any admin can promote anyone to Owner"); ForceLogout enforces the same rule. - Voice moderation on MUTE_MEMBERS: voice_mod_mute/deafen/move/kick WS commands (bit + strict outrank, 5/s rate limit, audit-logged). voice_states gains server_muted/server_deafened, carried on voice_state; server mute is enforced at the SFU via LiveKit MutePublishedTrack and the target's own unmute attempts are refused with SERVER_MUTED/SERVER_DEAFENED. Move/kick run the hub voice-leave routine then send voice_moved (client rejoins through the normal join path) or voice_disconnected. Client voice-row menu grows a moderation section gated on the bit. - Bulk delete: POST /api/v1/channels/{id}/messages/purge {limit 1-100, before?} gated on READ|MANAGE_MESSAGES, soft-deletes preserving tombstones, one message_purge audit row, fans out a single chat_bulk_deleted broadcast. Channel context menu gains "Purge Messages…" for holders of MANAGE_MESSAGES. - Honest kick semantics: the session-revoking "Kick" action is renamed Force Logout in the client and admin panel (endpoint unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): use slices.Contains in voice moderation tests (modernize) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(test): widen the occupied pre_restore window in the abort-restore test The test blocked the safety backup by occupying pre_restore_<ts>.db names for the next 4 seconds; on slow Windows CI runners the request outlived the window and the restore succeeded, failing the 500 assertion. Occupy two minutes of candidates instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 3 — real mentions (server resolution, badges, notifications, autocomplete) - Mentions resolve server-side at send time: whole-word @username parsing (address-shaped text rejected), case-insensitive against unique usernames, 20-mention cap; stored in message_mentions in the same writer transaction as the message. chat_message/chat_edited and REST history/pinned/search carry mentions + mentions_everyone. - New MENTION_EVERYONE permission (bit 21, seeded to Owner/Admin/Moderator) gates @everyone/@here; never honored in DMs. @here skips offline users. Fan-out respects per-channel read permissions and skips users who blocked the author. - read_states.mention_count is live: incremented on insert (never on edit), zeroed by channel_focus, shipped per channel in ready. - Client: mentions highlight only when they resolve; mentioning the current user accents the whole row; #channel-name renders a navigating chip; channels show a red mention badge that outranks the unread badge; notifications say "X mentioned you in #channel" and the suppress-@everyone pref now suppresses only honored everyone-mentions; the composer gets an @-autocomplete popup (prefix-ranked, keyboard-driven, @everyone/@here offered only with the permission). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 4 — markdown rendering, message navigation, reactions/media/read-state polish - Discord-flavored markdown via a tokenizer (message-list/markdown.ts): bold/italic/underline/strike/spoiler with nesting, escaping and a word-boundary rule keeping snake_case literal; line-start quotes, headings, lists; masked links restricted to absolute http(s) + isSafeUrl (rejects render as literal source); language-tagged code fences with a hand-rolled highlighter (no new dependency); markdown is inert inside code. Renderer stays a strict DOM builder — no innerHTML. Composer gains Ctrl+B/I/U wrapping. - Message navigation: GET /channels/{id}/messages/around/{messageId} (half-before/half-after window, has-more flags via over-fetch); detached-window support in the messages store with a "Jump to Present" pill; search/pin jumps fetch the window when the target isn't loaded; reply previews are clickable; "Copy Message Link" + owncord://message/{channel}/{message} deep-link route; pasted message links render as jump chips. - Who-reacted: GET .../reactions/{emoji}/users (100 cap) + hover tooltip with per-message+emoji cache invalidated on reaction_update. - Inline media: video/audio attachments render native players from MIME allowlists (unknown containers keep the download chip); SVG stays out. - Read-state polish: NEW-messages divider, explicit Mark as Read / Mark All as Read, DM unread count badges (real counts shipped in ready instead of a dot; DM mention counts survive reconnect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 5 — role CRUD, per-user overrides, override matrix, client channel management - Roles are real entities: /admin/api/roles CRUD + reorder behind MANAGE_ROLES, with all rules in a new RoleService measured against the actor's position (only strictly-below roles may be touched; never grant a bit your own role lacks; seeded Owner immutable; default role undeletable — deletion reassigns members, drops its overrides, and invalidates exactly the moved members' cached perms in one writer transaction). Case-insensitive unique names (migration 023), normalized colors, roles_update broadcast keeps clients current, and both admin surfaces stopped hardcoding the four seeded roles. A new ASCII guard test protects sqlc-generated SQL from a byte/rune offset bug that silently splices queries when comments contain non-ASCII. - Per-user channel overrides (migration 024): resolution is now base -> role override -> user override with one implementation (EffectiveChannelPerms); both layers load in two batch queries behind every visibility/permission site, per-role visibility memoization removed (two members of one role can now differ), and the @everyone fan-out honors user-layer allow and deny. Admin REST + full tri-state override matrix UI (role or user per channel) replace the single "Can access" checkbox; the visibility-agreement test grew a same-role different-overrides case. - Categories stopped being magic strings: any channel type under any free-text category (server + client validation removed), category editable everywhere with datalist suggestions, voice channels group under their real category. - Desktop channel management: Edit Channel gains slowmode presets, NSFW toggle, and voice user/video limits (bounds-checked server-side, broadcast on channel_create/update via one shared constructor); NSFW channels show a per-session age-gate overlay; VIEW_AUDIT_LOG holders get an Audit Log entry point opening the admin panel at #audit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 6 — custom emoji, profiles & presence, group DMs, DM calls, channel mutes - Custom emoji end-to-end: the dormant emoji table gains a mime column and real routes (list/upload/delete + authenticated image serving, MANAGE_SERVER-gated, 512KiB / 128px caps validated against sniffed bytes, SVG refused, 200-emoji cap, audited, emoji_update broadcast). :shortcode: renders inline (jumbo when emoji-only, never in code), the picker gains a Server category, the composer a :-autocomplete, reactions accept and render custom emoji, and the admin panel gets an Emoji section. - Profiles: avatar upload (sniffed, capped, served authenticated) with one shared client avatar helper replacing letter-initials everywhere; display_name (heading with @username handle preserved for mentions), about, and custom_status columns with sanitized bounds; user_update broadcast keeps clients current. - Presence: invisible is a real stored status collapsed to offline for every other viewer at every serialization site (owner sees truth); connect no longer force-stamps online (idle/dnd/invisible survive reconnect — the flash-online bug is gone); auto-idle after 10 minutes of inactivity that never overrides a manual status. The @here fan-out now collapses status first so invisible users are not pinged. - Group DMs: channels.is_group discriminator; create (2-8 others, bidirectional block checks), rename (participants only), leave (channel deleted with the last participant); per-viewer dm_channel_open payloads; stacked-avatar rows, multi-select member picker, participant headers; 1:1-only composer block gating. - DM calls: call_ring/call_decline signaling over existing DM voice (no new call state), Call button in DM headers, incoming-call banner with accept/decline/30s timeout and chime. - Per-channel mutes (client prefs): muted channels/DMs stay silent for non-mention noise (badge dims, mentions still notify), managed from context menus and the Notifications tab. The dead Friends nav item is removed as the plan prescribed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * Pre-release review fixes + v1.2.0-alpha.1 prep (#1305) * fix(review): pre-release security & performance fixes for the parity work Security: - Channel-override endpoints (role + per-user) now enforce grantability: a MANAGE_CHANNELS holder can no longer grant itself or a user a permission bit its own role lacks, and the role-layer endpoint refuses targeting a role at or above the actor's position (Administrator bypasses). Closes a privilege-escalation path opened when the override routes were downgraded from ADMINISTRATOR-only. - DM voice events no longer leak: channelReadAudience resolves a DM channel's audience from its participants (intersected with connected clients) instead of the role scan, which passed every user with base READ_MESSAGES since DMs carry no overrides. A private DM call's voice_state/voice_leave now reaches only its participants. - Invisible users no longer flash online on connect: member_join carries a viewer-safe status (db.BroadcastStatus) and the client defaults a missing status to offline instead of hardcoding online. - Voice moderation can no longer reach a private DM call: voiceModTarget refuses a DM-channel target unless the actor is a participant, with the same shape as "not in voice" so nothing about the call leaks. Correctness: - Un-deafening a member now also clears the deafen-implied server mute, so the target regains the ability to unmute themselves instead of staying silenced at the SFU until a separate unmute. Performance: - IncrementMentionCounts batches its upserts into chunked multi-row statements instead of one exec per recipient, so an @everyone mention holds the SQLite writer for one exec per 500 readers instead of N. - applyMentionCounts resolves mentions against a set built once from the readers instead of a nested O(mentions x readers) scan. - The markdown parser's bracket/paren matching is computed once per line instead of rescanned at every opener, removing the O(n^2) worst case on pathological input. - Video/audio attachment blob URLs are now LRU-capped and revoked, and the attachment caches are cleared on logout, fixing an unbounded per-session Blob leak. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * chore(release): prep v1.2.0-alpha.1 Bump the client manifests (package.json, package-lock.json, tauri.conf.json, Cargo.toml, Cargo.lock) from 1.1.0-alpha.5 to 1.2.0-alpha.1 so the release workflow's verify-versions guard passes for tag v1.2.0-alpha.1. The server version is injected via ldflags at build time and needs no bump. Add a curated CHANGELOG section for v1.2.0-alpha.1 documenting the Discord-parity feature drop (mentions, markdown, custom emoji, message navigation, role management, per-user overrides, voice moderation, profiles, group DMs, DM calls, channel mutes) and the pre-release security/performance review, plus an operator note covering the nine new migrations and the new WebSocket message types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * perf(mentions): apply mention counts off the send path SendMessage resolved every reader and wrote the mention/@everyone badge counts synchronously after the commit but before returning, so a mention in a large channel delayed delivering the message to everyone else by the full reader-resolution chain plus the batched increment. Move that bookkeeping onto a background goroutine via an injectable dispatcher field (bg, defaulting to `go fn()`). The write already ran on a cancellation-detached context and swallowed its errors, so detaching it from the request is safe; the count is advisory, so the tiny window where a reader's channel_focus clears it just before the increment lands is harmless (matching Discord's eventual consistency). Tests read the counts synchronously right after a send, so the shared mention fixture and the ws mentions test opt into an inline runner (RunBackgroundInlineForTest / the hub's RunMentionCountsInlineForTest seam); a new test exercises the real async path by polling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * refactor(client): extract shared inline-autocomplete factory MentionAutocomplete and EmojiAutocomplete duplicated ~90 lines of identical listbox scaffolding (AbortController cleanup, suggestions/ activeIndex state, the root listbox + .ma-list, mousedown-to-choose rows, and a byte-identical arrow/Enter/Tab/Escape keydown switch), so a fix to one silently diverged from the other. Factor that into createInlineAutocomplete<T>, parameterized by the four things that actually differ: the filter, the selected value, the per-row children, and the row/root test ids + class (emoji keeps the shared mention-autocomplete base class plus its own, and only mentions prime the list on create). Both components become thin adapters that keep their existing exports — createMention/EmojiAutocomplete, the pure filter functions, and the MIN/MAX constants — unchanged, so MessageInput and every test are untouched and still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): drop now-unused appendChildren import in MentionAutocomplete The row rendering moved into the shared inline-autocomplete factory, so the import is no longer referenced; oxlint fails the Client Static Checks job on the unused identifier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(review): full-project review — hierarchy, role positions, search, clarity (#1306) From a full-codebase review (Opus security + Sonnet server/client + Haiku consistency): - Per-user channel overrides now enforce the same role-hierarchy guard the role-layer endpoint already has: a non-admin MANAGE_CHANNELS holder can no longer write or clear a per-user override against a member ranked at or above their own. Without it, because the per-user layer is last in the resolution order, a Moderator could deny a higher-ranked member the channel access their role grants. Applied to both PUT and DELETE. - CreateRole no longer places two default-positioned roles at the same position: it steps to the highest free slot below the actor and rejects an explicit position that is already taken. Colliding positions read as equal rank in every hierarchy check, so two such roles could never manage each other's members. The rank guard still takes precedence over the collision message for an at/above-rank position. - Search overlay no longer silently drops a query that arrives inside the 500ms rate-limit window (which sits above the 300ms debounce): it reschedules the search for when the window opens instead of leaving the previous query's results on screen. - Corrected a misleading TODO on chat_send attachments: they are upload UUIDs resolved by ownership at link time, not URLs, so a javascript:/data: string is never stored or rendered — a scheme check would wrongly reject valid ids. The comment now states this and the loop variable/error name say "id". Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR Co-authored-by: Claude <noreply@anthropic.com> * Test hardening: fuzzing, contract/upgrade, load, e2e (#1307) * fix(image): reject zero-dimension images in header decode FuzzImageDimensions found two inputs the emoji/image size guard accepted as valid with a nil error despite having no real dimensions: - a GIF whose logical screen descriptor decodes to height=0 via Go's own image.DecodeConfig, and - a VP8 keyframe whose size field is all zeros (VP8, unlike VP8L/VP8X, stores the size directly, so 0x0 is a validly-shaped header). Both callers compare the returned size straight against their pixel cap, so a degenerate 0-dimension header slipped through as a "small" image. Reject non-positive dimensions centrally in imageDimensions and reject zero VP8 dimensions in webpDimensions, so the invariant holds even for a caller that forgets its own bounds check. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): add Go fuzzers and TS property tests for parsers/validators Adds coverage on the parsers and validators most exposed to hostile input, each with a tricky seed corpus and invariant assertions: Server (Go native fuzzing): - FuzzParseMentionTokens: never panics; resolved count within cap. - FuzzSanitizeFTSQuery: output never errors against real SQLite FTS5. - FuzzValidateShortcode: accepted shortcodes match the documented charset/length. - FuzzEffectivePerms / FuzzEffectiveChannelPerms: ADMINISTRATOR implies all bits, user-deny beats role-allow, result is a subset of AllPerms. Client (fast-check property tests): - markdown tokenizer never throws and emits no script/on*/javascript: sinks, bounded time on pathological input. - mention/emoji content parsing never throws. - filterMentionSuggestions/filterEmojiSuggestions never throw and respect the caps and the MIN_EMOJI_QUERY/permission gates. The image-header fuzzer that found the zero-dimension bug landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(migration): add full-chain and upgrade round-trip tests Applies every embedded migration to a fresh DB and asserts the resulting schema is coherent, then applies the full chain on top of a pre-parity (migration 019) snapshot and asserts it upgrades without error and preserves seeded rows. Protects existing operators on the v1.2.0 upgrade (9 new migrations, 020 through 028). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(protocol): assert protocol schema matches generated Go constants Asserts every wire constant in docs/protocol-schema.json has a matching generated Go constant and vice-versa, with a small explicit exception list for intentionally-undocumented internal constants. Catches the chat_command-style drift the review flagged before it reaches the wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(load): add hub load/soak harness with goleak verification Adds a long test (skipped under -short, run under -race in CI) that concurrently registers and unregisters 200 WS clients across churn rounds while six broadcaster goroutines fan out to the hub, then asserts via go.uber.org/goleak that no goroutines leak and no deadlock or panic occurs. Exercises the client registry, broadcast audience resolution, and the background mention goroutine under contention -- the class of bug the race detector only reveals at scale. Adds a BroadcastVoiceEventForTest seam to export_test.go for the broadcaster loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(e2e): add blocking parity-feature Playwright specs Adds end-to-end coverage for the v1.2.0 parity features that had none, all tagged "@parity" and driven through the existing mocked-Tauri harness (tests/e2e/helpers.ts) — 15 tests across three files: - gating-badges.parity.spec.ts: NSFW age-gate mount/continue, mention red badge (ready-payload render + live incoming-mention bump), per-channel mute toggle + localStorage persistence. - social.parity.spec.ts: group-DM create via the member picker (asserts the POST /dms/group request), group render + leave (DELETE), and Change Role via the member context menu (asserts the PATCH /admin/api/users/{id}). - emoji-voicemod.parity.spec.ts: custom-emoji ":shortcode" autocomplete + message-list <img> render, and the voice-moderation menu — both the admin-can path (asserts voice_mod_mute / voice_mod_kick ws_send) and the gated path (menu absent without MUTE_MEMBERS). The specs assert the exact outgoing HTTP/WS request where the flow is request-driven, not just DOM side effects. No product bugs were found. Adds a dedicated CI job "Client E2E (parity subset, blocking)" that runs only the @parity specs (playwright --grep "@parity") WITHOUT continue-on-error, so a regression in these features fails CI. The pre-existing full e2e job stays non-blocking, per the maintainer note that it needs a few green pushes before graduating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * More hardening: fuzz the input surface + fix mis-written tests (#1308) * fix(upload): keep sanitizeUploadFilename output a safe, valid basename FuzzSanitizeUploadFilename found two inputs the upload-filename sanitizer returned unchanged in violation of its own contract: - "/" survived verbatim: filepath.Base("/") returns "/" (root is its own basename), and the final reserved-name check only special-cased "", ".", and "..", so a path separator reached the served download name and the client's save-dialog prefill. - a name longer than the 255-byte cap was truncated with a byte slice (name[:max]), which can land mid-rune and yield invalid UTF-8 — which then misbehaves in JSON encoding, on disk, and in download-name handling. Now any residual '/' is dropped in the character filter, and truncation trims back to the last full rune so the result is always valid UTF-8. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): fuzz the file/path and content/identity input surface Adds Go native fuzzers on the untrusted-input parsers/validators the first fuzzing pass didn't reach, each with a tricky seed corpus and both a never-panics and a semantic/security invariant: - storage.sanitizeFilename + resolvedPath composition (a name that passes sanitize must resolve inside the storage dir — no traversal), and storage.ValidateFileType (error iff a blocked magic prefix matches, for any header length). - plugin.validateRelativePath (accepted paths are non-absolute, separator- and traversal-free). - service.sanitizeContent: output carries no surviving <script/js:/on* sink, is length-bounded, and is idempotent (the bluemonday StrictPolicy contract). Two documented regression seeds pin the "inert plain text that merely contains the word javascript:/onclick=" non-bug. - auth.ValidateUsername / ValidatePasswordStrength — accept implies the documented charset/length. - api.validateAvatarURL (never accepts a non-https / javascript: / data: URL) and api.validateDisplayName. - ws.parseParticipantIdentity / parseRoomChannelID — never panic on adversarial LiveKit webhook strings. Each target survived active fuzzing (hundreds of thousands to millions of execs) with no crash; the one real bug found (sanitizeUploadFilename) landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test: make mis-written tests actually assert their claimed behavior A test-quality audit found tests that ran an action but asserted nothing (or asserted a tautology), so they would pass even if the code under test were deleted. Each is now wired to the real observable effect it names — no product code changed, no assertion weakened: Client (vitest): - notifications.test.ts: 19 notifyIncomingMessage tests had zero expect() calls; each now asserts the sendNotification / requestUserAttention / oscillator mock per its name (suppress vs fire, truncation, fallback title), with mockClear() so a stale call can't make it trivially green. Three catch-path tests now assert the debug log fired. One test whose title contradicted its body (and the code's guard) was renamed to match verified behavior. - livekit-session.test.ts: token-refresh test asserts the stored token and the rearmed refresh timer; the two "no active room" device-switch tests assert Room.switchActiveDevice is not called. - connection-stats.test.ts: the "start is idempotent" test now advances timers and asserts the poll callback fires once per tick (no double interval). - voice-audio-tab.test.ts: the cleanup test now actually starts a camera preview (it previously couldn't reach the camera-stop path) and asserts both mic and camera tracks are stopped. - dispatcher.test.ts: replaced an expect(true).toBe(true) with assertions on the voice-store speaking state the handler writes, incl. a control. - sidebar-area.test.ts: performs the back-navigation the test described and asserts the pre-DM text channel (not the DM) is restored. - profiles.test.ts: asserts no profile is created/mutated for a missing id. - log-persistence.test.ts: activeFlush tests assert flush sequencing, and the cleanup error test asserts the logged error. Server (Go): - db/coverage_boost_test.go: TestCreateAttachment_WithDimensions now links the attachment to a message and verifies the persisted width/height via GetAttachmentsByMessageIDs, instead of only checking a row exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * style(fuzz): satisfy golangci-lint on the new fuzz seed corpora - Escape the raw bidi/zero-width Unicode format characters embedded in the seed strings as \u escape sequences (staticcheck ST1018) — same runes, now greppable and lint-clean. - Range over strings.SplitSeq instead of strings.Split in the relative-path fuzzer's traversal check (modernize). No change to what any seed exercises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * docs(changelog): note pre-release test hardening and the two bugs it found #1307 and #1308 landed fuzzing, migration/protocol/load tests, a blocking @parity e2e job, and a test-quality audit. Two of those were real product fixes (zero-dimension image headers, sanitizeUploadFilename) that belong in the release notes, not just the test log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(changelog): restore the alpha.5 behavioural notes dropped in the rewrite The v1.2.0-alpha.1 section replaced the v1.1.0-alpha.5 one wholesale, taking the LiveKit-proxy origin-gate and log-stream API-token bullets with it. Both fixes are in this release's code (#1293, #1294, #1295) — only their operator notes went missing, and an operator upgrading from alpha.3 would never have seen them. Restored verbatim from main. This is the sole content main had that dev lacked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): gate CREDENTIAL_FALLBACK_KEY_FILE to non-Windows `cargo clippy -- -D warnings` failed the Windows Tauri build with "constant CREDENTIAL_FALLBACK_KEY_FILE is never used". Its only consumer, `fallback_crypto`, is `#[cfg(not(windows))]` (lib.rs:6) because Windows seals fallback entries with DPAPI instead — so on Windows the constant is genuinely dead and -D warnings promotes that to an error. Gated the constant to match its consumer rather than silencing it with #[allow(dead_code)], so it still trips if it ever goes dead on the platforms that do use it. Latent on dev, not introduced here: Tauri Full Build is gated on base_ref == 'main', and the fast suite only compiles Rust on ubuntu (rust-tests runs on ubuntu-22.04), where fallback_crypto *is* compiled. Nothing built the Rust lib for Windows until this dev -> main PR. Verified locally on Windows: `cargo clippy -- -D warnings` and `cargo clippy --all-targets -- -D warnings` both exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(voice): stop writing a credential byte to the log on bad LiveKit config CodeQL go/clear-text-logging (high, alert #13): the YAML-safety check in generateConfig rejected a bad credential with fmt.Errorf("LiveKit credential contains unsafe YAML character %q", ch) where ch is a byte taken from LiveKitAPIKey or LiveKitAPISecret. Start() wraps that error and api/router.go logs it, so a byte of the API key or secret reached the server log in clear text. The check now uses strings.ContainsAny and names the offending config field instead of echoing the byte — strictly more useful to an operator, who previously got a character with no indication of which credential it came from. Same rejection set, so behaviour is otherwise unchanged. Adds a regression test asserting the error names the field and contains no part of either credential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(plugin): resolve UI asset paths at construction, not per request CodeQL go/path-injection (high, alerts #11 and #12): AssetHandler built the on-disk path from req.URL.Path on every request, then validated it with filepath.Rel. The validation was sound — traversal was already blocked by the manifest allowlist, the Rel check, and the serve-time Lstat — but a path was still being constructed from user input, which is the pattern the rule flags and the one that goes wrong when someone later edits the ordering. Each declared asset is now resolved and traversal-checked once, when the handler is built, into an asset-name -> absolute-path map. At serve time the request path is only ever a map key, so no filesystem path is derived from user input at all. An asset that fails validation is absent from the map and 404s, as an undeclared file already did. Also moves filepath.Abs/Join/Rel off the per-request path. The serve-time Lstat symlink and IsRegular checks stay exactly as they were — they close the post-install TOCTOU window and are still needed per request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(plugin): constrain default-build registry tests to !wazero registry_test.go opens "Registry lifecycle tests for the default (non-wazero) build" and asserts activation fails with ErrRuntimeUnavailable, but carried no build constraint. Under -tags wazero a real runtime is linked in, so TestRegistry_Activate_ WithoutRuntime and TestRegistry_EnablePlugin_RollsBackWhenActivationFails both failed. Nothing caught it: CI builds all three tag variants but only runs tests untagged, so these have been red under -tags wazero without surfacing. Adds the //go:build !wazero the file always implied, matching the sandbox_default.go / sandbox_wazero.go split already used here. Its helpers are used by no other file, so nothing else loses coverage; the wazero build keeps its own activation tests in sandbox_wazero_test.go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -278,6 +278,48 @@ jobs:
|
||||
Client/tauri-client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
|
||||
# the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW
|
||||
# gate, group DMs, role change, custom-emoji autocomplete, voice moderation).
|
||||
# These are new and authored green, so unlike the full legacy suite above they
|
||||
# gate PRs: a regression on one of these features must fail CI. Kept as its own
|
||||
# job (not folded into the non-blocking suite) so the legacy suite can keep
|
||||
# earning its "few green pushes" before it too graduates to blocking.
|
||||
client-e2e-parity:
|
||||
name: Client E2E (parity subset, blocking)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run parity e2e specs
|
||||
run: npx playwright test --config=playwright.config.ts --grep "@parity"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-report-parity
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Image build is verification only, so it is skipped on dev to keep day-to-day
|
||||
# work on the fast check suite. Runs for main pushes and PRs targeting main.
|
||||
server-docker-build:
|
||||
|
||||
@@ -135,6 +135,29 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run tauri build -- --bundles appimage,deb
|
||||
|
||||
# linuxdeploy bundles the runner's libwayland-* into the AppImage, which
|
||||
# breaks Mesa EGL init on newer hosts (white window on Arch/Fedora —
|
||||
# EGL_BAD_PARAMETER). Strip them and regenerate the updater artifact +
|
||||
# signatures for the patched image.
|
||||
- name: Strip host-incompatible libs from AppImage and re-sign
|
||||
working-directory: Client/tauri-client
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle/appimage"
|
||||
APPIMAGE=$(find "$BUNDLE_DIR" -name "*.AppImage" ! -name "*.sig" | head -1)
|
||||
bash scripts/strip-appimage-bundled-libs.sh "$APPIMAGE"
|
||||
TARBALL="$APPIMAGE.tar.gz"
|
||||
rm -f "$TARBALL" "$APPIMAGE.sig" "$TARBALL.sig"
|
||||
tar czf "$TARBALL" -C "$(dirname "$APPIMAGE")" "$(basename "$APPIMAGE")"
|
||||
KEY_PATH=$(mktemp)
|
||||
printf '%s' "$TAURI_SIGNING_PRIVATE_KEY" > "$KEY_PATH"
|
||||
trap 'rm -f "$KEY_PATH"' EXIT
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$APPIMAGE"
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$TARBALL"
|
||||
|
||||
- name: Stage Linux release assets
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -266,6 +289,26 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run tauri build -- --bundles appimage,deb
|
||||
|
||||
# Same strip + re-sign as the x86_64 job — see the comment there.
|
||||
- name: Strip host-incompatible libs from AppImage and re-sign
|
||||
working-directory: Client/tauri-client
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle/appimage"
|
||||
APPIMAGE=$(find "$BUNDLE_DIR" -name "*.AppImage" ! -name "*.sig" | head -1)
|
||||
bash scripts/strip-appimage-bundled-libs.sh "$APPIMAGE"
|
||||
TARBALL="$APPIMAGE.tar.gz"
|
||||
rm -f "$TARBALL" "$APPIMAGE.sig" "$TARBALL.sig"
|
||||
tar czf "$TARBALL" -C "$(dirname "$APPIMAGE")" "$(basename "$APPIMAGE")"
|
||||
KEY_PATH=$(mktemp)
|
||||
printf '%s' "$TAURI_SIGNING_PRIVATE_KEY" > "$KEY_PATH"
|
||||
trap 'rm -f "$KEY_PATH"' EXIT
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$APPIMAGE"
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$TARBALL"
|
||||
|
||||
- name: Stage Linux ARM64 release assets
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+119
-3
@@ -5,14 +5,118 @@ tooling (`npm run changelog`) auto-generates entries from commit messages
|
||||
on each release; this file is the curated counterpart that calls out
|
||||
behavioural changes operators must know about.
|
||||
|
||||
## Unreleased — v1.1.0-alpha series (Phase B + C)
|
||||
## v1.2.0-alpha.1 — Discord feature parity
|
||||
|
||||
> **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is
|
||||
> superseded; versioning continues forward as `v1.1.0-alpha.N` so deployed
|
||||
> servers and clients keep receiving updates. Releases are published to this
|
||||
> superseded; versioning continues forward from `v1.1.0-alpha.N` so deployed
|
||||
> servers and clients keep receiving updates. This release bumps the minor to
|
||||
> `v1.2.0-alpha.1` to mark a large feature drop. Releases are published to this
|
||||
> repository's [Releases](https://github.com/J3vb/OwnCord/releases) page,
|
||||
> including a full source snapshot with every release.
|
||||
|
||||
This release closes most of the feature gap against basic Discord (see
|
||||
[docs/plans/discord-parity.md](docs/plans/discord-parity.md) for the full
|
||||
gap analysis and per-item detail). The work landed as six phases plus a
|
||||
pre-release security and performance review.
|
||||
|
||||
### Messaging & mentions
|
||||
|
||||
- **Real mentions.** `@username` is now resolved server-side against unique
|
||||
usernames (address-shaped text like `mail@example` is rejected), stored per
|
||||
message, and carried on the wire — so a mention notifies, highlights the
|
||||
message, and drives a red per-channel mention badge distinct from the plain
|
||||
unread count. `@everyone` / `@here` are gated on a new `MENTION_EVERYONE`
|
||||
permission (`@here` skips offline and invisible users). `#channel` names
|
||||
render as clickable navigation chips, and the composer gains an `@`
|
||||
autocomplete.
|
||||
- **Markdown rendering.** Messages render Discord-flavoured markdown — bold,
|
||||
italic, underline, strikethrough, spoilers, block quotes, headings, lists,
|
||||
masked links (`http(s)` only), and fenced code blocks with a language tag
|
||||
and lightweight syntax highlighting. Rendering is a strict DOM builder with
|
||||
no `innerHTML`. `Ctrl+B/I/U` wrap the selection in the composer.
|
||||
- **Custom emoji.** Server emoji can be uploaded and managed (admin panel,
|
||||
`MANAGE_SERVER`); `:shortcode:` renders inline in messages (jumbo when a
|
||||
message is emoji-only), appears in the picker and a `:`-autocomplete, and can
|
||||
be used as a reaction.
|
||||
- **Message navigation.** Search results, pinned messages, reply previews, and
|
||||
message permalinks (`owncord://message/…`, copyable from the hover bar) all
|
||||
jump to the target — fetching a window around it when it is not loaded, with
|
||||
a "Jump to Present" affordance. Reactions show a who-reacted tooltip on
|
||||
hover, video and audio attachments get inline players, and a "NEW" divider
|
||||
plus explicit Mark as Read / Mark All as Read round out read state.
|
||||
- **Bulk delete.** `POST /channels/{id}/messages/purge` soft-deletes the newest
|
||||
N messages (`MANAGE_MESSAGES`), broadcasting one `chat_bulk_deleted` event.
|
||||
|
||||
### Roles, permissions & moderation
|
||||
|
||||
- **Role management.** Roles are now first-class: create, edit, delete, reorder,
|
||||
and edit permission masks and colours from the admin panel, all gated on
|
||||
`MANAGE_ROLES` and bounded by the actor's own position (you cannot touch a
|
||||
role at or above your rank, nor grant a permission bit your own role lacks).
|
||||
- **The permission bits are live.** The six previously-decorative bits
|
||||
(`MANAGE_CHANNELS`, `KICK_MEMBERS`, `MUTE_MEMBERS`, `MANAGE_ROLES`,
|
||||
`MANAGE_SERVER`, `VIEW_AUDIT_LOG`) are now enforced per admin route group, so
|
||||
a Moderator role can actually moderate without being a full Administrator.
|
||||
- **Per-user channel overrides.** Channel permissions resolve in Discord's
|
||||
order — base role → role override → user override — with a tri-state override
|
||||
matrix editor (role or user) in the admin panel.
|
||||
- **Voice moderation.** Holders of `MUTE_MEMBERS` can server-mute, server-deafen,
|
||||
move, or disconnect a lower-ranked user; a server mute is enforced at the SFU.
|
||||
- **Channel management from the desktop client.** Topics render and are editable,
|
||||
plus slowmode, an NSFW flag (with a per-session age gate), and voice
|
||||
user/video limits. Categories are now free text (any type under any name).
|
||||
|
||||
### Social & profiles
|
||||
|
||||
- **Profiles.** Avatar uploads (replacing letter-initials everywhere), display
|
||||
names (with the `@username` handle preserved for mentions), an about/bio, and
|
||||
a custom status line.
|
||||
- **Presence.** Invisible is now a real status that never leaks to other users
|
||||
and survives a reconnect (the previous flash-online-on-connect bug is fixed);
|
||||
a 10-minute auto-idle that never overrides a manual status.
|
||||
- **Group DMs** (2–10 participants, name, leave), **DM calls** with ringing
|
||||
(Call button + incoming-call banner over the existing DM voice path), and
|
||||
**per-channel notification mutes** (mentions still notify; other noise is
|
||||
silenced).
|
||||
- **Quick wins from phase 1.** Block/unblock from the member menu, temporary
|
||||
bans, server-driven role colours, a mounted profile popup, and archived
|
||||
channels that actually hide.
|
||||
|
||||
### Security & performance review (pre-release)
|
||||
|
||||
- Channel-override endpoints now enforce grantability: a `MANAGE_CHANNELS`
|
||||
holder cannot grant itself or a user a permission bit its own role lacks,
|
||||
closing a privilege-escalation path.
|
||||
- DM voice events (`voice_state`/`voice_leave`) are delivered only to the DM's
|
||||
participants instead of every user with base `READ_MESSAGES`.
|
||||
- Voice moderation cannot reach a private DM call the actor is not part of.
|
||||
- Mention-count bookkeeping is batched (one writer exec per 500 readers instead
|
||||
of one per reader) and resolved against a set; the markdown parser's
|
||||
bracket matching is amortized-linear; video/audio attachment blobs are
|
||||
LRU-capped and revoked, and cleared on logout.
|
||||
|
||||
### Test hardening (pre-release)
|
||||
|
||||
The hostile-input surface is now covered by Go native fuzzers and
|
||||
client-side property tests (mention/emoji parsing, FTS query sanitizing,
|
||||
permission resolution, markdown tokenizing, filename/path sanitizing,
|
||||
content sanitizing, credential validation, avatar URLs, LiveKit webhook
|
||||
identities), which found and fixed two real bugs:
|
||||
|
||||
- **Zero-dimension images are rejected.** A GIF decoding to height 0, and a
|
||||
VP8 keyframe with an all-zero size field, both passed the image size guard
|
||||
as "small". `imageDimensions` now rejects non-positive dimensions centrally.
|
||||
- **Upload filenames stay safe basenames.** `/` survived sanitizing verbatim
|
||||
(`filepath.Base("/")` is `"/"`), and over-length names were truncated
|
||||
mid-rune into invalid UTF-8. Both are fixed at the sanitizer.
|
||||
|
||||
Also added: a full migration-chain and pre-parity (019) upgrade round-trip
|
||||
test, a protocol-schema/generated-constant drift test, a 200-client hub
|
||||
load/soak test with `goleak` verification, and a blocking `@parity`
|
||||
Playwright job covering the new parity features. Separately, a test-quality
|
||||
audit rewired tests that asserted nothing (or a tautology) to assert their
|
||||
claimed behaviour — no product code changed and no assertion weakened.
|
||||
|
||||
### Phase B — Acceleration
|
||||
|
||||
- **Event persistence layer (Step 7).** A new `events` table backs the
|
||||
@@ -134,6 +238,18 @@ behavioural changes operators must know about.
|
||||
- **Plugin admin endpoints require admin session auth in addition to
|
||||
the existing IP restriction.** A previous prerelease shipped with only
|
||||
the IP gate; that has been corrected.
|
||||
- **The parity work adds nine database migrations (`020`–`028`) that apply
|
||||
automatically on first boot.** They add the `message_mentions`,
|
||||
`channel_user_overrides`, and emoji-supporting tables/columns, per-user
|
||||
profile fields (`display_name`, `about`, `custom_status`), channel flags
|
||||
(`nsfw`, `is_group`), and the `server_muted`/`server_deafened` voice-state
|
||||
columns; a migration also seeds the new `MENTION_EVERYONE` permission bit
|
||||
into the Owner/Admin/Moderator roles. No manual step is required, but take a
|
||||
backup before upgrading as usual. The release also introduces new WebSocket
|
||||
message types (`roles_update`, `emoji_update`, `chat_bulk_deleted`,
|
||||
`voice_mod_*`, `voice_moved`, `voice_disconnected`, `mark_read`,
|
||||
`call_ring`/`call_incoming`/`call_decline`); older clients ignore unknown
|
||||
types, and older servers omit the new fields (the client fails safe).
|
||||
|
||||
### Deferred work
|
||||
|
||||
|
||||
Generated
+43
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"version": "1.1.0-alpha.5",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "owncord-client",
|
||||
"version": "1.1.0-alpha.5",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"dependencies": {
|
||||
"@jitsi/rnnoise-wasm": "^0.2.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"eslint": "^10.8.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.1.1",
|
||||
"oxlint": "^1.76.0",
|
||||
@@ -5028,6 +5029,29 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
|
||||
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -6554,6 +6578,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "1.1.0-alpha.5",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -40,6 +40,7 @@
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"eslint": "^10.8.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.1.1",
|
||||
"oxlint": "^1.76.0",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Strip host-incompatible libraries from a Tauri-built AppImage.
|
||||
#
|
||||
# linuxdeploy bundles the build host's (Ubuntu 22.04) libwayland-* into the
|
||||
# AppImage and AppRun forces them onto LD_LIBRARY_PATH. Newer hosts' Mesa
|
||||
# dlopens libwayland-client during EGL init — picking up the stale bundled
|
||||
# copy makes eglGetDisplay fail (EGL_BAD_PARAMETER) and WebKit aborts,
|
||||
# leaving a white window. Every supported distro ships libwayland >= the
|
||||
# 1.20 the client links against, so the host copy is always the right one.
|
||||
# Verified 2026-07-31: stock alpha.5 AppImage white-screens on Arch; the
|
||||
# same image with these libs removed renders normally on Arch and Ubuntu.
|
||||
#
|
||||
# Usage: strip-appimage-bundled-libs.sh <path-to.AppImage>
|
||||
# Rewrites the AppImage in place (same filename). Signatures and updater
|
||||
# tar.gz artifacts must be regenerated afterwards by the caller.
|
||||
set -euo pipefail
|
||||
|
||||
APPIMAGE_PATH="${1:?usage: $0 <path-to.AppImage>}"
|
||||
APPIMAGE_PATH="$(readlink -f "$APPIMAGE_PATH")"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
APPIMAGETOOL="$WORKDIR/appimagetool"
|
||||
curl -fsSL -o "$APPIMAGETOOL" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
|
||||
chmod +x "$APPIMAGETOOL"
|
||||
|
||||
cd "$WORKDIR"
|
||||
"$APPIMAGE_PATH" --appimage-extract > /dev/null
|
||||
|
||||
removed=0
|
||||
for lib in squashfs-root/usr/lib/libwayland-*.so*; do
|
||||
[ -e "$lib" ] || continue
|
||||
echo "removing bundled $(basename "$lib")"
|
||||
rm -f "$lib"
|
||||
removed=$((removed + 1))
|
||||
done
|
||||
if [ "$removed" -eq 0 ]; then
|
||||
echo "::warning::no bundled libwayland-* found in $APPIMAGE_PATH — linuxdeploy may have stopped bundling it; strip step is now a no-op"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --appimage-extract-and-run: run without FUSE (CI containers/runners).
|
||||
# ARCH is required when repacking on a host arch that differs from the
|
||||
# payload naming; here it always matches the runner.
|
||||
ARCH="$ARCH" "$APPIMAGETOOL" --appimage-extract-and-run --no-appstream \
|
||||
squashfs-root "$WORKDIR/repacked.AppImage"
|
||||
mv "$WORKDIR/repacked.AppImage" "$APPIMAGE_PATH"
|
||||
echo "stripped $removed bundled wayland libs from $(basename "$APPIMAGE_PATH")"
|
||||
Generated
+2
-1
@@ -3274,7 +3274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owncord-client"
|
||||
version = "1.1.0-alpha.5"
|
||||
version = "1.2.0-alpha.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"device_query",
|
||||
@@ -3306,6 +3306,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webpki-roots 1.0.9",
|
||||
"windows 0.58.0",
|
||||
"windows-sys 0.60.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "1.1.0-alpha.5"
|
||||
version = "1.2.0-alpha.1"
|
||||
edition = "2021"
|
||||
# Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate
|
||||
# cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver
|
||||
@@ -117,3 +117,11 @@ windows-sys = { version = "0.60", features = [
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
device_query = "2"
|
||||
# Direct access to the WebKitGTK webview for voice/video support. WebKitGTK
|
||||
# denies getUserMedia/enumerateDevices permission requests by default (wry
|
||||
# installs no handler on Linux, unlike its macOS backend which auto-grants),
|
||||
# and ships with media-stream/WebRTC settings off — so microphones and cameras
|
||||
# are invisible to the webview without this hook. Version-pinned to match
|
||||
# wry's own `=2.0.2` pin so both link the same crate build; v2_38 gates the
|
||||
# enable-webrtc setting.
|
||||
webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
|
||||
|
||||
@@ -8,6 +8,16 @@ pub const IDENTITY_PINS_STORE: &str = "identity_pins.json";
|
||||
pub const SETTINGS_STORE: &str = "settings.json";
|
||||
|
||||
/// Tauri store file for the degraded-mode credential fallback (see
|
||||
/// `secret_store`). Values are DPAPI ciphertext, never plaintext, and the file
|
||||
/// only exists on a machine whose OS credential store failed a round-trip.
|
||||
/// `secret_store`). Values are ciphertext (DPAPI on Windows, ChaCha20-Poly1305
|
||||
/// elsewhere), never plaintext, and the file only exists on a machine whose OS
|
||||
/// credential store failed a round-trip.
|
||||
pub const CREDENTIAL_FALLBACK_STORE: &str = "credential_fallback.json";
|
||||
|
||||
/// Per-install key that seals the non-Windows credential fallback entries
|
||||
/// (see `fallback_crypto`). Written once, owner-only (0600).
|
||||
///
|
||||
/// Gated to match its only consumer: `fallback_crypto` is `cfg(not(windows))`
|
||||
/// because Windows seals fallback entries with DPAPI instead, so on Windows
|
||||
/// this constant would be dead code and `-D warnings` fails the build.
|
||||
#[cfg(not(windows))]
|
||||
pub const CREDENTIAL_FALLBACK_KEY_FILE: &str = "credential_fallback.key";
|
||||
|
||||
@@ -161,9 +161,10 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
|
||||
/// Save the long-term identity private key for `host`.
|
||||
///
|
||||
/// The write is read back before this returns. A machine whose credential store
|
||||
/// accepts writes without keeping them falls through to the DPAPI file; if that
|
||||
/// is also unavailable this returns an error rather than reporting a success
|
||||
/// that would leave peers rejecting the user's voice announce after a restart.
|
||||
/// accepts writes without keeping them falls through to the encrypted fallback
|
||||
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
|
||||
/// unavailable this returns an error rather than reporting a success that would
|
||||
/// leave peers rejecting the user's voice announce after a restart.
|
||||
#[tauri::command]
|
||||
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Encryption for the non-Windows credential fallback file.
|
||||
//!
|
||||
//! Windows parks fallback secrets behind DPAPI, whose key lives with the OS.
|
||||
//! macOS and Linux have no DPAPI equivalent that works while the Keychain /
|
||||
//! Secret Service itself is the thing that failed, so this module seals
|
||||
//! secrets with ChaCha20-Poly1305 (via `ring`, already in the tree) under a
|
||||
//! per-install random key stored next to the app data (mode 0600).
|
||||
//!
|
||||
//! This is damage control, not a vault: an attacker who can read both the key
|
||||
//! file and the fallback store as this user has the secrets, exactly as they
|
||||
//! would with DPAPI under the same user account. What it buys is (a) secrets
|
||||
//! at rest are never plaintext, (b) a copied fallback store is useless without
|
||||
//! the key file beside it, and (c) an entry cannot be moved between accounts
|
||||
//! — the account name is bound in as AEAD associated data, mirroring the DPAPI
|
||||
//! entropy on Windows. The OS credential store always remains the primary
|
||||
//! store; this file only ever holds entries whose keychain write failed a
|
||||
//! verified round-trip (see `secret_store`).
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305, NONCE_LEN};
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
|
||||
use crate::constants::CREDENTIAL_FALLBACK_KEY_FILE;
|
||||
|
||||
/// Size of the sealing key in bytes (ChaCha20-Poly1305).
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// Load the per-install sealing key from `dir`, creating it on first use.
|
||||
///
|
||||
/// The key file is written with owner-only permissions (0600) and never
|
||||
/// rewritten once it exists — losing it orphans every sealed entry, which the
|
||||
/// caller treats the same as an absent entry.
|
||||
pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE);
|
||||
|
||||
match fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let key: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| {
|
||||
format!(
|
||||
"credential fallback key file has {} bytes, expected {KEY_LEN} — \
|
||||
refusing to use it",
|
||||
bytes.len()
|
||||
)
|
||||
})?;
|
||||
return Ok(key);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("failed to read credential fallback key: {e}")),
|
||||
}
|
||||
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
SystemRandom::new()
|
||||
.fill(&mut key)
|
||||
.map_err(|_| "system RNG failed generating the fallback key".to_string())?;
|
||||
|
||||
fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("failed to create app data dir for fallback key: {e}"))?;
|
||||
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(&key)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|e| format!("failed to write credential fallback key: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
// Lost the create race to another thread — use the winner's key.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|e| format!("failed to re-read credential fallback key: {e}"))?;
|
||||
bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "concurrently written fallback key has the wrong size".to_string())
|
||||
}
|
||||
Err(e) => Err(format!("failed to create credential fallback key: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seal `plaintext` under `key`, binding `aad` (the service + account name).
|
||||
///
|
||||
/// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random
|
||||
/// per call; at the fallback store's write volume (a handful per login) the
|
||||
/// birthday bound on 96-bit nonces is not a concern.
|
||||
pub fn protect(key: &[u8; KEY_LEN], plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let unbound = UnboundKey::new(&CHACHA20_POLY1305, key)
|
||||
.map_err(|_| "failed to build the fallback sealing key".to_string())?;
|
||||
let sealing = LessSafeKey::new(unbound);
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
SystemRandom::new()
|
||||
.fill(&mut nonce_bytes)
|
||||
.map_err(|_| "system RNG failed generating a nonce".to_string())?;
|
||||
let nonce = Nonce::assume_unique_for_key(nonce_bytes);
|
||||
|
||||
let mut in_out = plaintext.to_vec();
|
||||
sealing
|
||||
.seal_in_place_append_tag(nonce, Aad::from(aad), &mut in_out)
|
||||
.map_err(|_| "sealing the fallback entry failed".to_string())?;
|
||||
|
||||
let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
|
||||
blob.extend_from_slice(&nonce_bytes);
|
||||
blob.append(&mut in_out);
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
/// Open a blob produced by [`protect`]. Fails on tampering, a wrong key, or a
|
||||
/// blob moved to a different account's slot (AAD mismatch).
|
||||
pub fn unprotect(key: &[u8; KEY_LEN], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if blob.len() < NONCE_LEN + CHACHA20_POLY1305.tag_len() {
|
||||
return Err("fallback entry is too short to be a sealed blob".to_string());
|
||||
}
|
||||
let unbound = UnboundKey::new(&CHACHA20_POLY1305, key)
|
||||
.map_err(|_| "failed to build the fallback sealing key".to_string())?;
|
||||
let opening = LessSafeKey::new(unbound);
|
||||
|
||||
let nonce_bytes: [u8; NONCE_LEN] = blob[..NONCE_LEN].try_into().expect("length checked");
|
||||
let nonce = Nonce::assume_unique_for_key(nonce_bytes);
|
||||
|
||||
let mut in_out = blob[NONCE_LEN..].to_vec();
|
||||
let plaintext = opening
|
||||
.open_in_place(nonce, Aad::from(aad), &mut in_out)
|
||||
.map_err(|_| {
|
||||
"fallback entry failed authentication — wrong key, tampered data, or an entry \
|
||||
moved between accounts"
|
||||
.to_string()
|
||||
})?;
|
||||
Ok(plaintext.to_vec())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> [u8; KEY_LEN] {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
SystemRandom::new().fill(&mut key).unwrap();
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_a_secret() {
|
||||
let key = test_key();
|
||||
let blob = protect(&key, b"hunter2", b"aad").unwrap();
|
||||
assert_ne!(&blob[NONCE_LEN..], b"hunter2", "blob must not be plaintext");
|
||||
assert_eq!(unprotect(&key, &blob, b"aad").unwrap(), b"hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_foreign_aad() {
|
||||
// A blob moved to another account's slot must not decrypt — the same
|
||||
// property dpapi_entropy provides on Windows.
|
||||
let key = test_key();
|
||||
let blob = protect(&key, b"secret", b"com.owncord.client\x01a.example").unwrap();
|
||||
assert!(unprotect(&key, &blob, b"com.owncord.client\x01b.example").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_wrong_key_and_tampering() {
|
||||
let key = test_key();
|
||||
let blob = protect(&key, b"secret", b"aad").unwrap();
|
||||
|
||||
let other = test_key();
|
||||
assert!(unprotect(&other, &blob, b"aad").is_err());
|
||||
|
||||
let mut tampered = blob.clone();
|
||||
let last = tampered.len() - 1;
|
||||
tampered[last] ^= 0x01;
|
||||
assert!(unprotect(&key, &tampered, b"aad").is_err());
|
||||
|
||||
assert!(unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(), "truncated blob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonces_are_unique_per_seal() {
|
||||
let key = test_key();
|
||||
let a = protect(&key, b"same", b"aad").unwrap();
|
||||
let b = protect(&key, b"same", b"aad").unwrap();
|
||||
assert_ne!(a, b, "two seals of the same plaintext must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_and_reuses_the_key_file() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"owncord-fallback-key-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
let first = load_or_create_key(&dir).unwrap();
|
||||
let second = load_or_create_key(&dir).unwrap();
|
||||
assert_eq!(first, second, "the key must be stable across loads");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(dir.join(CREDENTIAL_FALLBACK_KEY_FILE))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "key file must be owner-only");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_corrupt_key_file() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"owncord-fallback-badkey-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join(CREDENTIAL_FALLBACK_KEY_FILE), b"short").unwrap();
|
||||
|
||||
let err = load_or_create_key(&dir).unwrap_err();
|
||||
assert!(err.contains("expected 32"), "unexpected error: {err}");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ mod constants;
|
||||
mod credentials;
|
||||
#[cfg(windows)]
|
||||
mod dpapi;
|
||||
#[cfg(not(windows))]
|
||||
mod fallback_crypto;
|
||||
mod http_proxy;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_media;
|
||||
mod livekit_proxy;
|
||||
mod ptt;
|
||||
mod secret_store;
|
||||
@@ -135,6 +139,10 @@ pub fn run() {
|
||||
// persistent store, every later credential symptom follows from it.
|
||||
secret_store::log_compiled_backend();
|
||||
tray::create_tray(app.handle())?;
|
||||
// WebKitGTK denies mic/camera access by default — grant it so
|
||||
// voice/video works on Linux (no-op elsewhere; see linux_media).
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_media::enable_media_capture(app.handle());
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Linux-only WebKitGTK media capture support.
|
||||
//!
|
||||
//! On Windows and macOS the webview grants media capture itself (wry's
|
||||
//! WKWebView delegate auto-grants; WebView2 prompts). WebKitGTK does
|
||||
//! neither: `enable-media-stream` and `enable-webrtc` default to off, and
|
||||
//! any `permission-request` signal without a handler is denied. The result
|
||||
//! is that `navigator.mediaDevices.getUserMedia` fails and
|
||||
//! `enumerateDevices` returns nothing — no microphones or cameras are ever
|
||||
//! detected on Linux without this hook.
|
||||
//!
|
||||
//! Only media-related permission requests are granted here; everything else
|
||||
//! (geolocation, web notifications, …) falls through to WebKit's default
|
||||
//! deny so this hook does not widen the webview's surface beyond capture.
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Enable media streams / WebRTC on the main window's WebKitGTK webview and
|
||||
/// auto-grant its microphone/camera permission requests.
|
||||
pub fn enable_media_capture(app: &AppHandle) {
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
log::error!("linux_media: main window not found; media capture stays unavailable");
|
||||
return;
|
||||
};
|
||||
let result = window.with_webview(|webview| {
|
||||
use webkit2gtk::glib::prelude::Cast;
|
||||
use webkit2gtk::{
|
||||
DeviceInfoPermissionRequest, PermissionRequestExt, SettingsExt,
|
||||
UserMediaPermissionRequest, WebViewExt,
|
||||
};
|
||||
|
||||
let webview = webview.inner();
|
||||
if let Some(settings) = webview.settings() {
|
||||
settings.set_enable_media_stream(true);
|
||||
settings.set_enable_webrtc(true);
|
||||
} else {
|
||||
log::error!("linux_media: webview has no settings object");
|
||||
}
|
||||
webview.connect_permission_request(|_, request| {
|
||||
// UserMediaPermissionRequest covers getUserMedia (mic/camera);
|
||||
// DeviceInfoPermissionRequest covers enumerateDevices labels.
|
||||
let is_media = request.downcast_ref::<UserMediaPermissionRequest>().is_some()
|
||||
|| request.downcast_ref::<DeviceInfoPermissionRequest>().is_some();
|
||||
if is_media {
|
||||
request.allow();
|
||||
return true;
|
||||
}
|
||||
// Unhandled — WebKit applies its default (deny).
|
||||
false
|
||||
});
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::error!("linux_media: failed to configure webview media capture: {e}");
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,20 @@
|
||||
//!
|
||||
//! The keychain is the right store; the fallback is damage control, not a
|
||||
//! default. It engages only after a write has been proven not to round-trip,
|
||||
//! and only on Windows, where DPAPI can protect the file at rest with a
|
||||
//! user-scoped key. On macOS and Linux a failing Keychain / Secret Service is
|
||||
//! reported as an error rather than silently downgraded to a file — writing a
|
||||
//! login password or an identity private key to plaintext disk there would be a
|
||||
//! worse outcome than not persisting it.
|
||||
//! on every desktop platform. On Windows the fallback file is protected by
|
||||
//! DPAPI (user-scoped, key held by the OS). On macOS and Linux — where the
|
||||
//! thing that failed *is* the OS secret store, so no OS-held key is available
|
||||
//! — entries are sealed with ChaCha20-Poly1305 under a per-install random key
|
||||
//! file (owner-only, see [`crate::fallback_crypto`]). That is honest
|
||||
//! damage-control, not a vault: same-user malware can read both files, exactly
|
||||
//! as it could call DPAPI. What it fixes is the real-world failure this module
|
||||
//! kept hitting — a Linux desktop with no Secret Service provider (no
|
||||
//! gnome-keyring / KWallet) or a locked macOS Keychain previously had nowhere
|
||||
//! to save at all, so credentials and the voice-E2EE identity key silently
|
||||
//! never survived a restart. Secrets at rest are never plaintext, and the OS
|
||||
//! credential store always wins again the moment it starts round-tripping.
|
||||
|
||||
use serde::Serialize;
|
||||
// Only the DPAPI fallback stores JSON values, and that is Windows-only.
|
||||
#[cfg(windows)]
|
||||
use serde_json::Value;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
@@ -59,11 +64,24 @@ pub const SERVICE: &str = "com.owncord.client";
|
||||
pub enum Backend {
|
||||
/// The OS credential store. The expected answer on every healthy machine.
|
||||
Keyring,
|
||||
/// DPAPI-protected file under the app data dir, used only after the OS
|
||||
/// credential store accepted a write and then failed to return it.
|
||||
/// DPAPI-protected file under the app data dir (Windows), used only after
|
||||
/// the OS credential store accepted a write and then failed to return it.
|
||||
// Constructed only on its own platform; both variants exist everywhere so
|
||||
// the serialized Backend union is identical across OS builds.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
DpapiFile,
|
||||
/// ChaCha20-Poly1305-sealed file under the app data dir (macOS/Linux),
|
||||
/// engaged under the same failed-round-trip condition as `DpapiFile`.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
EncryptedFile,
|
||||
}
|
||||
|
||||
/// The fallback backend this platform's build parks degraded secrets in.
|
||||
#[cfg(windows)]
|
||||
const FALLBACK_BACKEND: Backend = Backend::DpapiFile;
|
||||
#[cfg(not(windows))]
|
||||
const FALLBACK_BACKEND: Backend = Backend::EncryptedFile;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,10 +132,10 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
|
||||
|
||||
set_fallback(app, account, secret)?;
|
||||
log::warn!(
|
||||
"{SERVICE}: account '{account}' is stored in the DPAPI fallback file, not the OS \
|
||||
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
|
||||
credential store. See docs/credential-storage.md"
|
||||
);
|
||||
Ok(Backend::DpapiFile)
|
||||
Ok(FALLBACK_BACKEND)
|
||||
}
|
||||
|
||||
/// Load the secret for `account`, or `None` when nothing is stored.
|
||||
@@ -218,25 +236,64 @@ fn keyring_delete(account: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Degraded-mode fallback (Windows only, DPAPI-protected)
|
||||
// Degraded-mode fallback (all desktop platforms; sealing differs per OS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Entropy bound into the DPAPI blob for `account`.
|
||||
/// Associated data bound into the sealed blob for `account` (the DPAPI
|
||||
/// "entropy" on Windows, the AEAD AAD elsewhere).
|
||||
///
|
||||
/// Including the service and account means a ciphertext lifted from one entry
|
||||
/// cannot be pasted over another and still decrypt — the identity key for one
|
||||
/// host cannot be made to load as another's.
|
||||
#[cfg(windows)]
|
||||
fn dpapi_entropy(account: &str) -> Vec<u8> {
|
||||
fn fallback_aad(account: &str) -> Vec<u8> {
|
||||
format!("{SERVICE}\u{1}{account}").into_bytes()
|
||||
}
|
||||
|
||||
/// Seal `secret` for the fallback store. Windows: DPAPI (user-scoped, OS-held
|
||||
/// key). Elsewhere: ChaCha20-Poly1305 under the per-install key file.
|
||||
#[cfg(windows)]
|
||||
fn protect_secret(_app: &AppHandle, account: &str, secret: &str) -> Result<Vec<u8>, String> {
|
||||
crate::dpapi::protect(secret.as_bytes(), &fallback_aad(account))
|
||||
.map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn protect_secret(app: &AppHandle, account: &str, secret: &str) -> Result<Vec<u8>, String> {
|
||||
use tauri::Manager;
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("cannot resolve the app data dir for the fallback key: {e}"))?;
|
||||
let key = crate::fallback_crypto::load_or_create_key(&dir)?;
|
||||
crate::fallback_crypto::protect(&key, secret.as_bytes(), &fallback_aad(account))
|
||||
}
|
||||
|
||||
/// Open a blob written by [`protect_secret`]. Errors are logged by the caller.
|
||||
#[cfg(windows)]
|
||||
fn unprotect_secret(_app: &AppHandle, account: &str, blob: &[u8]) -> Result<Vec<u8>, String> {
|
||||
crate::dpapi::unprotect(blob, &fallback_aad(account)).map_err(|code| {
|
||||
format!(
|
||||
"DPAPI unprotect failed (Win32 error {code}) — the entry was written by a \
|
||||
different Windows user or on a different machine"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn unprotect_secret(app: &AppHandle, account: &str, blob: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use tauri::Manager;
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("cannot resolve the app data dir for the fallback key: {e}"))?;
|
||||
let key = crate::fallback_crypto::load_or_create_key(&dir)?;
|
||||
crate::fallback_crypto::unprotect(&key, blob, &fallback_aad(account))
|
||||
}
|
||||
|
||||
fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), String> {
|
||||
use base64::Engine as _;
|
||||
|
||||
let blob = crate::dpapi::protect(secret.as_bytes(), &dpapi_entropy(account))
|
||||
.map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))?;
|
||||
let blob = protect_secret(app, account, secret)?;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(blob);
|
||||
|
||||
let store = app
|
||||
@@ -258,20 +315,6 @@ fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_fallback(_app: &AppHandle, account: &str, _secret: &str) -> Result<(), String> {
|
||||
// Deliberately no file fallback here: see the module header. The Keychain
|
||||
// and Secret Service are the right stores on these platforms, and a
|
||||
// plaintext file holding a login password or an identity private key is a
|
||||
// worse outcome than failing to persist.
|
||||
Err(format!(
|
||||
"the OS credential store did not accept '{account}' and there is no fallback store on \
|
||||
this platform — check that the Keychain (macOS) or a Secret Service provider such as \
|
||||
gnome-keyring / KWallet (Linux) is running and unlocked"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_fallback(app: &AppHandle, account: &str) -> Option<String> {
|
||||
use base64::Engine as _;
|
||||
|
||||
@@ -287,22 +330,14 @@ fn get_fallback(app: &AppHandle, account: &str) -> Option<String> {
|
||||
.decode(encoded)
|
||||
.map_err(|e| log::warn!("credential fallback entry for '{account}' is not base64: {e}"))
|
||||
.ok()?;
|
||||
let plaintext = crate::dpapi::unprotect(&blob, &dpapi_entropy(account))
|
||||
.map_err(|code| {
|
||||
log::warn!("DPAPI unprotect failed for '{account}' (Win32 error {code}) — the entry \
|
||||
was written by a different Windows user or on a different machine")
|
||||
})
|
||||
let plaintext = unprotect_secret(app, account, &blob)
|
||||
.map_err(|e| log::warn!("credential fallback entry for '{account}' did not open: {e}"))
|
||||
.ok()?;
|
||||
String::from_utf8(plaintext)
|
||||
.map_err(|_| log::warn!("credential fallback entry for '{account}' is not valid UTF-8"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn get_fallback(_app: &AppHandle, _account: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Drop any fallback copy of `account`. Best-effort: a failure here is logged,
|
||||
/// never propagated, because it must not mask the outcome of the real store.
|
||||
fn clear_fallback(app: &AppHandle, account: &str) {
|
||||
@@ -353,9 +388,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Pins the IPC wire format to the variant names, which is what
|
||||
/// `tauri-typegen` emits into `generated/types.ts` as
|
||||
/// `type Backend = "Keyring" | "DpapiFile"`. Renaming a variant, or adding
|
||||
/// a serde rename, desyncs the generated union from the runtime value.
|
||||
/// `tauri-typegen` emits into `generated/types.ts`. Renaming a variant, or
|
||||
/// adding a serde rename, desyncs the generated union from the runtime
|
||||
/// value.
|
||||
#[test]
|
||||
fn backend_serializes_as_its_variant_name() {
|
||||
assert_eq!(
|
||||
@@ -366,26 +401,29 @@ mod tests {
|
||||
serde_json::to_string(&Backend::DpapiFile).unwrap(),
|
||||
"\"DpapiFile\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Backend::EncryptedFile).unwrap(),
|
||||
"\"EncryptedFile\""
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn dpapi_entropy_is_account_specific() {
|
||||
assert_ne!(dpapi_entropy("host.example"), dpapi_entropy("identity:host.example"));
|
||||
assert_eq!(dpapi_entropy("host.example"), dpapi_entropy("host.example"));
|
||||
fn fallback_aad_is_account_specific() {
|
||||
assert_ne!(fallback_aad("host.example"), fallback_aad("identity:host.example"));
|
||||
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn dpapi_round_trips_and_rejects_foreign_entropy() {
|
||||
let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0";
|
||||
let blob = crate::dpapi::protect(secret, &dpapi_entropy("identity:a.example")).unwrap();
|
||||
let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap();
|
||||
assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext");
|
||||
|
||||
let back = crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:a.example")).unwrap();
|
||||
let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap();
|
||||
assert_eq!(back, secret);
|
||||
|
||||
// A blob moved to another account's slot must not decrypt.
|
||||
assert!(crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:b.example")).is_err());
|
||||
assert!(crate::dpapi::unprotect(&blob, &fallback_aad("identity:b.example")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "1.1.0-alpha.5",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* AdminActions — context menu helpers for admin operations on members and channels.
|
||||
* Provides confirmation steps for destructive actions (kick, ban, delete).
|
||||
* Provides confirmation steps for destructive actions (force logout, ban, delete).
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { appendPurgeSection } from "./purge-prompt";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -14,18 +15,51 @@ export interface MemberContextMenuOptions {
|
||||
username: string;
|
||||
currentRole: string;
|
||||
availableRoles: readonly string[];
|
||||
/** When false, only the non-admin actions (block/unblock) are rendered. */
|
||||
showAdminActions: boolean;
|
||||
/**
|
||||
* Per-action gates, each defaulting to `showAdminActions`. They mirror the
|
||||
* server's KICK_MEMBERS / BAN_MEMBERS / MANAGE_ROLES bits so a moderator
|
||||
* sees only the actions its role actually holds. canKick gates "Force
|
||||
* Logout" — the KICK_MEMBERS bit buys session revocation, not removal.
|
||||
*/
|
||||
canKick?: boolean;
|
||||
canBan?: boolean;
|
||||
canManageRoles?: boolean;
|
||||
/** Whether the local user currently blocks this member (labels the toggle). */
|
||||
isBlocked: boolean;
|
||||
onToggleBlock(): Promise<void>;
|
||||
/** Revokes every session the target holds (the "Force Logout" item). */
|
||||
onKick(): Promise<void>;
|
||||
/** The reason is stored and displayed by the server; empty means "no reason given". */
|
||||
onBan(reason: string): Promise<void>;
|
||||
/**
|
||||
* The reason is stored and displayed by the server; empty means "no reason
|
||||
* given". durationHours 0 = permanent, otherwise the ban auto-expires.
|
||||
*/
|
||||
onBan(reason: string, durationHours: number): Promise<void>;
|
||||
onChangeRole(newRole: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Ban duration choices offered in the ban flow (label → hours; 0 = permanent). */
|
||||
const BAN_DURATIONS: readonly { readonly label: string; readonly hours: number }[] = [
|
||||
{ label: "Forever", hours: 0 },
|
||||
{ label: "1 hour", hours: 1 },
|
||||
{ label: "1 day", hours: 24 },
|
||||
{ label: "7 days", hours: 24 * 7 },
|
||||
{ label: "30 days", hours: 24 * 30 },
|
||||
] as const;
|
||||
|
||||
export interface ChannelContextMenuOptions {
|
||||
channelId: number;
|
||||
channelName: string;
|
||||
onEdit(): void;
|
||||
onDelete(): Promise<void>;
|
||||
onCreate(): void;
|
||||
/**
|
||||
* Bulk-delete the newest `count` messages. Omitted when the local user's
|
||||
* role lacks MANAGE_MESSAGES — the section is then not rendered at all,
|
||||
* mirroring the server's gate.
|
||||
*/
|
||||
onPurge?(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface ContextMenuResult {
|
||||
@@ -60,7 +94,7 @@ const CONFIRM_TIMEOUT_MS = 4000;
|
||||
*
|
||||
* The armed state auto-disarms after a few seconds so a menu left open doesn't
|
||||
* turn a stray second click into a ban, and the item shows progress while the
|
||||
* request is running — a slow kick used to look like nothing happened.
|
||||
* request is running — a slow force logout used to look like nothing happened.
|
||||
*/
|
||||
function withConfirmation(
|
||||
item: HTMLDivElement,
|
||||
@@ -130,66 +164,155 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
const ac = new AbortController();
|
||||
const menu = createElement("div", { class: "context-menu" });
|
||||
|
||||
// Role submenu trigger
|
||||
const roleItem = createElement(
|
||||
// Block / Unblock — available to every member, not just admins. Blocking is
|
||||
// disruptive (kills DMs both ways) so it confirms; unblocking is one click.
|
||||
const blockItem = createElement(
|
||||
"div",
|
||||
{
|
||||
class: "context-menu__item",
|
||||
class: options.isBlocked
|
||||
? "context-menu__item"
|
||||
: "context-menu__item context-menu__item--danger",
|
||||
"data-testid": "block-toggle",
|
||||
},
|
||||
"Change Role",
|
||||
options.isBlocked ? "Unblock" : "Block",
|
||||
);
|
||||
|
||||
const roleSub = createElement("div", { class: "context-menu__submenu" });
|
||||
for (const role of options.availableRoles) {
|
||||
const cls =
|
||||
role === options.currentRole
|
||||
? "context-menu__item context-menu__item--active"
|
||||
: "context-menu__item";
|
||||
const roleOption = createMenuItem(
|
||||
role,
|
||||
cls,
|
||||
() => {
|
||||
if (role !== options.currentRole) {
|
||||
void options.onChangeRole(role);
|
||||
}
|
||||
if (options.isBlocked) {
|
||||
let unblockRunning = false;
|
||||
blockItem.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (unblockRunning) return;
|
||||
unblockRunning = true;
|
||||
setText(blockItem, "Unblocking...");
|
||||
blockItem.classList.add("context-menu__item--pending");
|
||||
const done = (): void => {
|
||||
unblockRunning = false;
|
||||
blockItem.classList.remove("context-menu__item--pending");
|
||||
setText(blockItem, "Unblock");
|
||||
};
|
||||
void options.onToggleBlock().then(done, done);
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
} else {
|
||||
withConfirmation(
|
||||
blockItem,
|
||||
"Are you sure?",
|
||||
() => options.onToggleBlock(),
|
||||
ac.signal,
|
||||
"Blocking...",
|
||||
);
|
||||
roleSub.appendChild(roleOption);
|
||||
}
|
||||
|
||||
roleItem.addEventListener(
|
||||
"mouseenter",
|
||||
() => {
|
||||
roleSub.style.display = "";
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
roleItem.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
roleSub.style.display = "none";
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
const canManageRoles = options.canManageRoles ?? options.showAdminActions;
|
||||
const canKick = options.canKick ?? options.showAdminActions;
|
||||
const canBan = options.canBan ?? options.showAdminActions;
|
||||
|
||||
roleSub.style.display = "none";
|
||||
appendChildren(roleItem, roleSub);
|
||||
menu.appendChild(roleItem);
|
||||
if (!options.showAdminActions || (!canManageRoles && !canKick && !canBan)) {
|
||||
menu.appendChild(blockItem);
|
||||
return {
|
||||
element: menu,
|
||||
destroy(): void {
|
||||
ac.abort();
|
||||
menu.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Role submenu trigger
|
||||
if (canManageRoles) {
|
||||
const roleItem = createElement(
|
||||
"div",
|
||||
{
|
||||
class: "context-menu__item",
|
||||
},
|
||||
"Change Role",
|
||||
);
|
||||
|
||||
const roleSub = createElement("div", { class: "context-menu__submenu" });
|
||||
for (const role of options.availableRoles) {
|
||||
const cls =
|
||||
role === options.currentRole
|
||||
? "context-menu__item context-menu__item--active"
|
||||
: "context-menu__item";
|
||||
const roleOption = createMenuItem(
|
||||
role,
|
||||
cls,
|
||||
() => {
|
||||
if (role !== options.currentRole) {
|
||||
void options.onChangeRole(role);
|
||||
}
|
||||
},
|
||||
ac.signal,
|
||||
);
|
||||
roleSub.appendChild(roleOption);
|
||||
}
|
||||
|
||||
roleItem.addEventListener(
|
||||
"mouseenter",
|
||||
() => {
|
||||
roleSub.style.display = "";
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
roleItem.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
roleSub.style.display = "none";
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
roleSub.style.display = "none";
|
||||
appendChildren(roleItem, roleSub);
|
||||
menu.appendChild(roleItem);
|
||||
|
||||
menu.appendChild(createSeparator());
|
||||
}
|
||||
|
||||
// Force Logout with confirmation. Named for what it does: the server revokes
|
||||
// the target's sessions (KICK_MEMBERS), it does not remove a membership —
|
||||
// there is no membership model — so the user can sign straight back in.
|
||||
if (canKick) {
|
||||
const kickItem = createElement(
|
||||
"div",
|
||||
{
|
||||
class: "context-menu__item context-menu__item--danger",
|
||||
"data-testid": "force-logout",
|
||||
},
|
||||
"Force Logout",
|
||||
);
|
||||
withConfirmation(
|
||||
kickItem,
|
||||
"Log them out?",
|
||||
() => options.onKick(),
|
||||
ac.signal,
|
||||
"Logging out...",
|
||||
);
|
||||
menu.appendChild(kickItem);
|
||||
}
|
||||
|
||||
if (canBan) appendBanFlow(menu, options, ac.signal);
|
||||
|
||||
menu.appendChild(createSeparator());
|
||||
menu.appendChild(blockItem);
|
||||
|
||||
// Kick with confirmation
|
||||
const kickItem = createElement(
|
||||
"div",
|
||||
{
|
||||
class: "context-menu__item context-menu__item--danger",
|
||||
},
|
||||
"Kick",
|
||||
);
|
||||
withConfirmation(kickItem, "Are you sure?", () => options.onKick(), ac.signal, "Kicking...");
|
||||
menu.appendChild(kickItem);
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
menu.remove();
|
||||
}
|
||||
|
||||
return { element: menu, destroy };
|
||||
}
|
||||
|
||||
/** Ban entry plus its reason/duration form. Split out so the member menu can
|
||||
* omit it wholesale for an actor without BAN_MEMBERS. */
|
||||
function appendBanFlow(
|
||||
menu: HTMLDivElement,
|
||||
options: MemberContextMenuOptions,
|
||||
signal: AbortSignal,
|
||||
): void {
|
||||
// Ban — collects the reason the server stores and displays alongside the ban.
|
||||
const banItem = createElement(
|
||||
"div",
|
||||
@@ -210,12 +333,21 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
"data-testid": "ban-reason-input",
|
||||
style: "width:100%;font-size:12px",
|
||||
});
|
||||
const banDurationSelect = createElement("select", {
|
||||
class: "form-input",
|
||||
"data-testid": "ban-duration-select",
|
||||
style: "width:100%;font-size:12px;margin-top:4px",
|
||||
});
|
||||
for (const d of BAN_DURATIONS) {
|
||||
const opt = createElement("option", { value: String(d.hours) }, d.label);
|
||||
banDurationSelect.appendChild(opt);
|
||||
}
|
||||
const banConfirm = createElement(
|
||||
"div",
|
||||
{ class: "context-menu__item context-menu__item--danger", "data-testid": "ban-confirm" },
|
||||
"Confirm Ban",
|
||||
);
|
||||
appendChildren(banReasonRow, banReasonInput, banConfirm);
|
||||
appendChildren(banReasonRow, banReasonInput, banDurationSelect, banConfirm);
|
||||
|
||||
banItem.addEventListener(
|
||||
"click",
|
||||
@@ -225,12 +357,16 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
banReasonRow.style.display = "";
|
||||
banReasonInput.focus();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Typing a reason must not close the menu or trigger the outside-click guard.
|
||||
banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal: ac.signal });
|
||||
banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal: ac.signal });
|
||||
banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal });
|
||||
banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal });
|
||||
banDurationSelect.addEventListener("click", (e) => e.stopPropagation(), { signal });
|
||||
banDurationSelect.addEventListener("mousedown", (e) => e.stopPropagation(), {
|
||||
signal,
|
||||
});
|
||||
|
||||
let banRunning = false;
|
||||
function submitBan(): void {
|
||||
@@ -243,7 +379,8 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
banConfirm.classList.remove("context-menu__item--pending");
|
||||
setText(banConfirm, "Confirm Ban");
|
||||
};
|
||||
void options.onBan(banReasonInput.value.trim()).then(done, done);
|
||||
const durationHours = Number.parseInt(banDurationSelect.value, 10) || 0;
|
||||
void options.onBan(banReasonInput.value.trim(), durationHours).then(done, done);
|
||||
}
|
||||
|
||||
banConfirm.addEventListener(
|
||||
@@ -252,7 +389,7 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
e.stopPropagation();
|
||||
submitBan();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
{ signal },
|
||||
);
|
||||
banReasonInput.addEventListener(
|
||||
"keydown",
|
||||
@@ -262,17 +399,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
submitBan();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
{ signal },
|
||||
);
|
||||
|
||||
appendChildren(menu, banItem, banReasonRow);
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
menu.remove();
|
||||
}
|
||||
|
||||
return { element: menu, destroy };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -314,6 +444,17 @@ export function createChannelContextMenu(options: ChannelContextMenuOptions): Co
|
||||
withConfirmation(deleteItem, "Are you sure?", () => options.onDelete(), ac.signal, "Deleting...");
|
||||
menu.appendChild(deleteItem);
|
||||
|
||||
const onPurge = options.onPurge;
|
||||
if (onPurge !== undefined) {
|
||||
appendPurgeSection(menu, {
|
||||
itemClass: "context-menu__item",
|
||||
dangerItemClass: "context-menu__item context-menu__item--danger",
|
||||
separatorClass: "context-menu__separator",
|
||||
onPurge: (count) => onPurge(count),
|
||||
signal: ac.signal,
|
||||
});
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
menu.remove();
|
||||
|
||||
@@ -7,26 +7,28 @@
|
||||
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
|
||||
import { createIcon, type IconName } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import {
|
||||
channelsStore,
|
||||
getChannelsByCategory,
|
||||
setActiveChannel,
|
||||
clearUnread,
|
||||
} from "@stores/channels.store";
|
||||
import { channelsStore, getChannelsByCategory } from "@stores/channels.store";
|
||||
import { navigateToChannel } from "@lib/channel-navigation";
|
||||
import { markAllRead, unreadChannelIds } from "@lib/read-state";
|
||||
import { isChannelMuted } from "@lib/channel-mutes";
|
||||
import { dmStore } from "@stores/dm.store";
|
||||
import type { Channel } from "@stores/channels.store";
|
||||
import { authStore, getCurrentUser } from "@stores/auth.store";
|
||||
import { uiStore, toggleCategory, isCategoryCollapsed } from "@stores/ui.store";
|
||||
import { voiceStore, getChannelVoiceUsers, getPeerVerification } from "@stores/voice.store";
|
||||
import type { PeerVerification } from "@stores/voice.store";
|
||||
import type { PeerVerification, VoiceUser } from "@stores/voice.store";
|
||||
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
|
||||
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
|
||||
import { showUserVolumeMenu } from "./channel-sidebar/volume-menu";
|
||||
import { attachChannelContextMenu } from "./channel-sidebar/context-menu";
|
||||
import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu";
|
||||
import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu";
|
||||
import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder";
|
||||
import { rePinPeerIdentity } from "@lib/livekitSession";
|
||||
import { createIdentityMismatchModal } from "./CertMismatchModal";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { roleHasPermission, canManageChannels } from "@lib/permissions";
|
||||
import { Permission } from "@lib/types";
|
||||
import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto";
|
||||
|
||||
const log = createLogger("ChannelSidebar");
|
||||
@@ -139,9 +141,30 @@ export interface ChannelReorderData {
|
||||
readonly newPosition: number;
|
||||
}
|
||||
|
||||
/** Moderator actions on another user's voice session. Supplied by the page,
|
||||
* which owns the WS socket; the sidebar only decides whether to offer them. */
|
||||
export interface VoiceModerationCallbacks {
|
||||
readonly onServerMute: (channelId: number, userId: number, muted: boolean) => void;
|
||||
readonly onServerDeafen: (channelId: number, userId: number, deafened: boolean) => void;
|
||||
readonly onMove: (userId: number, toChannelId: number) => void;
|
||||
readonly onDisconnect: (userId: number) => void;
|
||||
}
|
||||
|
||||
/** Whether the signed-in user's role holds MUTE_MEMBERS. The server enforces
|
||||
* it (and the rank rule the client cannot evaluate); this only decides whether
|
||||
* the menu is worth offering. Derived through the same helper as the
|
||||
* member-list moderation gates so the two cannot disagree about who is a
|
||||
* moderator. */
|
||||
export function canModerateVoice(): boolean {
|
||||
const role = getCurrentUser()?.role ?? "";
|
||||
return roleHasPermission(role, Permission.MUTE_MEMBERS);
|
||||
}
|
||||
|
||||
export interface ChannelSidebarOptions {
|
||||
readonly onVoiceJoin: (channelId: number) => void;
|
||||
readonly onVoiceLeave: () => void;
|
||||
/** Voice moderation wiring; the moderation menu section is hidden without it. */
|
||||
readonly onVoiceModerate?: VoiceModerationCallbacks;
|
||||
/** Called when the user clicks the "+" on a category header. */
|
||||
readonly onCreateChannel?: (category: string) => void;
|
||||
/** Called when the user right-clicks a channel and selects Edit. */
|
||||
@@ -150,6 +173,8 @@ export interface ChannelSidebarOptions {
|
||||
readonly onDeleteChannel?: (channel: Channel) => void;
|
||||
/** Called when the user drags a channel to a new position. */
|
||||
readonly onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void;
|
||||
/** Bulk-delete the newest `count` messages; gated on MANAGE_MESSAGES. */
|
||||
readonly onPurgeChannel?: (channel: Channel, count: number) => Promise<void>;
|
||||
/** Called when the user clicks a voice user row to watch their stream. */
|
||||
readonly onWatchStream?: (userId: number) => void;
|
||||
}
|
||||
@@ -164,6 +189,39 @@ function pickAvatarColor(username: string): string {
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2";
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker on an age-restricted channel row.
|
||||
*
|
||||
* A glyph plus a title rather than a coloured name: the flag is information
|
||||
* about the channel, and recolouring the name would collide with the unread
|
||||
* and mention states the row already encodes that way.
|
||||
*/
|
||||
function nsfwIndicator(channelId: number): HTMLSpanElement {
|
||||
const badge = createElement("span", {
|
||||
class: "ch-nsfw",
|
||||
"data-testid": `channel-nsfw-${channelId}`,
|
||||
"aria-label": "Age restricted",
|
||||
});
|
||||
badge.title = "Age-restricted channel";
|
||||
badge.appendChild(createIcon("shield-alert", 13));
|
||||
return badge;
|
||||
}
|
||||
|
||||
/**
|
||||
* "3/5" for a voice channel that has a user limit, or null when it is
|
||||
* unlimited (0) — a count with no ceiling is already shown by the participant
|
||||
* rows underneath, and "3/0" would read as a bug.
|
||||
*
|
||||
* Purely a readout: the server owns capacity and refuses a join over the limit
|
||||
* with CHANNEL_FULL. The client never blocks the click, because its copy of
|
||||
* the participant list can lag and a join it refused locally would be a
|
||||
* mistake nobody could correct.
|
||||
*/
|
||||
function voiceCapacityLabel(channel: Channel, connected: number): string | null {
|
||||
if (channel.voiceMaxUsers <= 0) return null;
|
||||
return `${connected}/${channel.voiceMaxUsers}`;
|
||||
}
|
||||
|
||||
function renderTextChannelItem(
|
||||
channel: Channel,
|
||||
isActive: boolean,
|
||||
@@ -173,6 +231,7 @@ function renderTextChannelItem(
|
||||
"channel-item",
|
||||
isActive ? "active" : "",
|
||||
channel.unreadCount > 0 ? "unread" : "",
|
||||
channel.mentionCount > 0 ? "mentioned" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
@@ -190,29 +249,77 @@ function renderTextChannelItem(
|
||||
|
||||
appendChildren(item, prefix, name);
|
||||
|
||||
if (channel.unreadCount > 0) {
|
||||
const badge = createElement("span", { class: "unread-badge" }, String(channel.unreadCount));
|
||||
// Age-restricted marker. Next to the name rather than replacing the "#", so
|
||||
// the channel still reads as a channel and the mark is visible whether or
|
||||
// not the reader has already accepted the gate this session.
|
||||
if (channel.nsfw) {
|
||||
item.appendChild(nsfwIndicator(channel.id));
|
||||
}
|
||||
|
||||
// A muted channel still counts its unreads — it has not stopped existing,
|
||||
// it has stopped shouting — so the badge dims rather than disappearing. The
|
||||
// mention badge is deliberately left alone: a mute silences chatter, never
|
||||
// something addressed to the reader.
|
||||
const muted = isChannelMuted(channel.id);
|
||||
if (muted) {
|
||||
item.classList.add("muted");
|
||||
}
|
||||
|
||||
// A mention badge outranks the plain unread badge: only one is shown, and
|
||||
// it counts the mentions, not the messages.
|
||||
if (channel.mentionCount > 0) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{ class: "mention-badge", "data-testid": `channel-mentions-${channel.id}` },
|
||||
String(channel.mentionCount),
|
||||
);
|
||||
badge.title = `${channel.mentionCount} mention${channel.mentionCount === 1 ? "" : "s"}`;
|
||||
item.appendChild(badge);
|
||||
} else if (channel.unreadCount > 0) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{ class: muted ? "unread-badge muted" : "unread-badge" },
|
||||
String(channel.unreadCount),
|
||||
);
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
setActiveChannel(channel.id);
|
||||
clearUnread(channel.id);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
item.addEventListener("click", () => navigateToChannel(channel.id), { signal });
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Moderation section for one participant row, or undefined when the local
|
||||
* user may not moderate voice (which hides the section entirely). Move targets
|
||||
* are the other voice channels; the server re-checks that the TARGET may
|
||||
* connect to the one picked. */
|
||||
function buildVoiceModOptions(
|
||||
channelId: number,
|
||||
user: VoiceUser,
|
||||
cb?: VoiceModerationCallbacks,
|
||||
): VoiceModMenuOptions | undefined {
|
||||
if (cb === undefined || !canModerateVoice()) return undefined;
|
||||
const moveTargets = Array.from(channelsStore.getState().channels.values())
|
||||
.filter((ch) => ch.type === "voice" && ch.id !== channelId)
|
||||
.map((ch) => ({ id: ch.id, name: ch.name }));
|
||||
return {
|
||||
serverMuted: user.serverMuted === true,
|
||||
serverDeafened: user.serverDeafened === true,
|
||||
moveTargets,
|
||||
onServerMute: (muted) => cb.onServerMute(channelId, user.userId, muted),
|
||||
onServerDeafen: (deafened) => cb.onServerDeafen(channelId, user.userId, deafened),
|
||||
onMove: (toChannelId) => cb.onMove(user.userId, toChannelId),
|
||||
onDisconnect: () => cb.onDisconnect(user.userId),
|
||||
};
|
||||
}
|
||||
|
||||
function renderVoiceChannelItem(
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onWatchStream?: (userId: number) => void,
|
||||
onVoiceModerate?: VoiceModerationCallbacks,
|
||||
): HTMLDivElement {
|
||||
const voiceState = voiceStore.getState();
|
||||
const isJoined = voiceState.currentChannelId === channel.id;
|
||||
@@ -243,6 +350,22 @@ function renderVoiceChannelItem(
|
||||
|
||||
appendChildren(item, prefix, name);
|
||||
|
||||
if (channel.nsfw) {
|
||||
item.appendChild(nsfwIndicator(channel.id));
|
||||
}
|
||||
|
||||
const voiceUsers = getChannelVoiceUsers(channel.id);
|
||||
const capacity = voiceCapacityLabel(channel, voiceUsers.length);
|
||||
if (capacity !== null) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{ class: "ch-capacity", "data-testid": `channel-capacity-${channel.id}` },
|
||||
capacity,
|
||||
);
|
||||
badge.title = `${voiceUsers.length} of ${channel.voiceMaxUsers} connected`;
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
@@ -260,7 +383,6 @@ function renderVoiceChannelItem(
|
||||
wrapper.appendChild(item);
|
||||
|
||||
// Render connected voice users below the channel
|
||||
const voiceUsers = getChannelVoiceUsers(channel.id);
|
||||
if (voiceUsers.length > 0) {
|
||||
const usersContainer = createElement("div", { class: "voice-users-list" });
|
||||
for (const user of voiceUsers) {
|
||||
@@ -293,17 +415,26 @@ function renderVoiceChannelItem(
|
||||
row.appendChild(liveBadge);
|
||||
}
|
||||
|
||||
// A moderator-imposed mute/deafen gets its own class and tooltip: the
|
||||
// same mic-off glyph would otherwise read as an ordinary self-mute.
|
||||
if (user.deafened) {
|
||||
// Deafened: show both mic-off and headphones-off
|
||||
const muteIcon = createElement("span", { class: "vu-muted" });
|
||||
const muteIcon = createElement("span", {
|
||||
class: user.serverMuted === true ? "vu-muted vu-server-muted" : "vu-muted",
|
||||
});
|
||||
if (user.serverMuted === true) muteIcon.title = "Muted by a moderator";
|
||||
muteIcon.appendChild(createIcon("mic-off", 14));
|
||||
const deafIcon = createElement("span", { class: "vu-muted" });
|
||||
const deafIcon = createElement("span", {
|
||||
class: user.serverDeafened === true ? "vu-muted vu-server-muted" : "vu-muted",
|
||||
});
|
||||
if (user.serverDeafened === true) deafIcon.title = "Deafened by a moderator";
|
||||
deafIcon.appendChild(createIcon("headphones-off", 14));
|
||||
row.appendChild(muteIcon);
|
||||
row.appendChild(deafIcon);
|
||||
} else if (user.muted) {
|
||||
// Muted only: show mic-off
|
||||
const muteIcon = createElement("span", { class: "vu-muted" });
|
||||
const muteIcon = createElement("span", {
|
||||
class: user.serverMuted === true ? "vu-muted vu-server-muted" : "vu-muted",
|
||||
});
|
||||
if (user.serverMuted === true) muteIcon.title = "Muted by a moderator";
|
||||
muteIcon.appendChild(createIcon("mic-off", 14));
|
||||
row.appendChild(muteIcon);
|
||||
}
|
||||
@@ -349,6 +480,7 @@ function renderVoiceChannelItem(
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
signal,
|
||||
buildVoiceModOptions(channel.id, user, onVoiceModerate),
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
@@ -419,14 +551,23 @@ function renderChannelItem(
|
||||
channels?: readonly Channel[],
|
||||
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
|
||||
onWatchStream?: (userId: number) => void,
|
||||
onVoiceModerate?: VoiceModerationCallbacks,
|
||||
onPurgeChannel?: (channel: Channel, count: number) => Promise<void>,
|
||||
): HTMLDivElement {
|
||||
let el: HTMLDivElement;
|
||||
if (channel.type === "voice") {
|
||||
el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave, onWatchStream);
|
||||
el = renderVoiceChannelItem(
|
||||
channel,
|
||||
signal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onWatchStream,
|
||||
onVoiceModerate,
|
||||
);
|
||||
} else {
|
||||
el = renderTextChannelItem(channel, isActive, signal);
|
||||
}
|
||||
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel);
|
||||
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);
|
||||
if (containerEl !== undefined && channels !== undefined) {
|
||||
attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
|
||||
}
|
||||
@@ -445,6 +586,8 @@ function renderCategoryGroup(
|
||||
onDeleteChannel?: (channel: Channel) => void,
|
||||
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
|
||||
onWatchStream?: (userId: number) => void,
|
||||
onVoiceModerate?: VoiceModerationCallbacks,
|
||||
onPurgeChannel?: (channel: Channel, count: number) => Promise<void>,
|
||||
): HTMLDivElement {
|
||||
const group = createElement("div", {});
|
||||
|
||||
@@ -462,11 +605,11 @@ function renderCategoryGroup(
|
||||
appendChildren(header, arrow, label);
|
||||
|
||||
if (onCreateChannel !== undefined) {
|
||||
const user = getCurrentUser();
|
||||
const role = user?.role?.toLowerCase() ?? "";
|
||||
const canManageChannels = role === "owner" || role === "admin";
|
||||
|
||||
if (canManageChannels) {
|
||||
// MANAGE_CHANNELS is enforced server-side on /admin/api/channels*, so
|
||||
// gate on the bit; the role-name check only stands in when the `ready`
|
||||
// role list has no entry for this role. Same derivation as the channel
|
||||
// context menu's Edit/Delete items.
|
||||
if (canManageChannels()) {
|
||||
const addBtn = createElement(
|
||||
"span",
|
||||
{
|
||||
@@ -514,6 +657,8 @@ function renderCategoryGroup(
|
||||
channels,
|
||||
onReorderChannel,
|
||||
onWatchStream,
|
||||
onVoiceModerate,
|
||||
onPurgeChannel,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -536,6 +681,8 @@ function renderCategoryGroup(
|
||||
channels,
|
||||
onReorderChannel,
|
||||
onWatchStream,
|
||||
onVoiceModerate,
|
||||
onPurgeChannel,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -554,11 +701,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
onDeleteChannel,
|
||||
onReorderChannel,
|
||||
onWatchStream,
|
||||
onVoiceModerate,
|
||||
onPurgeChannel,
|
||||
} = options;
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let channelList: HTMLDivElement | null = null;
|
||||
let serverNameEl: HTMLSpanElement | null = null;
|
||||
let markAllBtn: HTMLButtonElement | null = null;
|
||||
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
@@ -576,7 +726,15 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide Mark All as Read while nothing is unread — a header button that can
|
||||
* never do anything is worse than no button. */
|
||||
function updateMarkAllBtn(): void {
|
||||
if (markAllBtn === null) return;
|
||||
markAllBtn.classList.toggle("visible", unreadChannelIds().length > 0);
|
||||
}
|
||||
|
||||
function renderChannels(): void {
|
||||
updateMarkAllBtn();
|
||||
if (channelList === null) {
|
||||
return;
|
||||
}
|
||||
@@ -613,6 +771,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
onDeleteChannel,
|
||||
onReorderChannel,
|
||||
onWatchStream,
|
||||
onVoiceModerate,
|
||||
onPurgeChannel,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -620,8 +780,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
rebuildVoiceRowCache();
|
||||
}
|
||||
|
||||
/** Redraw when a row's mute is toggled (see CHANNEL_MUTE_CHANGED). */
|
||||
function handleMuteChanged(): void {
|
||||
renderChannels();
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
|
||||
root.addEventListener(CHANNEL_MUTE_CHANGED, handleMuteChanged, { signal: ac.signal });
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "channel-sidebar-header" });
|
||||
@@ -629,6 +795,26 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
serverNameEl = createElement("h2", {}, authState.serverName ?? "Server Name");
|
||||
header.appendChild(serverNameEl);
|
||||
|
||||
// Mark All as Read lives on the server header — it is a server-wide action,
|
||||
// and it only appears while something is actually unread so the header does
|
||||
// not carry a permanently dead button.
|
||||
markAllBtn = createElement("button", {
|
||||
class: "sidebar-mark-all-read",
|
||||
title: "Mark All as Read",
|
||||
"aria-label": "Mark All as Read",
|
||||
"data-testid": "mark-all-read",
|
||||
});
|
||||
markAllBtn.appendChild(createIcon("check", 16));
|
||||
markAllBtn.addEventListener(
|
||||
"click",
|
||||
(e: Event) => {
|
||||
e.stopPropagation();
|
||||
markAllRead();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
header.appendChild(markAllBtn);
|
||||
|
||||
// Channel list
|
||||
channelList = createElement("div", { class: "channel-list" });
|
||||
|
||||
@@ -638,6 +824,10 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
// Initial render
|
||||
renderChannels();
|
||||
|
||||
// DM badges live in dm.store, and Mark All as Read covers them too, so the
|
||||
// header button's visibility has to track that store as well.
|
||||
unsubscribers.push(dmStore.subscribeSelector((s) => s.channels, updateMarkAllBtn));
|
||||
|
||||
// Subscribe to channels store changes (channels map OR active channel)
|
||||
const unsubChannelsMap = channelsStore.subscribeSelector(
|
||||
(s) => s.channels,
|
||||
@@ -692,7 +882,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
// Include the E2EE verification status so a verified↔unverified↔mismatch
|
||||
// flip re-renders the badge (it lives outside voiceUsers, in peerVerifications).
|
||||
const verif = state.peerVerifications?.get(uid);
|
||||
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`;
|
||||
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}` : ""}`;
|
||||
}
|
||||
}
|
||||
return structSig;
|
||||
@@ -730,6 +920,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
}
|
||||
channelList = null;
|
||||
serverNameEl = null;
|
||||
markAllBtn = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
/**
|
||||
* CreateChannelModal — modal for creating a new channel under a specific
|
||||
* category. The channel type is automatically restricted based on the
|
||||
* category: voice categories only allow voice channels, text categories
|
||||
* allow text and announcement channels.
|
||||
* CreateChannelModal — modal for creating a new channel.
|
||||
*
|
||||
* The category is an editable text field pre-filled with the group the "+" was
|
||||
* clicked on, backed by a <datalist> of the categories already in use. It used
|
||||
* to be read-only, and the channel TYPE was inferred from the category name
|
||||
* ("voice" anywhere in it meant voice-only), which made every other category
|
||||
* name second-class: a voice channel could not live under "Gaming", and
|
||||
* renaming a category silently changed what could be created there. Categories
|
||||
* are free text and grouping is a display concern, so every type is offered
|
||||
* under every category — the server agrees (it validates the type alone).
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { ChannelType } from "@lib/types";
|
||||
import { getKnownCategories, UNCATEGORIZED_VOICE_CATEGORY } from "@stores/channels.store";
|
||||
|
||||
export interface CreateChannelModalOptions {
|
||||
/** The category this channel will be created under. */
|
||||
/** The category the create affordance was invoked from ("" = uncategorized). */
|
||||
readonly category: string;
|
||||
/** Called when the user submits the form. */
|
||||
readonly onCreate: (data: { name: string; type: ChannelType; category: string }) => Promise<void>;
|
||||
@@ -19,17 +26,18 @@ export interface CreateChannelModalOptions {
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** Returns true if the category name indicates a voice section. */
|
||||
export function isVoiceCategory(category: string): boolean {
|
||||
return category.toLowerCase().includes("voice");
|
||||
}
|
||||
/** Every channel type is creatable under every category. */
|
||||
export const CHANNEL_TYPES: readonly ChannelType[] = ["text", "voice", "announcement"] as const;
|
||||
|
||||
/** Returns the allowed channel types for a given category. */
|
||||
export function allowedTypesForCategory(category: string): readonly ChannelType[] {
|
||||
if (isVoiceCategory(category)) {
|
||||
return ["voice"] as const;
|
||||
}
|
||||
return ["text", "announcement"] as const;
|
||||
/**
|
||||
* The type pre-selected for a category. Only a hint for the dropdown's initial
|
||||
* value — every type stays selectable. The one case worth guessing is the
|
||||
* synthetic "Voice" fallback group the sidebar puts uncategorized voice
|
||||
* channels in: creating from its "+" almost certainly means another voice
|
||||
* channel.
|
||||
*/
|
||||
export function defaultTypeForCategory(category: string): ChannelType {
|
||||
return category === UNCATEGORIZED_VOICE_CATEGORY ? "voice" : "text";
|
||||
}
|
||||
|
||||
export function createCreateChannelModal(options: CreateChannelModalOptions): MountableComponent {
|
||||
@@ -37,8 +45,6 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
|
||||
const allowedTypes = allowedTypesForCategory(category);
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
class: "modal-overlay visible",
|
||||
@@ -62,15 +68,23 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
// Body
|
||||
const body = createElement("div", { class: "modal-body" });
|
||||
|
||||
// Category (read-only display)
|
||||
// Category — free text, with the categories already in use as suggestions.
|
||||
const categoryGroup = createElement("div", { class: "form-group" });
|
||||
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
|
||||
const categoryDisplay = createElement("div", {
|
||||
const categoryInput = createElement("input", {
|
||||
class: "form-input",
|
||||
style: "opacity: 0.7; cursor: default;",
|
||||
type: "text",
|
||||
list: "create-channel-categories",
|
||||
autocomplete: "off",
|
||||
placeholder: "Leave blank for no category",
|
||||
"data-testid": "channel-category-input",
|
||||
});
|
||||
setText(categoryDisplay, category);
|
||||
appendChildren(categoryGroup, categoryLabel, categoryDisplay);
|
||||
categoryInput.value = category;
|
||||
const categoryList = createElement("datalist", { id: "create-channel-categories" });
|
||||
for (const known of getKnownCategories()) {
|
||||
categoryList.appendChild(createElement("option", { value: known }));
|
||||
}
|
||||
appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList);
|
||||
|
||||
// Channel name
|
||||
const nameGroup = createElement("div", { class: "form-group" });
|
||||
@@ -78,7 +92,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
const nameInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: isVoiceCategory(category) ? "lounge" : "general",
|
||||
placeholder: defaultTypeForCategory(category) === "voice" ? "lounge" : "general",
|
||||
"data-testid": "channel-name-input",
|
||||
});
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
@@ -91,10 +105,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
"data-testid": "channel-type-select",
|
||||
});
|
||||
|
||||
for (const t of allowedTypes) {
|
||||
for (const t of CHANNEL_TYPES) {
|
||||
const opt = createElement("option", { value: t }, t.charAt(0).toUpperCase() + t.slice(1));
|
||||
typeSelect.appendChild(opt);
|
||||
}
|
||||
typeSelect.value = defaultTypeForCategory(category);
|
||||
appendChildren(typeGroup, typeLabel, typeSelect);
|
||||
|
||||
// Error display
|
||||
@@ -146,7 +161,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
await onCreate({
|
||||
name,
|
||||
type: typeSelect.value as ChannelType,
|
||||
category,
|
||||
category: categoryInput.value.trim(),
|
||||
});
|
||||
} catch (err) {
|
||||
errorEl.style.display = "block";
|
||||
|
||||
@@ -49,6 +49,9 @@ const STATUS_COLORS: Readonly<Record<UserStatus, string>> = {
|
||||
online: "#3ba55d",
|
||||
idle: "#faa61a",
|
||||
dnd: "#ed4245",
|
||||
// A DM partner is never invisible from here — the server maps it to offline
|
||||
// for everyone but its owner — but the map has to be total over UserStatus.
|
||||
invisible: "#747f8d",
|
||||
offline: "#747f8d",
|
||||
};
|
||||
|
||||
@@ -56,6 +59,7 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
|
||||
online: "Online",
|
||||
idle: "Idle",
|
||||
dnd: "Do Not Disturb",
|
||||
invisible: "Invisible",
|
||||
offline: "Offline",
|
||||
};
|
||||
|
||||
|
||||
@@ -4,34 +4,65 @@
|
||||
*
|
||||
* Uses the `channel-sidebar` container class (shared with channel sidebar)
|
||||
* and DM-specific classes from app.css: dm-sidebar-header, dm-search,
|
||||
* dm-nav-item, dm-section-label, dm-add, dm-item, dm-avatar, dm-status,
|
||||
* dm-section-label, dm-add, dm-item, dm-avatar, dm-status,
|
||||
* dm-name, dm-close, dm-unread.
|
||||
*
|
||||
* Rows are keyed on the DM *channel*, not on a recipient user: a group DM has
|
||||
* no single recipient, and the same person can be in both a 1:1 and a group
|
||||
* with you, so a user id no longer identifies a conversation.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import { showContextMenu } from "@lib/context-menu";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { isSafeUrl } from "./message-list/attachments";
|
||||
|
||||
/** One member of a group DM, as far as the sidebar needs to draw them. */
|
||||
export interface DmParticipant {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
}
|
||||
|
||||
export interface DmConversation {
|
||||
/** The DM channel. The row's identity — see the module comment. */
|
||||
readonly channelId: number;
|
||||
/** The other party of a 1:1 DM; for a group, the first participant. */
|
||||
readonly userId: number;
|
||||
/** What the row is labelled: a group's name or joined members, else a user. */
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
readonly avatarColor?: string;
|
||||
readonly status?: "online" | "idle" | "dnd" | "offline";
|
||||
/** True for a group DM: draws stacked avatars and a participant count. */
|
||||
readonly isGroup?: boolean;
|
||||
/** Everyone but the current user. Drives the stack and the count. */
|
||||
readonly participants?: readonly DmParticipant[];
|
||||
readonly lastMessage: string;
|
||||
readonly timestamp: string;
|
||||
readonly unread: boolean;
|
||||
/** Unread message count. Drives the numeric badge; a conversation marked
|
||||
* `unread` with no count still shows the plain dot (older payloads). */
|
||||
readonly unreadCount?: number;
|
||||
/** Unread messages here that mention the current user. Outranks the unread
|
||||
* badge, exactly as it does in the channel list. */
|
||||
readonly mentionCount?: number;
|
||||
/** Muted: the unread badge renders dimmed. The mention badge does not —
|
||||
* a mute silences chatter, never something addressed to you. */
|
||||
readonly muted?: boolean;
|
||||
readonly active?: boolean;
|
||||
}
|
||||
|
||||
export interface DmSidebarOptions {
|
||||
readonly conversations: readonly DmConversation[];
|
||||
readonly onSelectConversation: (userId: number) => void;
|
||||
readonly onSelectConversation: (channelId: number) => void;
|
||||
readonly onNewDm: () => void;
|
||||
readonly onCloseDm?: (userId: number) => void;
|
||||
readonly onFriendsClick?: () => void;
|
||||
readonly friendsActive?: boolean;
|
||||
/** Close a 1:1 DM / leave a group. The component does not distinguish —
|
||||
* which one it is is the server's call, and the label says so. */
|
||||
readonly onCloseDm?: (channelId: number) => void;
|
||||
readonly onToggleMute?: (channelId: number) => void;
|
||||
readonly onRenameGroup?: (channelId: number) => void;
|
||||
readonly onBack?: () => void;
|
||||
readonly serverName?: string;
|
||||
}
|
||||
@@ -43,67 +74,137 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
offline: "var(--text-micro)",
|
||||
};
|
||||
|
||||
/** Fill one avatar circle: the picture if it is safe to load, else the letter. */
|
||||
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
|
||||
if (avatar !== null && isSafeUrl(avatar)) {
|
||||
const img = createElement("img", { src: avatar, alt: label });
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.borderRadius = "50%";
|
||||
el.appendChild(img);
|
||||
return;
|
||||
}
|
||||
setText(el, label.charAt(0).toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar block for a row: one circle for a 1:1 DM with a presence dot, or
|
||||
* two overlapping circles for a group.
|
||||
*
|
||||
* A group deliberately gets no presence dot — "is this group online" has no
|
||||
* answer, and showing the first member's would be a fact about one person
|
||||
* presented as a fact about the conversation.
|
||||
*/
|
||||
function buildAvatar(convo: DmConversation): HTMLDivElement {
|
||||
const avatarBg = convo.avatarColor ?? "#5865F2";
|
||||
|
||||
if (convo.isGroup === true) {
|
||||
const stack = createElement("div", {
|
||||
class: "dm-avatar dm-avatar-stack",
|
||||
"data-testid": `dm-avatar-stack-${convo.channelId}`,
|
||||
});
|
||||
const shown = (convo.participants ?? []).slice(0, 2);
|
||||
// An empty group (every other member has left) still needs a mark, so fall
|
||||
// back to the row's own label rather than rendering an empty circle.
|
||||
const faces = shown.length > 0 ? shown : [{ id: 0, username: convo.username, avatar: null }];
|
||||
faces.forEach((p, i) => {
|
||||
const face = createElement("div", { class: `dm-avatar-face dm-avatar-face-${i}` });
|
||||
face.style.background = avatarBg;
|
||||
paintAvatar(face, p.avatar, p.username);
|
||||
stack.appendChild(face);
|
||||
});
|
||||
return stack;
|
||||
}
|
||||
|
||||
const avatar = createElement("div", { class: "dm-avatar" });
|
||||
avatar.style.background = avatarBg;
|
||||
paintAvatar(avatar, convo.avatar, convo.username);
|
||||
|
||||
const statusKey = convo.status ?? "offline";
|
||||
const statusDot = createElement("span", { class: "dm-status" });
|
||||
statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)";
|
||||
avatar.appendChild(statusDot);
|
||||
return avatar;
|
||||
}
|
||||
|
||||
function renderDmItem(
|
||||
convo: DmConversation,
|
||||
onSelect: (userId: number) => void,
|
||||
onClose: ((userId: number) => void) | undefined,
|
||||
options: DmSidebarOptions,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const item = createElement("div", { class: "dm-item" });
|
||||
if (convo.active === true) {
|
||||
item.classList.add("active");
|
||||
}
|
||||
if (convo.muted === true) {
|
||||
item.classList.add("muted");
|
||||
}
|
||||
item.dataset.channelId = String(convo.channelId);
|
||||
item.dataset.userId = String(convo.userId);
|
||||
|
||||
// Avatar with status dot
|
||||
const avatarBg = convo.avatarColor ?? "#5865F2";
|
||||
const avatar = createElement("div", { class: "dm-avatar" });
|
||||
avatar.style.background = avatarBg;
|
||||
const avatar = buildAvatar(convo);
|
||||
|
||||
if (convo.avatar !== null && isSafeUrl(convo.avatar)) {
|
||||
const img = createElement("img", {
|
||||
src: convo.avatar,
|
||||
alt: convo.username,
|
||||
});
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.borderRadius = "50%";
|
||||
avatar.appendChild(img);
|
||||
} else {
|
||||
setText(avatar, convo.username.charAt(0).toUpperCase());
|
||||
}
|
||||
|
||||
// Status indicator dot
|
||||
const statusKey = convo.status ?? "offline";
|
||||
const statusDot = createElement("span", { class: "dm-status" });
|
||||
statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)";
|
||||
avatar.appendChild(statusDot);
|
||||
|
||||
// Username
|
||||
const name = createElement("span", { class: "dm-name" }, convo.username);
|
||||
|
||||
// Close button (hidden by default, shown on hover via CSS)
|
||||
appendChildren(item, avatar, name);
|
||||
|
||||
// Participant count, groups only: the label may be a name that says nothing
|
||||
// about size, and "who else is in here" is the first thing you want to know.
|
||||
if (convo.isGroup === true) {
|
||||
const count = (convo.participants ?? []).length + 1;
|
||||
const countEl = createElement(
|
||||
"span",
|
||||
{ class: "dm-member-count", "data-testid": `dm-members-${convo.channelId}` },
|
||||
String(count),
|
||||
);
|
||||
countEl.title = `${count} members`;
|
||||
item.appendChild(countEl);
|
||||
}
|
||||
|
||||
// Close / leave button (hidden by default, shown on hover via CSS)
|
||||
const closeBtn = createElement("button", {
|
||||
class: "dm-close",
|
||||
title: "Close DM",
|
||||
title: convo.isGroup === true ? "Leave group" : "Close DM",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
closeBtn.addEventListener(
|
||||
"click",
|
||||
(e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (onClose !== undefined) {
|
||||
onClose(convo.userId);
|
||||
}
|
||||
options.onCloseDm?.(convo.channelId);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
item.appendChild(closeBtn);
|
||||
|
||||
appendChildren(item, avatar, name, closeBtn);
|
||||
|
||||
// Unread dot
|
||||
if (convo.unread) {
|
||||
// A mention badge outranks the unread badge, which in turn outranks the bare
|
||||
// dot — the dot is only what is left when the payload carries no counts.
|
||||
//
|
||||
// A muted conversation dims the unread badge but NOT the mention badge: the
|
||||
// whole point of Discord's mute is that things addressed to you still get
|
||||
// through, so dimming both would make a mute unsafe to use.
|
||||
const mentionCount = convo.mentionCount ?? 0;
|
||||
const unreadCount = convo.unreadCount ?? 0;
|
||||
if (mentionCount > 0) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{ class: "dm-mention-badge", "data-testid": `dm-mentions-${convo.channelId}` },
|
||||
String(mentionCount),
|
||||
);
|
||||
badge.title = `${mentionCount} mention${mentionCount === 1 ? "" : "s"}`;
|
||||
item.appendChild(badge);
|
||||
} else if (unreadCount > 0) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{
|
||||
class: convo.muted === true ? "dm-unread-badge muted" : "dm-unread-badge",
|
||||
"data-testid": `dm-unread-${convo.channelId}`,
|
||||
},
|
||||
String(unreadCount),
|
||||
);
|
||||
badge.title = `${unreadCount} unread message${unreadCount === 1 ? "" : "s"}`;
|
||||
item.appendChild(badge);
|
||||
} else if (convo.unread) {
|
||||
const unreadDot = createElement("span", { class: "dm-unread" });
|
||||
item.appendChild(unreadDot);
|
||||
}
|
||||
@@ -118,7 +219,43 @@ function renderDmItem(
|
||||
}
|
||||
}
|
||||
item.classList.add("active");
|
||||
onSelect(convo.userId);
|
||||
options.onSelectConversation(convo.channelId);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
"contextmenu",
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const items = [];
|
||||
if (options.onToggleMute !== undefined) {
|
||||
const toggle = options.onToggleMute;
|
||||
items.push({
|
||||
label: convo.muted === true ? "Unmute Conversation" : "Mute Conversation",
|
||||
testId: `dm-mute-${convo.channelId}`,
|
||||
onClick: () => toggle(convo.channelId),
|
||||
});
|
||||
}
|
||||
if (convo.isGroup === true && options.onRenameGroup !== undefined) {
|
||||
const rename = options.onRenameGroup;
|
||||
items.push({
|
||||
label: "Rename Group",
|
||||
testId: `dm-rename-${convo.channelId}`,
|
||||
onClick: () => rename(convo.channelId),
|
||||
});
|
||||
}
|
||||
if (options.onCloseDm !== undefined) {
|
||||
const close = options.onCloseDm;
|
||||
items.push({
|
||||
label: convo.isGroup === true ? "Leave Group" : "Close DM",
|
||||
danger: true,
|
||||
testId: `dm-close-${convo.channelId}`,
|
||||
onClick: () => close(convo.channelId),
|
||||
});
|
||||
}
|
||||
if (items.length === 0) return;
|
||||
showContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: "dm-context-menu" });
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -141,7 +278,7 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
|
||||
class: "dm-back-header",
|
||||
"data-testid": "dm-back-header",
|
||||
});
|
||||
const arrow = createElement("span", { class: "dm-back-arrow" }, "\u2190");
|
||||
const arrow = createElement("span", { class: "dm-back-arrow" }, "←");
|
||||
const backInfo = createElement("div", { class: "dm-back-info" });
|
||||
const backTitle = createElement(
|
||||
"div",
|
||||
@@ -163,22 +300,6 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
|
||||
});
|
||||
header.appendChild(searchInput);
|
||||
|
||||
// Friends nav item
|
||||
const friendsNav = createElement("div", { class: "dm-nav-item" });
|
||||
if (options.friendsActive === true) {
|
||||
friendsNav.classList.add("active");
|
||||
}
|
||||
setText(friendsNav, "Friends");
|
||||
friendsNav.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
if (options.onFriendsClick !== undefined) {
|
||||
options.onFriendsClick();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Section label with + button
|
||||
const sectionLabel = createElement("div", { class: "dm-section-label" });
|
||||
setText(sectionLabel, "Direct Messages");
|
||||
@@ -195,11 +316,9 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
|
||||
(a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0),
|
||||
);
|
||||
|
||||
const items = sorted.map((convo) =>
|
||||
renderDmItem(convo, options.onSelectConversation, options.onCloseDm, ac.signal),
|
||||
);
|
||||
const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal));
|
||||
|
||||
appendChildren(root, header, friendsNav, sectionLabel, ...items);
|
||||
appendChildren(root, header, sectionLabel, ...items);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,101 @@
|
||||
/**
|
||||
* EditChannelModal — modal for editing an existing channel's name and topic.
|
||||
* Only visible to admin/owner users.
|
||||
* EditChannelModal — modal for editing an existing channel's name, topic,
|
||||
* category, slow mode, NSFW flag and (for voice channels) its capacity limits.
|
||||
* Mounted only for actors holding MANAGE_CHANNELS; the server enforces the same
|
||||
* bit on the PATCH behind it.
|
||||
*
|
||||
* Category is free text with a <datalist> of the categories already in use:
|
||||
* moving a channel between groups is a rename, not a recreate, and no category
|
||||
* name is special (a voice channel groups under whatever it carries).
|
||||
*
|
||||
* Slow mode is a preset <select> rather than a number box. The server accepts
|
||||
* any value in 0…21600, but the useful values are a short list, and a free
|
||||
* number field mostly produces typos ("300" meant as minutes) that only surface
|
||||
* when a member cannot post for five hours. A stored value outside the presets
|
||||
* — set through the admin panel, which does offer a free number — is kept and
|
||||
* shown as its own option rather than being silently rounded to a neighbour.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { getKnownCategories } from "@stores/channels.store";
|
||||
|
||||
/** The server's ceiling for `slow_mode`, mirrored so the UI cannot exceed it. */
|
||||
export const MAX_SLOW_MODE_SECONDS = 21600;
|
||||
/** The server's ceiling for both voice capacity limits. */
|
||||
export const MAX_VOICE_LIMIT = 99;
|
||||
|
||||
/** Slow-mode presets, in seconds. 0 = off. */
|
||||
const SLOW_MODE_PRESETS: readonly { readonly seconds: number; readonly label: string }[] = [
|
||||
{ seconds: 0, label: "Off" },
|
||||
{ seconds: 5, label: "5 seconds" },
|
||||
{ seconds: 10, label: "10 seconds" },
|
||||
{ seconds: 15, label: "15 seconds" },
|
||||
{ seconds: 30, label: "30 seconds" },
|
||||
{ seconds: 60, label: "1 minute" },
|
||||
{ seconds: 120, label: "2 minutes" },
|
||||
{ seconds: 300, label: "5 minutes" },
|
||||
{ seconds: 600, label: "10 minutes" },
|
||||
{ seconds: 900, label: "15 minutes" },
|
||||
{ seconds: 1800, label: "30 minutes" },
|
||||
{ seconds: 3600, label: "1 hour" },
|
||||
{ seconds: 7200, label: "2 hours" },
|
||||
{ seconds: 21600, label: "6 hours" },
|
||||
] as const;
|
||||
|
||||
/** Human label for a second count, for a value that is off the preset list. */
|
||||
export function formatSlowMode(seconds: number): string {
|
||||
const preset = SLOW_MODE_PRESETS.find((p) => p.seconds === seconds);
|
||||
if (preset !== undefined) return preset.label;
|
||||
if (seconds % 3600 === 0) {
|
||||
const hours = seconds / 3600;
|
||||
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (seconds % 60 === 0) {
|
||||
const minutes = seconds / 60;
|
||||
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
return `${seconds} seconds`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a value into the server's accepted slow-mode range.
|
||||
* Applied to the STORED value as well as the submitted one, so a row carrying
|
||||
* something out of range still opens the modal on a legal option.
|
||||
*/
|
||||
export function clampSlowMode(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(MAX_SLOW_MODE_SECONDS, Math.max(0, Math.trunc(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a voice limit into the server's accepted range.
|
||||
*
|
||||
* A `<input type="number" max>` is advisory — typing past it, or pasting, still
|
||||
* produces the larger value — so the bound is applied here rather than trusting
|
||||
* the attribute and letting the server 400 a form the user had no way to fix.
|
||||
*/
|
||||
export function clampVoiceLimit(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(MAX_VOICE_LIMIT, Math.max(0, Math.trunc(value)));
|
||||
}
|
||||
|
||||
/** The fields an edit submits. Mirrors the PATCH body. */
|
||||
export interface EditChannelData {
|
||||
readonly name: string;
|
||||
readonly topic: string;
|
||||
readonly category: string;
|
||||
readonly slow_mode: number;
|
||||
readonly nsfw: boolean;
|
||||
/**
|
||||
* Only present for a voice channel. A text channel's PATCH omits them
|
||||
* entirely rather than sending 0, so an edit here cannot wipe limits the
|
||||
* channel carries.
|
||||
*/
|
||||
readonly voice_max_users?: number;
|
||||
readonly voice_max_video?: number;
|
||||
}
|
||||
|
||||
export interface EditChannelModalOptions {
|
||||
/** Current channel ID. */
|
||||
@@ -14,14 +104,59 @@ export interface EditChannelModalOptions {
|
||||
readonly channelName: string;
|
||||
/** Current channel type (displayed, not editable). */
|
||||
readonly channelType: string;
|
||||
/** Current channel topic ("" = none). */
|
||||
readonly channelTopic?: string;
|
||||
/** Current channel category ("" = uncategorized). */
|
||||
readonly channelCategory?: string;
|
||||
/** Current cooldown in seconds (0 = off). */
|
||||
readonly channelSlowMode?: number;
|
||||
/** Whether the channel is currently flagged age-restricted. */
|
||||
readonly channelNsfw?: boolean;
|
||||
/** Current voice capacity limits (0 = unlimited). Voice channels only. */
|
||||
readonly channelVoiceMaxUsers?: number;
|
||||
readonly channelVoiceMaxVideo?: number;
|
||||
/** Called when the user saves changes. */
|
||||
readonly onSave: (data: { name: string }) => Promise<void>;
|
||||
readonly onSave: (data: EditChannelData) => Promise<void>;
|
||||
/** Called when the modal is closed. */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** A labelled number input constrained to 0…MAX_VOICE_LIMIT. */
|
||||
function buildVoiceLimitField(
|
||||
labelText: string,
|
||||
hintText: string,
|
||||
testId: string,
|
||||
value: number,
|
||||
): { group: HTMLDivElement; input: HTMLInputElement } {
|
||||
const group = createElement("div", { class: "form-group" });
|
||||
const label = createElement("label", { class: "form-label" }, labelText);
|
||||
const input = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "number",
|
||||
min: "0",
|
||||
max: String(MAX_VOICE_LIMIT),
|
||||
"data-testid": testId,
|
||||
});
|
||||
input.value = String(clampVoiceLimit(value));
|
||||
const hint = createElement("div", { class: "form-hint" }, hintText);
|
||||
appendChildren(group, label, input, hint);
|
||||
return { group, input };
|
||||
}
|
||||
|
||||
export function createEditChannelModal(options: EditChannelModalOptions): MountableComponent {
|
||||
const { channelName, channelType, onSave, onClose } = options;
|
||||
const {
|
||||
channelName,
|
||||
channelType,
|
||||
channelTopic,
|
||||
channelCategory,
|
||||
channelSlowMode,
|
||||
channelNsfw,
|
||||
channelVoiceMaxUsers,
|
||||
channelVoiceMaxVideo,
|
||||
onSave,
|
||||
onClose,
|
||||
} = options;
|
||||
const isVoice = channelType === "voice";
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
|
||||
@@ -70,14 +205,119 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
|
||||
nameInput.value = channelName;
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
|
||||
// Channel topic (optional, shown in the chat header)
|
||||
const topicGroup = createElement("div", { class: "form-group" });
|
||||
const topicLabel = createElement("label", { class: "form-label" }, "Topic");
|
||||
const topicInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: "What's this channel about? (optional)",
|
||||
maxlength: "1024",
|
||||
"data-testid": "edit-channel-topic-input",
|
||||
});
|
||||
topicInput.value = channelTopic ?? "";
|
||||
appendChildren(topicGroup, topicLabel, topicInput);
|
||||
|
||||
// Channel category (free text, suggestions from the categories in use)
|
||||
const categoryGroup = createElement("div", { class: "form-group" });
|
||||
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
|
||||
const categoryInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
list: "edit-channel-categories",
|
||||
autocomplete: "off",
|
||||
placeholder: "Leave blank for no category",
|
||||
"data-testid": "edit-channel-category-input",
|
||||
});
|
||||
categoryInput.value = channelCategory ?? "";
|
||||
const categoryList = createElement("datalist", { id: "edit-channel-categories" });
|
||||
for (const known of getKnownCategories()) {
|
||||
categoryList.appendChild(createElement("option", { value: known }));
|
||||
}
|
||||
appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList);
|
||||
|
||||
// Slow mode (presets; a stored off-preset value keeps its own option)
|
||||
const currentSlowMode = clampSlowMode(channelSlowMode ?? 0);
|
||||
const slowGroup = createElement("div", { class: "form-group" });
|
||||
const slowLabel = createElement("label", { class: "form-label" }, "Slow Mode");
|
||||
const slowSelect = createElement("select", {
|
||||
class: "form-input",
|
||||
"data-testid": "edit-channel-slowmode-select",
|
||||
});
|
||||
const choices = SLOW_MODE_PRESETS.some((p) => p.seconds === currentSlowMode)
|
||||
? [...SLOW_MODE_PRESETS]
|
||||
: [
|
||||
...SLOW_MODE_PRESETS,
|
||||
{ seconds: currentSlowMode, label: formatSlowMode(currentSlowMode) },
|
||||
];
|
||||
for (const choice of choices.toSorted((a, b) => a.seconds - b.seconds)) {
|
||||
const opt = createElement("option", { value: String(choice.seconds) }, choice.label);
|
||||
if (choice.seconds === currentSlowMode) opt.selected = true;
|
||||
slowSelect.appendChild(opt);
|
||||
}
|
||||
const slowHint = createElement(
|
||||
"div",
|
||||
{ class: "form-hint" },
|
||||
"Members must wait this long between messages. Holders of Manage Messages are exempt.",
|
||||
);
|
||||
appendChildren(slowGroup, slowLabel, slowSelect, slowHint);
|
||||
|
||||
// NSFW flag. The copy states the limit of the feature: the server does not
|
||||
// filter anything, so promising otherwise here would be a lie.
|
||||
const nsfwGroup = createElement("div", { class: "form-group" });
|
||||
const nsfwLabelRow = createElement("label", { class: "form-check" });
|
||||
const nsfwInput = createElement("input", {
|
||||
type: "checkbox",
|
||||
"data-testid": "edit-channel-nsfw-checkbox",
|
||||
});
|
||||
nsfwInput.checked = channelNsfw === true;
|
||||
const nsfwText = createElement("span", {}, "Age-restricted (NSFW)");
|
||||
appendChildren(nsfwLabelRow, nsfwInput, nsfwText);
|
||||
const nsfwHint = createElement(
|
||||
"div",
|
||||
{ class: "form-hint" },
|
||||
"Members see a one-time warning each session before opening the channel, and the channel is marked in the sidebar. Nothing is filtered.",
|
||||
);
|
||||
appendChildren(nsfwGroup, nsfwLabelRow, nsfwHint);
|
||||
|
||||
appendChildren(body, typeGroup, nameGroup, topicGroup, categoryGroup, slowGroup, nsfwGroup);
|
||||
|
||||
// Voice-only section. Rendered for a voice channel alone: the columns exist
|
||||
// on every row, but on a text channel they are values nothing reads, and
|
||||
// offering them would imply an enforcement that does not happen.
|
||||
let maxUsersInput: HTMLInputElement | null = null;
|
||||
let maxVideoInput: HTMLInputElement | null = null;
|
||||
if (isVoice) {
|
||||
const voiceSection = createElement("div", {
|
||||
class: "form-section",
|
||||
"data-testid": "edit-channel-voice-section",
|
||||
});
|
||||
const voiceHeading = createElement("div", { class: "form-section-title" }, "Voice Limits");
|
||||
const users = buildVoiceLimitField(
|
||||
"User Limit",
|
||||
"How many members may be connected at once. 0 = unlimited.",
|
||||
"edit-channel-max-users-input",
|
||||
channelVoiceMaxUsers ?? 0,
|
||||
);
|
||||
const video = buildVoiceLimitField(
|
||||
"Video Limit",
|
||||
"How many may have a camera or screen share on at once. 0 = unlimited.",
|
||||
"edit-channel-max-video-input",
|
||||
channelVoiceMaxVideo ?? 0,
|
||||
);
|
||||
maxUsersInput = users.input;
|
||||
maxVideoInput = video.input;
|
||||
appendChildren(voiceSection, voiceHeading, users.group, video.group);
|
||||
body.appendChild(voiceSection);
|
||||
}
|
||||
|
||||
// Error display
|
||||
const errorEl = createElement("div", {
|
||||
class: "form-group",
|
||||
style: "color: var(--red); font-size: 13px; display: none;",
|
||||
"data-testid": "edit-channel-error",
|
||||
});
|
||||
|
||||
appendChildren(body, typeGroup, nameGroup, errorEl);
|
||||
body.appendChild(errorEl);
|
||||
|
||||
// Footer
|
||||
const footer = createElement("div", { class: "modal-footer" });
|
||||
@@ -114,8 +354,22 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
|
||||
saveBtn.setAttribute("disabled", "true");
|
||||
setText(saveBtn, "Saving...");
|
||||
|
||||
const data: EditChannelData = {
|
||||
name,
|
||||
topic: topicInput.value.trim(),
|
||||
category: categoryInput.value.trim(),
|
||||
slow_mode: clampSlowMode(Number.parseInt(slowSelect.value, 10)),
|
||||
nsfw: nsfwInput.checked,
|
||||
...(maxUsersInput !== null
|
||||
? { voice_max_users: clampVoiceLimit(Number.parseInt(maxUsersInput.value, 10)) }
|
||||
: {}),
|
||||
...(maxVideoInput !== null
|
||||
? { voice_max_video: clampVoiceLimit(Number.parseInt(maxVideoInput.value, 10)) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await onSave({ name });
|
||||
await onSave(data);
|
||||
} catch (err) {
|
||||
errorEl.style.display = "block";
|
||||
setText(errorEl, err instanceof Error ? err.message : "Failed to update channel");
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* EmojiAutocomplete — inline emoji picker the composer opens on ":".
|
||||
*
|
||||
* Deliberately the same shape as MentionAutocomplete (setQuery / handleKeydown
|
||||
* / destroy, mousedown-to-choose, arrow-key navigation): the composer drives
|
||||
* both through one code path, and a user who has learned one has learned the
|
||||
* other.
|
||||
*
|
||||
* Two sources in one list: the server's custom emoji, which insert their
|
||||
* `:shortcode:` text, and the built-in unicode set, which inserts the character
|
||||
* itself. Custom emoji come first — they are the ones a shortcode is really
|
||||
* for, and there are far fewer of them.
|
||||
*
|
||||
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
*/
|
||||
|
||||
import { createElement, setText } from "@lib/dom";
|
||||
import { EMOJI_NAMES } from "@components/EmojiPicker";
|
||||
import { buildCustomEmojiImage } from "@components/message-list/custom-emoji";
|
||||
import { listCustomEmoji, type CustomEmoji } from "@stores/emoji.store";
|
||||
import {
|
||||
createInlineAutocomplete,
|
||||
type InlineAutocompleteComponent,
|
||||
} from "@components/inline-autocomplete";
|
||||
|
||||
/** Maximum rows shown at once — the popup is a shortcut, not the picker. */
|
||||
export const MAX_EMOJI_SUGGESTIONS = 10;
|
||||
|
||||
/**
|
||||
* The shortest query that opens the popup. One character after the colon would
|
||||
* match most of the unicode set and fire on ordinary prose ("note: a thing").
|
||||
*/
|
||||
export const MIN_EMOJI_QUERY = 2;
|
||||
|
||||
export interface EmojiSuggestion {
|
||||
/** Row label — the shortcode, or the unicode emoji's primary name. */
|
||||
readonly label: string;
|
||||
/** Text inserted into the composer, replacing the `:query` under the caret. */
|
||||
readonly insert: string;
|
||||
/** Secondary line: the remaining keywords, or the literal token for custom. */
|
||||
readonly detail: string;
|
||||
readonly kind: "custom" | "unicode";
|
||||
/** The character to show as the preview, or null for a custom emoji image. */
|
||||
readonly char: string | null;
|
||||
/** The custom emoji this row stands for, or null for a unicode one. */
|
||||
readonly emoji: CustomEmoji | null;
|
||||
}
|
||||
|
||||
export interface EmojiAutocompleteOptions {
|
||||
/** Called with the text to insert (`:wave:` or a unicode character). */
|
||||
readonly onSelect: (insert: string) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** Same shape as the shared inline-autocomplete widget. */
|
||||
export type EmojiAutocompleteComponent = InlineAutocompleteComponent;
|
||||
|
||||
function byLabel(a: EmojiSuggestion, b: EmojiSuggestion): number {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
/** The preview cell for one row: the custom emoji's image, or the character. */
|
||||
function buildPreview(s: EmojiSuggestion): HTMLSpanElement {
|
||||
const preview = createElement("span", { class: "ea-preview" });
|
||||
if (s.emoji !== null) preview.appendChild(buildCustomEmojiImage(s.emoji));
|
||||
else setText(preview, s.char ?? "");
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions for `query`, in the order the popup lists them: custom emoji
|
||||
* first (prefix matches before substring), then unicode, alphabetical within
|
||||
* each group.
|
||||
*
|
||||
* A query shorter than MIN_EMOJI_QUERY yields nothing at all, so the composer
|
||||
* never opens a popup over a lone colon.
|
||||
*/
|
||||
export function filterEmojiSuggestions(query: string): EmojiSuggestion[] {
|
||||
const q = query.toLowerCase();
|
||||
if (q.length < MIN_EMOJI_QUERY) return [];
|
||||
|
||||
const customPrefix: EmojiSuggestion[] = [];
|
||||
const customSubstring: EmojiSuggestion[] = [];
|
||||
for (const emoji of listCustomEmoji()) {
|
||||
const name = emoji.shortcode;
|
||||
if (!name.includes(q)) continue;
|
||||
const entry: EmojiSuggestion = {
|
||||
label: name,
|
||||
insert: `:${name}:`,
|
||||
detail: "Server emoji",
|
||||
kind: "custom",
|
||||
char: null,
|
||||
emoji,
|
||||
};
|
||||
if (name.startsWith(q)) customPrefix.push(entry);
|
||||
else customSubstring.push(entry);
|
||||
}
|
||||
|
||||
const unicodePrefix: EmojiSuggestion[] = [];
|
||||
const unicodeSubstring: EmojiSuggestion[] = [];
|
||||
for (const [char, keywords] of Object.entries(EMOJI_NAMES)) {
|
||||
if (!keywords.includes(q)) continue;
|
||||
const words = keywords.split(" ");
|
||||
const primary = words[0] ?? keywords;
|
||||
const entry: EmojiSuggestion = {
|
||||
label: primary,
|
||||
insert: char,
|
||||
detail: words.slice(1).join(" "),
|
||||
kind: "unicode",
|
||||
char,
|
||||
emoji: null,
|
||||
};
|
||||
// "Prefix" means some whole keyword starts with the query, not just the
|
||||
// primary one — typing ":fire" should rank 🔥 ("fire hot flame lit") above
|
||||
// an emoji that merely contains "fire" mid-word.
|
||||
if (words.some((w) => w.startsWith(q))) unicodePrefix.push(entry);
|
||||
else unicodeSubstring.push(entry);
|
||||
}
|
||||
|
||||
customPrefix.sort(byLabel);
|
||||
customSubstring.sort(byLabel);
|
||||
unicodePrefix.sort(byLabel);
|
||||
unicodeSubstring.sort(byLabel);
|
||||
|
||||
return [...customPrefix, ...customSubstring, ...unicodePrefix, ...unicodeSubstring].slice(
|
||||
0,
|
||||
MAX_EMOJI_SUGGESTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/** One emoji row: preview cell, `:label:`/name, and a keyword detail line. */
|
||||
function renderEmojiRow(s: EmojiSuggestion): HTMLElement[] {
|
||||
const name = createElement("span", { class: "ma-name" });
|
||||
setText(name, s.kind === "custom" ? `:${s.label}:` : s.label);
|
||||
const detail = createElement("span", { class: "ma-detail" });
|
||||
setText(detail, s.detail);
|
||||
return [buildPreview(s), name, detail];
|
||||
}
|
||||
|
||||
export function createEmojiAutocomplete(
|
||||
options: EmojiAutocompleteOptions,
|
||||
): EmojiAutocompleteComponent {
|
||||
return createInlineAutocomplete<EmojiSuggestion>({
|
||||
// Shares the base class deliberately (the composer test selects
|
||||
// `.mention-autocomplete:not(.emoji-autocomplete)` to distinguish them).
|
||||
rootClass: "mention-autocomplete emoji-autocomplete",
|
||||
rootTestId: "emoji-autocomplete",
|
||||
filter: filterEmojiSuggestions,
|
||||
valueOf: (s) => s.insert,
|
||||
rowTestId: (s) => `emoji-option-${s.label}`,
|
||||
renderRow: renderEmojiRow,
|
||||
// Unlike mentions, emoji stay empty until the composer types past
|
||||
// MIN_EMOJI_QUERY, so there is nothing to prime on create.
|
||||
onSelect: options.onSelect,
|
||||
onClose: options.onClose,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -13,11 +14,19 @@ export interface CustomEmoji {
|
||||
}
|
||||
|
||||
export interface EmojiPickerOptions {
|
||||
/**
|
||||
* The server's custom emoji, shown as a "Server" category above the unicode
|
||||
* ones. Selecting one inserts its `:shortcode:` — the composer sends text,
|
||||
* and the renderer turns that text back into the image.
|
||||
*/
|
||||
readonly customEmoji?: readonly CustomEmoji[];
|
||||
readonly onSelect: (emoji: string) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** The category label the server's own emoji appear under. */
|
||||
export const SERVER_CATEGORY = "Server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in emoji data (common subset by category)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -274,8 +283,14 @@ const CATEGORIES: readonly EmojiCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Emoji name lookup for search. Maps emoji character → searchable keywords. */
|
||||
const EMOJI_NAMES: Readonly<Record<string, string>> = {
|
||||
/**
|
||||
* Emoji name lookup for search. Maps emoji character → searchable keywords.
|
||||
*
|
||||
* Exported because the composer's `:` autocomplete searches the same list the
|
||||
* picker does — two independently-maintained name tables would mean typing
|
||||
* `:fire` and searching "fire" disagreeing about what exists.
|
||||
*/
|
||||
export const EMOJI_NAMES: Readonly<Record<string, string>> = {
|
||||
"😀": "grinning face happy smile",
|
||||
"😃": "smiley face happy smile",
|
||||
"😄": "smile happy grin",
|
||||
@@ -555,10 +570,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
const recent = getRecentEmoji();
|
||||
const cats: EmojiCategory[] = [{ name: "Recent", emoji: recent }];
|
||||
|
||||
// Custom server emoji
|
||||
// The server's own emoji, as the `:shortcode:` tokens a message carries.
|
||||
if (options.customEmoji && options.customEmoji.length > 0) {
|
||||
cats.push({
|
||||
name: "Custom",
|
||||
name: SERVER_CATEGORY,
|
||||
emoji: options.customEmoji.map((e) => `:${e.shortcode}:`),
|
||||
});
|
||||
}
|
||||
@@ -582,7 +597,16 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
class: "ep-emoji",
|
||||
title: emoji,
|
||||
});
|
||||
setText(span, emoji);
|
||||
// A `:shortcode:` entry shows its image; everything else is the character
|
||||
// itself. An unresolvable shortcode falls back to the text, which is what
|
||||
// it would render as in a message anyway.
|
||||
const image = buildCustomEmojiNode(emoji);
|
||||
if (image !== null) {
|
||||
span.classList.add("ep-emoji-custom");
|
||||
span.appendChild(image);
|
||||
} else {
|
||||
setText(span, emoji);
|
||||
}
|
||||
span.addEventListener("click", () => handleEmojiClick(emoji), { signal });
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* IncomingCallBanner — the toast-like strip that appears when somebody rings a
|
||||
* DM you are in.
|
||||
*
|
||||
* It is deliberately a banner and not a modal: a ring is an offer, not a
|
||||
* demand, and a modal would block the app until the 30s timer expired. Accept
|
||||
* joins the DM's voice channel; Decline tells the ringer to stop.
|
||||
*
|
||||
* All state lives in @lib/call-ring — this only draws whatever it is handed.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { RingState } from "@lib/call-ring";
|
||||
|
||||
export interface IncomingCallBannerOptions {
|
||||
readonly onAccept: () => void;
|
||||
readonly onDecline: () => void;
|
||||
}
|
||||
|
||||
export interface IncomingCallBannerComponent extends MountableComponent {
|
||||
/** Show the banner for a ring, or hide it with null. */
|
||||
readonly setRing: (state: RingState | null) => void;
|
||||
}
|
||||
|
||||
export function createIncomingCallBanner(
|
||||
options: IncomingCallBannerOptions,
|
||||
): IncomingCallBannerComponent {
|
||||
const ac = new AbortController();
|
||||
|
||||
const root = createElement("div", {
|
||||
class: "incoming-call-banner",
|
||||
role: "alert",
|
||||
"data-testid": "incoming-call-banner",
|
||||
});
|
||||
root.style.display = "none";
|
||||
|
||||
const icon = createElement("div", { class: "incoming-call-icon" });
|
||||
icon.appendChild(createIcon("phone", 20));
|
||||
|
||||
const info = createElement("div", { class: "incoming-call-info" });
|
||||
const title = createElement("div", {
|
||||
class: "incoming-call-title",
|
||||
"data-testid": "incoming-call-title",
|
||||
});
|
||||
const subtitle = createElement("div", { class: "incoming-call-subtitle" }, "Incoming call");
|
||||
appendChildren(info, title, subtitle);
|
||||
|
||||
const acceptBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn btn-primary incoming-call-accept",
|
||||
type: "button",
|
||||
"data-testid": "incoming-call-accept",
|
||||
},
|
||||
"Accept",
|
||||
);
|
||||
acceptBtn.addEventListener("click", () => options.onAccept(), { signal: ac.signal });
|
||||
|
||||
const declineBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn btn-danger incoming-call-decline",
|
||||
type: "button",
|
||||
"data-testid": "incoming-call-decline",
|
||||
},
|
||||
"Decline",
|
||||
);
|
||||
declineBtn.addEventListener("click", () => options.onDecline(), { signal: ac.signal });
|
||||
|
||||
const actions = createElement("div", { class: "incoming-call-actions" });
|
||||
appendChildren(actions, acceptBtn, declineBtn);
|
||||
appendChildren(root, icon, info, actions);
|
||||
|
||||
function setRing(state: RingState | null): void {
|
||||
if (state === null) {
|
||||
root.style.display = "none";
|
||||
setText(title, "");
|
||||
return;
|
||||
}
|
||||
// setText, never innerHTML: the username is user-controlled.
|
||||
setText(title, `${state.fromUsername} is calling`);
|
||||
root.style.display = "";
|
||||
}
|
||||
|
||||
return {
|
||||
mount(container: Element): void {
|
||||
container.appendChild(root);
|
||||
},
|
||||
destroy(): void {
|
||||
ac.abort();
|
||||
root.remove();
|
||||
},
|
||||
setRing,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,47 @@
|
||||
/**
|
||||
* MemberList component — shows server members grouped by role with online status.
|
||||
* Subscribes to membersStore for reactive updates.
|
||||
* Right-click context menu for admin actions (kick, ban, role change).
|
||||
* Right-click context menu for admin actions (force logout, ban, role change).
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { Disposable } from "@lib/disposable";
|
||||
import { membersStore, type Member, type MembersState } from "@stores/members.store";
|
||||
import {
|
||||
membersStore,
|
||||
memberDisplayName,
|
||||
type Member,
|
||||
type MembersState,
|
||||
} from "@stores/members.store";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { blocksStore } from "@stores/blocks.store";
|
||||
import { channelsStore, type ChannelsState } from "@stores/channels.store";
|
||||
import { createMemberContextMenu } from "@components/AdminActions";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import {
|
||||
createUserProfilePopup,
|
||||
type UserProfilePopupComponent,
|
||||
} from "@components/UserProfilePopup";
|
||||
import { Permission, type ReadyRole, type UserStatus } from "@lib/types";
|
||||
import { roleHasPermission } from "@lib/permissions";
|
||||
import { createAvatarElement } from "@lib/avatar";
|
||||
|
||||
/** Options for configuring admin action callbacks on the member list. */
|
||||
export interface MemberListOptions {
|
||||
/** Role name of the signed-in user; resolved against the server's role list
|
||||
* to get the permission mask that gates the moderation menu items. */
|
||||
readonly currentUserRole: string;
|
||||
/** Force logout: revokes the target's sessions (KICK_MEMBERS). */
|
||||
readonly onKick: (userId: number, username: string) => Promise<void>;
|
||||
readonly onBan: (userId: number, username: string, reason: string) => Promise<void>;
|
||||
readonly onBan: (
|
||||
userId: number,
|
||||
username: string,
|
||||
reason: string,
|
||||
durationHours: number,
|
||||
) => Promise<void>;
|
||||
readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise<void>;
|
||||
readonly onToggleBlock: (userId: number, username: string, block: boolean) => Promise<void>;
|
||||
/** Start a DM with a user (wires the profile popup's Message button). */
|
||||
readonly onMessageUser?: (userId: number) => void;
|
||||
}
|
||||
|
||||
/** Roles offered in the "Change Role" submenu when the server hasn't sent any. */
|
||||
@@ -36,18 +59,69 @@ function assignableRoleNames(): readonly string[] {
|
||||
return roles.length > 0 ? roles : FALLBACK_ASSIGNABLE_ROLES;
|
||||
}
|
||||
|
||||
/** Ordered role groups with display names and CSS color variables. */
|
||||
const ROLE_GROUPS: readonly {
|
||||
/** Which moderation menu items the signed-in user may see. */
|
||||
interface ModerationGates {
|
||||
readonly canKick: boolean;
|
||||
readonly canBan: boolean;
|
||||
readonly canManageRoles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu items the signed-in user's role permits, from the permission mask the
|
||||
* server ships in `ready`. Administrator implies all three. When the role name
|
||||
* has no match in that list (pre-`ready`, or an older server that sent none)
|
||||
* the legacy owner/admin name check stands in — a mask of 0 would otherwise
|
||||
* hide moderation from every actual admin.
|
||||
*/
|
||||
function moderationGates(roleName: string): ModerationGates {
|
||||
return {
|
||||
canKick: roleHasPermission(roleName, Permission.KICK_MEMBERS),
|
||||
canBan: roleHasPermission(roleName, Permission.BAN_MEMBERS),
|
||||
canManageRoles: roleHasPermission(roleName, Permission.MANAGE_ROLES),
|
||||
};
|
||||
}
|
||||
|
||||
interface RoleGroup {
|
||||
readonly role: string;
|
||||
readonly label: string;
|
||||
readonly colorVar: string;
|
||||
}[] = [
|
||||
{ role: "owner", label: "OWNER", colorVar: "var(--role-owner, #e74c3c)" },
|
||||
{ role: "admin", label: "ADMIN", colorVar: "var(--role-admin, #f39c12)" },
|
||||
{ role: "moderator", label: "MODERATOR", colorVar: "var(--role-mod, #2ecc71)" },
|
||||
{ role: "member", label: "MEMBER", colorVar: "var(--role-member, #949ba4)" },
|
||||
}
|
||||
|
||||
/** Theme-variable fallbacks for the seeded roles (used when the server sends no color). */
|
||||
const FALLBACK_ROLE_COLORS: Record<string, string> = {
|
||||
owner: "var(--role-owner, #e74c3c)",
|
||||
admin: "var(--role-admin, #f39c12)",
|
||||
moderator: "var(--role-mod, #2ecc71)",
|
||||
};
|
||||
|
||||
const MEMBER_COLOR = "var(--role-member, #949ba4)";
|
||||
|
||||
/** Ordered role groups used when the server hasn't sent a role list. */
|
||||
const FALLBACK_ROLE_GROUPS: readonly RoleGroup[] = [
|
||||
{ role: "owner", label: "OWNER", colorVar: FALLBACK_ROLE_COLORS["owner"]! },
|
||||
{ role: "admin", label: "ADMIN", colorVar: FALLBACK_ROLE_COLORS["admin"]! },
|
||||
{ role: "moderator", label: "MODERATOR", colorVar: FALLBACK_ROLE_COLORS["moderator"]! },
|
||||
{ role: "member", label: "MEMBER", colorVar: MEMBER_COLOR },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Role groups from the server's `ready` role list (already ordered by position,
|
||||
* highest first), colored by the server's role color when set. A hardcoded
|
||||
* list rendered custom roles nowhere and ignored `roles.color` entirely.
|
||||
*/
|
||||
function roleGroups(): readonly RoleGroup[] {
|
||||
const roles = channelsStore.getState().roles;
|
||||
if (roles.length === 0) return FALLBACK_ROLE_GROUPS;
|
||||
return roles.map((r) => {
|
||||
const key = r.name.toLowerCase();
|
||||
return {
|
||||
role: key,
|
||||
label: r.name.toUpperCase(),
|
||||
colorVar: r.color ?? FALLBACK_ROLE_COLORS[key] ?? MEMBER_COLOR,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Status priority for sorting: lower = higher priority (shown first). */
|
||||
function statusPriority(status: UserStatus): number {
|
||||
switch (status) {
|
||||
@@ -57,6 +131,10 @@ function statusPriority(status: UserStatus): number {
|
||||
return 1;
|
||||
case "dnd":
|
||||
return 2;
|
||||
// "invisible" only ever describes the signed-in user (the server shows
|
||||
// everyone else offline), and it sorts with offline because that is where
|
||||
// they appear to everybody — including, in this list, to themselves.
|
||||
case "invisible":
|
||||
case "offline":
|
||||
return 3;
|
||||
default:
|
||||
@@ -72,6 +150,7 @@ function statusColor(status: UserStatus): string {
|
||||
return "var(--yellow)";
|
||||
case "dnd":
|
||||
return "var(--red)";
|
||||
case "invisible":
|
||||
case "offline":
|
||||
return "var(--text-micro)";
|
||||
default:
|
||||
@@ -79,7 +158,13 @@ function statusColor(status: UserStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the statuses that render a member as "not here". */
|
||||
function isAwayStatus(status: UserStatus): boolean {
|
||||
return status === "offline" || status === "invisible";
|
||||
}
|
||||
|
||||
let activeMenu: { element: HTMLDivElement; destroy(): void } | null = null;
|
||||
let activePopup: UserProfilePopupComponent | null = null;
|
||||
|
||||
function closeActiveMenu(): void {
|
||||
if (activeMenu !== null) {
|
||||
@@ -88,6 +173,13 @@ function closeActiveMenu(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function closeActivePopup(): void {
|
||||
if (activePopup !== null) {
|
||||
activePopup.destroy?.();
|
||||
activePopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent): void {
|
||||
if (activeMenu !== null && !activeMenu.element.contains(e.target as Node)) {
|
||||
closeActiveMenu();
|
||||
@@ -102,15 +194,13 @@ function createMemberItem(
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const item = createElement("div", {
|
||||
class: member.status === "offline" ? "member-item offline" : "member-item",
|
||||
class: isAwayStatus(member.status) ? "member-item offline" : "member-item",
|
||||
"data-testid": `member-${member.id}`,
|
||||
});
|
||||
|
||||
const initial = member.username.charAt(0).toUpperCase() || "?";
|
||||
const avatar = createElement(
|
||||
"div",
|
||||
{ class: "mi-avatar", style: `background: ${colorVar}` },
|
||||
initial,
|
||||
const avatar = createAvatarElement(
|
||||
{ username: member.username, displayName: member.displayName, avatar: member.avatar },
|
||||
{ className: "mi-avatar", background: colorVar },
|
||||
);
|
||||
|
||||
const statusDot = createElement("div", {
|
||||
@@ -121,10 +211,54 @@ function createMemberItem(
|
||||
});
|
||||
avatar.appendChild(statusDot);
|
||||
|
||||
// Name + custom status stack. The custom status is only rendered when there
|
||||
// is one, so a member without it keeps the single-line row it always had.
|
||||
const nameWrap = createElement("div", { class: "mi-text" });
|
||||
const name = createElement("span", { class: "mi-name", style: `color: ${colorVar}` });
|
||||
setText(name, member.username);
|
||||
setText(name, memberDisplayName(member));
|
||||
nameWrap.appendChild(name);
|
||||
const custom = member.customStatus;
|
||||
if (typeof custom === "string" && custom.length > 0) {
|
||||
const customEl = createElement("span", {
|
||||
class: "mi-custom-status",
|
||||
"data-testid": `member-custom-status-${member.id}`,
|
||||
});
|
||||
setText(customEl, custom);
|
||||
nameWrap.appendChild(customEl);
|
||||
}
|
||||
|
||||
appendChildren(item, avatar, name);
|
||||
appendChildren(item, avatar, nameWrap);
|
||||
|
||||
// Left-click opens the profile popup (previously dead code — built and
|
||||
// tested but never mounted from anywhere).
|
||||
item.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
closeActiveMenu();
|
||||
closeActivePopup();
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
const isSelf = member.id === currentUserId;
|
||||
const onMessageUser = opts.onMessageUser;
|
||||
activePopup = createUserProfilePopup({
|
||||
user: {
|
||||
id: member.id,
|
||||
username: member.username,
|
||||
avatar: member.avatar,
|
||||
role: member.role,
|
||||
status: member.status,
|
||||
displayName: member.displayName,
|
||||
customStatus: member.customStatus,
|
||||
},
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
...(isSelf || onMessageUser === undefined
|
||||
? {}
|
||||
: { onMessage: (userId: number) => onMessageUser(userId) }),
|
||||
});
|
||||
activePopup.mount(document.body);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Context menu for admin actions
|
||||
item.addEventListener(
|
||||
@@ -136,9 +270,10 @@ function createMemberItem(
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
if (member.id === currentUserId) return;
|
||||
|
||||
// Only admins and owners can use admin actions
|
||||
const role = opts.currentUserRole.toLowerCase();
|
||||
if (role !== "owner" && role !== "admin") return;
|
||||
// Moderation actions are permission-gated per item (a role name told us
|
||||
// nothing about what its bits allow); block/unblock is open to everyone.
|
||||
const gates = moderationGates(opts.currentUserRole);
|
||||
const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles;
|
||||
|
||||
closeActiveMenu();
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
@@ -147,14 +282,22 @@ function createMemberItem(
|
||||
// custom roles unreachable and, worse, unresolvable to a role id, so
|
||||
// picking one silently did nothing.
|
||||
const availableRoles = assignableRoleNames();
|
||||
const isBlocked = blocksStore.getState().blockedByMe.has(member.id);
|
||||
|
||||
activeMenu = createMemberContextMenu({
|
||||
userId: member.id,
|
||||
username: member.username,
|
||||
currentRole: member.role.toLowerCase(),
|
||||
availableRoles,
|
||||
showAdminActions,
|
||||
canKick: gates.canKick,
|
||||
canBan: gates.canBan,
|
||||
canManageRoles: gates.canManageRoles,
|
||||
isBlocked,
|
||||
onToggleBlock: () => opts.onToggleBlock(member.id, member.username, !isBlocked),
|
||||
onKick: () => opts.onKick(member.id, member.username),
|
||||
onBan: (reason: string) => opts.onBan(member.id, member.username, reason),
|
||||
onBan: (reason: string, durationHours: number) =>
|
||||
opts.onBan(member.id, member.username, reason, durationHours),
|
||||
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
|
||||
});
|
||||
|
||||
@@ -208,25 +351,47 @@ function renderList(
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of ROLE_GROUPS) {
|
||||
const groupMembers = (buckets.get(group.role) ?? []).toSorted(
|
||||
(a, b) => statusPriority(a.status) - statusPriority(b.status),
|
||||
);
|
||||
const groups = roleGroups();
|
||||
const rendered = new Set<string>();
|
||||
for (const group of groups) {
|
||||
rendered.add(group.role);
|
||||
appendGroup(root, group, buckets.get(group.role) ?? [], opts, signal, rowsByUserId);
|
||||
}
|
||||
|
||||
if (groupMembers.length === 0) continue;
|
||||
// Members whose role isn't in the server's role list (e.g. a role deleted
|
||||
// mid-session) still render, in a gray group, instead of vanishing.
|
||||
const leftovers = [...buckets.keys()].filter((role) => !rendered.has(role)).toSorted();
|
||||
for (const role of leftovers) {
|
||||
const group: RoleGroup = { role, label: role.toUpperCase(), colorVar: MEMBER_COLOR };
|
||||
appendGroup(root, group, buckets.get(role) ?? [], opts, signal, rowsByUserId);
|
||||
}
|
||||
}
|
||||
|
||||
const header = createElement(
|
||||
"div",
|
||||
{ class: "member-role-group" },
|
||||
`${group.label} \u2014 ${groupMembers.length}`,
|
||||
);
|
||||
root.appendChild(header);
|
||||
function appendGroup(
|
||||
root: HTMLDivElement,
|
||||
group: RoleGroup,
|
||||
members: readonly Member[],
|
||||
opts: MemberListOptions,
|
||||
signal: AbortSignal,
|
||||
rowsByUserId: Map<number, HTMLDivElement>,
|
||||
): void {
|
||||
const groupMembers = members.toSorted(
|
||||
(a, b) => statusPriority(a.status) - statusPriority(b.status),
|
||||
);
|
||||
|
||||
for (const member of groupMembers) {
|
||||
const item = createMemberItem(member, group.colorVar, opts, signal);
|
||||
rowsByUserId.set(member.id, item);
|
||||
root.appendChild(item);
|
||||
}
|
||||
if (groupMembers.length === 0) return;
|
||||
|
||||
const header = createElement(
|
||||
"div",
|
||||
{ class: "member-role-group" },
|
||||
`${group.label} \u2014 ${groupMembers.length}`,
|
||||
);
|
||||
root.appendChild(header);
|
||||
|
||||
for (const member of groupMembers) {
|
||||
const item = createMemberItem(member, group.colorVar, opts, signal);
|
||||
rowsByUserId.set(member.id, item);
|
||||
root.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +411,10 @@ function isPresenceOnlyChange(
|
||||
before.username !== member.username ||
|
||||
before.role !== member.role ||
|
||||
before.avatar !== member.avatar ||
|
||||
before.displayName !== member.displayName ||
|
||||
// A custom status is rendered as its own line, so a change to it is a
|
||||
// structural change, not a dot recolor.
|
||||
before.customStatus !== member.customStatus ||
|
||||
before.identityPublicKey !== member.identityPublicKey
|
||||
) {
|
||||
return false;
|
||||
@@ -268,7 +437,7 @@ function patchPresence(
|
||||
if (before === undefined || before.status === member.status) continue;
|
||||
const row = rowsByUserId.get(id);
|
||||
if (row === undefined) continue;
|
||||
row.classList.toggle("offline", member.status === "offline");
|
||||
row.classList.toggle("offline", isAwayStatus(member.status));
|
||||
const dot = row.querySelector<HTMLDivElement>(".mi-status");
|
||||
if (dot !== null) {
|
||||
dot.style.background = statusColor(member.status);
|
||||
@@ -305,11 +474,26 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
},
|
||||
);
|
||||
|
||||
// Role groups, their labels and their colors all come from the server's
|
||||
// role list, which role management makes mutable at runtime (a roles_update
|
||||
// broadcast replaces it). Without this the list kept the old grouping until
|
||||
// some unrelated member change happened to force a re-render.
|
||||
disposable.onStoreChange<ChannelsState, readonly ReadyRole[]>(
|
||||
channelsStore,
|
||||
(s) => s.roles,
|
||||
() => {
|
||||
if (root !== null) {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
closeActiveMenu();
|
||||
closeActivePopup();
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
disposable.destroy();
|
||||
rowsByUserId.clear();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* MentionAutocomplete — inline member picker the composer opens on "@".
|
||||
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
*/
|
||||
|
||||
import { createElement, setText } from "@lib/dom";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { currentUserHasPermission } from "@lib/permissions";
|
||||
import { Permission } from "@lib/types";
|
||||
import { EVERYONE_TOKEN, HERE_TOKEN } from "@lib/mentions";
|
||||
import {
|
||||
createInlineAutocomplete,
|
||||
type InlineAutocompleteComponent,
|
||||
} from "@components/inline-autocomplete";
|
||||
|
||||
/** Maximum rows shown at once — the popup is a shortcut, not the member list. */
|
||||
export const MAX_MENTION_SUGGESTIONS = 10;
|
||||
|
||||
export interface MentionSuggestion {
|
||||
/** Token inserted after the "@", e.g. "alice" or "everyone". */
|
||||
readonly token: string;
|
||||
/** Row label. Equal to `token` for users. */
|
||||
readonly label: string;
|
||||
/** Secondary line (role for users, meaning for @everyone/@here). */
|
||||
readonly detail: string;
|
||||
readonly kind: "user" | "broadcast";
|
||||
/** User id, or null for @everyone/@here. */
|
||||
readonly userId: number | null;
|
||||
}
|
||||
|
||||
export interface MentionAutocompleteOptions {
|
||||
/** Called with the token to insert (without the leading "@"). */
|
||||
readonly onSelect: (token: string) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** Same shape as the shared inline-autocomplete widget. */
|
||||
export type MentionAutocompleteComponent = InlineAutocompleteComponent;
|
||||
|
||||
function byLabel(a: MentionSuggestion, b: MentionSuggestion): number {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions for `query`, in the order the popup lists them: prefix matches
|
||||
* before substring matches, alphabetical within each group.
|
||||
*
|
||||
* @everyone / @here are offered only when the signed-in user's role holds
|
||||
* MENTION_EVERYONE — offering a token the server will refuse to honour would
|
||||
* be a lie. The server still enforces.
|
||||
*/
|
||||
export function filterMentionSuggestions(query: string): MentionSuggestion[] {
|
||||
const q = query.toLowerCase();
|
||||
const prefix: MentionSuggestion[] = [];
|
||||
const substring: MentionSuggestion[] = [];
|
||||
|
||||
for (const member of membersStore.getState().members.values()) {
|
||||
const lower = member.username.toLowerCase();
|
||||
if (q !== "" && !lower.includes(q)) continue;
|
||||
const entry: MentionSuggestion = {
|
||||
token: member.username,
|
||||
label: member.username,
|
||||
detail: member.role,
|
||||
kind: "user",
|
||||
userId: member.id,
|
||||
};
|
||||
if (q === "" || lower.startsWith(q)) {
|
||||
prefix.push(entry);
|
||||
} else {
|
||||
substring.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
prefix.sort(byLabel);
|
||||
substring.sort(byLabel);
|
||||
|
||||
const broadcasts: MentionSuggestion[] = [];
|
||||
if (currentUserHasPermission(Permission.MENTION_EVERYONE)) {
|
||||
const all: MentionSuggestion[] = [
|
||||
{
|
||||
token: EVERYONE_TOKEN,
|
||||
label: EVERYONE_TOKEN,
|
||||
detail: "Notify everyone in this channel",
|
||||
kind: "broadcast",
|
||||
userId: null,
|
||||
},
|
||||
{
|
||||
token: HERE_TOKEN,
|
||||
label: HERE_TOKEN,
|
||||
detail: "Notify everyone who is online",
|
||||
kind: "broadcast",
|
||||
userId: null,
|
||||
},
|
||||
];
|
||||
broadcasts.push(...all.filter((s) => q === "" || s.token.startsWith(q)));
|
||||
}
|
||||
|
||||
return [...broadcasts, ...prefix, ...substring].slice(0, MAX_MENTION_SUGGESTIONS);
|
||||
}
|
||||
|
||||
/** One mention row: `@label` plus a role / broadcast-meaning detail line. */
|
||||
function renderMentionRow(s: MentionSuggestion): HTMLElement[] {
|
||||
const name = createElement("span", { class: "ma-name" });
|
||||
setText(name, `@${s.label}`);
|
||||
const detail = createElement("span", { class: "ma-detail" });
|
||||
setText(detail, s.detail);
|
||||
return [name, detail];
|
||||
}
|
||||
|
||||
export function createMentionAutocomplete(
|
||||
options: MentionAutocompleteOptions,
|
||||
): MentionAutocompleteComponent {
|
||||
return createInlineAutocomplete<MentionSuggestion>({
|
||||
rootClass: "mention-autocomplete",
|
||||
rootTestId: "mention-autocomplete",
|
||||
filter: filterMentionSuggestions,
|
||||
valueOf: (s) => s.token,
|
||||
rowTestId: (s) => `mention-option-${s.token}`,
|
||||
renderRow: renderMentionRow,
|
||||
// Open already populated with the full member list.
|
||||
primeOnCreate: true,
|
||||
onSelect: options.onSelect,
|
||||
onClose: options.onClose,
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,16 @@ import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { createEmojiPicker } from "@components/EmojiPicker";
|
||||
import { createGifPicker } from "@components/GifPicker";
|
||||
import {
|
||||
createMentionAutocomplete,
|
||||
type MentionAutocompleteComponent,
|
||||
} from "@components/MentionAutocomplete";
|
||||
import {
|
||||
createEmojiAutocomplete,
|
||||
MIN_EMOJI_QUERY,
|
||||
type EmojiAutocompleteComponent,
|
||||
} from "@components/EmojiAutocomplete";
|
||||
import { listCustomEmoji } from "@stores/emoji.store";
|
||||
import type { GifApi } from "@lib/gifProvider";
|
||||
|
||||
export interface MessageInputOptions {
|
||||
@@ -50,6 +60,59 @@ export type MessageInputComponent = MountableComponent & {
|
||||
openFilePicker(): void;
|
||||
};
|
||||
|
||||
/** Ctrl/Cmd shortcut → markdown marker it wraps the selection in. */
|
||||
const FORMAT_MARKERS: Readonly<Record<string, string>> = {
|
||||
b: "**",
|
||||
i: "*",
|
||||
u: "__",
|
||||
};
|
||||
|
||||
export interface WrapResult {
|
||||
readonly value: string;
|
||||
readonly selectionStart: number;
|
||||
readonly selectionEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap (or unwrap) `[start, end)` of `value` in `marker`, returning the new
|
||||
* value and where the selection should land. With an empty selection the
|
||||
* markers are inserted around the caret so typing continues inside them.
|
||||
*
|
||||
* Pure so the behaviour can be tested without a DOM selection.
|
||||
*/
|
||||
export function wrapWithMarker(
|
||||
value: string,
|
||||
start: number,
|
||||
end: number,
|
||||
marker: string,
|
||||
): WrapResult {
|
||||
const selected = value.slice(start, end);
|
||||
const len = marker.length;
|
||||
|
||||
// Already wrapped — pressing the shortcut again takes the markers back off.
|
||||
if (selected.length > 2 * len && selected.startsWith(marker) && selected.endsWith(marker)) {
|
||||
const inner = selected.slice(len, selected.length - len);
|
||||
return {
|
||||
value: value.slice(0, start) + inner + value.slice(end),
|
||||
selectionStart: start,
|
||||
selectionEnd: start + inner.length,
|
||||
};
|
||||
}
|
||||
if (value.slice(start - len, start) === marker && value.slice(end, end + len) === marker) {
|
||||
return {
|
||||
value: value.slice(0, start - len) + selected + value.slice(end + len),
|
||||
selectionStart: start - len,
|
||||
selectionEnd: start - len + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: value.slice(0, start) + marker + selected + marker + value.slice(end),
|
||||
selectionStart: start + len,
|
||||
selectionEnd: start + len + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
const TYPING_THROTTLE_MS = 3_000;
|
||||
const MAX_TEXTAREA_HEIGHT = 200;
|
||||
const SEND_DEBOUNCE_MS = 200;
|
||||
@@ -94,6 +157,12 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
let attachmentPreviewBar: HTMLDivElement | null = null;
|
||||
/** Set by mount() when file uploads are wired; backs openFilePicker(). */
|
||||
let openPicker: (() => void) | null = null;
|
||||
let mentionPopup: MentionAutocompleteComponent | null = null;
|
||||
/** Index of the "@" the open popup is completing; -1 when closed. */
|
||||
let mentionStart = -1;
|
||||
let emojiPopup: EmojiAutocompleteComponent | null = null;
|
||||
/** Index of the ":" the open emoji popup is completing; -1 when closed. */
|
||||
let emojiStart = -1;
|
||||
|
||||
/** Pending attachment IDs to send with the next message. */
|
||||
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] =
|
||||
@@ -105,6 +174,161 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
/** Timer IDs for cleanup on destroy. */
|
||||
const activeTimers: Set<ReturnType<typeof setTimeout>> = new Set();
|
||||
|
||||
/**
|
||||
* The @token immediately before the caret, or null. The leading boundary
|
||||
* mirrors the server's mention rule, so the popup never offers a completion
|
||||
* for text ("mail@dom") that a send would not resolve as a mention.
|
||||
*/
|
||||
function activeMentionToken(): { query: string; start: number } | null {
|
||||
if (textarea === null) return null;
|
||||
const caret = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, caret);
|
||||
const match = /(?:^|[^\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{0,64})$/u.exec(before);
|
||||
if (match === null) return null;
|
||||
const query = match[1] ?? "";
|
||||
return { query, start: caret - query.length - 1 };
|
||||
}
|
||||
|
||||
function closeMentionPopup(): void {
|
||||
if (mentionPopup === null) return;
|
||||
mentionPopup.destroy();
|
||||
mentionPopup = null;
|
||||
mentionStart = -1;
|
||||
}
|
||||
|
||||
/** Replace the token under the caret with "@token ". */
|
||||
function insertMention(token: string): void {
|
||||
if (textarea === null || mentionStart < 0) {
|
||||
closeMentionPopup();
|
||||
return;
|
||||
}
|
||||
const caret = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, mentionStart);
|
||||
const after = textarea.value.slice(caret);
|
||||
const inserted = `@${token} `;
|
||||
textarea.value = before + inserted + after;
|
||||
const pos = before.length + inserted.length;
|
||||
textarea.selectionStart = pos;
|
||||
textarea.selectionEnd = pos;
|
||||
closeMentionPopup();
|
||||
autoResize();
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* The `:token` immediately before the caret, or null. The leading boundary
|
||||
* keeps the popup out of ordinary prose: a colon that follows a word ("see
|
||||
* this:thing", a "10:30" clock, an "http://" scheme) is punctuation, not the
|
||||
* start of a shortcode. A completed `:token:` is skipped too — it is already
|
||||
* an emoji, and re-offering completions over it would fight the user.
|
||||
*/
|
||||
function activeEmojiToken(): { query: string; start: number } | null {
|
||||
if (textarea === null) return null;
|
||||
const caret = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, caret);
|
||||
const match = /(?:^|\s):([A-Za-z0-9_]{0,32})$/.exec(before);
|
||||
if (match === null) return null;
|
||||
const query = match[1] ?? "";
|
||||
if (query.length < MIN_EMOJI_QUERY) return null;
|
||||
return { query, start: caret - query.length - 1 };
|
||||
}
|
||||
|
||||
function closeEmojiPopup(): void {
|
||||
if (emojiPopup === null) return;
|
||||
emojiPopup.destroy();
|
||||
emojiPopup = null;
|
||||
emojiStart = -1;
|
||||
}
|
||||
|
||||
/** Replace the `:token` under the caret with the chosen emoji, plus a space. */
|
||||
function insertEmoji(insert: string): void {
|
||||
if (textarea === null || emojiStart < 0) {
|
||||
closeEmojiPopup();
|
||||
return;
|
||||
}
|
||||
const caret = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, emojiStart);
|
||||
const after = textarea.value.slice(caret);
|
||||
const inserted = `${insert} `;
|
||||
textarea.value = before + inserted + after;
|
||||
const pos = before.length + inserted.length;
|
||||
textarea.selectionStart = pos;
|
||||
textarea.selectionEnd = pos;
|
||||
closeEmojiPopup();
|
||||
autoResize();
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
/** Open, refilter, or close the emoji popup for whatever is under the caret. */
|
||||
function syncEmojiPopup(): void {
|
||||
const active = disabledReason === null ? activeEmojiToken() : null;
|
||||
if (active === null) {
|
||||
closeEmojiPopup();
|
||||
return;
|
||||
}
|
||||
if (emojiPopup === null) {
|
||||
emojiPopup = createEmojiAutocomplete({
|
||||
onSelect: insertEmoji,
|
||||
onClose: closeEmojiPopup,
|
||||
});
|
||||
root?.appendChild(emojiPopup.element);
|
||||
}
|
||||
emojiStart = active.start;
|
||||
if (!emojiPopup.setQuery(active.query)) {
|
||||
closeEmojiPopup();
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a formatting marker to the current textarea selection. */
|
||||
function applyFormatting(marker: string): void {
|
||||
if (textarea === null || disabledReason !== null) return;
|
||||
const result = wrapWithMarker(
|
||||
textarea.value,
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd,
|
||||
marker,
|
||||
);
|
||||
textarea.value = result.value;
|
||||
textarea.selectionStart = result.selectionStart;
|
||||
textarea.selectionEnd = result.selectionEnd;
|
||||
autoResize();
|
||||
maybeEmitTyping();
|
||||
}
|
||||
|
||||
/** Open, refilter, or close the popup for whatever is under the caret. */
|
||||
function syncMentionPopup(): void {
|
||||
const active = disabledReason === null ? activeMentionToken() : null;
|
||||
if (active === null) {
|
||||
closeMentionPopup();
|
||||
return;
|
||||
}
|
||||
if (mentionPopup === null) {
|
||||
mentionPopup = createMentionAutocomplete({
|
||||
onSelect: insertMention,
|
||||
onClose: closeMentionPopup,
|
||||
});
|
||||
root?.appendChild(mentionPopup.element);
|
||||
}
|
||||
mentionStart = active.start;
|
||||
if (!mentionPopup.setQuery(active.query)) {
|
||||
closeMentionPopup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive both completion popups from one caret position. Only one can be open:
|
||||
* the caret sits in exactly one token, and two stacked popups over the same
|
||||
* textarea would race for the arrow keys.
|
||||
*/
|
||||
function syncAutocomplete(): void {
|
||||
syncMentionPopup();
|
||||
if (mentionPopup !== null) {
|
||||
closeEmojiPopup();
|
||||
return;
|
||||
}
|
||||
syncEmojiPopup();
|
||||
}
|
||||
|
||||
function showReplyBar(username: string): void {
|
||||
if (replyBar === null || replyText === null) return;
|
||||
setText(replyText, `Replying to @${username}`);
|
||||
@@ -479,12 +703,32 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
() => {
|
||||
autoResize();
|
||||
maybeEmitTyping();
|
||||
syncAutocomplete();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
textarea.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
// Whichever popup is open owns navigation keys, so Enter completes the
|
||||
// token instead of sending a half-typed message.
|
||||
if (mentionPopup?.handleKeydown(e) === true) return;
|
||||
if (emojiPopup?.handleKeydown(e) === true) return;
|
||||
|
||||
// Ctrl+B / Ctrl+I / Ctrl+U wrap the selection in markdown markers.
|
||||
// The composer owns Ctrl+U while it has focus, so the propagation stop
|
||||
// is load-bearing: without it the global upload shortcut would fire on
|
||||
// top of the underline.
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
|
||||
const marker = FORMAT_MARKERS[e.key.toLowerCase()];
|
||||
if (marker !== undefined) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
applyFormatting(marker);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
@@ -520,6 +764,17 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Caret moves that aren't typing (click, blur) also decide the popup's fate.
|
||||
textarea.addEventListener("click", syncAutocomplete, { signal });
|
||||
textarea.addEventListener(
|
||||
"blur",
|
||||
() => {
|
||||
closeMentionPopup();
|
||||
closeEmojiPopup();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
sendBtn.addEventListener("click", handleSend, { signal });
|
||||
|
||||
// Picker state (declared together so both toggle functions can cross-close)
|
||||
@@ -558,6 +813,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
return;
|
||||
}
|
||||
emojiPicker = createEmojiPicker({
|
||||
// Read the set at open time, not at mount: an emoji_update while the
|
||||
// composer is alive must be in the next picker the user opens.
|
||||
customEmoji: listCustomEmoji(),
|
||||
onSelect: (emoji: string) => {
|
||||
if (textarea !== null) {
|
||||
const start = textarea.selectionStart;
|
||||
@@ -649,6 +907,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
cleanupPickers = () => {
|
||||
closeEmojiPicker();
|
||||
closeGifPicker();
|
||||
closeMentionPopup();
|
||||
closeEmojiPopup();
|
||||
};
|
||||
|
||||
appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn);
|
||||
|
||||
@@ -11,13 +11,22 @@ import {
|
||||
getChannelMessages,
|
||||
hasMoreMessages,
|
||||
getHistoryLoadState,
|
||||
isWindowDetached,
|
||||
} from "@stores/messages.store";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { unobserveMedia } from "@lib/media-visibility";
|
||||
|
||||
const log = createLogger("message-list");
|
||||
import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers";
|
||||
import {
|
||||
shouldGroup,
|
||||
isSameDay,
|
||||
renderDayDivider,
|
||||
renderNewDivider,
|
||||
renderMessage,
|
||||
} from "./message-list/renderers";
|
||||
import { getUnreadOnOpen } from "@stores/channels.store";
|
||||
import { isAudioMime, isVideoMime } from "./message-list/attachments";
|
||||
import { FenwickTree } from "./message-list/fenwick";
|
||||
|
||||
// -- Options ------------------------------------------------------------------
|
||||
@@ -39,6 +48,17 @@ export interface MessageListOptions {
|
||||
readonly onDeleteDraft?: (correlationId: string) => void;
|
||||
/** Retry a failed first-page history fetch. */
|
||||
readonly onRetryLoad?: () => void;
|
||||
/**
|
||||
* Jump to another message in this channel — the reply bar above a reply, and
|
||||
* any other in-row affordance. May target a message outside the loaded
|
||||
* window; the handler is expected to fetch the around-window in that case.
|
||||
*/
|
||||
readonly onJumpToMessage?: (messageId: number) => void;
|
||||
/**
|
||||
* Leave a detached around-window and reload the live tail. Wired to the
|
||||
* "Jump to Present" pill, which only appears while the window is detached.
|
||||
*/
|
||||
readonly onJumpToPresent?: () => void;
|
||||
}
|
||||
|
||||
// -- Constants ----------------------------------------------------------------
|
||||
@@ -68,20 +88,31 @@ interface VirtualItemDivider {
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
type VirtualItem = VirtualItemMessage | VirtualItemDivider;
|
||||
/** The "NEW" line marking where the reader's unread messages begin. At most
|
||||
* one per list, and only for a visit that opened with unread messages. */
|
||||
interface VirtualItemNewDivider {
|
||||
readonly kind: "new-divider";
|
||||
}
|
||||
|
||||
type VirtualItem = VirtualItemMessage | VirtualItemDivider | VirtualItemNewDivider;
|
||||
|
||||
// -- Smart height estimation --------------------------------------------------
|
||||
|
||||
function estimateItemHeight(item: VirtualItem): number {
|
||||
if (item.kind === "divider") return 32;
|
||||
if (item.kind === "divider" || item.kind === "new-divider") return 32;
|
||||
|
||||
// Non-grouped: min-height 2.75rem (44px @16px root) + margin-top 17px = 61px
|
||||
// Grouped: min-height 1.375rem (22px @16px root) + margin-top 0px = 22px
|
||||
let height = item.isGrouped ? 22 : 61;
|
||||
|
||||
// Image attachments
|
||||
// Media attachments. Video shares the image box, so it reserves the same
|
||||
// space; the audio player is a chip-height row.
|
||||
for (const att of item.message.attachments) {
|
||||
if (att.mime.startsWith("image/")) {
|
||||
if (isVideoMime(att.mime)) {
|
||||
height += 220;
|
||||
} else if (isAudioMime(att.mime)) {
|
||||
height += 96;
|
||||
} else if (att.mime.startsWith("image/")) {
|
||||
height += 220;
|
||||
}
|
||||
}
|
||||
@@ -108,16 +139,24 @@ function buildVirtualItems(
|
||||
messages: readonly Message[],
|
||||
seedPrevMsg: Message | null = null,
|
||||
seedLastTimestamp: string | null = null,
|
||||
newDividerAt = -1,
|
||||
): readonly VirtualItem[] {
|
||||
const items: VirtualItem[] = [];
|
||||
let lastTimestamp: string | null = seedLastTimestamp;
|
||||
let prevMsg: Message | null = seedPrevMsg;
|
||||
|
||||
for (const msg of messages) {
|
||||
for (const [i, msg] of messages.entries()) {
|
||||
if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) {
|
||||
items.push({ kind: "divider", timestamp: msg.timestamp });
|
||||
}
|
||||
const isGrouped = prevMsg !== null && shouldGroup(prevMsg, msg);
|
||||
const isFirstUnread = i === newDividerAt;
|
||||
if (isFirstUnread) {
|
||||
items.push({ kind: "new-divider" });
|
||||
}
|
||||
// A message directly under the NEW line starts a fresh block: rendering it
|
||||
// as a grouped continuation of a message from before the line hides both
|
||||
// its author and the fact that the line is there.
|
||||
const isGrouped = !isFirstUnread && prevMsg !== null && shouldGroup(prevMsg, msg);
|
||||
items.push({ kind: "message", message: msg, isGrouped });
|
||||
lastTimestamp = msg.timestamp;
|
||||
prevMsg = msg;
|
||||
@@ -125,6 +164,19 @@ function buildVirtualItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the first unread message in `messages`, or -1 for none.
|
||||
*
|
||||
* Derived from the unread count the channel had when it was opened (the
|
||||
* badge itself is cleared by the visit): the last N loaded messages are the
|
||||
* unread ones. Clamped to 0 when the whole loaded window is unread, and
|
||||
* suppressed at 0-length so an empty channel never renders a lone divider.
|
||||
*/
|
||||
function firstUnreadIndex(messages: readonly Message[], unreadOnOpen: number): number {
|
||||
if (unreadOnOpen <= 0 || messages.length === 0) return -1;
|
||||
return Math.max(0, messages.length - unreadOnOpen);
|
||||
}
|
||||
|
||||
// -- Empty state --------------------------------------------------------------
|
||||
|
||||
function renderEmptyState(channelName: string, channelType?: string): HTMLDivElement {
|
||||
@@ -197,17 +249,39 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
let bottomSpacer: HTMLDivElement | null = null;
|
||||
let contentContainer: HTMLDivElement | null = null;
|
||||
let scrollToBottomBtn: HTMLButtonElement | null = null;
|
||||
let jumpToPresentPill: HTMLButtonElement | null = null;
|
||||
let renderedStart = 0;
|
||||
let renderedEnd = 0;
|
||||
|
||||
/**
|
||||
* Unread count this channel carried when the visit that created this list
|
||||
* began. Read once here, not per render: the badge is cleared by the visit
|
||||
* itself, and the divider must stay put for the whole visit rather than
|
||||
* jumping as new messages arrive. Zero once the reader comes back, which is
|
||||
* what makes the divider clear on the next visit.
|
||||
*
|
||||
* Suppressed while the window is detached (jumped to an old message): the
|
||||
* loaded slice is then not the tail, so "the last N messages" would put the
|
||||
* line somewhere arbitrary.
|
||||
*/
|
||||
const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Height estimation (Fenwick tree backed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Render one virtual item — the single place the three item kinds map to DOM. */
|
||||
function renderVirtualItem(item: VirtualItem): HTMLElement {
|
||||
if (item.kind === "divider") return renderDayDivider(item.timestamp);
|
||||
if (item.kind === "new-divider") return renderNewDivider();
|
||||
return renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal);
|
||||
}
|
||||
|
||||
function itemKey(index: number): string {
|
||||
const item = virtualItems[index];
|
||||
if (item === undefined) return `idx-${index}`;
|
||||
if (item.kind === "divider") return `div-${item.timestamp}`;
|
||||
if (item.kind === "new-divider") return "new-divider";
|
||||
return `msg-${item.message.id}`;
|
||||
}
|
||||
|
||||
@@ -271,6 +345,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
}
|
||||
}
|
||||
|
||||
/** The pill is the only signal that the bottom of the list is not "now". */
|
||||
function updateJumpToPresentPill(): void {
|
||||
if (jumpToPresentPill === null) return;
|
||||
jumpToPresentPill.classList.toggle("visible", isWindowDetached(options.channelId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render visible window
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -410,14 +490,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
clearChildren(contentContainer);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = start; i < end; i++) {
|
||||
const item = virtualItems[i]!;
|
||||
if (item.kind === "divider") {
|
||||
fragment.appendChild(renderDayDivider(item.timestamp));
|
||||
} else {
|
||||
fragment.appendChild(
|
||||
renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal),
|
||||
);
|
||||
}
|
||||
fragment.appendChild(renderVirtualItem(virtualItems[i]!));
|
||||
}
|
||||
contentContainer.appendChild(fragment);
|
||||
|
||||
@@ -439,7 +512,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
function rebuildItems(): void {
|
||||
allMessages = getChannelMessages(options.channelId);
|
||||
virtualItems = buildVirtualItems(allMessages);
|
||||
virtualItems = buildVirtualItems(
|
||||
allMessages,
|
||||
null,
|
||||
null,
|
||||
firstUnreadIndex(allMessages, unreadOnOpen),
|
||||
);
|
||||
|
||||
// Build Fenwick tree initialized with smart estimates / cached heights
|
||||
tree = new FenwickTree(virtualItems.length);
|
||||
@@ -516,13 +594,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
// The rendered window includes the old tail — append the new rows.
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const item of appendedItems) {
|
||||
if (item.kind === "divider") {
|
||||
fragment.appendChild(renderDayDivider(item.timestamp));
|
||||
} else {
|
||||
fragment.appendChild(
|
||||
renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal),
|
||||
);
|
||||
}
|
||||
fragment.appendChild(renderVirtualItem(item));
|
||||
}
|
||||
contentContainer.appendChild(fragment);
|
||||
renderedEnd = virtualItems.length;
|
||||
@@ -676,11 +748,21 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
jumpToPresentPill = createElement("button", {
|
||||
class: "jump-to-present-pill",
|
||||
"data-testid": "jump-to-present",
|
||||
});
|
||||
jumpToPresentPill.textContent = "Jump to Present ↓";
|
||||
jumpToPresentPill.addEventListener("click", () => options.onJumpToPresent?.(), {
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
root.appendChild(topSpacer);
|
||||
root.appendChild(contentContainer);
|
||||
root.appendChild(bottomSpacer);
|
||||
root.appendChild(scrollAnchor);
|
||||
root.appendChild(scrollToBottomBtn);
|
||||
root.appendChild(jumpToPresentPill);
|
||||
|
||||
root.addEventListener("scroll", handleScroll, {
|
||||
signal: ac.signal,
|
||||
@@ -722,6 +804,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
parentContainer.appendChild(root);
|
||||
|
||||
renderAll();
|
||||
updateJumpToPresentPill();
|
||||
scrollToBottom();
|
||||
const initialScrollRaf = requestAnimationFrame(() => scrollToBottom());
|
||||
ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf));
|
||||
@@ -750,6 +833,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
),
|
||||
);
|
||||
|
||||
// Show/hide the pill as the window detaches from (and reattaches to) the
|
||||
// live tail. No re-render — only the pill's visibility changes.
|
||||
unsubscribers.push(
|
||||
messagesStore.subscribeSelector(
|
||||
(s) => s.detachedChannels.has(options.channelId),
|
||||
() => {
|
||||
updateJumpToPresentPill();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Only re-render when member roles change, not on presence/typing updates.
|
||||
// The store bumps roleRevision solely on membership/role mutations, so
|
||||
// selecting the counter avoids rebuilding a role map per notification.
|
||||
@@ -801,6 +895,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
topSpacer = null;
|
||||
bottomSpacer = null;
|
||||
scrollToBottomBtn = null;
|
||||
jumpToPresentPill = null;
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: number): boolean {
|
||||
@@ -811,6 +906,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
if (idx === -1) return false;
|
||||
|
||||
root.scrollTop = offsetBefore(idx);
|
||||
// Force the rebuild path: a scroll-driven renderWindow only moves spacers,
|
||||
// so without this the target row can sit outside the rendered window and
|
||||
// there is nothing to flash (and nothing to look at after the scroll).
|
||||
renderedStart = -1;
|
||||
renderWindow();
|
||||
|
||||
// Briefly highlight the target message element
|
||||
@@ -819,9 +918,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
const el = contentContainer.children[localIdx] as HTMLElement | undefined;
|
||||
if (el !== undefined) {
|
||||
el.classList.add("highlight-flash");
|
||||
setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
el.classList.remove("highlight-flash");
|
||||
}, 1500);
|
||||
// Unmounting mid-flash must not leave a timer pointing at a dead node.
|
||||
ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* NsfwGate — the age-gate shown over a channel flagged NSFW.
|
||||
*
|
||||
* The server does nothing with the flag beyond storing and broadcasting it (see
|
||||
* `@lib/nsfw-gate`), so this overlay is the whole of the feature on the reading
|
||||
* side. It covers the message area rather than replacing it: the channel is
|
||||
* mounted and live underneath, and accepting the warning reveals it without a
|
||||
* refetch.
|
||||
*
|
||||
* Deliberately not a `.modal-overlay`: a modal is a decision about the app,
|
||||
* while this is a property of the channel you just opened. It fills its
|
||||
* container, so mounting it into the messages slot gates exactly the content it
|
||||
* is warning about and leaves the sidebar and header usable.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { acknowledgeNsfw } from "@lib/nsfw-gate";
|
||||
|
||||
export interface NsfwGateOptions {
|
||||
/** Channel being gated — its id keys the per-session acknowledgement. */
|
||||
readonly channelId: number;
|
||||
/** Channel name, shown without the leading '#'. */
|
||||
readonly channelName: string;
|
||||
/** Called after the acknowledgement is recorded. */
|
||||
readonly onContinue: () => void;
|
||||
/**
|
||||
* Called when the reader declines. Optional: without it the gate offers only
|
||||
* "Continue", which is right for a container the reader can simply navigate
|
||||
* away from.
|
||||
*/
|
||||
readonly onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function createNsfwGate(options: NsfwGateOptions): MountableComponent {
|
||||
const { channelId, channelName, onContinue, onCancel } = options;
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", {
|
||||
class: "nsfw-gate",
|
||||
"data-testid": "nsfw-gate",
|
||||
role: "dialog",
|
||||
"aria-modal": "false",
|
||||
"aria-label": `Age restricted channel ${channelName}`,
|
||||
});
|
||||
|
||||
const card = createElement("div", { class: "nsfw-gate-card" });
|
||||
|
||||
const iconWrap = createElement("div", { class: "nsfw-gate-icon" });
|
||||
iconWrap.appendChild(createIcon("shield-alert", 40));
|
||||
|
||||
const title = createElement("h2", { class: "nsfw-gate-title" });
|
||||
setText(title, `#${channelName}`);
|
||||
|
||||
const body = createElement("p", { class: "nsfw-gate-body" });
|
||||
setText(body, "This channel may contain sensitive content — Continue?");
|
||||
|
||||
// Says plainly what the flag is and is not, so nobody reads the gate as a
|
||||
// promise the server is filtering something.
|
||||
const note = createElement("p", { class: "nsfw-gate-note" });
|
||||
setText(
|
||||
note,
|
||||
"The channel has been marked age-restricted by a moderator. Nothing is filtered — you are only being asked once per session.",
|
||||
);
|
||||
|
||||
const actions = createElement("div", { class: "nsfw-gate-actions" });
|
||||
|
||||
if (onCancel !== undefined) {
|
||||
const backBtn = createElement(
|
||||
"button",
|
||||
{ class: "btn-modal-cancel", type: "button", "data-testid": "nsfw-gate-back" },
|
||||
"Go Back",
|
||||
);
|
||||
backBtn.addEventListener("click", onCancel, { signal: ac.signal });
|
||||
actions.appendChild(backBtn);
|
||||
}
|
||||
|
||||
const continueBtn = createElement(
|
||||
"button",
|
||||
{ class: "btn-modal-save", type: "button", "data-testid": "nsfw-gate-continue" },
|
||||
"Continue",
|
||||
);
|
||||
continueBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
// Record first, then notify: the caller's handler tears this component
|
||||
// down, and an acknowledgement written afterwards would race it.
|
||||
acknowledgeNsfw(channelId);
|
||||
onContinue();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
actions.appendChild(continueBtn);
|
||||
|
||||
appendChildren(card, iconWrap, title, body, note, actions);
|
||||
root.appendChild(card);
|
||||
container.appendChild(root);
|
||||
continueBtn.focus();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -113,7 +113,16 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
|
||||
|
||||
function doSearch(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastSearchTime < MIN_SEARCH_INTERVAL_MS) return;
|
||||
const sinceLast = now - lastSearchTime;
|
||||
if (sinceLast < MIN_SEARCH_INTERVAL_MS) {
|
||||
// Too soon after the previous search. Don't drop this query — that would
|
||||
// leave the earlier query's results on screen for what the user is now
|
||||
// typing. Reschedule for when the rate-limit window opens, reusing the
|
||||
// debounce timer so destroy() still tears it down.
|
||||
if (debounceTimer !== null) window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(doSearch, MIN_SEARCH_INTERVAL_MS - sinceLast);
|
||||
return;
|
||||
}
|
||||
lastSearchTime = now;
|
||||
|
||||
const query = input.value.trim();
|
||||
|
||||
@@ -28,7 +28,18 @@ import { createLogsTab } from "./settings/LogsTab";
|
||||
export interface SettingsOverlayOptions {
|
||||
onClose(): void;
|
||||
onChangePassword(oldPassword: string, newPassword: string): Promise<void>;
|
||||
onUpdateProfile(username: string): Promise<void>;
|
||||
/**
|
||||
* Patch the signed-in user's profile. Every field is optional and omitted
|
||||
* means "leave unchanged"; an empty string clears the nullable ones, which
|
||||
* is how the API itself distinguishes the two.
|
||||
*/
|
||||
onUpdateProfile(patch: {
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
}): Promise<void>;
|
||||
/** Upload an avatar image. Resolves with the URL the server stored. */
|
||||
onUploadAvatar(file: File): Promise<string>;
|
||||
onLogout(): void;
|
||||
onDeleteAccount(password: string): Promise<void>;
|
||||
onStatusChange(status: UserStatus): void;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createElement, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { MAX_CUSTOM_STATUS_LEN } from "@lib/userStatus";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -16,11 +17,18 @@ import type { UserStatus } from "@lib/types";
|
||||
export interface StatusPickerOptions {
|
||||
readonly currentStatus: UserStatus;
|
||||
readonly onStatusChange: (status: UserStatus) => void;
|
||||
/** The custom status line to pre-fill the input with. */
|
||||
readonly currentCustomStatus?: string;
|
||||
/** Called when the user commits a custom status (Enter or blur). Passing an
|
||||
* empty string means "clear it". Omitted = the input is not rendered. */
|
||||
readonly onCustomStatusChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
export type StatusPickerComponent = MountableComponent & {
|
||||
/** Update the displayed status without recreating the picker. */
|
||||
setStatus(status: UserStatus): void;
|
||||
/** Update the custom status input without recreating the picker. */
|
||||
setCustomStatus(text: string): void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,11 +41,17 @@ interface StatusDef {
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "invisible" is its own value now, not "offline" wearing a different label.
|
||||
* The server stores it as chosen and shows everyone else offline, so the
|
||||
* picker can finally send what it means — and the status survives a reconnect
|
||||
* instead of flashing back to online.
|
||||
*/
|
||||
const STATUS_DEFS: readonly StatusDef[] = [
|
||||
{ value: "online", label: "Online", color: "#3ba55d" },
|
||||
{ value: "idle", label: "Idle", color: "#faa61a" },
|
||||
{ value: "dnd", label: "Do Not Disturb", color: "#ed4245" },
|
||||
{ value: "offline", label: "Invisible", color: "#747f8d" },
|
||||
{ value: "invisible", label: "Invisible", color: "#747f8d" },
|
||||
];
|
||||
|
||||
function colorForStatus(status: UserStatus): string {
|
||||
@@ -57,6 +71,11 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
|
||||
let dotEl: HTMLDivElement | null = null;
|
||||
let dropdownEl: HTMLDivElement | null = null;
|
||||
let checkEls = new Map<UserStatus, HTMLSpanElement>();
|
||||
let customInputEl: HTMLInputElement | null = null;
|
||||
/** Last text handed to the callback. Guards the blur-after-Enter double
|
||||
* send, which would otherwise cost a second presence_update against the
|
||||
* server's one-per-ten-seconds limit. */
|
||||
let lastCommittedCustom = options.currentCustomStatus ?? "";
|
||||
|
||||
// ---- Dropdown visibility --------------------------------------------------
|
||||
|
||||
@@ -144,6 +163,58 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Set a custom status" row. Only built when a handler was supplied —
|
||||
* an input whose value goes nowhere is worse than no input.
|
||||
*/
|
||||
function buildCustomStatusRow(onChange: (text: string) => void): HTMLDivElement {
|
||||
const row = createElement("div", { class: "status-picker-custom" });
|
||||
const input = createElement("input", {
|
||||
class: "status-picker-custom-input",
|
||||
type: "text",
|
||||
placeholder: "Set a custom status",
|
||||
maxlength: String(MAX_CUSTOM_STATUS_LEN),
|
||||
"aria-label": "Custom status",
|
||||
"data-testid": "custom-status-input",
|
||||
});
|
||||
input.value = options.currentCustomStatus ?? "";
|
||||
customInputEl = input;
|
||||
|
||||
const commit = (): void => {
|
||||
const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);
|
||||
if (text === lastCommittedCustom) return;
|
||||
lastCommittedCustom = text;
|
||||
input.value = text;
|
||||
onChange(text);
|
||||
};
|
||||
|
||||
input.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
// Keystrokes inside the input must not reach the dropdown's own
|
||||
// Enter/Escape handling, which would close the menu mid-edit.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
closeDropdown();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
input.value = lastCommittedCustom;
|
||||
closeDropdown();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
input.addEventListener("blur", commit, { signal });
|
||||
// The row is inside the dropdown; clicking the input must not be treated
|
||||
// as picking a status or as an outside click.
|
||||
input.addEventListener("click", (e: MouseEvent) => e.stopPropagation(), { signal });
|
||||
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---- MountableComponent ---------------------------------------------------
|
||||
|
||||
function mount(container: Element): void {
|
||||
@@ -186,6 +257,11 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
|
||||
for (const def of STATUS_DEFS) {
|
||||
dropdownEl.appendChild(buildOption(def));
|
||||
}
|
||||
const onCustomStatusChange = options.onCustomStatusChange;
|
||||
if (onCustomStatusChange !== undefined) {
|
||||
dropdownEl.appendChild(createElement("div", { class: "status-picker-divider" }));
|
||||
dropdownEl.appendChild(buildCustomStatusRow(onCustomStatusChange));
|
||||
}
|
||||
|
||||
appendChildren(root, dotEl, dropdownEl);
|
||||
container.appendChild(root);
|
||||
@@ -221,11 +297,17 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
|
||||
root = null;
|
||||
dotEl = null;
|
||||
dropdownEl = null;
|
||||
customInputEl = null;
|
||||
}
|
||||
|
||||
function setStatus(status: UserStatus): void {
|
||||
applyStatus(status);
|
||||
}
|
||||
|
||||
return { mount, destroy, setStatus };
|
||||
function setCustomStatus(text: string): void {
|
||||
lastCommittedCustom = text;
|
||||
if (customInputEl !== null) customInputEl.value = text;
|
||||
}
|
||||
|
||||
return { mount, destroy, setStatus, setCustomStatus };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ import { authStore } from "@stores/auth.store";
|
||||
import { openSettings, uiStore } from "@stores/ui.store";
|
||||
import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { loadUserStatus, onUserStatusChange, saveUserStatus } from "@lib/userStatus";
|
||||
import {
|
||||
loadCustomStatus,
|
||||
loadUserStatus,
|
||||
onUserStatusChange,
|
||||
saveCustomStatus,
|
||||
saveUserStatus,
|
||||
} from "@lib/userStatus";
|
||||
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
|
||||
export interface UserBarOptions {
|
||||
@@ -19,6 +27,15 @@ export interface UserBarOptions {
|
||||
readonly ws?: WsClient | null;
|
||||
}
|
||||
|
||||
/** Status labels for the line under the username. */
|
||||
const STATUS_TEXT: Readonly<Record<UserStatus, string>> = {
|
||||
online: "Online",
|
||||
idle: "Idle",
|
||||
dnd: "Do Not Disturb",
|
||||
invisible: "Invisible",
|
||||
offline: "Offline",
|
||||
};
|
||||
|
||||
export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
const disposable = new Disposable();
|
||||
let root: HTMLDivElement | null = null;
|
||||
@@ -26,24 +43,71 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
// Element references for targeted updates
|
||||
let avatarEl: HTMLDivElement | null = null;
|
||||
let avatarTextEl: HTMLSpanElement | null = null;
|
||||
let avatarImgEl: HTMLImageElement | null = null;
|
||||
/** Avatar URL currently rendered, so a re-render for an unrelated auth
|
||||
* change doesn't re-fetch the same picture. */
|
||||
let renderedAvatarUrl: string | null = null;
|
||||
let nameEl: HTMLSpanElement | null = null;
|
||||
let statusEl: HTMLSpanElement | null = null;
|
||||
let statusPicker: StatusPickerComponent | null = null;
|
||||
|
||||
/** Swap the letter for the uploaded picture, or back again. */
|
||||
function renderAvatar(subject: {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
}): void {
|
||||
if (avatarEl === null) return;
|
||||
if (avatarTextEl !== null) setText(avatarTextEl, avatarInitial(subject));
|
||||
|
||||
const url = isRenderableAvatar(subject.avatar) ? resolveServerUrl(subject.avatar) : null;
|
||||
if (url === renderedAvatarUrl) return;
|
||||
renderedAvatarUrl = url;
|
||||
|
||||
if (avatarImgEl !== null) {
|
||||
avatarImgEl.remove();
|
||||
avatarImgEl = null;
|
||||
}
|
||||
if (url === null) {
|
||||
if (avatarTextEl !== null) avatarTextEl.style.display = "";
|
||||
avatarEl.style.background = "var(--accent)";
|
||||
return;
|
||||
}
|
||||
void fetchImageAsDataUrl(url).then((dataUrl) => {
|
||||
// The URL may have changed again (or the bar been torn down) while the
|
||||
// bytes were in flight.
|
||||
if (dataUrl === null || avatarEl === null || renderedAvatarUrl !== url) return;
|
||||
const img = createElement("img", {
|
||||
class: "avatar-img",
|
||||
src: dataUrl,
|
||||
alt: subject.username,
|
||||
});
|
||||
avatarImgEl = img;
|
||||
if (avatarTextEl !== null) avatarTextEl.style.display = "none";
|
||||
avatarEl.style.background = "transparent";
|
||||
avatarEl.insertBefore(img, avatarEl.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
function updateFromState(): void {
|
||||
const state = authStore.getState();
|
||||
const user = state.user;
|
||||
const username = user?.username ?? "Unknown";
|
||||
const initial = username.charAt(0).toUpperCase() || "?";
|
||||
const subject = {
|
||||
username: user?.username ?? "Unknown",
|
||||
displayName: user?.display_name ?? null,
|
||||
avatar: user?.avatar ?? null,
|
||||
};
|
||||
|
||||
if (avatarTextEl !== null) {
|
||||
setText(avatarTextEl, initial);
|
||||
}
|
||||
renderAvatar(subject);
|
||||
if (nameEl !== null) {
|
||||
setText(nameEl, username);
|
||||
setText(nameEl, resolveDisplayName(subject));
|
||||
}
|
||||
if (statusEl !== null) {
|
||||
setText(statusEl, state.isAuthenticated ? "Online" : "Offline");
|
||||
// The bar shows the user's own chosen status, invisible included —
|
||||
// everyone else is told offline, but lying to the owner about their own
|
||||
// state is exactly the bug real invisible exists to fix.
|
||||
const text = state.isAuthenticated ? (STATUS_TEXT[loadUserStatus()] ?? "Online") : "Offline";
|
||||
setText(statusEl, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,21 +150,39 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
// Start from the stored selection, not a hardcoded "online" — otherwise
|
||||
// this picker and the settings Account tab show different statuses.
|
||||
currentStatus: loadUserStatus(),
|
||||
currentCustomStatus: loadCustomStatus(),
|
||||
onStatusChange: (status: UserStatus) => {
|
||||
saveUserStatus(status);
|
||||
updateFromState();
|
||||
const ws = options?.ws;
|
||||
if (ws !== null && ws !== undefined && canSetStatus()) {
|
||||
// No custom_status field: a plain status change must leave whatever
|
||||
// text the user set standing.
|
||||
ws.send({ type: "presence_update", payload: { status } } as never);
|
||||
}
|
||||
},
|
||||
onCustomStatusChange: (text: string) => {
|
||||
saveCustomStatus(text);
|
||||
const ws = options?.ws;
|
||||
if (ws !== null && ws !== undefined && canSetStatus()) {
|
||||
ws.send({
|
||||
type: "presence_update",
|
||||
payload: { status: loadUserStatus(), custom_status: text },
|
||||
} as never);
|
||||
}
|
||||
},
|
||||
});
|
||||
statusPicker.mount(statusPickerWrap);
|
||||
|
||||
// Reflect status changes made on the settings Account tab.
|
||||
disposable.addCleanup(
|
||||
onUserStatusChange((status) => statusPicker?.setStatus(status), {
|
||||
signal: disposable.signal,
|
||||
}),
|
||||
onUserStatusChange(
|
||||
(status) => {
|
||||
statusPicker?.setStatus(status);
|
||||
updateFromState();
|
||||
},
|
||||
{ signal: disposable.signal },
|
||||
),
|
||||
);
|
||||
|
||||
// Disable picker (with a reason) when the connection is down
|
||||
@@ -172,6 +254,8 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
}
|
||||
avatarEl = null;
|
||||
avatarTextEl = null;
|
||||
avatarImgEl = null;
|
||||
renderedAvatarUrl = null;
|
||||
nameEl = null;
|
||||
statusEl = null;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
* A11y: role="dialog", aria-label, focus trap, return focus on close.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { isSafeUrl } from "./message-list/attachments";
|
||||
import { createAvatarElement, resolveDisplayName } from "@lib/avatar";
|
||||
import { roleColorVar } from "./message-list/formatting";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -25,7 +26,12 @@ export interface UserProfileData {
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
/** Nickname. When set the popup shows it as the heading and the username
|
||||
* underneath, because the username is still the handle you @mention. */
|
||||
readonly displayName?: string | null;
|
||||
readonly about?: string | null;
|
||||
/** Free-text status line, shown under the name. */
|
||||
readonly customStatus?: string | null;
|
||||
readonly joinDate?: string | null;
|
||||
readonly isDeleted?: boolean;
|
||||
}
|
||||
@@ -58,6 +64,9 @@ const STATUS_COLORS: Record<UserStatus, string> = {
|
||||
online: "#3ba55d",
|
||||
idle: "#faa61a",
|
||||
dnd: "#ed4245",
|
||||
// Only ever reached for the signed-in user looking at their own profile —
|
||||
// the server maps invisible to offline for everyone else.
|
||||
invisible: "#747f8d",
|
||||
offline: "#747f8d",
|
||||
};
|
||||
|
||||
@@ -65,16 +74,10 @@ const STATUS_LABELS: Record<UserStatus, string> = {
|
||||
online: "Online",
|
||||
idle: "Idle",
|
||||
dnd: "Do Not Disturb",
|
||||
invisible: "Invisible",
|
||||
offline: "Offline",
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
owner: "#e74c3c",
|
||||
admin: "#f39c12",
|
||||
moderator: "#2ecc71",
|
||||
member: "#949ba4",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,28 +135,22 @@ export function createUserProfilePopup(
|
||||
}
|
||||
|
||||
function buildAvatar(user: UserProfileData): HTMLDivElement {
|
||||
const wrapper = createElement("div", { class: "upp-avatar" });
|
||||
|
||||
if (user.isDeleted === true) {
|
||||
wrapper.style.background = "#4e5058";
|
||||
const text = createElement("span", {}, "?");
|
||||
wrapper.appendChild(text);
|
||||
} else if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
|
||||
const img = createElement("img", {
|
||||
src: user.avatar,
|
||||
alt: user.username,
|
||||
class: "upp-avatar-img",
|
||||
});
|
||||
img.style.width = "64px";
|
||||
img.style.height = "64px";
|
||||
img.style.borderRadius = "50%";
|
||||
wrapper.appendChild(img);
|
||||
} else {
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = user.username.charAt(0).toUpperCase() || "?";
|
||||
const text = createElement("span", {}, initial);
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
// The shared helper is what makes uploaded avatars work here and in the
|
||||
// message rows and member list at the same time: it fetches the
|
||||
// authenticated file through the cert-pinned path and falls back to the
|
||||
// letter until (or unless) the bytes arrive.
|
||||
const wrapper = createAvatarElement(
|
||||
{
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
isDeleted: user.isDeleted,
|
||||
},
|
||||
{
|
||||
className: "upp-avatar",
|
||||
background: user.isDeleted === true ? "#4e5058" : "var(--accent, #5865f2)",
|
||||
},
|
||||
);
|
||||
|
||||
// Status dot overlay
|
||||
const statusDot = createElement("div", { class: "upp-status-dot" });
|
||||
@@ -167,7 +164,7 @@ export function createUserProfilePopup(
|
||||
function mount(container: Element): void {
|
||||
previousFocus = document.activeElement;
|
||||
const user = options.user;
|
||||
const displayName = user.isDeleted === true ? "[deleted]" : user.username;
|
||||
const displayName = user.isDeleted === true ? "[deleted]" : resolveDisplayName(user);
|
||||
|
||||
// Overlay for outside-click detection
|
||||
overlay = createElement("div", {
|
||||
@@ -207,10 +204,24 @@ export function createUserProfilePopup(
|
||||
nameEl.style.color = "var(--text-faint, #80848e)";
|
||||
}
|
||||
|
||||
// Username line, shown only when a display name is standing in for it.
|
||||
// @mentions still resolve by username, so the popup has to keep telling
|
||||
// you what to type.
|
||||
const handleEl = createElement("div", { class: "upp-username-handle" });
|
||||
if (user.isDeleted !== true && displayName !== user.username) {
|
||||
setText(handleEl, `@${user.username}`);
|
||||
}
|
||||
|
||||
// Custom status line — the user's own words, under the name.
|
||||
const customStatusEl = createElement("div", { class: "upp-custom-status" });
|
||||
if (typeof user.customStatus === "string" && user.customStatus.length > 0) {
|
||||
setText(customStatusEl, user.customStatus);
|
||||
}
|
||||
|
||||
// Role badge
|
||||
const roleBadge = createElement("span", { class: "upp-role-badge" });
|
||||
const roleDot = createElement("span", { class: "upp-role-dot" });
|
||||
roleDot.style.background = ROLE_COLORS[user.role] ?? ROLE_COLORS.member ?? "";
|
||||
roleDot.style.background = roleColorVar(user.role.toLowerCase());
|
||||
const roleLabel = createElement(
|
||||
"span",
|
||||
{},
|
||||
@@ -244,53 +255,64 @@ export function createUserProfilePopup(
|
||||
// Divider
|
||||
const divider = createElement("div", { class: "upp-divider" });
|
||||
|
||||
// Actions
|
||||
// Actions — only render buttons that are actually wired up, so the popup
|
||||
// never shows a dead control (e.g. Call before DM calls exist, or Message
|
||||
// on your own profile).
|
||||
const actions = createElement("div", { class: "upp-actions" });
|
||||
|
||||
const messageBtn = createElement("button", {
|
||||
class: "upp-action-btn",
|
||||
"data-testid": "upp-message-btn",
|
||||
});
|
||||
messageBtn.appendChild(createIcon("send", 16));
|
||||
messageBtn.appendChild(document.createTextNode(" Message"));
|
||||
messageBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
options.onMessage?.(user.id);
|
||||
close();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (options.onMessage !== undefined) {
|
||||
const onMessage = options.onMessage;
|
||||
const messageBtn = createElement("button", {
|
||||
class: "upp-action-btn",
|
||||
"data-testid": "upp-message-btn",
|
||||
});
|
||||
messageBtn.appendChild(createIcon("send", 16));
|
||||
messageBtn.appendChild(document.createTextNode(" Message"));
|
||||
messageBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
onMessage(user.id);
|
||||
close();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
actions.appendChild(messageBtn);
|
||||
}
|
||||
|
||||
const callBtn = createElement("button", {
|
||||
class: "upp-action-btn",
|
||||
"data-testid": "upp-call-btn",
|
||||
});
|
||||
callBtn.appendChild(createIcon("phone", 16));
|
||||
callBtn.appendChild(document.createTextNode(" Call"));
|
||||
callBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
options.onCall?.(user.id);
|
||||
close();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
appendChildren(actions, messageBtn, callBtn);
|
||||
if (options.onCall !== undefined) {
|
||||
const onCall = options.onCall;
|
||||
const callBtn = createElement("button", {
|
||||
class: "upp-action-btn",
|
||||
"data-testid": "upp-call-btn",
|
||||
});
|
||||
callBtn.appendChild(createIcon("phone", 16));
|
||||
callBtn.appendChild(document.createTextNode(" Call"));
|
||||
callBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
onCall(user.id);
|
||||
close();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
actions.appendChild(callBtn);
|
||||
}
|
||||
|
||||
// Assemble popup
|
||||
appendChildren(
|
||||
popup,
|
||||
avatar,
|
||||
nameEl,
|
||||
handleEl,
|
||||
customStatusEl,
|
||||
roleBadge,
|
||||
statusLine,
|
||||
aboutSection,
|
||||
joinSection,
|
||||
divider,
|
||||
actions,
|
||||
);
|
||||
if (actions.childElementCount > 0) {
|
||||
appendChildren(popup, divider, actions);
|
||||
}
|
||||
|
||||
overlay.appendChild(popup);
|
||||
container.appendChild(overlay);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { IconName } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { voiceStore, type VoiceStatus } from "@stores/voice.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
import { uiStore } from "@stores/ui.store";
|
||||
import {
|
||||
createConnectionStatsPoller,
|
||||
@@ -230,22 +231,44 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
updateStatus(voice.voiceStatus);
|
||||
updateFrozen(uiStore.getState().connectionStatus);
|
||||
|
||||
// Channel name
|
||||
// Channel name. A DM call resolves through the DM store rather than the
|
||||
// channels store: the channels-store row for a DM is synthesised when the
|
||||
// conversation is opened, so accepting a call for a DM the user has not
|
||||
// looked at yet would otherwise label the call "Voice Channel".
|
||||
const channel = channelsStore.getState().channels.get(channelId);
|
||||
setText(channelNameEl, channel?.name ?? "Voice Channel");
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
setText(
|
||||
channelNameEl,
|
||||
dm !== undefined ? dmDisplayName(dm) : (channel?.name ?? "Voice Channel"),
|
||||
);
|
||||
|
||||
// Toggle button active states, swap icons, and update aria-pressed
|
||||
muteBtn?.classList.toggle("active-ctrl", voice.localMuted);
|
||||
deafenBtn?.classList.toggle("active-ctrl", voice.localDeafened);
|
||||
cameraBtn?.classList.toggle("active-ctrl", voice.localCamera);
|
||||
|
||||
// A moderator-imposed mute/deafen is not ours to lift: the server refuses
|
||||
// the unmute, so disable the control and say why instead of letting the
|
||||
// click bounce off with an error toast.
|
||||
const serverMuted = voice.localServerMuted === true;
|
||||
const serverDeafened = voice.localServerDeafened === true;
|
||||
if (muteBtn) {
|
||||
swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic");
|
||||
muteBtn.setAttribute("aria-pressed", String(voice.localMuted));
|
||||
// Only ever tighten: updateFrozen ran above and owns the socket-down
|
||||
// disable, which must not be relaxed here.
|
||||
if (serverMuted) {
|
||||
muteBtn.disabled = true;
|
||||
muteBtn.title = "You were muted by a moderator";
|
||||
}
|
||||
}
|
||||
if (deafenBtn) {
|
||||
swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones");
|
||||
deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened));
|
||||
if (serverDeafened) {
|
||||
deafenBtn.disabled = true;
|
||||
deafenBtn.title = "You were deafened by a moderator";
|
||||
}
|
||||
}
|
||||
if (cameraBtn) {
|
||||
swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera");
|
||||
@@ -435,6 +458,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
channelId: s.currentChannelId,
|
||||
muted: s.localMuted,
|
||||
deafened: s.localDeafened,
|
||||
serverMuted: s.localServerMuted,
|
||||
serverDeafened: s.localServerDeafened,
|
||||
camera: s.localCamera,
|
||||
screenshare: s.localScreenshare,
|
||||
listenOnly: s.listenOnly,
|
||||
@@ -445,6 +470,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
a.channelId === b.channelId &&
|
||||
a.muted === b.muted &&
|
||||
a.deafened === b.deafened &&
|
||||
a.serverMuted === b.serverMuted &&
|
||||
a.serverDeafened === b.serverDeafened &&
|
||||
a.camera === b.camera &&
|
||||
a.screenshare === b.screenshare &&
|
||||
a.listenOnly === b.listenOnly &&
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
/**
|
||||
* Channel context menu — right-click on a channel for Edit/Delete actions.
|
||||
* Only shown to admin/owner roles.
|
||||
* Channel context menu — right-click on a channel for Mark as Read/Edit/Delete/
|
||||
* Purge. Mark as Read is offered to everyone (it only touches the caller's own
|
||||
* read state); Edit and Delete follow the server's MANAGE_CHANNELS gate; Purge
|
||||
* follows its MANAGE_MESSAGES gate.
|
||||
*
|
||||
* Both gates are permission bits, not role names: a custom role granted
|
||||
* MANAGE_CHANNELS could edit a channel through the API while the client hid
|
||||
* the menu item, because the old check asked whether the role was literally
|
||||
* called "owner" or "admin".
|
||||
*/
|
||||
|
||||
import { createElement } from "@lib/dom";
|
||||
import type { Channel } from "@stores/channels.store";
|
||||
import { getCurrentUser } from "@stores/auth.store";
|
||||
import { hasPermission, currentUserPermissions, canManageChannels } from "@lib/permissions";
|
||||
import { Permission } from "@lib/types";
|
||||
import { markChannelRead, hasUnread } from "@lib/read-state";
|
||||
import { isChannelMuted, toggleChannelMute } from "@lib/channel-mutes";
|
||||
import { appendPurgeSection } from "@components/purge-prompt";
|
||||
|
||||
/** Attach a right-click context menu to a channel element for edit/delete. */
|
||||
/** Bubbles from a channel row when its mute is toggled. */
|
||||
export const CHANNEL_MUTE_CHANGED = "owncord:channel-mute-changed";
|
||||
|
||||
/** Attach a right-click context menu to a channel element for edit/delete/purge. */
|
||||
export function attachChannelContextMenu(
|
||||
el: HTMLElement,
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
onEdit?: (channel: Channel) => void,
|
||||
onDelete?: (channel: Channel) => void,
|
||||
onPurge?: (channel: Channel, count: number) => Promise<void>,
|
||||
): void {
|
||||
if (onEdit === undefined && onDelete === undefined) {
|
||||
return;
|
||||
}
|
||||
const user = getCurrentUser();
|
||||
const role = user?.role?.toLowerCase() ?? "";
|
||||
if (role !== "owner" && role !== "admin") {
|
||||
const canManage = canManageChannels();
|
||||
|
||||
// Voice channels hold no messages, and the server rejects a purge in a DM,
|
||||
// so the section is offered only where it can succeed.
|
||||
const canPurge =
|
||||
onPurge !== undefined &&
|
||||
channel.type !== "voice" &&
|
||||
hasPermission(currentUserPermissions(), Permission.MANAGE_MESSAGES);
|
||||
|
||||
const showEdit = canManage && onEdit !== undefined;
|
||||
const showDelete = canManage && onDelete !== undefined;
|
||||
// Mark as Read touches only the caller's own read state, so it needs no
|
||||
// permission — but a voice channel holds no messages to read.
|
||||
const showMarkRead = channel.type !== "voice";
|
||||
// Muting silences notifications, which a voice channel does not produce.
|
||||
const showMute = channel.type !== "voice";
|
||||
if (!showMarkRead && !showMute && !showEdit && !showDelete && !canPurge) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,7 +66,67 @@ export function attachChannelContextMenu(
|
||||
menu.style.left = `${e.clientX}px`;
|
||||
menu.style.top = `${e.clientY}px`;
|
||||
|
||||
if (onEdit !== undefined) {
|
||||
if (showMarkRead) {
|
||||
// Disabled rather than hidden: a menu whose entries move between
|
||||
// right-clicks is harder to use than one with a greyed-out row.
|
||||
const unread = hasUnread(channel.id);
|
||||
const markItem = createElement(
|
||||
"div",
|
||||
{
|
||||
class: unread ? "context-menu-item" : "context-menu-item disabled",
|
||||
"data-testid": "ctx-mark-read",
|
||||
},
|
||||
"Mark as Read",
|
||||
);
|
||||
if (unread) {
|
||||
markItem.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
closeMenu();
|
||||
markChannelRead(channel.id);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
menu.appendChild(markItem);
|
||||
}
|
||||
|
||||
if (showMute) {
|
||||
// "Until turned off": there is no timed mute, because a timed one needs
|
||||
// a stored expiry the client would have to sweep, and the affordance it
|
||||
// buys ("quiet for 8 hours") is one the user can reproduce by unmuting.
|
||||
const muted = isChannelMuted(channel.id);
|
||||
const muteItem = createElement(
|
||||
"div",
|
||||
{ class: "context-menu-item", "data-testid": "ctx-mute-channel" },
|
||||
muted ? "Unmute Channel" : "Mute Channel",
|
||||
);
|
||||
muteItem.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
closeMenu();
|
||||
toggleChannelMute(channel.id);
|
||||
// Mute state lives in localStorage, so there is no store change to
|
||||
// subscribe to. A bubbling DOM event lets the sidebar redraw the
|
||||
// row without threading a callback through four layers of
|
||||
// positional render arguments.
|
||||
el.dispatchEvent(
|
||||
new CustomEvent(CHANNEL_MUTE_CHANGED, {
|
||||
bubbles: true,
|
||||
detail: { channelId: channel.id },
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
menu.appendChild(muteItem);
|
||||
}
|
||||
|
||||
if ((showMarkRead || showMute) && (showEdit || showDelete || canPurge)) {
|
||||
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
|
||||
}
|
||||
|
||||
if (showEdit && onEdit !== undefined) {
|
||||
const editItem = createElement(
|
||||
"div",
|
||||
{ class: "context-menu-item", "data-testid": "ctx-edit-channel" },
|
||||
@@ -57,8 +143,8 @@ export function attachChannelContextMenu(
|
||||
menu.appendChild(editItem);
|
||||
}
|
||||
|
||||
if (onDelete !== undefined) {
|
||||
if (onEdit !== undefined) {
|
||||
if (showDelete && onDelete !== undefined) {
|
||||
if (showEdit) {
|
||||
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
|
||||
}
|
||||
const deleteItem = createElement(
|
||||
@@ -77,6 +163,17 @@ export function attachChannelContextMenu(
|
||||
menu.appendChild(deleteItem);
|
||||
}
|
||||
|
||||
if (canPurge && onPurge !== undefined) {
|
||||
appendPurgeSection(menu, {
|
||||
itemClass: "context-menu-item",
|
||||
dangerItemClass: "context-menu-item danger",
|
||||
separatorClass: showEdit || showDelete ? "context-menu-sep" : "",
|
||||
onPurge: (count) => onPurge(channel, count),
|
||||
signal,
|
||||
onDone: () => closeMenu(),
|
||||
});
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Close menu on click elsewhere — use a per-menu AbortController
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
/**
|
||||
* Per-user volume context menu — right-click on a voice user row
|
||||
* to adjust their playback volume locally.
|
||||
* Per-user context menu on a voice participant row: local playback volume for
|
||||
* everyone, plus a moderation section for users whose role holds MUTE_MEMBERS.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
|
||||
|
||||
/** Moderation section wiring. Passed only when the local user may moderate
|
||||
* voice; the menu renders the section iff this is present, so the permission
|
||||
* decision stays with the caller (which knows the role list). */
|
||||
export interface VoiceModMenuOptions {
|
||||
/** Current moderator-imposed state of the target, for the toggle labels. */
|
||||
readonly serverMuted: boolean;
|
||||
readonly serverDeafened: boolean;
|
||||
/** Voice channels the target can be moved to (the current one excluded). */
|
||||
readonly moveTargets: readonly { readonly id: number; readonly name: string }[];
|
||||
readonly onServerMute: (muted: boolean) => void;
|
||||
readonly onServerDeafen: (deafened: boolean) => void;
|
||||
readonly onMove: (toChannelId: number) => void;
|
||||
readonly onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export function showUserVolumeMenu(
|
||||
userId: number,
|
||||
username: string,
|
||||
x: number,
|
||||
y: number,
|
||||
signal: AbortSignal,
|
||||
mod?: VoiceModMenuOptions,
|
||||
): void {
|
||||
// Remove any existing context menus and abort their dismiss controllers
|
||||
document.querySelectorAll(".user-vol-menu").forEach((el) => {
|
||||
@@ -85,6 +101,12 @@ export function showUserVolumeMenu(
|
||||
});
|
||||
menu.appendChild(resetBtn);
|
||||
|
||||
if (mod !== undefined) {
|
||||
appendModerationSection(menu, mod, () => {
|
||||
menu.remove();
|
||||
});
|
||||
}
|
||||
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
document.body.appendChild(menu);
|
||||
@@ -112,3 +134,78 @@ export function showUserVolumeMenu(
|
||||
dismissAc.abort();
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds the moderation rows. close() runs after any action so the menu does
|
||||
* not linger showing stale labels while the server round-trip is in flight. */
|
||||
function appendModerationSection(
|
||||
menu: HTMLElement,
|
||||
mod: VoiceModMenuOptions,
|
||||
close: () => void,
|
||||
): void {
|
||||
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
|
||||
|
||||
const muteItem = createElement(
|
||||
"div",
|
||||
{ class: "context-menu-item", "data-action": "server-mute" },
|
||||
mod.serverMuted ? "Server Unmute" : "Server Mute",
|
||||
);
|
||||
muteItem.addEventListener("click", () => {
|
||||
mod.onServerMute(!mod.serverMuted);
|
||||
close();
|
||||
});
|
||||
menu.appendChild(muteItem);
|
||||
|
||||
const deafenItem = createElement(
|
||||
"div",
|
||||
{ class: "context-menu-item", "data-action": "server-deafen" },
|
||||
mod.serverDeafened ? "Server Undeafen" : "Server Deafen",
|
||||
);
|
||||
deafenItem.addEventListener("click", () => {
|
||||
mod.onServerDeafen(!mod.serverDeafened);
|
||||
close();
|
||||
});
|
||||
menu.appendChild(deafenItem);
|
||||
|
||||
if (mod.moveTargets.length > 0) {
|
||||
// Hover-revealed flyout, same shape as the AdminActions role submenu.
|
||||
const moveWrap = createElement("div", {
|
||||
class: "context-menu-item context-menu-item--submenu",
|
||||
"data-action": "move-to",
|
||||
});
|
||||
moveWrap.appendChild(createElement("span", {}, "Move to"));
|
||||
const sub = createElement("div", { class: "context-menu__submenu" });
|
||||
sub.style.display = "none";
|
||||
moveWrap.addEventListener("mouseenter", () => {
|
||||
sub.style.display = "";
|
||||
});
|
||||
moveWrap.addEventListener("mouseleave", () => {
|
||||
sub.style.display = "none";
|
||||
});
|
||||
for (const ch of mod.moveTargets) {
|
||||
const item = createElement(
|
||||
"div",
|
||||
{ class: "context-menu-item", "data-move-channel": String(ch.id) },
|
||||
ch.name,
|
||||
);
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
mod.onMove(ch.id);
|
||||
close();
|
||||
});
|
||||
sub.appendChild(item);
|
||||
}
|
||||
moveWrap.appendChild(sub);
|
||||
menu.appendChild(moveWrap);
|
||||
}
|
||||
|
||||
const kickItem = createElement(
|
||||
"div",
|
||||
{ class: "context-menu-item danger", "data-action": "voice-disconnect" },
|
||||
"Disconnect",
|
||||
);
|
||||
kickItem.addEventListener("click", () => {
|
||||
mod.onDisconnect();
|
||||
close();
|
||||
});
|
||||
menu.appendChild(kickItem);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* inline-autocomplete — the shared listbox the composer opens over the textarea
|
||||
* for "@" mentions and ":" emoji. Both popups are the same widget: a filtered,
|
||||
* keyboard-navigable list whose rows are chosen on mousedown (never click, so
|
||||
* the textarea keeps focus). Only the suggestion source, the row contents, and
|
||||
* a couple of flags differ, so those are injected and everything else — arrow
|
||||
* navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here
|
||||
* once instead of being duplicated in each popup.
|
||||
*
|
||||
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
*/
|
||||
|
||||
import { createElement, clearChildren, appendChildren } from "@lib/dom";
|
||||
|
||||
export interface InlineAutocompleteConfig<T> {
|
||||
/**
|
||||
* CSS class(es) on the root element. Mentions use `"mention-autocomplete"`;
|
||||
* emoji use `"mention-autocomplete emoji-autocomplete"` (sharing the base
|
||||
* class deliberately — a composer test selects
|
||||
* `.mention-autocomplete:not(.emoji-autocomplete)` to tell them apart).
|
||||
*/
|
||||
readonly rootClass: string;
|
||||
/** `data-testid` on the root element. */
|
||||
readonly rootTestId: string;
|
||||
/** Suggestions for the text typed after the trigger, already ordered/capped. */
|
||||
readonly filter: (query: string) => T[];
|
||||
/** The value passed to onSelect when a row is chosen (token / insert text). */
|
||||
readonly valueOf: (item: T) => string;
|
||||
/** `data-testid` for one row. */
|
||||
readonly rowTestId: (item: T) => string;
|
||||
/** The children of one row (name/detail spans, an optional preview, …). */
|
||||
readonly renderRow: (item: T) => readonly HTMLElement[];
|
||||
/**
|
||||
* When true, prime the list with `setQuery("")` on creation so the popup
|
||||
* opens already populated (mentions list every member; emoji stay empty
|
||||
* until the composer types past the minimum query).
|
||||
*/
|
||||
readonly primeOnCreate?: boolean;
|
||||
/** Called with `valueOf(picked)` when a row is chosen. */
|
||||
readonly onSelect: (value: string) => void;
|
||||
/** Called when the user dismisses the popup (Escape). */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export interface InlineAutocompleteComponent {
|
||||
readonly element: HTMLDivElement;
|
||||
/**
|
||||
* Re-filter for `query`. Returns false when nothing matches, which the
|
||||
* composer treats as "close the popup" rather than leaving an empty box.
|
||||
*/
|
||||
setQuery(query: string): boolean;
|
||||
/** Handle a composer keydown. Returns true when the key was consumed. */
|
||||
handleKeydown(e: KeyboardEvent): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createInlineAutocomplete<T>(
|
||||
cfg: InlineAutocompleteConfig<T>,
|
||||
): InlineAutocompleteComponent {
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
|
||||
let suggestions: T[] = [];
|
||||
let activeIndex = 0;
|
||||
|
||||
const root = createElement("div", {
|
||||
class: cfg.rootClass,
|
||||
role: "listbox",
|
||||
"data-testid": cfg.rootTestId,
|
||||
});
|
||||
const list = createElement("div", { class: "ma-list" });
|
||||
root.appendChild(list);
|
||||
|
||||
function choose(index: number): void {
|
||||
const picked = suggestions[index];
|
||||
if (picked === undefined) return;
|
||||
cfg.onSelect(cfg.valueOf(picked));
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
clearChildren(list);
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
const s = suggestions[i]!;
|
||||
const row = createElement("div", {
|
||||
class: i === activeIndex ? "ma-item ma-item--active" : "ma-item",
|
||||
role: "option",
|
||||
"aria-selected": i === activeIndex ? "true" : "false",
|
||||
"data-testid": cfg.rowTestId(s),
|
||||
});
|
||||
appendChildren(row, ...cfg.renderRow(s));
|
||||
// mousedown, not click: the textarea must not lose focus before the
|
||||
// insertion runs.
|
||||
row.addEventListener(
|
||||
"mousedown",
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
choose(i);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function setQuery(query: string): boolean {
|
||||
suggestions = cfg.filter(query);
|
||||
activeIndex = 0;
|
||||
render();
|
||||
return suggestions.length > 0;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): boolean {
|
||||
if (suggestions.length === 0) return false;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
render();
|
||||
return true;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
render();
|
||||
return true;
|
||||
case "Enter":
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
choose(activeIndex);
|
||||
return true;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
cfg.onClose();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
root.remove();
|
||||
}
|
||||
|
||||
if (cfg.primeOnCreate === true) setQuery("");
|
||||
|
||||
return { element: root, setQuery, handleKeydown, destroy };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { loadPref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { ensureHttpProxy } from "@lib/httpProxy";
|
||||
import { getToken } from "@stores/auth.store";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
const log = createLogger("attachments");
|
||||
@@ -55,8 +56,48 @@ export function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Strip any `; codecs=…` parameters and normalise case before matching. */
|
||||
function baseMime(mime: string): string {
|
||||
return (mime.split(";")[0] ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Whether the attachment should render as an inline <img>.
|
||||
* image/svg+xml is excluded: an SVG can carry script, and it is the one image
|
||||
* type the data-URI allowlist already refuses — inlining it only ever produced
|
||||
* a permanently-loading placeholder, so it belongs on the download chip. */
|
||||
export function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith("image/");
|
||||
const base = baseMime(mime);
|
||||
return base.startsWith("image/") && base !== "image/svg+xml";
|
||||
}
|
||||
|
||||
/** Container MIME types we are willing to hand to a <video> element.
|
||||
* An allowlist, not a `video/` prefix test: an unknown container gets the
|
||||
* download chip rather than a player that silently fails to decode. */
|
||||
const INLINE_VIDEO_MIMES = new Set(["video/mp4", "video/webm", "video/ogg"]);
|
||||
|
||||
/** Container MIME types we are willing to hand to an <audio> element.
|
||||
* Includes the common aliases servers emit for MP3 and WAV. */
|
||||
const INLINE_AUDIO_MIMES = new Set([
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/wav",
|
||||
"audio/wave",
|
||||
"audio/x-wav",
|
||||
"audio/webm",
|
||||
]);
|
||||
|
||||
/** Whether the attachment should render as an inline <video> player.
|
||||
* image/svg+xml can never reach here — SVG stays excluded from every inline
|
||||
* path because it can carry script. */
|
||||
export function isVideoMime(mime: string): boolean {
|
||||
return INLINE_VIDEO_MIMES.has(baseMime(mime));
|
||||
}
|
||||
|
||||
/** Whether the attachment should render as an inline <audio> player. */
|
||||
export function isAudioMime(mime: string): boolean {
|
||||
return INLINE_AUDIO_MIMES.has(baseMime(mime));
|
||||
}
|
||||
|
||||
export function isSafeUrl(url: string): boolean {
|
||||
@@ -81,6 +122,11 @@ export function clearAttachmentCaches(): void {
|
||||
attachmentCacheGeneration += 1;
|
||||
memoryCache.clear();
|
||||
inFlight.clear();
|
||||
for (const objectUrl of mediaObjectUrls.values()) {
|
||||
revokeObjectUrl(objectUrl);
|
||||
}
|
||||
mediaObjectUrls.clear();
|
||||
mediaInFlight.clear();
|
||||
}
|
||||
|
||||
/** Safe MIME types allowed in data: URIs — blocks script injection via crafted Content-Type. */
|
||||
@@ -125,16 +171,23 @@ export function isTrustedServerUrl(url: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* If `url` targets the OwnCord server, return an equivalent URL pointing at the
|
||||
* Rust HTTP TOFU proxy's loopback origin (cert-pinned) with the same path and
|
||||
* query. Non-server URLs (external images) are returned unchanged so they use a
|
||||
* normal validated HTTPS fetch.
|
||||
* Fetch `url`, routing OwnCord-server URLs through the Rust HTTP TOFU proxy's
|
||||
* loopback origin (cert-pinned) with the session bearer token attached —
|
||||
* /api/v1/files/{id} enforces channel ACLs, so an unauthenticated request
|
||||
* would 401. The token is only ever sent to the configured server host;
|
||||
* non-server URLs (external images) get a normal validated HTTPS fetch with
|
||||
* no credentials.
|
||||
*/
|
||||
async function toFetchUrl(url: string): Promise<string> {
|
||||
if (!isServerUrl(url)) return url;
|
||||
async function fetchServerFile(url: string): Promise<Response> {
|
||||
if (!isServerUrl(url)) return tauriFetch(url);
|
||||
const parsed = new URL(url);
|
||||
const origin = await ensureHttpProxy(parsed.host);
|
||||
return `${origin}${parsed.pathname}${parsed.search}`;
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getToken();
|
||||
if (token !== null) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return tauriFetch(`${origin}${parsed.pathname}${parsed.search}`, { headers });
|
||||
}
|
||||
|
||||
/** In-flight fetch promises to prevent duplicate concurrent requests. */
|
||||
@@ -253,7 +306,7 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
|
||||
// use a normal validated HTTPS fetch. isSafeUrl restricts to http/https and
|
||||
// responses are only used as image data, never executed.
|
||||
try {
|
||||
const res = await tauriFetch(await toFetchUrl(url));
|
||||
const res = await fetchServerFile(url);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const rawCt = res.headers.get("content-type") ?? "";
|
||||
@@ -291,11 +344,183 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media (video/audio) sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolved blob: URLs keyed by attachment URL, so re-rendering a row (virtual
|
||||
* scroll rebuilds the window constantly) reuses one download. */
|
||||
const mediaObjectUrls = new Map<string, string>();
|
||||
/** In-flight media fetches, deduplicated the same way images are. */
|
||||
const mediaInFlight = new Map<string, Promise<string | null>>();
|
||||
/** FIFO cap mirroring memoryCache's CACHE_MAX, kept far lower: each entry
|
||||
* pins a whole video/audio Blob (not a small base64 thumbnail string), so an
|
||||
* unbounded map here quietly holds every clip ever viewed in the session. */
|
||||
const MEDIA_CACHE_MAX = 20;
|
||||
|
||||
function createObjectUrl(blob: Blob): string | null {
|
||||
// jsdom (and any non-browser host) may not implement the object-URL API.
|
||||
if (typeof URL.createObjectURL !== "function") return null;
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
function revokeObjectUrl(objectUrl: string): void {
|
||||
if (typeof URL.revokeObjectURL !== "function") return;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a video/audio attachment through the same authenticated,
|
||||
* cert-pinned path images use (fetchServerFile attaches the session bearer
|
||||
* token, which /api/v1/files/{id} requires) and hand back a blob: URL.
|
||||
*
|
||||
* Deliberately not the image path: a data: URI means base64-inflating the whole
|
||||
* file into a string and parking it in the LRU + IndexedDB caches, which is
|
||||
* fine for a 200 KB thumbnail and ruinous for a 50 MB video. The Content-Type
|
||||
* goes through the same allowlist so a crafted header cannot turn a
|
||||
* permission-checked download into an executable type.
|
||||
*/
|
||||
export function fetchMediaAsObjectUrl(url: string): Promise<string | null> {
|
||||
const generation = attachmentCacheGeneration;
|
||||
|
||||
const cached = mediaObjectUrls.get(url);
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const existing = mediaInFlight.get(url);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetchServerFile(url);
|
||||
if (!res.ok) return null;
|
||||
const contentType = sanitizeContentType(res.headers.get("content-type") ?? "");
|
||||
const buffer = await res.arrayBuffer();
|
||||
const objectUrl = createObjectUrl(new Blob([buffer], { type: contentType }));
|
||||
if (objectUrl === null) return null;
|
||||
// A cache clear (channel switch, logout) during the fetch means this
|
||||
// blob belongs to a session that is gone — release it rather than
|
||||
// resurrecting it into the fresh cache.
|
||||
if (generation !== attachmentCacheGeneration) {
|
||||
revokeObjectUrl(objectUrl);
|
||||
return null;
|
||||
}
|
||||
if (mediaObjectUrls.size >= MEDIA_CACHE_MAX) {
|
||||
const firstKey = mediaObjectUrls.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
const evicted = mediaObjectUrls.get(firstKey);
|
||||
mediaObjectUrls.delete(firstKey);
|
||||
if (evicted !== undefined) revokeObjectUrl(evicted);
|
||||
}
|
||||
}
|
||||
mediaObjectUrls.set(url, objectUrl);
|
||||
return objectUrl;
|
||||
} catch (err) {
|
||||
log.error("Failed to fetch media attachment", { url, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
mediaInFlight.set(url, promise);
|
||||
void promise.finally(() => {
|
||||
if (mediaInFlight.get(url) === promise) {
|
||||
mediaInFlight.delete(url);
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
// -- Attachment rendering -----------------------------------------------------
|
||||
|
||||
/** The filename + size + download row shared by the audio player and the
|
||||
* generic file chip. */
|
||||
function buildFileMeta(att: Attachment, resolvedUrl: string): HTMLDivElement {
|
||||
const info = createElement("div", { class: "msg-file-meta" });
|
||||
const nameEl = createElement("div", { class: "msg-file-name" }, att.filename);
|
||||
nameEl.addEventListener("click", () => {
|
||||
void downloadFile(resolvedUrl, att.filename);
|
||||
});
|
||||
const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size));
|
||||
appendChildren(info, nameEl, sizeEl);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** The circular download button used by every non-image attachment shape. */
|
||||
function buildDownloadButton(att: Attachment, resolvedUrl: string): HTMLButtonElement {
|
||||
const btn = createElement("button", {
|
||||
class: "msg-file-download",
|
||||
title: "Download",
|
||||
"aria-label": `Download ${att.filename}`,
|
||||
});
|
||||
btn.appendChild(createIcon("download", 16));
|
||||
btn.addEventListener("click", () => {
|
||||
void downloadFile(resolvedUrl, att.filename);
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Inline <video> player. Sized by the same .msg-image box as images so a
|
||||
* video never blows the message column out; the source arrives asynchronously
|
||||
* because it needs the session token attached. */
|
||||
function renderVideoAttachment(att: Attachment, resolvedUrl: string): HTMLDivElement {
|
||||
const wrap = createElement("div", { class: "msg-image msg-video" });
|
||||
|
||||
const video = createElement("video", { preload: "metadata" });
|
||||
video.controls = true;
|
||||
video.setAttribute("aria-label", att.filename);
|
||||
wrap.appendChild(video);
|
||||
|
||||
const overlay = createElement("div", { class: "msg-media-overlay" });
|
||||
overlay.appendChild(buildDownloadButton(att, resolvedUrl));
|
||||
wrap.appendChild(overlay);
|
||||
|
||||
void fetchMediaAsObjectUrl(resolvedUrl).then((objectUrl) => {
|
||||
if (objectUrl !== null) {
|
||||
video.src = objectUrl;
|
||||
} else {
|
||||
wrap.classList.add("msg-media-failed");
|
||||
}
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Inline <audio> player: a compact row carrying the player plus the same
|
||||
* filename / size / download affordances as the file chip. */
|
||||
function renderAudioAttachment(att: Attachment, resolvedUrl: string): HTMLDivElement {
|
||||
const wrap = createElement("div", { class: "msg-file msg-audio" });
|
||||
const inner = createElement("div", { class: "msg-file-inner" });
|
||||
|
||||
const info = buildFileMeta(att, resolvedUrl);
|
||||
const audio = createElement("audio", { preload: "metadata" });
|
||||
audio.controls = true;
|
||||
audio.setAttribute("aria-label", att.filename);
|
||||
info.appendChild(audio);
|
||||
|
||||
appendChildren(inner, info, buildDownloadButton(att, resolvedUrl));
|
||||
wrap.appendChild(inner);
|
||||
|
||||
void fetchMediaAsObjectUrl(resolvedUrl).then((objectUrl) => {
|
||||
if (objectUrl !== null) {
|
||||
audio.src = objectUrl;
|
||||
} else {
|
||||
wrap.classList.add("msg-media-failed");
|
||||
}
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderAttachment(att: Attachment): HTMLDivElement {
|
||||
const resolvedUrl = resolveServerUrl(att.url);
|
||||
if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) {
|
||||
const inlineable = isSafeUrl(resolvedUrl);
|
||||
if (inlineable && isVideoMime(att.mime)) {
|
||||
return renderVideoAttachment(att, resolvedUrl);
|
||||
}
|
||||
if (inlineable && isAudioMime(att.mime)) {
|
||||
return renderAudioAttachment(att, resolvedUrl);
|
||||
}
|
||||
if (isImageMime(att.mime) && inlineable) {
|
||||
const wrap = createElement("div", { class: "msg-image" });
|
||||
|
||||
// Reserve space using server-provided dimensions to prevent layout shift.
|
||||
@@ -383,22 +608,12 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
|
||||
const inner = createElement("div", { class: "msg-file-inner" });
|
||||
const icon = createElement("div", { class: "msg-file-icon" });
|
||||
icon.appendChild(createIcon("file-text", 20));
|
||||
const nameEl = createElement("div", { class: "msg-file-name" }, att.filename);
|
||||
nameEl.addEventListener("click", () => {
|
||||
void downloadFile(resolvedUrl, att.filename);
|
||||
});
|
||||
const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size));
|
||||
const info = createElement("div", {});
|
||||
appendChildren(info, nameEl, sizeEl);
|
||||
const downloadBtn = createElement("button", {
|
||||
class: "msg-file-download",
|
||||
title: "Download",
|
||||
});
|
||||
downloadBtn.appendChild(createIcon("download", 16));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
void downloadFile(resolvedUrl, att.filename);
|
||||
});
|
||||
appendChildren(inner, icon, info, downloadBtn);
|
||||
appendChildren(
|
||||
inner,
|
||||
icon,
|
||||
buildFileMeta(att, resolvedUrl),
|
||||
buildDownloadButton(att, resolvedUrl),
|
||||
);
|
||||
wrap.appendChild(inner);
|
||||
return wrap;
|
||||
}
|
||||
@@ -413,8 +628,9 @@ async function downloadFile(url: string, filename: string): Promise<void> {
|
||||
const filePath = await save({ defaultPath: filename });
|
||||
if (filePath === null) return; // User cancelled
|
||||
|
||||
// Fetch file data — server downloads go through the cert-pinned HTTP proxy.
|
||||
const res = await tauriFetch(await toFetchUrl(url));
|
||||
// Fetch file data — server downloads go through the cert-pinned HTTP proxy
|
||||
// with the session bearer token (the files endpoint requires auth).
|
||||
const res = await fetchServerFile(url);
|
||||
if (!res.ok) {
|
||||
log.error("Download failed", { filename, status: res.status });
|
||||
alert(`Download failed: server returned ${res.status}`);
|
||||
|
||||
@@ -1,41 +1,162 @@
|
||||
/**
|
||||
* Text content parsing — XSS-safe DOM builders for message text including
|
||||
* inline code, code blocks, @mentions, and URL linkification.
|
||||
* Text content parsing — XSS-safe DOM builders for message text.
|
||||
*
|
||||
* This is the *only* renderer for message content: Discord-flavoured markdown
|
||||
* (inline styles, spoilers, quotes, headings, lists, masked links, fenced code
|
||||
* with language tags), plus @mentions, #channel links and URL linkification.
|
||||
*
|
||||
* Everything here builds DOM nodes — never innerHTML — and every href is
|
||||
* checked with isSafeUrl before it reaches an anchor.
|
||||
*/
|
||||
|
||||
import { createElement, setText } from "@lib/dom";
|
||||
import { navigateToChannel, findChannelByName, findChannelById } from "@lib/channel-navigation";
|
||||
import { parseMessageLink } from "@lib/deep-link";
|
||||
import { jumpToMessage } from "@lib/message-navigation";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import {
|
||||
CHANNEL_TOKEN_REGEX,
|
||||
MENTION_TOKEN_REGEX,
|
||||
isEveryoneToken,
|
||||
resolveMentionUserId,
|
||||
type MentionInfo,
|
||||
} from "@lib/mentions";
|
||||
import { isSafeUrl } from "./attachments";
|
||||
import { EMOJI_TOKEN_REGEX, buildCustomEmojiNode, isEmojiOnlyMessage } from "./custom-emoji";
|
||||
import {
|
||||
parseInline,
|
||||
parseBlocks,
|
||||
type BlockNode,
|
||||
type InlineNode,
|
||||
type InlineStyle,
|
||||
} from "./markdown";
|
||||
import { highlightCode, resolveLanguage } from "./syntax-highlight";
|
||||
|
||||
// -- Regex constants ----------------------------------------------------------
|
||||
|
||||
export const MENTION_REGEX = /@(\w+)/g;
|
||||
export const CODE_BLOCK_REGEX = /```([\s\S]*?)```/g;
|
||||
export const INLINE_CODE_REGEX = /`([^`]+)`/g;
|
||||
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g;
|
||||
/** `[text](url)` — used to keep masked links from spawning link embeds. */
|
||||
export const MASKED_LINK_REGEX = /\[[^\]\n]+\]\((?:[^()\s]|\([^()\s]*\))+\)/g;
|
||||
/** `owncord://message/<channelId>/<messageId>` pasted into a message. */
|
||||
export const MESSAGE_LINK_REGEX = /owncord:\/\/message\/\d+\/\d+/g;
|
||||
|
||||
// -- Content rendering --------------------------------------------------------
|
||||
export type { MentionInfo };
|
||||
|
||||
export function renderInlineContent(text: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(INLINE_CODE_REGEX)) {
|
||||
const idx = match.index;
|
||||
if (idx === undefined) continue;
|
||||
if (idx > lastIndex) {
|
||||
fragment.appendChild(renderMentions(text.slice(lastIndex, idx)));
|
||||
/** Quotes may contain blocks, but a quote inside a quote inside a quote is a
|
||||
* fight the renderer does not need to have. */
|
||||
const MAX_BLOCK_DEPTH = 2;
|
||||
|
||||
// -- Inline rendering ---------------------------------------------------------
|
||||
|
||||
const STYLE_TAGS = {
|
||||
strong: "strong",
|
||||
em: "em",
|
||||
underline: "u",
|
||||
strike: "s",
|
||||
} as const satisfies Record<Exclude<InlineStyle, "spoiler">, keyof HTMLElementTagNameMap>;
|
||||
|
||||
const STYLE_CLASSES = {
|
||||
strong: "md-bold",
|
||||
em: "md-italic",
|
||||
underline: "md-underline",
|
||||
strike: "md-strike",
|
||||
} as const;
|
||||
|
||||
/** A spoiler: obscured until the reader asks for it, one span at a time. */
|
||||
function buildSpoiler(
|
||||
node: { readonly children: readonly InlineNode[] },
|
||||
info?: MentionInfo,
|
||||
): HTMLSpanElement {
|
||||
const span = createElement("span", {
|
||||
class: "msg-spoiler",
|
||||
role: "button",
|
||||
tabindex: "0",
|
||||
"aria-pressed": "false",
|
||||
"aria-label": "Spoiler — click to reveal",
|
||||
});
|
||||
appendInline(span, node.children, info);
|
||||
|
||||
const reveal = (e: Event): void => {
|
||||
if (span.classList.contains("revealed")) return;
|
||||
// Swallow the activation that revealed the text: a link hiding under a
|
||||
// spoiler must not open on the same click that uncovers it.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
span.classList.add("revealed");
|
||||
span.setAttribute("aria-pressed", "true");
|
||||
span.setAttribute("aria-label", "Spoiler — revealed");
|
||||
};
|
||||
span.addEventListener("click", reveal);
|
||||
span.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") reveal(e);
|
||||
});
|
||||
return span;
|
||||
}
|
||||
|
||||
/** A `[text](url)` anchor, or null when the URL is not a safe http(s) one. */
|
||||
function buildMaskedLink(
|
||||
node: { readonly url: string; readonly children: readonly InlineNode[] },
|
||||
info?: MentionInfo,
|
||||
): HTMLAnchorElement | null {
|
||||
// Absolute http(s) only: isSafeUrl resolves relatives against the app
|
||||
// origin, which is not something a message author gets to link to.
|
||||
if (!/^https?:\/\//i.test(node.url) || !isSafeUrl(node.url)) return null;
|
||||
const link = createElement("a", {
|
||||
class: "msg-link",
|
||||
href: node.url,
|
||||
title: node.url,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
});
|
||||
appendInline(link, node.children, info);
|
||||
return link;
|
||||
}
|
||||
|
||||
/** Turn inline nodes into DOM under `parent`. */
|
||||
function appendInline(parent: Node, nodes: readonly InlineNode[], info?: MentionInfo): void {
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case "text":
|
||||
// Plain runs are where mentions, #channels and bare URLs live.
|
||||
parent.appendChild(renderMentions(node.value, info));
|
||||
break;
|
||||
case "code": {
|
||||
const code = createElement("code", {});
|
||||
setText(code, node.value);
|
||||
parent.appendChild(code);
|
||||
break;
|
||||
}
|
||||
case "link": {
|
||||
const link = buildMaskedLink(node, info);
|
||||
if (link !== null) parent.appendChild(link);
|
||||
else parent.appendChild(document.createTextNode(node.raw));
|
||||
break;
|
||||
}
|
||||
case "spoiler":
|
||||
parent.appendChild(buildSpoiler(node, info));
|
||||
break;
|
||||
default: {
|
||||
const el = createElement(STYLE_TAGS[node.type], { class: STYLE_CLASSES[node.type] });
|
||||
appendInline(el, node.children, info);
|
||||
parent.appendChild(el);
|
||||
}
|
||||
}
|
||||
const code = createElement("code", {});
|
||||
setText(code, match[1]!);
|
||||
fragment.appendChild(code);
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(renderMentions(text.slice(lastIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one run of inline text: markdown styles, code spans, masked links,
|
||||
* mentions and autolinked URLs.
|
||||
*/
|
||||
export function renderInlineContent(text: string, info?: MentionInfo): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
appendInline(fragment, parseInline(text), info);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
export function renderMentions(text: string): DocumentFragment {
|
||||
export function renderMentions(text: string, info?: MentionInfo): DocumentFragment {
|
||||
// First pass: split by URLs, then handle mentions in non-URL segments
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
@@ -43,7 +164,7 @@ export function renderMentions(text: string): DocumentFragment {
|
||||
const idx = match.index;
|
||||
if (idx === undefined) continue;
|
||||
if (idx > lastIndex) {
|
||||
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx)));
|
||||
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx), info));
|
||||
}
|
||||
// Strip trailing punctuation that is likely sentence-level, not part of the URL
|
||||
const rawUrl = match[0];
|
||||
@@ -68,25 +189,158 @@ export function renderMentions(text: string): DocumentFragment {
|
||||
lastIndex = idx + rawUrl.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(renderMentionSegment(text.slice(lastIndex)));
|
||||
fragment.appendChild(renderMentionSegment(text.slice(lastIndex), info));
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/** Render @mentions within a text segment (no URLs). */
|
||||
export function renderMentionSegment(text: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(MENTION_REGEX)) {
|
||||
/** One recognised token in a prose segment, with the span it renders to. */
|
||||
interface TokenMatch {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
readonly node: Node;
|
||||
}
|
||||
|
||||
/** Build the highlight span for a resolved @token, or null to leave it as text. */
|
||||
function buildMentionNode(raw: string, token: string, info?: MentionInfo): HTMLSpanElement | null {
|
||||
if (isEveryoneToken(token)) {
|
||||
// A token the sender lacked MENTION_EVERYONE for carries no mention
|
||||
// semantics at all — the server says so, and it must not read as one.
|
||||
if (info?.mentionsEveryone !== true) return null;
|
||||
const span = createElement("span", { class: "mention mention-everyone mention-self" });
|
||||
setText(span, raw);
|
||||
return span;
|
||||
}
|
||||
const userId = resolveMentionUserId(token, info);
|
||||
if (userId === null) return null;
|
||||
const isSelf = authStore.getState().user?.id === userId;
|
||||
const span = createElement("span", {
|
||||
class: isSelf ? "mention mention-self" : "mention",
|
||||
"data-user-id": String(userId),
|
||||
});
|
||||
setText(span, raw);
|
||||
return span;
|
||||
}
|
||||
|
||||
/** Build the clickable chip for a `#name` that resolves, or null. */
|
||||
function buildChannelNode(name: string): HTMLSpanElement | null {
|
||||
const channel = findChannelByName(name);
|
||||
if (channel === null) return null;
|
||||
const chip = createElement("span", {
|
||||
class: "channel-mention",
|
||||
role: "link",
|
||||
tabindex: "0",
|
||||
"data-channel-id": String(channel.id),
|
||||
title: `Go to #${channel.name}`,
|
||||
});
|
||||
setText(chip, `#${channel.name}`);
|
||||
// Listeners are attached per node with no signal, matching the code-block
|
||||
// copy button above: these spans live and die with the message row.
|
||||
chip.addEventListener("click", () => navigateToChannel(channel.id));
|
||||
chip.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigateToChannel(channel.id);
|
||||
}
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the compact chip for a pasted `owncord://message/…` permalink, or null
|
||||
* when the link does not parse or points at a channel this user cannot see —
|
||||
* an unreachable jump reads better as the raw text it was typed as.
|
||||
*/
|
||||
function buildMessageLinkNode(url: string): HTMLSpanElement | null {
|
||||
const link = parseMessageLink(url);
|
||||
if (link === null) return null;
|
||||
const channel = findChannelById(link.channelId);
|
||||
if (channel === null) return null;
|
||||
|
||||
const chip = createElement("span", {
|
||||
class: "message-link-chip",
|
||||
role: "link",
|
||||
tabindex: "0",
|
||||
"data-channel-id": String(link.channelId),
|
||||
"data-message-id": String(link.messageId),
|
||||
title: `Jump to message in #${channel.name}`,
|
||||
});
|
||||
const label = createElement("span", { class: "mlc-channel" });
|
||||
setText(label, `#${channel.name}`);
|
||||
const action = createElement("span", { class: "mlc-action" });
|
||||
setText(action, "Jump");
|
||||
chip.appendChild(label);
|
||||
chip.appendChild(action);
|
||||
|
||||
const go = (): void => jumpToMessage(link.channelId, link.messageId);
|
||||
// Per-node listeners with no signal, like the #channel chip above: these
|
||||
// spans live and die with the message row.
|
||||
chip.addEventListener("click", go);
|
||||
chip.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
go();
|
||||
}
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render @mentions, #channel links and message permalinks within a text
|
||||
* segment (no http URLs). Tokens that resolve to nothing are left as plain text.
|
||||
*/
|
||||
export function renderMentionSegment(text: string, info?: MentionInfo): DocumentFragment {
|
||||
const matches: TokenMatch[] = [];
|
||||
|
||||
for (const match of text.matchAll(MESSAGE_LINK_REGEX)) {
|
||||
const idx = match.index;
|
||||
if (idx === undefined) continue;
|
||||
if (idx > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, idx)));
|
||||
const node = buildMessageLinkNode(match[0]);
|
||||
if (node !== null) matches.push({ start: idx, end: idx + match[0].length, node });
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
|
||||
const idx = match.index;
|
||||
const lead = match[1];
|
||||
const token = match[2];
|
||||
if (idx === undefined || lead === undefined || token === undefined) continue;
|
||||
if (match[3] === "@") continue; // address-shaped, e.g. "@bob@example.com"
|
||||
const start = idx + lead.length;
|
||||
const node = buildMentionNode(`@${token}`, token, info);
|
||||
if (node !== null) matches.push({ start, end: start + token.length + 1, node });
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(CHANNEL_TOKEN_REGEX)) {
|
||||
const idx = match.index;
|
||||
const lead = match[1];
|
||||
const name = match[2];
|
||||
if (idx === undefined || lead === undefined || name === undefined) continue;
|
||||
const start = idx + lead.length;
|
||||
const node = buildChannelNode(name);
|
||||
if (node !== null) matches.push({ start, end: start + name.length + 1, node });
|
||||
}
|
||||
|
||||
// `:shortcode:` custom emoji. This runs on prose segments only — code spans
|
||||
// never reach here (appendInline renders them verbatim) and fenced blocks are
|
||||
// split off before any of this, so a shortcode inside code stays code.
|
||||
for (const match of text.matchAll(EMOJI_TOKEN_REGEX)) {
|
||||
const idx = match.index;
|
||||
const shortcode = match[1];
|
||||
if (idx === undefined || shortcode === undefined) continue;
|
||||
const node = buildCustomEmojiNode(shortcode);
|
||||
if (node !== null) matches.push({ start: idx, end: idx + match[0].length, node });
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
matches.sort((a, b) => a.start - b.start);
|
||||
let lastIndex = 0;
|
||||
for (const m of matches) {
|
||||
if (m.start < lastIndex) continue; // overlapping token, keep the first
|
||||
if (m.start > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, m.start)));
|
||||
}
|
||||
const span = createElement("span", { class: "mention" });
|
||||
setText(span, match[0]);
|
||||
fragment.appendChild(span);
|
||||
lastIndex = idx + match[0].length;
|
||||
fragment.appendChild(m.node);
|
||||
lastIndex = m.end;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||
@@ -94,57 +348,176 @@ export function renderMentionSegment(text: string): DocumentFragment {
|
||||
return fragment;
|
||||
}
|
||||
|
||||
export function renderMessageContent(content: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
// -- Block rendering ----------------------------------------------------------
|
||||
|
||||
// Split on triple-backtick boundaries to avoid ReDoS from greedy regex.
|
||||
// Odd-indexed segments are code block contents; even-indexed are prose.
|
||||
const parts = content.split("```");
|
||||
/** Render list items, folding indented ones into a single nested level. */
|
||||
function buildList(
|
||||
block: Extract<BlockNode, { type: "list" }>,
|
||||
info: MentionInfo | undefined,
|
||||
): HTMLElement {
|
||||
const root = createElement(block.ordered ? "ol" : "ul", { class: "md-list" });
|
||||
if (block.ordered && block.start !== 1) root.setAttribute("start", String(block.start));
|
||||
let sublist: HTMLElement | null = null;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const segment = parts[i]!;
|
||||
if (i % 2 === 0) {
|
||||
// Prose segment
|
||||
const trimmed = i === 0 ? segment : i === parts.length - 1 ? segment.trim() : segment;
|
||||
if (trimmed.length > 0) {
|
||||
const text = createElement("div", { class: "msg-text" });
|
||||
text.appendChild(renderInlineContent(trimmed));
|
||||
fragment.appendChild(text);
|
||||
for (const item of block.items) {
|
||||
const li = createElement("li", { class: "md-li" });
|
||||
appendInline(li, parseInline(item.text), info);
|
||||
|
||||
const parentLi = root.lastElementChild;
|
||||
if (item.level === 1 && parentLi !== null) {
|
||||
if (sublist === null) {
|
||||
sublist = createElement(item.ordered ? "ol" : "ul", { class: "md-list md-list-nested" });
|
||||
parentLi.appendChild(sublist);
|
||||
}
|
||||
sublist.appendChild(li);
|
||||
continue;
|
||||
}
|
||||
sublist = null;
|
||||
root.appendChild(li);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Append the block structure of `text` to `parent`. */
|
||||
function appendBlocks(parent: HTMLElement, text: string, info?: MentionInfo, depth = 0): void {
|
||||
for (const block of parseBlocks(text)) {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const heading = createElement(`h${block.level}`, {
|
||||
class: `md-heading md-h${block.level}`,
|
||||
});
|
||||
appendInline(heading, parseInline(block.text), info);
|
||||
parent.appendChild(heading);
|
||||
break;
|
||||
}
|
||||
case "quote": {
|
||||
const quote = createElement("blockquote", { class: "md-quote" });
|
||||
if (depth + 1 >= MAX_BLOCK_DEPTH) {
|
||||
const para = createElement("div", { class: "md-p" });
|
||||
appendInline(para, parseInline(block.text), info);
|
||||
quote.appendChild(para);
|
||||
} else {
|
||||
appendBlocks(quote, block.text, info, depth + 1);
|
||||
}
|
||||
parent.appendChild(quote);
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
parent.appendChild(buildList(block, info));
|
||||
break;
|
||||
default: {
|
||||
const para = createElement("div", { class: "md-p" });
|
||||
appendInline(para, parseInline(block.text), info);
|
||||
parent.appendChild(para);
|
||||
}
|
||||
} else {
|
||||
// Code block segment
|
||||
const codeContent = segment.trim();
|
||||
const codeWrap = createElement("div", { class: "msg-codeblock-wrap" });
|
||||
const codeBlock = createElement("div", { class: "msg-codeblock" });
|
||||
setText(codeBlock, codeContent);
|
||||
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
|
||||
setText(copyBtn, "Copy");
|
||||
copyBtn.addEventListener("click", () => {
|
||||
void navigator.clipboard
|
||||
.writeText(codeContent)
|
||||
.then(() => {
|
||||
setText(copyBtn, "Copied!");
|
||||
setTimeout(() => setText(copyBtn, "Copy"), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
setText(copyBtn, "Failed");
|
||||
setTimeout(() => setText(copyBtn, "Copy"), 2000);
|
||||
});
|
||||
});
|
||||
codeWrap.appendChild(codeBlock);
|
||||
codeWrap.appendChild(copyBtn);
|
||||
fragment.appendChild(codeWrap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were no code blocks at all, ensure at least one text node
|
||||
if (parts.length === 1) {
|
||||
const text = createElement("div", { class: "msg-text" });
|
||||
text.appendChild(renderInlineContent(content));
|
||||
// Replace the fragment content (it already has the same, but handle empty edge case)
|
||||
if (fragment.childNodes.length === 0) {
|
||||
fragment.appendChild(text);
|
||||
// -- Code fences --------------------------------------------------------------
|
||||
|
||||
interface Segment {
|
||||
readonly kind: "prose" | "code";
|
||||
readonly text: string;
|
||||
/** Raw fence tag, e.g. "ts" — present only on code segments that had one. */
|
||||
readonly lang: string | null;
|
||||
}
|
||||
|
||||
const FENCE = "```";
|
||||
const LANG_TAG_REGEX = /^[A-Za-z][\w+#-]{0,19}$/;
|
||||
|
||||
/** Split a message into prose and fenced-code segments. */
|
||||
export function splitCodeFences(content: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let i = 0;
|
||||
while (i < content.length) {
|
||||
const open = content.indexOf(FENCE, i);
|
||||
const close = open < 0 ? -1 : content.indexOf(FENCE, open + FENCE.length);
|
||||
if (open < 0 || close < 0) break;
|
||||
|
||||
if (open > i) segments.push({ kind: "prose", text: content.slice(i, open), lang: null });
|
||||
|
||||
const inner = content.slice(open + FENCE.length, close);
|
||||
const newline = inner.indexOf("\n");
|
||||
const tag = newline > 0 ? inner.slice(0, newline).trim() : "";
|
||||
if (tag.length > 0 && LANG_TAG_REGEX.test(tag)) {
|
||||
segments.push({
|
||||
kind: "code",
|
||||
text: inner.slice(newline + 1).replace(/\s+$/, ""),
|
||||
lang: tag,
|
||||
});
|
||||
} else {
|
||||
segments.push({ kind: "code", text: inner.trim(), lang: null });
|
||||
}
|
||||
i = close + FENCE.length;
|
||||
}
|
||||
if (i < content.length) segments.push({ kind: "prose", text: content.slice(i), lang: null });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** A code block: language label, highlighted body, copy button. */
|
||||
function renderCodeBlock(code: string, lang: string | null): HTMLDivElement {
|
||||
const wrap = createElement("div", { class: "msg-codeblock-wrap" });
|
||||
|
||||
if (lang !== null) {
|
||||
const label = createElement("span", { class: "msg-codeblock-lang" });
|
||||
setText(label, lang);
|
||||
wrap.appendChild(label);
|
||||
}
|
||||
|
||||
const block = createElement("div", { class: "msg-codeblock" });
|
||||
const canonical = resolveLanguage(lang);
|
||||
if (canonical !== null) block.setAttribute("data-lang", canonical);
|
||||
for (const token of highlightCode(code, canonical)) {
|
||||
if (token.cls === null) {
|
||||
block.appendChild(document.createTextNode(token.text));
|
||||
continue;
|
||||
}
|
||||
const span = createElement("span", { class: `tok-${token.cls}` });
|
||||
setText(span, token.text);
|
||||
block.appendChild(span);
|
||||
}
|
||||
|
||||
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
|
||||
setText(copyBtn, "Copy");
|
||||
copyBtn.addEventListener("click", () => {
|
||||
void navigator.clipboard
|
||||
.writeText(code)
|
||||
.then(() => {
|
||||
setText(copyBtn, "Copied!");
|
||||
setTimeout(() => setText(copyBtn, "Copy"), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
setText(copyBtn, "Failed");
|
||||
setTimeout(() => setText(copyBtn, "Copy"), 2000);
|
||||
});
|
||||
});
|
||||
|
||||
wrap.appendChild(block);
|
||||
wrap.appendChild(copyBtn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderMessageContent(content: string, info?: MentionInfo): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// A message that is nothing but emoji renders them large, the way Discord
|
||||
// does. Decided once over the whole content — the class is what sizes both
|
||||
// the unicode glyphs and the custom-emoji images, so nothing downstream has
|
||||
// to be told about it.
|
||||
const jumboClass = isEmojiOnlyMessage(content) ? "msg-text msg-text-jumbo" : "msg-text";
|
||||
|
||||
for (const segment of splitCodeFences(content)) {
|
||||
if (segment.kind === "code") {
|
||||
fragment.appendChild(renderCodeBlock(segment.text, segment.lang));
|
||||
continue;
|
||||
}
|
||||
// Blank lines hugging a fence are formatting, not content.
|
||||
const prose = segment.text.replace(/^\n+/, "").replace(/\n+$/, "");
|
||||
if (prose.trim().length === 0) continue;
|
||||
const text = createElement("div", { class: jumboClass });
|
||||
appendBlocks(text, prose, info);
|
||||
fragment.appendChild(text);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Custom-emoji tokens in message content.
|
||||
*
|
||||
* `:shortcode:` renders as an inline image when the server knows that
|
||||
* shortcode and as the literal text it was typed as when it does not — the
|
||||
* same rule @mentions follow, and the reason a message full of colons never
|
||||
* turns into a wall of broken images.
|
||||
*
|
||||
* The image itself is behind the session token (GET /api/v1/emoji/{id}/image
|
||||
* is authenticated), so it is fetched through the same cert-pinned,
|
||||
* bearer-token path attachments use and swapped in as a data: URI. Assigning
|
||||
* the server URL straight to `img.src` would 401.
|
||||
*/
|
||||
|
||||
import { createElement } from "@lib/dom";
|
||||
import { resolveEmoji, type CustomEmoji } from "@stores/emoji.store";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./attachments";
|
||||
|
||||
/**
|
||||
* A `:shortcode:` token. Case-insensitive on the way in (the store lowercases
|
||||
* before lookup) so `:WAVE:` finds the same emoji `:wave:` does; the length
|
||||
* bounds mirror the server's validator, so a token this matches is one the
|
||||
* server could actually have stored.
|
||||
*/
|
||||
export const EMOJI_TOKEN_REGEX = /:([A-Za-z0-9_]{2,32}):/g;
|
||||
|
||||
/**
|
||||
* How many emoji a message may hold and still render jumbo. Discord's number.
|
||||
* Past it the message is a picture wall, not an expression, and 48px each
|
||||
* would push the rest of the channel off screen.
|
||||
*/
|
||||
export const MAX_JUMBO_EMOJI = 27;
|
||||
|
||||
/** One unicode emoji, including skin tones, ZWJ sequences, flags and keycaps. */
|
||||
const UNICODE_EMOJI = new RegExp(
|
||||
"^(?:" +
|
||||
// Keycap: digit/#/* + optional VS16 + the combining enclosing keycap.
|
||||
"[0-9#*]\\uFE0F?\\u{20E3}" +
|
||||
"|" +
|
||||
// Regional-indicator pair (flags) or any pictographic base.
|
||||
"(?:[\\u{1F1E6}-\\u{1F1FF}]|\\p{Extended_Pictographic})" +
|
||||
// Modifiers, variation selectors and ZWJ-joined continuations.
|
||||
"(?:\\uFE0F|\\u{20E3}|[\\u{1F3FB}-\\u{1F3FF}]|\\u200D(?:[\\u{1F1E6}-\\u{1F1FF}]|\\p{Extended_Pictographic})(?:\\uFE0F|[\\u{1F3FB}-\\u{1F3FF}])*)*" +
|
||||
")",
|
||||
"u",
|
||||
);
|
||||
|
||||
/** A single `:shortcode:` anchored at the start of the remaining text. */
|
||||
const LEADING_EMOJI_TOKEN = /^:([A-Za-z0-9_]{2,32}):/;
|
||||
|
||||
/**
|
||||
* Whether a message is nothing but emoji, which is what earns the jumbo size.
|
||||
*
|
||||
* "Nothing but" is literal: whitespace, unicode emoji, and `:shortcodes:` that
|
||||
* actually resolve. An unresolved shortcode is plain text, so `:nosuch:` alone
|
||||
* is a normal message — sizing it jumbo would promise an image that is never
|
||||
* going to appear.
|
||||
*/
|
||||
export function isEmojiOnlyMessage(content: string): boolean {
|
||||
let rest = content.trim();
|
||||
if (rest === "") return false;
|
||||
|
||||
let count = 0;
|
||||
while (rest.length > 0) {
|
||||
const ws = /^\s+/.exec(rest);
|
||||
if (ws !== null) {
|
||||
rest = rest.slice(ws[0].length);
|
||||
continue;
|
||||
}
|
||||
const token = LEADING_EMOJI_TOKEN.exec(rest);
|
||||
if (token !== null && resolveEmoji(token[1] ?? "") !== null) {
|
||||
count++;
|
||||
rest = rest.slice(token[0].length);
|
||||
continue;
|
||||
}
|
||||
const unicode = UNICODE_EMOJI.exec(rest);
|
||||
if (unicode !== null && unicode[0].length > 0) {
|
||||
count++;
|
||||
rest = rest.slice(unicode[0].length);
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return count > 0 && count <= MAX_JUMBO_EMOJI;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline image for one custom emoji. The element is returned immediately
|
||||
* with no `src`; the bytes arrive asynchronously and are swapped in when they
|
||||
* do. Until then (and forever, if the fetch fails) the `alt` text is the
|
||||
* shortcode, so the message still reads correctly.
|
||||
*/
|
||||
export function buildCustomEmojiImage(emoji: CustomEmoji): HTMLImageElement {
|
||||
const img = createElement("img", {
|
||||
class: "custom-emoji",
|
||||
alt: `:${emoji.shortcode}:`,
|
||||
title: `:${emoji.shortcode}:`,
|
||||
"data-shortcode": emoji.shortcode,
|
||||
draggable: "false",
|
||||
});
|
||||
void fetchImageAsDataUrl(resolveServerUrl(emoji.url)).then((dataUrl) => {
|
||||
if (dataUrl !== null) img.src = dataUrl;
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* The image node for `token` (with or without colons), or null when no such
|
||||
* emoji exists — the caller leaves an unresolved token as plain text.
|
||||
*/
|
||||
export function buildCustomEmojiNode(token: string): HTMLImageElement | null {
|
||||
const emoji = resolveEmoji(token);
|
||||
return emoji === null ? null : buildCustomEmojiImage(emoji);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Pure functions for timestamp parsing, display formatting, and role resolution.
|
||||
*/
|
||||
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
import { loadPref } from "@components/settings/helpers";
|
||||
@@ -123,10 +124,49 @@ export function getUserRole(userId: number): string {
|
||||
return membersStore.getState().members.get(userId)?.role ?? "member";
|
||||
}
|
||||
|
||||
/**
|
||||
* The author identity to render for a message, resolved against the member
|
||||
* store first and the message payload second.
|
||||
*
|
||||
* The store is preferred because it is the live copy: a rename or an avatar
|
||||
* change arrives as a `user_update` and patches every member, while the
|
||||
* messages already on screen keep whatever the author looked like when they
|
||||
* posted. The payload is the fallback for someone who is not in the member
|
||||
* list at all — a deleted account, or a poster from before this session.
|
||||
*/
|
||||
export function resolveAuthor(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
display_name?: string | null;
|
||||
}): { username: string; displayName: string | null; avatar: string | null } {
|
||||
const member = membersStore.getState().members.get(user.id);
|
||||
if (member !== undefined) {
|
||||
return {
|
||||
username: member.username,
|
||||
displayName: member.displayName ?? null,
|
||||
avatar: member.avatar,
|
||||
};
|
||||
}
|
||||
return {
|
||||
username: user.username,
|
||||
displayName: user.display_name ?? null,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
}
|
||||
|
||||
export function roleColorVar(role: string): string {
|
||||
if (!roleColorsEnabled) {
|
||||
return "var(--role-member)";
|
||||
}
|
||||
// Prefer the server's role color (shipped in `ready`); the theme variables
|
||||
// below are the fallback for the seeded roles when no color is set.
|
||||
const serverRole = channelsStore
|
||||
.getState()
|
||||
.roles.find((r) => r.name.toLowerCase() === role.toLowerCase());
|
||||
if (serverRole?.color != null && serverRole.color !== "") {
|
||||
return serverRole.color;
|
||||
}
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return "var(--role-owner)";
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Discord-flavoured inline markdown tokenizer.
|
||||
*
|
||||
* Pure and DOM-free on purpose: this module only decides *what* the text
|
||||
* means, `content-parser.ts` decides what nodes it becomes. Keeping the two
|
||||
* apart is what lets the renderer stay a strict DOM builder (no innerHTML)
|
||||
* while the grammar gets tested on its own.
|
||||
*
|
||||
* The tokenizer is a single left-to-right scan with recursive descent into
|
||||
* matched delimiter pairs — not a stack of regexes — so nesting
|
||||
* (`**bold *and italic* **`), escaping (`\*literal\*`) and "markdown is dead
|
||||
* inside code" all fall out of one rule set instead of fighting each other.
|
||||
*/
|
||||
|
||||
/** Emphasis-style wrappers, in the flavour Discord uses. */
|
||||
export type InlineStyle = "strong" | "em" | "underline" | "strike" | "spoiler";
|
||||
|
||||
export type InlineNode =
|
||||
| { readonly type: "text"; readonly value: string }
|
||||
| { readonly type: "code"; readonly value: string }
|
||||
| {
|
||||
readonly type: "link";
|
||||
readonly url: string;
|
||||
readonly raw: string;
|
||||
readonly children: readonly InlineNode[];
|
||||
}
|
||||
| { readonly type: InlineStyle; readonly children: readonly InlineNode[] };
|
||||
|
||||
/** Characters a backslash can neutralise. Anything else keeps its backslash. */
|
||||
const ESCAPABLE = "\\`*_~|[]()>#-+.!";
|
||||
|
||||
/** Nesting cap — a guard against pathological input, not a style choice. */
|
||||
const MAX_DEPTH = 6;
|
||||
|
||||
/** Bare-URL shape, kept in sync with URL_REGEX in content-parser. */
|
||||
const URL_START = /^https?:\/\//i;
|
||||
|
||||
/** Shared empty map for `parseInline` calls with nothing to bracket-match. */
|
||||
const EMPTY_MATCHES: ReadonlyMap<number, number> = new Map();
|
||||
|
||||
interface DelimSpec {
|
||||
readonly marker: string;
|
||||
/** Outermost style first; `***x***` is bold wrapping italic. */
|
||||
readonly styles: readonly InlineStyle[];
|
||||
}
|
||||
|
||||
/** Longest markers first — `***` must win over `**`, and `**` over `*`. */
|
||||
const DELIMS: readonly DelimSpec[] = [
|
||||
{ marker: "***", styles: ["strong", "em"] },
|
||||
{ marker: "___", styles: ["underline", "em"] },
|
||||
{ marker: "**", styles: ["strong"] },
|
||||
{ marker: "__", styles: ["underline"] },
|
||||
{ marker: "~~", styles: ["strike"] },
|
||||
{ marker: "||", styles: ["spoiler"] },
|
||||
{ marker: "*", styles: ["em"] },
|
||||
{ marker: "_", styles: ["em"] },
|
||||
];
|
||||
|
||||
function isWordChar(ch: string | undefined): boolean {
|
||||
return ch !== undefined && /[A-Za-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
function runLength(src: string, i: number, ch: string): number {
|
||||
let n = 0;
|
||||
while (i + n < src.length && src[i + n] === ch) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* End index (exclusive) of the code span opening at `i`, or -1 when it never
|
||||
* closes. Supports single and double backtick fences.
|
||||
*/
|
||||
function codeSpanEnd(src: string, i: number): number {
|
||||
const run = Math.min(runLength(src, i, "`"), 2);
|
||||
const fence = "`".repeat(run);
|
||||
const close = src.indexOf(fence, i + run);
|
||||
if (close < 0) return -1;
|
||||
return close + run;
|
||||
}
|
||||
|
||||
/**
|
||||
* End index (exclusive) of a bare URL starting at `i`, or `i` when there is
|
||||
* none. Trailing delimiter runs are given back so `**https://a/b**` still
|
||||
* closes its bold — a URL swallowing the closer is worse than a URL losing a
|
||||
* trailing asterisk it almost certainly never had.
|
||||
*/
|
||||
function urlEnd(src: string, i: number): number {
|
||||
if (!URL_START.test(src.slice(i, i + 8))) return i;
|
||||
let end = i;
|
||||
while (end < src.length && !/[\s<>"'`]/.test(src[end]!)) end++;
|
||||
const min = i + 8;
|
||||
for (;;) {
|
||||
const tail = src.slice(min, end);
|
||||
const run = /([*_~|])\1+$/.exec(tail);
|
||||
if (run !== null) {
|
||||
end -= run[0].length;
|
||||
continue;
|
||||
}
|
||||
if (end > min && src[end - 1] === "*") {
|
||||
end--;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** The delimiter opening at `i`, or null. */
|
||||
function matchDelim(src: string, i: number): DelimSpec | null {
|
||||
for (const d of DELIMS) {
|
||||
if (!src.startsWith(d.marker, i)) continue;
|
||||
// `snake_case_names` must stay literal: an underscore only opens emphasis
|
||||
// on a word boundary.
|
||||
if (d.marker[0] === "_" && isWordChar(src[i - 1])) continue;
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the delimiter run that closes `marker`, searching from `from`, or
|
||||
* -1. Escapes, code spans and bare URLs are skipped so a closer hiding inside
|
||||
* them is not mistaken for the real one.
|
||||
*/
|
||||
function scanClose(src: string, from: number, marker: string): number {
|
||||
const ch = marker[0]!;
|
||||
const len = marker.length;
|
||||
let j = from;
|
||||
while (j < src.length) {
|
||||
const c = src[j]!;
|
||||
if (c === "\\") {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "`") {
|
||||
const end = codeSpanEnd(src, j);
|
||||
if (end > 0) {
|
||||
j = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const u = urlEnd(src, j);
|
||||
if (u > j) {
|
||||
j = u;
|
||||
continue;
|
||||
}
|
||||
if (c === ch) {
|
||||
const run = runLength(src, j, ch);
|
||||
const usable = len > 1 ? run >= len : run === 1;
|
||||
if (usable && (ch !== "_" || !isWordChar(src[j + len]))) return j;
|
||||
j += run;
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches for every `openCh` in `src` to its balanced `closeCh`, computed in
|
||||
* one linear pass with a stack (an opener that never closes just never gets
|
||||
* an entry). A run's mismatched openers therefore cost O(1) each to look up
|
||||
* instead of a fresh O(n) rescan apiece — the same semantics as calling a
|
||||
* depth-counting scan from every individual opener (nesting balances the
|
||||
* same way, escapes swallow the following character unconditionally, and a
|
||||
* bare newline strands whatever is still open across it), just computed once
|
||||
* per `parseInline` invocation instead of once per opener.
|
||||
*/
|
||||
function buildMatches(src: string, openCh: string, closeCh: string): ReadonlyMap<number, number> {
|
||||
const matches = new Map<number, number>();
|
||||
const stack: number[] = [];
|
||||
for (let j = 0; j < src.length; j++) {
|
||||
const c = src[j]!;
|
||||
if (c === "\\") {
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (c === "\n") {
|
||||
// Nothing left open can span a newline; abandon it rather than let it
|
||||
// match something on a later line.
|
||||
stack.length = 0;
|
||||
continue;
|
||||
}
|
||||
if (c === openCh) stack.push(j);
|
||||
else if (c === closeCh) {
|
||||
const open = stack.pop();
|
||||
if (open !== undefined) matches.set(open, j);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Parse `[text](url)` at `i`. The URL is *not* validated here — that is the
|
||||
* renderer's job, which is why the raw source travels with the node. */
|
||||
function parseLink(
|
||||
src: string,
|
||||
i: number,
|
||||
depth: number,
|
||||
bracketMatches: ReadonlyMap<number, number>,
|
||||
parenMatches: ReadonlyMap<number, number>,
|
||||
): { node: InlineNode; end: number } | null {
|
||||
const close = bracketMatches.get(i) ?? -1;
|
||||
if (close < 0 || src[close + 1] !== "(") return null;
|
||||
const urlClose = parenMatches.get(close + 1) ?? -1;
|
||||
if (urlClose < 0) return null;
|
||||
const url = src.slice(close + 2, urlClose).trim();
|
||||
if (url.length === 0 || /\s/.test(url)) return null;
|
||||
const text = src.slice(i + 1, close);
|
||||
if (text.length === 0) return null;
|
||||
return {
|
||||
node: {
|
||||
type: "link",
|
||||
url,
|
||||
raw: src.slice(i, urlClose + 1),
|
||||
children: parseInline(text, depth + 1),
|
||||
},
|
||||
end: urlClose + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize one run of inline text (no block constructs, no newline meaning).
|
||||
*/
|
||||
export function parseInline(src: string, depth = 0): InlineNode[] {
|
||||
const out: InlineNode[] = [];
|
||||
let buf = "";
|
||||
const flush = (): void => {
|
||||
if (buf.length > 0) {
|
||||
out.push({ type: "text", value: buf });
|
||||
buf = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Built once per invocation (not once per `[`) — see buildMatches. Skipped
|
||||
// entirely when the substring has nothing to match, which is the common
|
||||
// case for recursive calls into styled/link text.
|
||||
const bracketMatches = src.includes("[") ? buildMatches(src, "[", "]") : EMPTY_MATCHES;
|
||||
const parenMatches = src.includes("(") ? buildMatches(src, "(", ")") : EMPTY_MATCHES;
|
||||
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
|
||||
if (c === "\\" && i + 1 < src.length && ESCAPABLE.includes(src[i + 1]!)) {
|
||||
buf += src[i + 1]!;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === "`") {
|
||||
const end = codeSpanEnd(src, i);
|
||||
if (end > i) {
|
||||
const run = Math.min(runLength(src, i, "`"), 2);
|
||||
flush();
|
||||
out.push({ type: "code", value: src.slice(i + run, end - run) });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// A bare URL is opaque: its underscores and asterisks are address, not
|
||||
// markup, and it is autolinked later by the renderer.
|
||||
const u = urlEnd(src, i);
|
||||
if (u > i) {
|
||||
buf += src.slice(i, u);
|
||||
i = u;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth < MAX_DEPTH && c === "[") {
|
||||
const link = parseLink(src, i, depth, bracketMatches, parenMatches);
|
||||
if (link !== null) {
|
||||
flush();
|
||||
out.push(link.node);
|
||||
i = link.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth < MAX_DEPTH) {
|
||||
const d = matchDelim(src, i);
|
||||
if (d !== null) {
|
||||
const innerStart = i + d.marker.length;
|
||||
const close = scanClose(src, innerStart, d.marker);
|
||||
if (close > innerStart) {
|
||||
flush();
|
||||
const children = parseInline(src.slice(innerStart, close), depth + 1);
|
||||
let node: InlineNode = { type: d.styles[d.styles.length - 1]!, children };
|
||||
for (let k = d.styles.length - 2; k >= 0; k--) {
|
||||
node = { type: d.styles[k]!, children: [node] };
|
||||
}
|
||||
out.push(node);
|
||||
i = close + d.marker.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf += c;
|
||||
i++;
|
||||
}
|
||||
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// -- Block constructs ---------------------------------------------------------
|
||||
|
||||
export type BlockNode =
|
||||
| { readonly type: "paragraph"; readonly text: string }
|
||||
| { readonly type: "heading"; readonly level: 1 | 2 | 3; readonly text: string }
|
||||
| { readonly type: "quote"; readonly text: string }
|
||||
| {
|
||||
readonly type: "list";
|
||||
readonly ordered: boolean;
|
||||
readonly start: number;
|
||||
readonly items: readonly ListItem[];
|
||||
};
|
||||
|
||||
export interface ListItem {
|
||||
readonly text: string;
|
||||
/** 0 for a top-level item, 1 for a single level of indentation. */
|
||||
readonly level: 0 | 1;
|
||||
readonly ordered: boolean;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,3}) +(.*)$/;
|
||||
const QUOTE_RE = /^> ?(.*)$/;
|
||||
const BLOCK_QUOTE_ALL_RE = /^>>> ?(.*)$/;
|
||||
const BULLET_RE = /^( *)([-*]) +(.*)$/;
|
||||
const ORDERED_RE = /^( *)(\d{1,9})[.)] +(.*)$/;
|
||||
|
||||
function listItemAt(line: string): ListItem | null {
|
||||
const bullet = BULLET_RE.exec(line);
|
||||
if (bullet !== null) {
|
||||
return { text: bullet[3]!, level: bullet[1]!.length >= 2 ? 1 : 0, ordered: false };
|
||||
}
|
||||
const ordered = ORDERED_RE.exec(line);
|
||||
if (ordered !== null) {
|
||||
return { text: ordered[3]!, level: ordered[1]!.length >= 2 ? 1 : 0, ordered: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderedStart(line: string): number {
|
||||
const m = ORDERED_RE.exec(line);
|
||||
return m === null ? 1 : Number(m[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a prose segment into block nodes. Block markers are only recognised at
|
||||
* the start of a line, exactly like Discord; everything else joins the
|
||||
* surrounding paragraph so inline styles may span line breaks.
|
||||
*/
|
||||
export function parseBlocks(text: string): BlockNode[] {
|
||||
const lines = text.split("\n");
|
||||
const out: BlockNode[] = [];
|
||||
let para: string[] = [];
|
||||
|
||||
const flushPara = (): void => {
|
||||
if (para.length > 0) {
|
||||
out.push({ type: "paragraph", text: para.join("\n") });
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
const all = BLOCK_QUOTE_ALL_RE.exec(line);
|
||||
if (all !== null) {
|
||||
flushPara();
|
||||
const rest = [all[1]!, ...lines.slice(i + 1)].join("\n");
|
||||
out.push({ type: "quote", text: rest });
|
||||
return out;
|
||||
}
|
||||
|
||||
const quote = QUOTE_RE.exec(line);
|
||||
if (quote !== null) {
|
||||
flushPara();
|
||||
const collected = [quote[1]!];
|
||||
while (i + 1 < lines.length) {
|
||||
const next = QUOTE_RE.exec(lines[i + 1]!);
|
||||
if (next === null) break;
|
||||
collected.push(next[1]!);
|
||||
i++;
|
||||
}
|
||||
out.push({ type: "quote", text: collected.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING_RE.exec(line);
|
||||
if (heading !== null) {
|
||||
flushPara();
|
||||
out.push({ type: "heading", level: heading[1]!.length as 1 | 2 | 3, text: heading[2]! });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = listItemAt(line);
|
||||
if (item !== null) {
|
||||
flushPara();
|
||||
const items: ListItem[] = [item];
|
||||
const ordered = item.ordered;
|
||||
const start = ordered ? orderedStart(line) : 1;
|
||||
while (i + 1 < lines.length) {
|
||||
const next = listItemAt(lines[i + 1]!);
|
||||
// A list ends when the marker style changes at the top level; nested
|
||||
// items may differ from their parent.
|
||||
if (next === null || (next.level === 0 && next.ordered !== ordered)) break;
|
||||
items.push(next);
|
||||
i++;
|
||||
}
|
||||
out.push({ type: "list", ordered, start, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
para.push(line);
|
||||
}
|
||||
|
||||
flushPara();
|
||||
return out;
|
||||
}
|
||||
@@ -11,7 +11,12 @@ import { observeMedia } from "@lib/media-visibility";
|
||||
import { loadPref } from "@components/settings/helpers";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { isSafeUrl } from "./attachments";
|
||||
import { CODE_BLOCK_REGEX, INLINE_CODE_REGEX, URL_REGEX } from "./content-parser";
|
||||
import {
|
||||
CODE_BLOCK_REGEX,
|
||||
INLINE_CODE_REGEX,
|
||||
MASKED_LINK_REGEX,
|
||||
URL_REGEX,
|
||||
} from "./content-parser";
|
||||
import { renderGenericLinkPreview } from "./embeds";
|
||||
|
||||
const log = createLogger("media");
|
||||
@@ -494,8 +499,12 @@ export function openImageLightbox(src: string, alt: string): void {
|
||||
|
||||
/** Extract all URLs from a message content string. */
|
||||
export function extractUrls(content: string): string[] {
|
||||
// Skip URLs inside code blocks
|
||||
const withoutCodeBlocks = content.replace(CODE_BLOCK_REGEX, "").replace(INLINE_CODE_REGEX, "");
|
||||
// Skip URLs inside code blocks, and inside masked links: `[text](url)` is a
|
||||
// deliberate act of hiding the address, so it gets no embed either.
|
||||
const withoutCodeBlocks = content
|
||||
.replace(CODE_BLOCK_REGEX, "")
|
||||
.replace(INLINE_CODE_REGEX, "")
|
||||
.replace(MASKED_LINK_REGEX, "");
|
||||
const matches = withoutCodeBlocks.match(URL_REGEX);
|
||||
return matches ?? [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Who-reacted tooltip — hovering a reaction pill names the people behind the
|
||||
* count.
|
||||
*
|
||||
* The reactor list is not part of the message payload (a page of chat carries
|
||||
* dozens of pills and almost none are ever hovered), so it is fetched on demand
|
||||
* from GET /channels/{id}/messages/{messageId}/reactions/{emoji}/users and
|
||||
* cached per message+emoji. The cache is invalidated by `reaction_update` for
|
||||
* that message, which is the only event that can change the answer.
|
||||
*
|
||||
* Hover is debounced 300ms, mirroring lib/streamPreview.ts: a pointer crossing
|
||||
* a row of pills must not fire a request per pill.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import type { ReactionUser } from "@lib/types";
|
||||
|
||||
const log = createLogger("reaction-tooltip");
|
||||
|
||||
/** Debounce before the hover turns into a fetch + tooltip. */
|
||||
export const REACTION_TOOLTIP_DEBOUNCE_MS = 300;
|
||||
|
||||
/** How many names are spelled out before collapsing into "and N others". */
|
||||
const MAX_NAMES = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetcher injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ReactionUsersFetcher = (
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
emoji: string,
|
||||
) => Promise<readonly ReactionUser[]>;
|
||||
|
||||
let fetcher: ReactionUsersFetcher | null = null;
|
||||
|
||||
/**
|
||||
* Register the transport used to fetch reactor lists. Called once from
|
||||
* MainPage with the live ApiClient, the same way setServerHost is. Until it is
|
||||
* set, hovering a pill is a no-op rather than an error — the renderer is used
|
||||
* by tests and previews that have no server.
|
||||
*/
|
||||
export function setReactionUsersFetcher(next: ReactionUsersFetcher | null): void {
|
||||
fetcher = next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** NUL separator: the server rejects control characters in an emoji, so no
|
||||
* emoji can contain it and no two (message, emoji) pairs can collide. */
|
||||
function cacheKey(messageId: number, emoji: string): string {
|
||||
return `${messageId}\u0000${emoji}`;
|
||||
}
|
||||
|
||||
/** Resolved reactor lists, keyed by message+emoji. */
|
||||
const cache = new Map<string, readonly ReactionUser[]>();
|
||||
/** In-flight requests, so a re-hover during the fetch does not duplicate it. */
|
||||
const inFlight = new Map<string, Promise<readonly ReactionUser[] | null>>();
|
||||
|
||||
/**
|
||||
* Drop every cached reactor list for a message. Called from the `reaction_update`
|
||||
* dispatch: any add/remove on that message makes all of its lists stale, and
|
||||
* the event carries only the one emoji that changed, so scoping the eviction to
|
||||
* that emoji would leave the others silently wrong after a race.
|
||||
*/
|
||||
export function invalidateReactionUsers(messageId: number): void {
|
||||
// Deleting the key currently being visited is well-defined for a Map
|
||||
// iterator, so no snapshot of the key set is needed.
|
||||
const prefix = `${messageId}\u0000`;
|
||||
for (const key of cache.keys()) {
|
||||
if (key.startsWith(prefix)) cache.delete(key);
|
||||
}
|
||||
for (const key of inFlight.keys()) {
|
||||
if (key.startsWith(prefix)) inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every cached reactor list (channel switch, logout, reconnect). */
|
||||
export function clearReactionUsersCache(): void {
|
||||
cache.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/** Cached reactor list for a message+emoji, or undefined when not fetched. */
|
||||
export function getCachedReactionUsers(
|
||||
messageId: number,
|
||||
emoji: string,
|
||||
): readonly ReactionUser[] | undefined {
|
||||
return cache.get(cacheKey(messageId, emoji));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactor list for a message+emoji, from cache when present. Returns null when
|
||||
* there is no fetcher registered or the request failed — callers show nothing
|
||||
* rather than an error, since this is a hover affordance.
|
||||
*/
|
||||
export function loadReactionUsers(
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
emoji: string,
|
||||
): Promise<readonly ReactionUser[] | null> {
|
||||
const key = cacheKey(messageId, emoji);
|
||||
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const existing = inFlight.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
const activeFetcher = fetcher;
|
||||
if (activeFetcher === null) return Promise.resolve(null);
|
||||
|
||||
const promise = activeFetcher(channelId, messageId, emoji).then(
|
||||
(users) => {
|
||||
// A concurrent invalidation dropped this key: the response describes a
|
||||
// state that has already changed, so it must not repopulate the cache.
|
||||
if (inFlight.get(key) === promise) {
|
||||
cache.set(key, users);
|
||||
}
|
||||
return users;
|
||||
},
|
||||
(err: unknown) => {
|
||||
log.warn("failed to load reaction users", {
|
||||
messageId,
|
||||
emoji,
|
||||
error: String(err),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
inFlight.set(key, promise);
|
||||
void promise.finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* "A", "A and B", "A, B and C", "A, B, C and 4 others".
|
||||
*
|
||||
* `totalCount` is the pill's count, which can exceed the fetched list (the
|
||||
* server caps it at 100) — the overflow phrasing is driven by it so a pill
|
||||
* reading 250 does not claim only 100 people reacted.
|
||||
*/
|
||||
export function formatReactorNames(
|
||||
usernames: readonly string[],
|
||||
totalCount = usernames.length,
|
||||
): string {
|
||||
if (usernames.length === 0) return "";
|
||||
|
||||
const total = Math.max(totalCount, usernames.length);
|
||||
const shown = usernames.slice(0, MAX_NAMES);
|
||||
const others = total - shown.length;
|
||||
|
||||
if (others > 0) {
|
||||
return `${shown.join(", ")} and ${others} ${others === 1 ? "other" : "others"}`;
|
||||
}
|
||||
if (shown.length === 1) return shown[0]!;
|
||||
return `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]!}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tooltip DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build the tooltip body. Text only — usernames are user-controlled, so they
|
||||
* go in via textContent, never markup. */
|
||||
export function buildReactionTooltip(
|
||||
emoji: string,
|
||||
users: readonly ReactionUser[],
|
||||
totalCount: number,
|
||||
): HTMLDivElement {
|
||||
const tip = createElement("div", {
|
||||
class: "reaction-tooltip",
|
||||
role: "tooltip",
|
||||
"data-testid": "reaction-tooltip",
|
||||
});
|
||||
const names = createElement("span", { class: "reaction-tooltip-names" });
|
||||
setText(
|
||||
names,
|
||||
formatReactorNames(
|
||||
users.map((u) => u.username),
|
||||
totalCount,
|
||||
),
|
||||
);
|
||||
const reacted = createElement("span", { class: "reaction-tooltip-emoji" });
|
||||
setText(reacted, `reacted with ${emoji}`);
|
||||
appendChildren(tip, names, reacted);
|
||||
return tip;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hover wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReactionTooltipTarget {
|
||||
readonly channelId: number;
|
||||
readonly messageId: number;
|
||||
readonly emoji: string;
|
||||
/** The pill's displayed count, used for the "and N others" tail. */
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
interface HoverState {
|
||||
timer: number;
|
||||
/** Bumped on every hide so a late fetch cannot show a stale tooltip. */
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const hoverStates = new WeakMap<HTMLElement, HoverState>();
|
||||
|
||||
function removeTooltip(chip: HTMLElement): void {
|
||||
chip.querySelector(".reaction-tooltip")?.remove();
|
||||
}
|
||||
|
||||
function hide(chip: HTMLElement): void {
|
||||
const state = hoverStates.get(chip);
|
||||
if (state !== undefined) {
|
||||
clearTimeout(state.timer);
|
||||
state.generation += 1;
|
||||
}
|
||||
removeTooltip(chip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach who-reacted hover behaviour to a reaction pill. Listeners are removed
|
||||
* with the message list's AbortSignal; the debounce timer is cleared on
|
||||
* mouseleave/focusout and on abort.
|
||||
*/
|
||||
export function attachReactionTooltip(
|
||||
chip: HTMLElement,
|
||||
target: ReactionTooltipTarget,
|
||||
signal: AbortSignal,
|
||||
): void {
|
||||
const show = (): void => {
|
||||
const state = hoverStates.get(chip);
|
||||
if (state === undefined) return;
|
||||
const generation = state.generation;
|
||||
|
||||
void loadReactionUsers(target.channelId, target.messageId, target.emoji).then((users) => {
|
||||
if (users === null || users.length === 0) return;
|
||||
// The pointer left (or the row was rebuilt) while the fetch was in
|
||||
// flight — do not pop a tooltip nobody is hovering.
|
||||
const current = hoverStates.get(chip);
|
||||
if (current === undefined || current.generation !== generation) return;
|
||||
if (!chip.isConnected) return;
|
||||
removeTooltip(chip);
|
||||
chip.appendChild(buildReactionTooltip(target.emoji, [...users], target.count));
|
||||
});
|
||||
};
|
||||
|
||||
const start = (): void => {
|
||||
hide(chip);
|
||||
const existing = hoverStates.get(chip);
|
||||
const generation = existing === undefined ? 0 : existing.generation;
|
||||
const timer = window.setTimeout(show, REACTION_TOOLTIP_DEBOUNCE_MS);
|
||||
hoverStates.set(chip, { timer, generation });
|
||||
};
|
||||
|
||||
const stop = (): void => hide(chip);
|
||||
|
||||
chip.addEventListener("mouseenter", start, { signal });
|
||||
chip.addEventListener("mouseleave", stop, { signal });
|
||||
// Keyboard accessibility: focus mirrors hover.
|
||||
chip.addEventListener("focusin", start, { signal });
|
||||
chip.addEventListener("focusout", stop, { signal });
|
||||
|
||||
signal.addEventListener("abort", () => hide(chip));
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
import { createElement } from "@lib/dom";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
import type { MessageListOptions } from "../MessageList";
|
||||
import { attachReactionTooltip } from "./reaction-tooltip";
|
||||
import { buildCustomEmojiNode } from "./custom-emoji";
|
||||
|
||||
// -- Reaction rendering -------------------------------------------------------
|
||||
|
||||
@@ -17,12 +19,30 @@ export function renderReactions(
|
||||
for (const reaction of msg.reactions) {
|
||||
const chip = createElement("span", {
|
||||
class: reaction.me ? "reaction-chip me" : "reaction-chip",
|
||||
// Focusable so the who-reacted tooltip is reachable without a pointer.
|
||||
tabindex: "0",
|
||||
"data-emoji": reaction.emoji,
|
||||
});
|
||||
const emoji = document.createTextNode(reaction.emoji);
|
||||
// Reaction strings are free-form, so a custom reaction is stored as the
|
||||
// literal ":shortcode:" text. Render the image when that resolves; when it
|
||||
// does not (the emoji was deleted, or the reaction predates it) the plain
|
||||
// text is exactly what the reaction is, and toggling it still works.
|
||||
const emoji: Node =
|
||||
buildCustomEmojiNode(reaction.emoji) ?? document.createTextNode(reaction.emoji);
|
||||
const count = createElement("span", { class: "rc-count" }, String(reaction.count));
|
||||
chip.appendChild(emoji);
|
||||
chip.appendChild(count);
|
||||
chip.addEventListener("click", () => opts.onReactionClick(msg.id, reaction.emoji), { signal });
|
||||
attachReactionTooltip(
|
||||
chip,
|
||||
{
|
||||
channelId: msg.channelId,
|
||||
messageId: msg.id,
|
||||
emoji: reaction.emoji,
|
||||
count: reaction.count,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
container.appendChild(chip);
|
||||
}
|
||||
const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+");
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createIcon } from "@lib/icons";
|
||||
import { loadPref } from "@lib/preferences";
|
||||
import { canManageMessages } from "@lib/permissions";
|
||||
import { showToast } from "@lib/toast";
|
||||
import { formatMessageLink } from "@lib/deep-link";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
import type { MessageListOptions } from "../MessageList";
|
||||
|
||||
@@ -47,8 +48,10 @@ export { setServerHost } from "./attachments";
|
||||
// -- Imports for composite functions ------------------------------------------
|
||||
|
||||
import { formatTime, formatFullDate, formatMessageTimestamp } from "./formatting";
|
||||
import { getUserRole, roleColorVar } from "./formatting";
|
||||
import { getUserRole, resolveAuthor, roleColorVar } from "./formatting";
|
||||
import { createAvatarElement, resolveDisplayName } from "@lib/avatar";
|
||||
import { renderMentions, renderMessageContent } from "./content-parser";
|
||||
import { highlightsCurrentUser } from "@lib/mentions";
|
||||
import { renderUrlEmbeds } from "./media";
|
||||
import { renderAttachment } from "./attachments";
|
||||
import { renderReactions } from "./reactions";
|
||||
@@ -66,24 +69,70 @@ export function renderDayDivider(iso: string): HTMLDivElement {
|
||||
return divider;
|
||||
}
|
||||
|
||||
function renderReplyRef(replyToId: number, allMessages: readonly Message[]): HTMLDivElement {
|
||||
/**
|
||||
* The "NEW" line above the first message the reader has not seen. Built exactly
|
||||
* like the day divider — same rule/label/rule shape — so the two read as one
|
||||
* family; only the accent colour distinguishes them.
|
||||
*/
|
||||
export function renderNewDivider(): HTMLDivElement {
|
||||
const divider = createElement("div", {
|
||||
class: "msg-new-divider",
|
||||
role: "separator",
|
||||
"data-testid": "new-messages-divider",
|
||||
});
|
||||
appendChildren(
|
||||
divider,
|
||||
createElement("span", { class: "line" }),
|
||||
createElement("span", { class: "label" }, "NEW"),
|
||||
createElement("span", { class: "line" }),
|
||||
);
|
||||
return divider;
|
||||
}
|
||||
|
||||
/**
|
||||
* The quoted bar above a reply. Clicking it jumps to the replied-to message —
|
||||
* including when that message is outside the loaded window, which is why the
|
||||
* bar stays clickable even in the "unknown message" case: the id is known, and
|
||||
* the jump path can fetch the window around it.
|
||||
*/
|
||||
function renderReplyRef(
|
||||
replyToId: number,
|
||||
allMessages: readonly Message[],
|
||||
opts: MessageListOptions,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const ref = allMessages.find((m) => m.id === replyToId);
|
||||
const bar = createElement("div", { class: "msg-reply-ref" });
|
||||
const bar = createElement("div", {
|
||||
class: "msg-reply-ref",
|
||||
role: "button",
|
||||
tabindex: "0",
|
||||
"data-reply-to": String(replyToId),
|
||||
title: "Jump to the replied-to message",
|
||||
});
|
||||
const jump = (): void => opts.onJumpToMessage?.(replyToId);
|
||||
bar.addEventListener("click", jump, { signal });
|
||||
bar.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
jump();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (ref) {
|
||||
const preview = ref.deleted ? "[message deleted]" : ref.content.slice(0, 100);
|
||||
const role = getUserRole(ref.user.id);
|
||||
const miniAvatar = createElement(
|
||||
"div",
|
||||
{
|
||||
class: "rr-avatar",
|
||||
style: `background: ${roleColorVar(role)}`,
|
||||
},
|
||||
ref.user.username.charAt(0).toUpperCase(),
|
||||
);
|
||||
const author = resolveAuthor(ref.user);
|
||||
const miniAvatar = createAvatarElement(author, {
|
||||
className: "rr-avatar",
|
||||
background: roleColorVar(role),
|
||||
});
|
||||
appendChildren(
|
||||
bar,
|
||||
miniAvatar,
|
||||
createElement("span", { class: "rr-author" }, ref.user.username),
|
||||
createElement("span", { class: "rr-author" }, resolveDisplayName(author)),
|
||||
createElement("span", { class: "rr-text" }, preview),
|
||||
);
|
||||
} else {
|
||||
@@ -136,21 +185,23 @@ export function renderMessage(
|
||||
|
||||
const statusClass =
|
||||
msg.status === "pending" ? " pending" : msg.status === "failed" ? " failed" : "";
|
||||
const mentionInfo = { mentions: msg.mentions, mentionsEveryone: msg.mentionsEveryone };
|
||||
// A deleted row shows no content, so it must not keep the mention accent.
|
||||
const mentionedClass =
|
||||
!msg.deleted && highlightsCurrentUser(msg.content, mentionInfo) ? " mentioned" : "";
|
||||
const el = createElement("div", {
|
||||
class: (isGrouped ? "message grouped" : "message") + statusClass,
|
||||
class: (isGrouped ? "message grouped" : "message") + statusClass + mentionedClass,
|
||||
"data-testid": `message-${msg.id}`,
|
||||
});
|
||||
|
||||
const role = getUserRole(msg.user.id);
|
||||
const initial = msg.user.username.charAt(0).toUpperCase();
|
||||
const avatar = createElement(
|
||||
"div",
|
||||
{
|
||||
class: "msg-avatar",
|
||||
style: `background: ${roleColorVar(role)}`,
|
||||
},
|
||||
initial,
|
||||
);
|
||||
// The author's current identity, not the one frozen into the payload: a
|
||||
// rename or a new avatar has to show up on the messages already on screen.
|
||||
const author = resolveAuthor(msg.user);
|
||||
const avatar = createAvatarElement(author, {
|
||||
className: "msg-avatar",
|
||||
background: roleColorVar(role),
|
||||
});
|
||||
el.appendChild(avatar);
|
||||
|
||||
if (isGrouped) {
|
||||
@@ -166,24 +217,27 @@ export function renderMessage(
|
||||
}
|
||||
|
||||
if (msg.replyTo !== null) {
|
||||
el.appendChild(renderReplyRef(msg.replyTo, allMessages));
|
||||
el.appendChild(renderReplyRef(msg.replyTo, allMessages, opts, signal));
|
||||
}
|
||||
|
||||
const header = createElement("div", { class: "msg-header" });
|
||||
const author = createElement(
|
||||
const authorEl = createElement(
|
||||
"span",
|
||||
{
|
||||
class: "msg-author",
|
||||
// The username stays as the title so the handle you would @mention is
|
||||
// one hover away even when a display name is standing in for it.
|
||||
title: author.username,
|
||||
style: `color: ${roleColorVar(role)}`,
|
||||
},
|
||||
msg.user.username,
|
||||
resolveDisplayName(author),
|
||||
);
|
||||
const time = createElement(
|
||||
"span",
|
||||
{ class: "msg-time", title: formatFullDate(msg.timestamp) },
|
||||
formatMessageTimestamp(msg.timestamp),
|
||||
);
|
||||
appendChildren(header, author, time);
|
||||
appendChildren(header, authorEl, time);
|
||||
el.appendChild(header);
|
||||
|
||||
if (msg.deleted) {
|
||||
@@ -193,7 +247,7 @@ export function renderMessage(
|
||||
setText(text, "[message deleted]");
|
||||
el.appendChild(text);
|
||||
} else {
|
||||
el.appendChild(renderMessageContent(msg.content));
|
||||
el.appendChild(renderMessageContent(msg.content, mentionInfo));
|
||||
if (msg.editedAt !== null) {
|
||||
el.appendChild(createElement("span", { class: "msg-edited" }, "(edited)"));
|
||||
}
|
||||
@@ -293,6 +347,26 @@ export function renderMessage(
|
||||
actionsBar.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
const copyLinkBtn = createElement("button", {
|
||||
"data-testid": `msg-copy-link-${msg.id}`,
|
||||
"aria-label": "Copy Message Link",
|
||||
});
|
||||
copyLinkBtn.appendChild(createIcon("link", 16));
|
||||
copyLinkBtn.title = "Copy Message Link";
|
||||
copyLinkBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
// No silent success: a copy with no feedback is indistinguishable
|
||||
// from a clipboard that refused.
|
||||
void navigator.clipboard.writeText(formatMessageLink(msg.channelId, msg.id)).then(
|
||||
() => showToast("Message link copied", "success"),
|
||||
() => showToast("Couldn't copy the message link", "error"),
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
actionsBar.appendChild(copyLinkBtn);
|
||||
|
||||
if (developerModeEnabled) {
|
||||
const copyIdBtn = createElement("button", {
|
||||
"data-testid": `msg-copy-id-${msg.id}`,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* A small, dependency-free syntax highlighter for fenced code blocks.
|
||||
*
|
||||
* The client ships no highlighting library (checked against package.json) and
|
||||
* a message list is the wrong place to pull one in, so this is a deliberately
|
||||
* shallow tokenizer: comments, strings, numbers and keywords — the four things
|
||||
* that make code scannable — and nothing that pretends to be a parser.
|
||||
*
|
||||
* Pure and DOM-free; the renderer turns tokens into spans.
|
||||
*/
|
||||
|
||||
export type TokenClass = "keyword" | "string" | "comment" | "number";
|
||||
|
||||
export interface CodeToken {
|
||||
readonly text: string;
|
||||
/** null renders as plain text. */
|
||||
readonly cls: TokenClass | null;
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
/** Sticky: matched only at the current index. */
|
||||
readonly re: RegExp;
|
||||
readonly cls: TokenClass;
|
||||
}
|
||||
|
||||
interface LangSpec {
|
||||
readonly patterns: readonly Pattern[];
|
||||
readonly keywords: ReadonlySet<string>;
|
||||
readonly ident?: RegExp;
|
||||
}
|
||||
|
||||
const kw = (words: string): ReadonlySet<string> => new Set(words.split(" "));
|
||||
|
||||
// -- Shared token patterns ----------------------------------------------------
|
||||
|
||||
const SLASH_LINE_COMMENT: Pattern = { re: /\/\/[^\n]*/y, cls: "comment" };
|
||||
const C_BLOCK_COMMENT: Pattern = { re: /\/\*[\s\S]*?(?:\*\/|$)/y, cls: "comment" };
|
||||
const HASH_COMMENT: Pattern = { re: /#[^\n]*/y, cls: "comment" };
|
||||
const DQ_STRING: Pattern = { re: /"(?:\\.|[^"\\\n])*"?/y, cls: "string" };
|
||||
const SQ_STRING: Pattern = { re: /'(?:\\.|[^'\\\n])*'?/y, cls: "string" };
|
||||
const BACKTICK_STRING: Pattern = { re: /`(?:\\.|[^`\\])*`?/y, cls: "string" };
|
||||
const NUMBER: Pattern = {
|
||||
re: /(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)[a-zA-Z_]*/y,
|
||||
cls: "number",
|
||||
};
|
||||
|
||||
const JS_LIKE: readonly Pattern[] = [
|
||||
SLASH_LINE_COMMENT,
|
||||
C_BLOCK_COMMENT,
|
||||
DQ_STRING,
|
||||
SQ_STRING,
|
||||
BACKTICK_STRING,
|
||||
NUMBER,
|
||||
];
|
||||
|
||||
const LANGS: Readonly<Record<string, LangSpec>> = {
|
||||
javascript: {
|
||||
patterns: JS_LIKE,
|
||||
keywords: kw(
|
||||
"const let var function return if else for while do break continue class extends new this " +
|
||||
"typeof instanceof in of null undefined true false async await import export from default " +
|
||||
"try catch finally throw switch case yield static get set delete void super",
|
||||
),
|
||||
},
|
||||
typescript: {
|
||||
patterns: JS_LIKE,
|
||||
keywords: kw(
|
||||
"const let var function return if else for while do break continue class extends new this " +
|
||||
"typeof instanceof in of null undefined true false async await import export from default " +
|
||||
"try catch finally throw switch case yield static get set delete void super " +
|
||||
"interface type enum implements public private protected readonly as satisfies keyof " +
|
||||
"namespace declare abstract infer never unknown any string number boolean",
|
||||
),
|
||||
},
|
||||
go: {
|
||||
patterns: [SLASH_LINE_COMMENT, C_BLOCK_COMMENT, DQ_STRING, BACKTICK_STRING, SQ_STRING, NUMBER],
|
||||
keywords: kw(
|
||||
"func package import var const type struct interface map chan go defer select switch case " +
|
||||
"if else for range return break continue fallthrough default goto nil true false iota " +
|
||||
"make new len cap append copy delete panic recover string int int8 int16 int32 int64 uint " +
|
||||
"uint8 uint16 uint32 uint64 float32 float64 bool byte rune error any",
|
||||
),
|
||||
},
|
||||
python: {
|
||||
patterns: [
|
||||
HASH_COMMENT,
|
||||
{ re: /(?:"""[\s\S]*?"""|'''[\s\S]*?''')/y, cls: "string" },
|
||||
{ re: /[rbfu]{0,2}"(?:\\.|[^"\\\n])*"?/y, cls: "string" },
|
||||
{ re: /[rbfu]{0,2}'(?:\\.|[^'\\\n])*'?/y, cls: "string" },
|
||||
NUMBER,
|
||||
],
|
||||
keywords: kw(
|
||||
"def class return if elif else for while import from as pass break continue try except " +
|
||||
"finally raise with lambda None True False and or not in is global nonlocal yield async " +
|
||||
"await del assert self print len range str int float bool list dict set tuple",
|
||||
),
|
||||
},
|
||||
rust: {
|
||||
patterns: [
|
||||
SLASH_LINE_COMMENT,
|
||||
C_BLOCK_COMMENT,
|
||||
{ re: /r#*"[\s\S]*?"#*/y, cls: "string" },
|
||||
DQ_STRING,
|
||||
{ re: /'(?:\\.|[^'\\\n])'/y, cls: "string" },
|
||||
NUMBER,
|
||||
],
|
||||
keywords: kw(
|
||||
"fn let mut const static struct enum impl trait use pub mod match if else for while loop " +
|
||||
"return break continue as where type dyn ref move unsafe crate in true false self Self " +
|
||||
"async await Some None Ok Err String Vec Option Result Box i8 i16 i32 i64 u8 u16 u32 u64 " +
|
||||
"usize isize f32 f64 bool str char",
|
||||
),
|
||||
},
|
||||
json: {
|
||||
patterns: [DQ_STRING, NUMBER],
|
||||
keywords: kw("true false null"),
|
||||
},
|
||||
bash: {
|
||||
patterns: [
|
||||
HASH_COMMENT,
|
||||
DQ_STRING,
|
||||
SQ_STRING,
|
||||
{ re: /\$(?:\{[^}\n]*\}|[A-Za-z_][A-Za-z0-9_]*|[0-9?@#*])/y, cls: "keyword" },
|
||||
NUMBER,
|
||||
],
|
||||
keywords: kw(
|
||||
"if then else elif fi for while until do done case esac function return in export local " +
|
||||
"readonly source alias unset shift trap eval exec set echo cd exit sudo apt npm git make",
|
||||
),
|
||||
},
|
||||
css: {
|
||||
patterns: [
|
||||
C_BLOCK_COMMENT,
|
||||
DQ_STRING,
|
||||
SQ_STRING,
|
||||
{ re: /@[-a-zA-Z]+/y, cls: "keyword" },
|
||||
{ re: /!important/y, cls: "keyword" },
|
||||
{ re: /[-a-zA-Z]+(?= *:)/y, cls: "keyword" },
|
||||
{ re: /#[0-9a-fA-F]{3,8}\b/y, cls: "number" },
|
||||
{
|
||||
re: /-?\d[\d.]*(?:px|em|rem|ex|ch|%|vh|vw|vmin|vmax|s|ms|deg|turn|fr|pt)?/y,
|
||||
cls: "number",
|
||||
},
|
||||
],
|
||||
keywords: kw(""),
|
||||
},
|
||||
html: {
|
||||
patterns: [
|
||||
{ re: /<!--[\s\S]*?(?:-->|$)/y, cls: "comment" },
|
||||
{ re: /<!DOCTYPE[^>\n]*>?/iy, cls: "keyword" },
|
||||
{ re: /<\/?[A-Za-z][\w:-]*/y, cls: "keyword" },
|
||||
DQ_STRING,
|
||||
SQ_STRING,
|
||||
],
|
||||
keywords: kw(""),
|
||||
},
|
||||
};
|
||||
|
||||
/** Aliases accepted after the opening fence. */
|
||||
const ALIASES: Readonly<Record<string, string>> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
node: "javascript",
|
||||
javascript: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
typescript: "typescript",
|
||||
go: "go",
|
||||
golang: "go",
|
||||
py: "python",
|
||||
python: "python",
|
||||
python3: "python",
|
||||
rs: "rust",
|
||||
rust: "rust",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
shell: "bash",
|
||||
console: "bash",
|
||||
css: "css",
|
||||
scss: "css",
|
||||
html: "html",
|
||||
xml: "html",
|
||||
svg: "html",
|
||||
};
|
||||
|
||||
const DEFAULT_IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/y;
|
||||
|
||||
/** Canonical language id for a fence tag, or null when unknown. */
|
||||
export function resolveLanguage(tag: string | null): string | null {
|
||||
if (tag === null) return null;
|
||||
return ALIASES[tag.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize `code` for `lang` (a canonical id from {@link resolveLanguage}).
|
||||
* Unknown languages return a single plain token, so the caller never has to
|
||||
* branch on support.
|
||||
*/
|
||||
export function highlightCode(code: string, lang: string | null): CodeToken[] {
|
||||
const spec = lang === null ? undefined : LANGS[lang];
|
||||
if (spec === undefined) return code.length > 0 ? [{ text: code, cls: null }] : [];
|
||||
|
||||
const tokens: CodeToken[] = [];
|
||||
let plain = "";
|
||||
const flush = (): void => {
|
||||
if (plain.length > 0) {
|
||||
tokens.push({ text: plain, cls: null });
|
||||
plain = "";
|
||||
}
|
||||
};
|
||||
const ident = spec.ident ?? DEFAULT_IDENT;
|
||||
|
||||
let i = 0;
|
||||
outer: while (i < code.length) {
|
||||
for (const p of spec.patterns) {
|
||||
p.re.lastIndex = i;
|
||||
const m = p.re.exec(code);
|
||||
if (m !== null && m[0].length > 0) {
|
||||
flush();
|
||||
tokens.push({ text: m[0], cls: p.cls });
|
||||
i += m[0].length;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
|
||||
ident.lastIndex = i;
|
||||
const word = ident.exec(code);
|
||||
if (word !== null && word[0].length > 0) {
|
||||
if (spec.keywords.has(word[0])) {
|
||||
flush();
|
||||
tokens.push({ text: word[0], cls: "keyword" });
|
||||
} else {
|
||||
plain += word[0];
|
||||
}
|
||||
i += word[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
plain += code[i]!;
|
||||
i++;
|
||||
}
|
||||
|
||||
flush();
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared "Purge Messages" context-menu section — the count prompt used by both
|
||||
* channel menus (the sidebar right-click menu and AdminActions'
|
||||
* createChannelContextMenu). The two menus predate each other and use different
|
||||
* class conventions, so the caller supplies the class names; the clamp, the
|
||||
* confirm step and the in-flight state live here once.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
|
||||
/** Server-side bounds on one purge request (docs/api.md). */
|
||||
export const PURGE_MIN_COUNT = 1;
|
||||
export const PURGE_MAX_COUNT = 100;
|
||||
export const PURGE_DEFAULT_COUNT = 50;
|
||||
|
||||
export interface PurgeSectionOptions {
|
||||
/** Class for ordinary rows in the host menu. */
|
||||
readonly itemClass: string;
|
||||
/** Class for the destructive confirm row. */
|
||||
readonly dangerItemClass: string;
|
||||
/** Class for the separator above the section, or "" to omit the separator. */
|
||||
readonly separatorClass: string;
|
||||
/** Runs the purge. Rejections are swallowed by the caller's toast handling. */
|
||||
readonly onPurge: (count: number) => void | Promise<void>;
|
||||
/** Aborts the section's listeners when the host menu is torn down. */
|
||||
readonly signal: AbortSignal;
|
||||
/** Called once the purge settles, so the host can close itself. */
|
||||
readonly onDone?: () => void;
|
||||
}
|
||||
|
||||
/** Clamp a raw input value into the server's accepted range. */
|
||||
export function clampPurgeCount(raw: string): number {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed)) return PURGE_DEFAULT_COUNT;
|
||||
return Math.min(PURGE_MAX_COUNT, Math.max(PURGE_MIN_COUNT, parsed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the trigger row plus its hidden count prompt to `menu`. The prompt is
|
||||
* revealed in place (rather than opening a modal) so the menu's outside-click
|
||||
* dismissal keeps working; typing in the input does not close it.
|
||||
*/
|
||||
export function appendPurgeSection(menu: HTMLElement, opts: PurgeSectionOptions): void {
|
||||
const { signal } = opts;
|
||||
|
||||
if (opts.separatorClass !== "") {
|
||||
menu.appendChild(createElement("div", { class: opts.separatorClass }));
|
||||
}
|
||||
|
||||
const trigger = createElement(
|
||||
"div",
|
||||
{ class: opts.itemClass, "data-testid": "ctx-purge-messages" },
|
||||
"Purge Messages…",
|
||||
);
|
||||
|
||||
const form = createElement("div", {
|
||||
class: "context-menu__reason",
|
||||
style: "display:none;padding:6px 8px",
|
||||
"data-testid": "purge-form",
|
||||
});
|
||||
const countInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "number",
|
||||
min: String(PURGE_MIN_COUNT),
|
||||
max: String(PURGE_MAX_COUNT),
|
||||
value: String(PURGE_DEFAULT_COUNT),
|
||||
"data-testid": "purge-count-input",
|
||||
style: "width:100%;font-size:12px",
|
||||
});
|
||||
const hint = createElement(
|
||||
"div",
|
||||
{ style: "font-size:11px;color:var(--text-muted);margin-top:4px" },
|
||||
`Deletes the newest ${PURGE_MIN_COUNT}–${PURGE_MAX_COUNT} messages.`,
|
||||
);
|
||||
const confirm = createElement(
|
||||
"div",
|
||||
{ class: opts.dangerItemClass, "data-testid": "purge-confirm" },
|
||||
"Confirm Purge",
|
||||
);
|
||||
appendChildren(form, countInput, hint, confirm);
|
||||
|
||||
trigger.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
trigger.style.display = "none";
|
||||
form.style.display = "";
|
||||
countInput.focus();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Typing a count must not trip the host menu's outside-click dismissal.
|
||||
for (const event of ["click", "mousedown"] as const) {
|
||||
countInput.addEventListener(event, (e: Event) => e.stopPropagation(), { signal });
|
||||
}
|
||||
|
||||
let running = false;
|
||||
function submit(): void {
|
||||
if (running) return;
|
||||
running = true;
|
||||
setText(confirm, "Purging…");
|
||||
const done = (): void => {
|
||||
running = false;
|
||||
setText(confirm, "Confirm Purge");
|
||||
opts.onDone?.();
|
||||
};
|
||||
const result = opts.onPurge(clampPurgeCount(countInput.value));
|
||||
if (result instanceof Promise) {
|
||||
void result.then(done, done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
confirm.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
submit();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
countInput.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
appendChildren(menu, trigger, form);
|
||||
}
|
||||
@@ -8,8 +8,19 @@ import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { loadUserStatus, saveUserStatus } from "@lib/userStatus";
|
||||
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments";
|
||||
import type { SettingsOverlayOptions } from "../SettingsOverlay";
|
||||
|
||||
/** Mirrors the server's caps so the form can bound itself instead of learning
|
||||
* about the limits from a rejected request. */
|
||||
const MAX_DISPLAY_NAME_LEN = 32;
|
||||
const MAX_ABOUT_LEN = 300;
|
||||
/** Mirrors maxAvatarFileBytes / maxAvatarDimension on the server. */
|
||||
const MAX_AVATAR_BYTES = 1024 * 1024;
|
||||
const MAX_AVATAR_DIMENSION = 1024;
|
||||
const ACCEPTED_AVATAR_TYPES = "image/png,image/jpeg,image/webp";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -20,13 +31,15 @@ interface ProfileCardResult {
|
||||
readonly usernameValue: HTMLDivElement;
|
||||
readonly editUserProfileBtn: HTMLButtonElement;
|
||||
readonly editUsernameBtn: HTMLButtonElement;
|
||||
/** The big avatar; the uploader swaps its contents on success. */
|
||||
readonly avatarLarge: HTMLDivElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile card builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildProfileCard(username: string): ProfileCardResult {
|
||||
function buildProfileCard(displayName: string, username: string): ProfileCardResult {
|
||||
const card = createElement("div", { class: "account-card" });
|
||||
const banner = createElement("div", { class: "account-banner" });
|
||||
|
||||
@@ -34,15 +47,15 @@ function buildProfileCard(username: string): ProfileCardResult {
|
||||
const avatarWrap = createElement("div", { class: "account-avatar-wrap" });
|
||||
const avatarLarge = createElement(
|
||||
"div",
|
||||
{ class: "account-avatar-large" },
|
||||
username.charAt(0).toUpperCase(),
|
||||
{ class: "account-avatar-large", "data-testid": "account-avatar" },
|
||||
avatarInitial({ username, displayName }),
|
||||
);
|
||||
const statusDot = createElement("div", { class: "account-status-dot" });
|
||||
appendChildren(avatarWrap, avatarLarge, statusDot);
|
||||
|
||||
// Header row
|
||||
const accountHeader = createElement("div", { class: "account-header" });
|
||||
const headerName = createElement("div", { class: "account-header-name" }, username);
|
||||
const headerName = createElement("div", { class: "account-header-name" }, displayName);
|
||||
const editUserProfileBtn = createElement("button", { class: "ac-btn" }, "Edit User Profile");
|
||||
appendChildren(accountHeader, headerName, editUserProfileBtn);
|
||||
|
||||
@@ -59,7 +72,247 @@ function buildProfileCard(username: string): ProfileCardResult {
|
||||
|
||||
appendChildren(card, banner, avatarWrap, accountHeader, fieldsContainer);
|
||||
|
||||
return { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn };
|
||||
return { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn, avatarLarge };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Avatar preview + uploader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Draw `url` into the big avatar, replacing the letter. Falls back to the
|
||||
* letter when there is nothing to draw or the fetch fails, because the file
|
||||
* route is authenticated and `<img src>` cannot carry the session token.
|
||||
*/
|
||||
function paintAvatar(
|
||||
target: HTMLDivElement,
|
||||
url: string | null,
|
||||
alt: string,
|
||||
initial: string,
|
||||
): void {
|
||||
const showInitial = (): void => {
|
||||
target.replaceChildren(document.createTextNode(initial));
|
||||
target.style.background = "";
|
||||
};
|
||||
if (url === null) {
|
||||
showInitial();
|
||||
return;
|
||||
}
|
||||
void fetchImageAsDataUrl(url).then((dataUrl) => {
|
||||
if (dataUrl === null || !target.isConnected) return;
|
||||
const img = createElement("img", { class: "avatar-img", src: dataUrl, alt });
|
||||
target.replaceChildren(img);
|
||||
target.style.background = "transparent";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a File into an object URL and measure it, so an image the server would
|
||||
* refuse is caught before a megabyte goes over the wire — and so the preview
|
||||
* shows what was actually picked rather than a spinner that ends in a 400.
|
||||
*/
|
||||
function measureImage(file: File): Promise<{ width: number; height: number } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
img.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Local validation mirroring the server's rules. Returns an error message. */
|
||||
export function validateAvatarFile(
|
||||
file: { size: number; type: string },
|
||||
dimensions: { width: number; height: number } | null,
|
||||
): string | null {
|
||||
if (!ACCEPTED_AVATAR_TYPES.split(",").includes(file.type)) {
|
||||
return "Avatar must be a PNG, JPEG or WebP image.";
|
||||
}
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
return `Avatar must be at most ${MAX_AVATAR_BYTES / 1024} KB.`;
|
||||
}
|
||||
if (dimensions === null) {
|
||||
return "That file could not be read as an image.";
|
||||
}
|
||||
if (dimensions.width > MAX_AVATAR_DIMENSION || dimensions.height > MAX_AVATAR_DIMENSION) {
|
||||
return `Avatar must be at most ${MAX_AVATAR_DIMENSION}x${MAX_AVATAR_DIMENSION} pixels.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAvatarUploader(
|
||||
options: SettingsOverlayOptions,
|
||||
avatarLarge: HTMLDivElement,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const wrapper = createElement("div", { class: "account-avatar-upload" });
|
||||
const input = createElement("input", {
|
||||
type: "file",
|
||||
accept: ACCEPTED_AVATAR_TYPES,
|
||||
style: "display:none",
|
||||
"data-testid": "avatar-file-input",
|
||||
});
|
||||
const uploadBtn = createElement(
|
||||
"button",
|
||||
{ class: "ac-btn", "data-testid": "avatar-upload-btn" },
|
||||
"Change Avatar",
|
||||
);
|
||||
const errorEl = createElement("div", {
|
||||
style: "color:var(--red);font-size:13px;margin-top:6px",
|
||||
"data-testid": "avatar-error",
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener("click", () => input.click(), { signal });
|
||||
|
||||
input.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
const file = input.files?.[0];
|
||||
if (file === undefined) return;
|
||||
setText(errorEl, "");
|
||||
void (async () => {
|
||||
const dimensions = await measureImage(file);
|
||||
const problem = validateAvatarFile(file, dimensions);
|
||||
if (problem !== null) {
|
||||
setText(errorEl, problem);
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
setText(uploadBtn, "Uploading...");
|
||||
try {
|
||||
const url = await options.onUploadAvatar(file);
|
||||
const user = authStore.getState().user;
|
||||
paintAvatar(
|
||||
avatarLarge,
|
||||
resolveServerUrl(url),
|
||||
user?.username ?? "avatar",
|
||||
avatarInitial({
|
||||
username: user?.username ?? "?",
|
||||
displayName: user?.display_name ?? null,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
setText(errorEl, err instanceof Error ? err.message : "Failed to upload avatar.");
|
||||
} finally {
|
||||
input.value = "";
|
||||
uploadBtn.disabled = false;
|
||||
setText(uploadBtn, "Change Avatar");
|
||||
}
|
||||
})();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
appendChildren(wrapper, input, uploadBtn, errorEl);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display name + about
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildProfileFields(
|
||||
options: SettingsOverlayOptions,
|
||||
onSaved: (displayName: string) => void,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const wrapper = createElement("div", {});
|
||||
const separator = createElement("div", { class: "settings-separator" });
|
||||
const header = createElement("div", { class: "settings-section-title" }, "Profile");
|
||||
|
||||
const user = authStore.getState().user;
|
||||
|
||||
const nameLabel = createElement("div", { class: "account-field-label" }, "Display Name");
|
||||
const nameInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: "Shown instead of your username",
|
||||
maxlength: String(MAX_DISPLAY_NAME_LEN),
|
||||
style: "margin-bottom:12px",
|
||||
"data-testid": "display-name-input",
|
||||
});
|
||||
nameInput.value = user?.display_name ?? "";
|
||||
|
||||
const aboutLabel = createElement("div", { class: "account-field-label" }, "About Me");
|
||||
const aboutInput = createElement("textarea", {
|
||||
class: "form-input",
|
||||
rows: "3",
|
||||
placeholder: "A little about you",
|
||||
maxlength: String(MAX_ABOUT_LEN),
|
||||
style: "margin-bottom:8px;resize:vertical",
|
||||
"data-testid": "about-input",
|
||||
});
|
||||
aboutInput.value = user?.about ?? "";
|
||||
|
||||
const statusEl = createElement("div", {
|
||||
style: "color:var(--red);font-size:13px;margin-bottom:8px",
|
||||
"data-testid": "profile-error",
|
||||
});
|
||||
const saveBtn = createElement(
|
||||
"button",
|
||||
{ class: "ac-btn", "data-testid": "profile-save-btn" },
|
||||
"Save Profile",
|
||||
);
|
||||
|
||||
saveBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
const displayName = nameInput.value.trim();
|
||||
const about = aboutInput.value.trim();
|
||||
// Both are sent unconditionally, empty string included: "" is how the
|
||||
// API says "clear it", and omitting a field means "leave it alone".
|
||||
statusEl.style.color = "var(--red)";
|
||||
setText(statusEl, "");
|
||||
saveBtn.disabled = true;
|
||||
setText(saveBtn, "Saving...");
|
||||
void options
|
||||
.onUpdateProfile({ display_name: displayName, about })
|
||||
.then(() => {
|
||||
statusEl.style.color = "var(--green)";
|
||||
setText(statusEl, "Profile saved.");
|
||||
onSaved(
|
||||
displayName.length > 0 ? displayName : (authStore.getState().user?.username ?? ""),
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setText(statusEl, err instanceof Error ? err.message : "Failed to save profile.");
|
||||
})
|
||||
.finally(() => {
|
||||
saveBtn.disabled = false;
|
||||
setText(saveBtn, "Save Profile");
|
||||
});
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
appendChildren(
|
||||
wrapper,
|
||||
separator,
|
||||
header,
|
||||
nameLabel,
|
||||
nameInput,
|
||||
aboutLabel,
|
||||
aboutInput,
|
||||
statusEl,
|
||||
saveBtn,
|
||||
);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -580,8 +833,10 @@ const STATUS_OPTIONS: readonly StatusOption[] = [
|
||||
color: "#ed4245",
|
||||
},
|
||||
{
|
||||
value: "offline",
|
||||
label: "Offline",
|
||||
// Its own status now, not "offline" relabeled: the server stores it as
|
||||
// chosen, shows everyone else offline, and honours it across reconnects.
|
||||
value: "invisible",
|
||||
label: "Invisible",
|
||||
description: "You will appear offline but still have full access",
|
||||
color: "#747f8d",
|
||||
},
|
||||
@@ -804,12 +1059,38 @@ export function buildAccountTab(
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const user = authStore.getState().user;
|
||||
const username = user?.username ?? "Unknown";
|
||||
const displayName = resolveDisplayName({
|
||||
username,
|
||||
displayName: user?.display_name ?? null,
|
||||
});
|
||||
|
||||
// Profile card
|
||||
const { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn } =
|
||||
buildProfileCard(username);
|
||||
const { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn, avatarLarge } =
|
||||
buildProfileCard(displayName, username);
|
||||
section.appendChild(card);
|
||||
|
||||
// Existing avatar, if any — the letter is only a fallback now.
|
||||
if (isRenderableAvatar(user?.avatar)) {
|
||||
paintAvatar(
|
||||
avatarLarge,
|
||||
resolveServerUrl(user.avatar),
|
||||
username,
|
||||
avatarInitial({ username, displayName: user?.display_name ?? null }),
|
||||
);
|
||||
}
|
||||
section.appendChild(buildAvatarUploader(options, avatarLarge, signal));
|
||||
|
||||
// Display name + about
|
||||
section.appendChild(
|
||||
buildProfileFields(
|
||||
options,
|
||||
(name) => {
|
||||
setText(headerName, name);
|
||||
},
|
||||
signal,
|
||||
),
|
||||
);
|
||||
|
||||
// Status selector
|
||||
section.appendChild(buildStatusSelector(options, signal));
|
||||
|
||||
@@ -822,6 +1103,7 @@ export function buildAccountTab(
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: "New username",
|
||||
"data-testid": "username-edit-input",
|
||||
});
|
||||
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
|
||||
const cancelBtn = createElement(
|
||||
@@ -864,7 +1146,7 @@ export function buildAccountTab(
|
||||
}
|
||||
setText(usernameError, "");
|
||||
void options
|
||||
.onUpdateProfile(newName)
|
||||
.onUpdateProfile({ username: newName })
|
||||
.then(() => {
|
||||
setText(headerName, newName);
|
||||
setText(usernameValue, newName);
|
||||
|
||||
@@ -176,6 +176,9 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
|
||||
const msgBinds: [string, string][] = [
|
||||
["Upload File", "Ctrl + U"],
|
||||
["Edit Last Message", "Arrow Up"],
|
||||
["Bold", "Ctrl + B"],
|
||||
["Italic", "Ctrl + I"],
|
||||
["Underline", "Ctrl + U"],
|
||||
];
|
||||
for (const [label, shortcut] of msgBinds) {
|
||||
const row = createElement("div", { class: "keybind-row" });
|
||||
@@ -187,5 +190,15 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
section.appendChild(
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 0 0; line-height: 1.4;",
|
||||
},
|
||||
"Formatting shortcuts wrap the selected text while the message box has focus; Ctrl + U uploads a file everywhere else.",
|
||||
),
|
||||
);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* Notifications settings tab — desktop notifications, taskbar flash, sounds.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
|
||||
import { loadPref, savePref, createToggle } from "./helpers";
|
||||
import { listMutedChannels, unmuteChannel } from "@lib/channel-mutes";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
|
||||
export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
@@ -24,7 +27,7 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
|
||||
{
|
||||
key: "suppressEveryone",
|
||||
label: "Suppress @everyone",
|
||||
desc: "Mute @everyone and @here mentions",
|
||||
desc: "Mute @everyone and @here — messages that name you still notify",
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
@@ -54,5 +57,82 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
section.appendChild(buildMutedChannelsSection(signal));
|
||||
return section;
|
||||
}
|
||||
|
||||
/** Best name available for a muted id: a channel, a DM, or neither. */
|
||||
function mutedChannelName(channelId: number): string {
|
||||
const ch = channelsStore.getState().channels.get(channelId);
|
||||
if (ch !== undefined && ch.type !== "dm") return `#${ch.name}`;
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
if (dm !== undefined) return `@${dmDisplayName(dm)}`;
|
||||
// A mute can outlive the channel it names (deleted channel, left group). It
|
||||
// is shown rather than hidden so the user can clear it.
|
||||
return `Channel ${channelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The muted-channel list.
|
||||
*
|
||||
* Mutes are set from a right-click on a row, which makes them easy to set and
|
||||
* easy to forget — a channel muted six weeks ago is silent for a reason nobody
|
||||
* remembers. This is the one place that answers "what have I silenced", and
|
||||
* the only place to undo it without finding the row again.
|
||||
*/
|
||||
function buildMutedChannelsSection(signal: AbortSignal): HTMLDivElement {
|
||||
const wrapper = createElement("div", { class: "setting-row", style: "display:block;" });
|
||||
const label = createElement("div", { class: "setting-label" }, "Muted Channels");
|
||||
const desc = createElement(
|
||||
"div",
|
||||
{ class: "setting-desc" },
|
||||
"Muted channels never notify you, but messages that mention you still do.",
|
||||
);
|
||||
const list = createElement("div", {
|
||||
class: "settings-muted-list",
|
||||
"data-testid": "muted-channel-list",
|
||||
});
|
||||
appendChildren(wrapper, label, desc, list);
|
||||
|
||||
function render(): void {
|
||||
clearChildren(list);
|
||||
const muted = listMutedChannels();
|
||||
if (muted.length === 0) {
|
||||
list.appendChild(
|
||||
createElement(
|
||||
"div",
|
||||
{ class: "setting-desc", "data-testid": "muted-empty" },
|
||||
"Nothing is muted.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const channelId of muted) {
|
||||
const row = createElement("div", { class: "settings-muted-row" });
|
||||
const name = createElement("span", { class: "settings-muted-name" });
|
||||
setText(name, mutedChannelName(channelId));
|
||||
const btn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn btn-secondary",
|
||||
type: "button",
|
||||
"data-testid": `unmute-${channelId}`,
|
||||
},
|
||||
"Unmute",
|
||||
);
|
||||
btn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
unmuteChannel(channelId);
|
||||
render();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
appendChildren(row, name, btn);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Opening the server's admin panel in the user's browser.
|
||||
*
|
||||
* The audit log stays admin-panel-only: it is a long, filterable, paginated
|
||||
* table over a REST endpoint the desktop client has no other use for, and
|
||||
* rebuilding it here would mean maintaining two of them. What the desktop
|
||||
* client owes its moderators is a way to *reach* it — hence this, rather than
|
||||
* a port of the view.
|
||||
*
|
||||
* The panel is opened in the real browser at `https://{host}/admin`, NOT
|
||||
* through the local TOFU proxy the REST client uses: that proxy exists so the
|
||||
* webview can talk to a self-signed server, and its loopback origin means
|
||||
* nothing to an external browser. A self-signed deployment therefore shows the
|
||||
* browser's certificate warning, which is the honest outcome — the operator is
|
||||
* the one who chose the certificate.
|
||||
*/
|
||||
|
||||
/** The admin-panel URL for `host`, deep-linked to `section` when given. */
|
||||
export function adminPanelUrl(host: string, section?: string): string {
|
||||
const base = `https://${host}/admin`;
|
||||
return section === undefined || section === "" ? base : `${base}#${section}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the admin panel in the user's default browser.
|
||||
*
|
||||
* The opener plugin is imported lazily so this module can be loaded (and the
|
||||
* URL builder tested) in an environment with no Tauri runtime.
|
||||
*/
|
||||
export async function openAdminPanel(host: string, section?: string): Promise<void> {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||
await openUrl(adminPanelUrl(host, section));
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
RegisterResponse,
|
||||
HealthResponse,
|
||||
MessagesResponse,
|
||||
MessagesAroundResponse,
|
||||
ReactionUsersResponse,
|
||||
PurgeResponse,
|
||||
SearchResponse,
|
||||
ApiError,
|
||||
ChannelType,
|
||||
@@ -22,6 +25,7 @@ import type {
|
||||
MemberResponse,
|
||||
DmChannelsResponse,
|
||||
CreateDmResponse,
|
||||
GroupDmResponse,
|
||||
BlockedUsersResponse,
|
||||
GifSearchResponse,
|
||||
} from "./types";
|
||||
@@ -276,12 +280,52 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
},
|
||||
|
||||
updateProfile(
|
||||
data: { username?: string; avatar?: string; identity_public_key?: string },
|
||||
data: {
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
identity_public_key?: string;
|
||||
/** Omit to leave unchanged; "" clears the field. */
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<MemberResponse> {
|
||||
return request<MemberResponse>("PATCH", "/users/me", data, signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload an avatar image (PNG/JPEG/WebP, max 1 MB, max 1024x1024).
|
||||
*
|
||||
* Multipart rather than JSON for the same reason attachments are, and it
|
||||
* shares uploadFile's shape: no Content-Type header (the browser has to
|
||||
* set the multipart boundary) and the bearer token attached by hand.
|
||||
* On success the server has already pointed the user's avatar at the
|
||||
* served file and broadcast a user_update.
|
||||
*/
|
||||
async uploadAvatar(file: File, signal?: AbortSignal): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const url = `${await baseUrl()}/users/me/avatar`;
|
||||
const h: Record<string, string> = {};
|
||||
if (config.token) {
|
||||
h["Authorization"] = `Bearer ${config.token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: "POST", headers: h, body: formData, signal });
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.();
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(401, err.error, err.message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(res.status, err.error, err.message);
|
||||
}
|
||||
return res.json() as Promise<UploadResponse>;
|
||||
},
|
||||
|
||||
changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
@@ -337,6 +381,67 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* The window of history centred on `messageId`, for jumping to a message
|
||||
* outside the loaded page. Messages come back oldest-first (already in
|
||||
* render order) — see MessagesAroundResponse. 404 when the message does
|
||||
* not live in this channel or has been deleted.
|
||||
*/
|
||||
getMessagesAround(
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
options?: { limit?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<MessagesAroundResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
const qs = params.toString();
|
||||
return request<MessagesAroundResponse>(
|
||||
"GET",
|
||||
`/channels/${channelId}/messages/around/${messageId}${qs ? `?${qs}` : ""}`,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk-delete the newest `limit` messages in a channel (1-100). Requires
|
||||
* MANAGE_MESSAGES; the server broadcasts one chat_bulk_deleted event, so
|
||||
* the local store is updated by the dispatcher rather than here.
|
||||
*/
|
||||
purgeMessages(
|
||||
channelId: number,
|
||||
limit: number,
|
||||
options?: { before?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<PurgeResponse> {
|
||||
return request<PurgeResponse>(
|
||||
"POST",
|
||||
`/channels/${channelId}/messages/purge`,
|
||||
{ limit, ...(options?.before !== undefined ? { before: options.before } : {}) },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* The users who reacted to a message with one emoji, for the who-reacted
|
||||
* tooltip. Oldest reaction first, capped at 100 server-side. The emoji is a
|
||||
* path segment, so it must be percent-encoded.
|
||||
*/
|
||||
getReactionUsers(
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
emoji: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReactionUsersResponse> {
|
||||
return request<ReactionUsersResponse>(
|
||||
"GET",
|
||||
`/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/users`,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
getPins(channelId: number, signal?: AbortSignal): Promise<MessagesResponse> {
|
||||
return request<MessagesResponse>("GET", `/channels/${channelId}/pins`, undefined, signal);
|
||||
},
|
||||
@@ -439,12 +544,47 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<void>("DELETE", `/invites/${code}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Emoji ─────────────────────────────────────────────
|
||||
// ── Custom emoji ──────────────────────────────────────
|
||||
//
|
||||
// Reading is open to any member; upload and delete require MANAGE_SERVER
|
||||
// and are refused server-side with 403 regardless of what the UI offers.
|
||||
|
||||
getEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> {
|
||||
/** The server's whole custom-emoji set. */
|
||||
listEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> {
|
||||
return request<EmojiResponse[]>("GET", "/emoji", undefined, signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload one custom emoji. The image is validated server-side (PNG/JPEG/
|
||||
* GIF/WebP, at most 512 KB and 128x128), so the only thing this promises
|
||||
* is to send it; a rejection arrives as an ApiClientError with the reason.
|
||||
*/
|
||||
async uploadEmoji(shortcode: string, file: File, signal?: AbortSignal): Promise<EmojiResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", shortcode);
|
||||
formData.append("file", file);
|
||||
|
||||
const url = `${await baseUrl()}/emoji`;
|
||||
const h: Record<string, string> = {};
|
||||
if (config.token) {
|
||||
h["Authorization"] = `Bearer ${config.token}`;
|
||||
}
|
||||
// Don't set Content-Type — browser sets multipart boundary
|
||||
|
||||
const res = await fetch(url, { method: "POST", headers: h, body: formData, signal });
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.();
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(401, err.error, err.message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(res.status, err.error, err.message);
|
||||
}
|
||||
return res.json() as Promise<EmojiResponse>;
|
||||
},
|
||||
|
||||
deleteEmoji(emojiId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
|
||||
},
|
||||
@@ -471,7 +611,30 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<CreateDmResponse>("POST", "/dms", { recipient_id: recipientId }, signal);
|
||||
},
|
||||
|
||||
/** Close a DM (hide from sidebar). */
|
||||
/** Create a group DM with 2..8 other users (3..10 total). */
|
||||
createGroupDm(
|
||||
recipientIds: readonly number[],
|
||||
name?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GroupDmResponse> {
|
||||
return request<GroupDmResponse>(
|
||||
"POST",
|
||||
"/dms/group",
|
||||
{ recipient_ids: [...recipientIds], name: name ?? "" },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
/** Set or clear a group DM's name. Any participant may; 1:1 DMs refuse. */
|
||||
renameGroupDm(channelId: number, name: string, signal?: AbortSignal): Promise<GroupDmResponse> {
|
||||
return request<GroupDmResponse>("PATCH", `/dms/${channelId}`, { name }, signal);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a DM from the sidebar. For a 1:1 this only hides it — the next
|
||||
* message from either side brings it back. For a group it is a *leave*:
|
||||
* the caller comes out of the participant list and cannot return unaided.
|
||||
*/
|
||||
closeDm(channelId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/dms/${channelId}`, undefined, signal);
|
||||
},
|
||||
@@ -481,6 +644,16 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<BlockedUsersResponse>("GET", "/blocks", undefined, signal);
|
||||
},
|
||||
|
||||
/** Block a user (prevents DMs in both directions). */
|
||||
blockUser(userId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("PUT", `/blocks/${userId}`, undefined, signal);
|
||||
},
|
||||
|
||||
/** Unblock a previously blocked user. */
|
||||
unblockUser(userId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/blocks/${userId}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Voice ─────────────────────────────────────────────
|
||||
|
||||
getVoiceCredentials(signal?: AbortSignal): Promise<VoiceCredentialsResponse> {
|
||||
@@ -527,9 +700,24 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
data: {
|
||||
name?: string;
|
||||
topic?: string;
|
||||
// Moving a channel between categories is a rename of free text; an
|
||||
// omitted field keeps the channel's current category server-side.
|
||||
category?: string;
|
||||
slow_mode?: number;
|
||||
position?: number;
|
||||
archived?: boolean;
|
||||
/**
|
||||
* Age-restriction label. Stored, broadcast and audited by the server,
|
||||
* which applies no content behaviour of its own to a flagged channel.
|
||||
*/
|
||||
nsfw?: boolean;
|
||||
/**
|
||||
* Voice capacity limits (0 = unlimited), enforced by the server on
|
||||
* join. Omit them on a text channel rather than sending 0 — every
|
||||
* field the body leaves out keeps its stored value.
|
||||
*/
|
||||
voice_max_users?: number;
|
||||
voice_max_video?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChannelResponse> {
|
||||
@@ -546,13 +734,22 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return adminRequest<void>("DELETE", `/users/${userId}/sessions`, undefined, signal);
|
||||
},
|
||||
|
||||
adminBanMember(userId: number, reason?: string, signal?: AbortSignal): Promise<void> {
|
||||
adminBanMember(
|
||||
userId: number,
|
||||
reason?: string,
|
||||
durationHours?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
return adminRequest<void>(
|
||||
"PATCH",
|
||||
`/users/${userId}`,
|
||||
{
|
||||
banned: true,
|
||||
ban_reason: reason ?? "",
|
||||
// Omitted/0 = permanent; otherwise the ban expires after this many hours.
|
||||
...(durationHours !== undefined && durationHours > 0
|
||||
? { ban_duration_hours: durationHours }
|
||||
: {}),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
@@ -72,10 +72,16 @@ export class AudioElements {
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
): void {
|
||||
// Guard: do not attach any remote audio while locally deafened.
|
||||
// Guard: do not attach remote voice audio while locally deafened.
|
||||
// applyRemoteAudioSubscriptionState() only covers participants present at
|
||||
// the time of deafen — this guard catches participants who join afterward.
|
||||
if (voiceStore.getState().localDeafened) {
|
||||
// Screen-share/stream audio is exempt: muting or deafening yourself gates
|
||||
// voices, not the content someone is streaming — that stays under its own
|
||||
// per-tile mute/volume controls.
|
||||
if (
|
||||
voiceStore.getState().localDeafened &&
|
||||
publication.source !== Track.Source.ScreenShareAudio
|
||||
) {
|
||||
publication.setSubscribed(false);
|
||||
return;
|
||||
}
|
||||
@@ -157,6 +163,10 @@ export class AudioElements {
|
||||
if (this.room === null) return;
|
||||
for (const participant of this.room.remoteParticipants.values()) {
|
||||
for (const publication of participant.audioTrackPublications.values()) {
|
||||
// Deafen gates voices only. Screen-share/stream audio keeps playing
|
||||
// when the user mutes/deafens themselves — it has its own per-tile
|
||||
// mute and volume controls.
|
||||
if (publication.source === Track.Source.ScreenShareAudio) continue;
|
||||
publication.setSubscribed(!deafened);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Auto-idle: flip to Idle after ten quiet minutes, back to Online on the first
|
||||
* sign of life.
|
||||
*
|
||||
* Entirely client-side. The server has no idea whether anyone is at the
|
||||
* keyboard, and giving it one would mean a heartbeat carrying activity data it
|
||||
* has no other use for; the client already knows, and a presence_update is the
|
||||
* message that already says so.
|
||||
*
|
||||
* The rule that makes this safe to leave running is narrow: it only ever moves
|
||||
* a status the *timer itself* is responsible for.
|
||||
*
|
||||
* - Manually chosen Idle, Do Not Disturb and Invisible are never touched.
|
||||
* Someone who set Do Not Disturb to be left alone would be dragged back to
|
||||
* Online by their own mouse otherwise, which is the opposite of what they
|
||||
* asked for.
|
||||
* - Only a manual Online becomes an automatic Idle, and only an automatic
|
||||
* Idle becomes Online again. A manual Idle is a statement, not a timeout.
|
||||
*
|
||||
* Input listening is throttled to one bookkeeping call per second: mousemove
|
||||
* fires hundreds of times a second and the timer's resolution is minutes, so
|
||||
* anything finer is pure cost.
|
||||
*/
|
||||
|
||||
import type { UserStatus } from "./types";
|
||||
import { loadUserStatus, loadUserStatusOrigin, saveUserStatus } from "./userStatus";
|
||||
|
||||
/** How long without input before the status flips to idle. Discord's number. */
|
||||
export const AUTO_IDLE_DELAY_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Minimum gap between two activity bookkeeping runs. */
|
||||
export const ACTIVITY_THROTTLE_MS = 1000;
|
||||
|
||||
/** Events that count as "the user is here". */
|
||||
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "wheel", "touchstart"] as const;
|
||||
|
||||
export interface AutoIdleOptions {
|
||||
/** Called when the timer decides the status should change. The caller sends
|
||||
* the presence_update and updates its own stores — this module owns the
|
||||
* decision, not the transport. */
|
||||
readonly onStatusChange: (status: UserStatus) => void;
|
||||
/** Injected in tests. Defaults to `window`. */
|
||||
readonly target?: Pick<Window, "addEventListener" | "removeEventListener">;
|
||||
/** Injected in tests. Defaults to AUTO_IDLE_DELAY_MS. */
|
||||
readonly delayMs?: number;
|
||||
}
|
||||
|
||||
export interface AutoIdleController {
|
||||
/** Report activity explicitly (e.g. after sending a message). */
|
||||
notifyActivity(): void;
|
||||
/** Stop listening and cancel the pending timer. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the timer may move the status right now, and to what.
|
||||
*
|
||||
* Exported because it is the entire policy, and a policy worth testing is
|
||||
* worth testing without a DOM and a ten-minute clock.
|
||||
*/
|
||||
export function nextAutoStatus(
|
||||
current: UserStatus,
|
||||
origin: "manual" | "auto",
|
||||
idle: boolean,
|
||||
): UserStatus | null {
|
||||
if (idle) {
|
||||
// Only a manual Online is eligible to become automatically idle. An
|
||||
// already-idle status (either origin) has nowhere to go, and dnd/invisible
|
||||
// are deliberate.
|
||||
return current === "online" && origin === "manual" ? "idle" : null;
|
||||
}
|
||||
// Coming back: only undo what the timer itself did.
|
||||
return current === "idle" && origin === "auto" ? "online" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the idle watcher. Returns a controller; call `destroy` on teardown.
|
||||
*/
|
||||
export function startAutoIdle(options: AutoIdleOptions): AutoIdleController {
|
||||
const target = options.target ?? window;
|
||||
const delayMs = options.delayMs ?? AUTO_IDLE_DELAY_MS;
|
||||
const ac = new AbortController();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastActivityRun = 0;
|
||||
let destroyed = false;
|
||||
/** True while the timer is the reason the status is idle. Kept in memory so
|
||||
* the hot path (one mousemove per pixel) is a boolean check rather than a
|
||||
* preference read. */
|
||||
let idleByTimer = false;
|
||||
|
||||
function apply(idle: boolean): void {
|
||||
const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);
|
||||
if (next === null) return;
|
||||
// The timer's writes are marked "auto" so a later return-to-activity knows
|
||||
// it is undoing its own work rather than a choice the user made.
|
||||
saveUserStatus(next, idle ? "auto" : "manual");
|
||||
idleByTimer = idle;
|
||||
options.onStatusChange(next);
|
||||
}
|
||||
|
||||
function arm(): void {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (destroyed) return;
|
||||
apply(true);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function onActivity(): void {
|
||||
if (destroyed) return;
|
||||
// Coming back is handled first and unthrottled: the very first event after
|
||||
// an idle flip has to restore Online even though it lands inside the
|
||||
// throttle window that follows.
|
||||
if (idleByTimer) {
|
||||
apply(false);
|
||||
idleByTimer = false;
|
||||
lastActivityRun = Date.now();
|
||||
arm();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastActivityRun < ACTIVITY_THROTTLE_MS) return;
|
||||
lastActivityRun = now;
|
||||
arm();
|
||||
}
|
||||
|
||||
for (const evt of ACTIVITY_EVENTS) {
|
||||
target.addEventListener(evt, onActivity, { passive: true, signal: ac.signal });
|
||||
}
|
||||
arm();
|
||||
|
||||
return {
|
||||
notifyActivity(): void {
|
||||
onActivity();
|
||||
},
|
||||
destroy(): void {
|
||||
destroyed = true;
|
||||
ac.abort();
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The one place that answers "how do I draw this user".
|
||||
*
|
||||
* Before phase 6 there were four answers: message rows, the reply preview, the
|
||||
* member list and the user bar each built a coloured `<div>` with a letter in
|
||||
* it, while `UserProfilePopup` alone knew how to render an actual image. Now
|
||||
* that avatars can be uploaded, every one of those surfaces has to be able to
|
||||
* show a picture — and the letter has to remain the fallback, because most
|
||||
* users will never upload one.
|
||||
*
|
||||
* Two rules the helper exists to enforce:
|
||||
*
|
||||
* - The image is fetched, not linked. `/api/v1/files/{id}` is authenticated,
|
||||
* and `<img src>` cannot carry an Authorization header, so assigning the
|
||||
* server URL directly would 401. Bytes go through the same cert-pinned,
|
||||
* bearer-token, cached path attachments and custom emoji use, and are
|
||||
* swapped in as a data: URI once they arrive.
|
||||
* - The letter is what renders until (and if) the bytes arrive. An avatar that
|
||||
* fails to load leaves a normal-looking row rather than a broken image.
|
||||
*/
|
||||
|
||||
import { createElement } from "@lib/dom";
|
||||
import {
|
||||
fetchImageAsDataUrl,
|
||||
isSafeUrl,
|
||||
resolveServerUrl,
|
||||
} from "@components/message-list/attachments";
|
||||
|
||||
/** Everything the helper needs to know about the user it is drawing. */
|
||||
export interface AvatarSubject {
|
||||
readonly username: string;
|
||||
/** Nickname, when set. Used for the initial and the alt text, so a row shows
|
||||
* the letter of the name the reader actually sees. */
|
||||
readonly displayName?: string | null;
|
||||
/** Avatar URL: a server-relative `/api/v1/files/{id}` or an https:// URL. */
|
||||
readonly avatar?: string | null;
|
||||
/** Renders as "?" on a neutral background and never fetches an image. */
|
||||
readonly isDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface AvatarOptions {
|
||||
/** Class applied to the wrapper — each surface keeps its own sizing rules. */
|
||||
readonly className: string;
|
||||
/** CSS background for the letter fallback (usually the user's role color). */
|
||||
readonly background?: string;
|
||||
/** Extra attributes for the wrapper (data-testid, title, …). */
|
||||
readonly attrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The name to render for a user: display name when set, username otherwise. */
|
||||
export function resolveDisplayName(subject: AvatarSubject): string {
|
||||
const display = subject.displayName;
|
||||
if (typeof display === "string" && display.trim().length > 0) return display;
|
||||
return subject.username;
|
||||
}
|
||||
|
||||
/** The single letter a user with no avatar is drawn as. */
|
||||
export function avatarInitial(subject: AvatarSubject): string {
|
||||
if (subject.isDeleted === true) return "?";
|
||||
return resolveDisplayName(subject).charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `url` is something worth trying to load as an avatar. Empty, absent
|
||||
* and non-http(s) values all fall back to the letter rather than producing an
|
||||
* `<img>` that can only fail.
|
||||
*
|
||||
* The shape check comes first and is deliberate: `resolveServerUrl` prefixes
|
||||
* anything that does not already start with a scheme, so a bare
|
||||
* `javascript:alert(1)` would come back as `https://host` + that string and
|
||||
* sail through `isSafeUrl`. Only a server-relative path or an already-absolute
|
||||
* http(s) URL is a candidate.
|
||||
*/
|
||||
export function isRenderableAvatar(url: string | null | undefined): url is string {
|
||||
if (typeof url !== "string" || url.length === 0) return false;
|
||||
const absolute = url.startsWith("http://") || url.startsWith("https://");
|
||||
if (!absolute && !url.startsWith("/")) return false;
|
||||
return isSafeUrl(resolveServerUrl(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an avatar element: the letter fallback immediately, the image swapped
|
||||
* in asynchronously when there is one to fetch.
|
||||
*
|
||||
* The returned element is usable synchronously — callers append it and move on.
|
||||
*/
|
||||
export function createAvatarElement(
|
||||
subject: AvatarSubject,
|
||||
options: AvatarOptions,
|
||||
): HTMLDivElement {
|
||||
const wrapper = createElement("div", {
|
||||
class: options.className,
|
||||
...options.attrs,
|
||||
});
|
||||
if (options.background !== undefined) {
|
||||
wrapper.style.background = options.background;
|
||||
}
|
||||
|
||||
const letter = createElement("span", { class: "avatar-initial" }, avatarInitial(subject));
|
||||
wrapper.appendChild(letter);
|
||||
|
||||
if (subject.isDeleted === true || !isRenderableAvatar(subject.avatar)) {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
const resolved = resolveServerUrl(subject.avatar);
|
||||
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
|
||||
// The row may have been torn down while the fetch was in flight; an
|
||||
// element with no parent is one nobody is looking at.
|
||||
if (dataUrl === null || !wrapper.isConnected) return;
|
||||
const img = createElement("img", {
|
||||
class: "avatar-img",
|
||||
src: dataUrl,
|
||||
alt: resolveDisplayName(subject),
|
||||
loading: "lazy",
|
||||
decoding: "async",
|
||||
});
|
||||
// Replacing rather than hiding keeps the letter out of the accessibility
|
||||
// tree once a real picture is there.
|
||||
letter.remove();
|
||||
wrapper.style.background = "transparent";
|
||||
wrapper.insertBefore(img, wrapper.firstChild);
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Incoming-call ring state.
|
||||
*
|
||||
* A "call" in a DM is not a server-side object — it is somebody being present
|
||||
* in that DM's voice channel. Ringing is the ephemeral nudge that says "come
|
||||
* look", and this module is the whole of its client-side lifetime:
|
||||
*
|
||||
* (none) --call_incoming--> ringing --accept---> (none) [+ join voice]
|
||||
* --decline--> (none) [+ call_decline]
|
||||
* --timeout--> (none) after 30s
|
||||
* --ringer left-> (none)
|
||||
*
|
||||
* It is kept apart from the banner that draws it because the interesting part
|
||||
* is the transitions, and a statechart with no DOM in it is a statechart that
|
||||
* can be tested without one. Every exit runs through `stopRinging`, so there
|
||||
* is exactly one place that can leave the chime playing.
|
||||
*/
|
||||
|
||||
export const RING_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A ring in flight. */
|
||||
export interface RingState {
|
||||
readonly channelId: number;
|
||||
readonly fromUserId: number;
|
||||
readonly fromUsername: string;
|
||||
}
|
||||
|
||||
/** Why a ring ended. Reported so the caller knows whether to answer back. */
|
||||
export type RingEndReason = "accepted" | "declined" | "timeout" | "ringer-left" | "superseded";
|
||||
|
||||
export interface RingControllerOptions {
|
||||
/** Draw (or clear, with null) the incoming-call banner. */
|
||||
readonly onRingStateChange: (state: RingState | null) => void;
|
||||
/** Start/stop the repeating chime. */
|
||||
readonly onChime: (playing: boolean) => void;
|
||||
/** Join the DM's voice channel — the accept action. */
|
||||
readonly onAccept: (channelId: number) => void;
|
||||
/** Tell the ringer we are not picking up. Not sent on timeout: a timeout is
|
||||
* "nobody was there", and the ringer's own 30s window covers it. */
|
||||
readonly onDecline: (channelId: number) => void;
|
||||
/** Test seam for the 30s timer. */
|
||||
readonly setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
||||
readonly clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
export interface RingController {
|
||||
/** A call_incoming arrived. */
|
||||
readonly incoming: (state: RingState) => void;
|
||||
/** The user accepted. No-op when nothing is ringing. */
|
||||
readonly accept: () => void;
|
||||
/** The user declined. No-op when nothing is ringing. */
|
||||
readonly decline: () => void;
|
||||
/**
|
||||
* A call_declined arrived, or the ringer left the DM's voice channel — both
|
||||
* mean "stop ringing for this channel". Ignored when the current ring is for
|
||||
* a different channel, so a stale signal cannot silence a live call.
|
||||
*/
|
||||
readonly cancel: (channelId: number, reason?: RingEndReason) => void;
|
||||
/** The ring in flight, or null. */
|
||||
readonly current: () => RingState | null;
|
||||
/** Tear down: stops the chime and the timer. */
|
||||
readonly destroy: () => void;
|
||||
}
|
||||
|
||||
export function createRingController(opts: RingControllerOptions): RingController {
|
||||
const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
||||
|
||||
let state: RingState | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function stopRinging(): void {
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (state === null) return;
|
||||
state = null;
|
||||
opts.onChime(false);
|
||||
opts.onRingStateChange(null);
|
||||
}
|
||||
|
||||
function incoming(next: RingState): void {
|
||||
// A second ring replaces the first rather than queueing: two banners at
|
||||
// once is two decisions the user did not ask to make, and the newer ring
|
||||
// is the one they can still answer.
|
||||
if (state !== null && state.channelId !== next.channelId) {
|
||||
stopRinging();
|
||||
}
|
||||
state = next;
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = setTimer(() => {
|
||||
// Timeout is silent by design — see onDecline's comment.
|
||||
stopRinging();
|
||||
}, RING_TIMEOUT_MS);
|
||||
opts.onRingStateChange(next);
|
||||
opts.onChime(true);
|
||||
}
|
||||
|
||||
function accept(): void {
|
||||
const active = state;
|
||||
if (active === null) return;
|
||||
stopRinging();
|
||||
opts.onAccept(active.channelId);
|
||||
}
|
||||
|
||||
function decline(): void {
|
||||
const active = state;
|
||||
if (active === null) return;
|
||||
stopRinging();
|
||||
opts.onDecline(active.channelId);
|
||||
}
|
||||
|
||||
function cancel(channelId: number): void {
|
||||
if (state === null || state.channelId !== channelId) return;
|
||||
stopRinging();
|
||||
}
|
||||
|
||||
return {
|
||||
incoming,
|
||||
accept,
|
||||
decline,
|
||||
cancel,
|
||||
current: () => state,
|
||||
destroy: () => stopRinging(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Per-channel notification mutes.
|
||||
*
|
||||
* A mute silences the *noise* a channel makes and nothing else. Discord's
|
||||
* semantics, which this follows exactly:
|
||||
*
|
||||
* - no desktop notification, no chime, no taskbar flash;
|
||||
* - the unread badge still counts, but renders dimmed — the channel has not
|
||||
* stopped existing, it has stopped shouting;
|
||||
* - a message that mentions you STILL notifies and still shows the red
|
||||
* mention badge. A mute is "stop telling me about the chatter", not "hide
|
||||
* things addressed to me", and a mute that swallowed a direct mention
|
||||
* would be a mute nobody could safely use.
|
||||
*
|
||||
* It is a client-side preference on purpose. The server has no per-user
|
||||
* channel settings table, and "which of my devices bothers me" is a property
|
||||
* of the device, not of the account — the same reason `desktopNotifications`
|
||||
* and `notificationSounds` live in localStorage next to it.
|
||||
*/
|
||||
|
||||
import { loadPref, savePref } from "./preferences";
|
||||
|
||||
/** localStorage key (under the shared settings prefix). */
|
||||
const MUTED_KEY = "mutedChannels";
|
||||
|
||||
/**
|
||||
* Cached parse of the stored list. Notification gating runs on every incoming
|
||||
* message, and a JSON.parse per message for a list that changes on a menu
|
||||
* click is work nobody asked for. Invalidated by the pref-change event
|
||||
* `savePref` already dispatches, so a mute set in another part of the app (or
|
||||
* another tab, via `storage`) is picked up without a reload.
|
||||
*/
|
||||
let cache: ReadonlySet<number> | null = null;
|
||||
|
||||
function readMuted(): ReadonlySet<number> {
|
||||
if (cache !== null) return cache;
|
||||
const raw = loadPref<unknown[]>(MUTED_KEY, []);
|
||||
const ids = new Set<number>();
|
||||
if (Array.isArray(raw)) {
|
||||
for (const v of raw) {
|
||||
// Corrupted storage is treated as absent rather than fatal: a bad entry
|
||||
// must not cost the user their other mutes.
|
||||
if (typeof v === "number" && Number.isInteger(v) && v > 0) ids.add(v);
|
||||
}
|
||||
}
|
||||
cache = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
function writeMuted(ids: ReadonlySet<number>): void {
|
||||
cache = ids;
|
||||
savePref(MUTED_KEY, [...ids]);
|
||||
}
|
||||
|
||||
/** Drop the cached parse. Exported for tests and for logout. */
|
||||
export function invalidateMuteCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("owncord:pref-change", (e) => {
|
||||
const detail = (e as CustomEvent<{ key?: string }>).detail;
|
||||
if (detail?.key === MUTED_KEY) invalidateMuteCache();
|
||||
});
|
||||
// Cross-tab: the native storage event fires only in the *other* tab.
|
||||
window.addEventListener("storage", () => invalidateMuteCache());
|
||||
}
|
||||
|
||||
/** Whether a channel (or DM) is muted. */
|
||||
export function isChannelMuted(channelId: number): boolean {
|
||||
return readMuted().has(channelId);
|
||||
}
|
||||
|
||||
/** Every muted channel id, ascending — a stable order for the settings list. */
|
||||
export function listMutedChannels(): readonly number[] {
|
||||
return [...readMuted()].toSorted((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Mute a channel. Idempotent. */
|
||||
export function muteChannel(channelId: number): void {
|
||||
const next = new Set(readMuted());
|
||||
next.add(channelId);
|
||||
writeMuted(next);
|
||||
}
|
||||
|
||||
/** Unmute a channel. Idempotent. */
|
||||
export function unmuteChannel(channelId: number): void {
|
||||
const next = new Set(readMuted());
|
||||
next.delete(channelId);
|
||||
writeMuted(next);
|
||||
}
|
||||
|
||||
/** Flip a channel's mute and report the new state. */
|
||||
export function toggleChannelMute(channelId: number): boolean {
|
||||
if (isChannelMuted(channelId)) {
|
||||
unmuteChannel(channelId);
|
||||
return false;
|
||||
}
|
||||
muteChannel(channelId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an incoming message in `channelId` may raise a notification, given
|
||||
* whether it mentions the reader.
|
||||
*
|
||||
* This is the whole mute rule in one place, so the desktop popup, the chime
|
||||
* and the taskbar flash cannot end up applying three slightly different
|
||||
* versions of it.
|
||||
*/
|
||||
export function notificationAllowed(channelId: number, mentioned: boolean): boolean {
|
||||
return mentioned || !isChannelMuted(channelId);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Single entry point for "open this channel", so every affordance that can
|
||||
* navigate (sidebar item, quick switcher, #channel link in a message) clears
|
||||
* the same badges and leaves the app in the same state.
|
||||
*/
|
||||
|
||||
import { setActiveChannel, clearUnread, channelsStore } from "@stores/channels.store";
|
||||
|
||||
/**
|
||||
* Activate `channelId`, clearing its unread and mention badges.
|
||||
*
|
||||
* No-op for an id the channel store does not know: the caller resolved a name
|
||||
* that no longer exists, and blanking the active channel would be worse than
|
||||
* staying put.
|
||||
*/
|
||||
export function navigateToChannel(channelId: number): void {
|
||||
if (!channelsStore.getState().channels.has(channelId)) return;
|
||||
setActiveChannel(channelId);
|
||||
clearUnread(channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a visible channel by id, for affordances that carry an id rather
|
||||
* than a name (message permalinks). Returns null when the channel is not in
|
||||
* this user's channel list — a permalink to somewhere they cannot see must
|
||||
* degrade quietly, not render a chip that goes nowhere.
|
||||
*/
|
||||
export function findChannelById(channelId: number): { id: number; name: string } | null {
|
||||
const ch = channelsStore.getState().channels.get(channelId);
|
||||
return ch === undefined ? null : { id: ch.id, name: ch.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a channel by name (case-insensitive), as written in a `#name` token.
|
||||
* DM channels are excluded — they are addressed through the DM sidebar and
|
||||
* have no user-visible `#name`.
|
||||
*/
|
||||
export function findChannelByName(name: string): { id: number; name: string } | null {
|
||||
const wanted = name.toLowerCase();
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "dm") continue;
|
||||
if (ch.name.toLowerCase() === wanted) return { id: ch.id, name: ch.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
/** Offset added to userId to produce a unique tile ID for screenshare tiles in the video grid. */
|
||||
export const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
|
||||
|
||||
/**
|
||||
* Total participants a group DM holds, creator included. Mirrors
|
||||
* `db.MaxGroupDMParticipants` on the server, which is the authority — this
|
||||
* copy exists so the picker can refuse a 12th selection without a round trip.
|
||||
*/
|
||||
export const MAX_GROUP_DM_PARTICIPANTS = 10;
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* owncord:// deep links.
|
||||
*
|
||||
* OwnCord invites are *registration* invites (a code you supply when creating
|
||||
* an account on a server), so a deep link can only pre-fill and open the
|
||||
* register form — it cannot complete a join on its own. Accepted forms:
|
||||
* Two routes share the scheme:
|
||||
*
|
||||
* owncord://invite/<code>
|
||||
* owncord://invite/<code> registration invite
|
||||
* owncord://invite/<code>?host=<host>
|
||||
* owncord://<code> (bare code)
|
||||
* owncord://<code> (bare code — invite)
|
||||
* owncord://message/<channelId>/<messageId> message permalink
|
||||
*
|
||||
* OwnCord invites are *registration* invites (a code you supply when creating
|
||||
* an account on a server), so an invite link can only pre-fill and open the
|
||||
* register form — it cannot complete a join on its own. A message link opens
|
||||
* the channel and jumps to the message, and is ignored when the channel is not
|
||||
* visible to this user.
|
||||
*
|
||||
* Cold starts are handled via getCurrent(); while the app is already running,
|
||||
* the single-instance plugin (built with the "deep-link" feature) forwards the
|
||||
@@ -20,31 +25,78 @@ const log = createLogger("deep-link");
|
||||
|
||||
const SCHEME = "owncord";
|
||||
const PREFIX = `${SCHEME}://`;
|
||||
/** Route segment that owns the message-permalink form. */
|
||||
const MESSAGE_ROUTE = "message";
|
||||
|
||||
export interface InviteLink {
|
||||
readonly code: string;
|
||||
readonly host?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an owncord:// invite link. Returns null if the URL isn't an owncord://
|
||||
* link or carries no code. Pure — no side effects, safe to unit test.
|
||||
*/
|
||||
export function parseInviteLink(url: string): InviteLink | null {
|
||||
export interface MessageLink {
|
||||
readonly channelId: number;
|
||||
readonly messageId: number;
|
||||
}
|
||||
|
||||
/** Split an owncord:// URL into its path segments, or null for other schemes. */
|
||||
function linkSegments(url: string): { segments: string[]; query: string } | null {
|
||||
if (!url.startsWith(PREFIX)) return null;
|
||||
|
||||
let rest = url.slice(PREFIX.length);
|
||||
let host: string | undefined;
|
||||
|
||||
let query = "";
|
||||
const queryStart = rest.indexOf("?");
|
||||
if (queryStart !== -1) {
|
||||
const params = new URLSearchParams(rest.slice(queryStart + 1));
|
||||
const h = params.get("host")?.trim();
|
||||
if (h) host = h;
|
||||
query = rest.slice(queryStart + 1);
|
||||
rest = rest.slice(0, queryStart);
|
||||
}
|
||||
return { segments: rest.replace(/\/+$/, "").split("/").filter(Boolean), query };
|
||||
}
|
||||
|
||||
const segments = rest.replace(/\/+$/, "").split("/").filter(Boolean);
|
||||
/** Parse a positive integer segment, or null when it is anything else. */
|
||||
function parseIdSegment(raw: string | undefined): number | null {
|
||||
if (raw === undefined || !/^\d+$/.test(raw)) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical permalink for a message. The inverse of
|
||||
* {@link parseMessageLink}.
|
||||
*/
|
||||
export function formatMessageLink(channelId: number, messageId: number): string {
|
||||
return `${PREFIX}${MESSAGE_ROUTE}/${channelId}/${messageId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `owncord://message/<channelId>/<messageId>` permalink. Returns null
|
||||
* for any other owncord:// route, another scheme, or non-numeric ids. Pure.
|
||||
*/
|
||||
export function parseMessageLink(url: string): MessageLink | null {
|
||||
const parts = linkSegments(url);
|
||||
if (parts === null || parts.segments[0] !== MESSAGE_ROUTE) return null;
|
||||
const channelId = parseIdSegment(parts.segments[1]);
|
||||
const messageId = parseIdSegment(parts.segments[2]);
|
||||
if (channelId === null || messageId === null) return null;
|
||||
return { channelId, messageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an owncord:// invite link. Returns null if the URL isn't an owncord://
|
||||
* link, is a different route (e.g. a message permalink), or carries no code.
|
||||
* Pure — no side effects, safe to unit test.
|
||||
*/
|
||||
export function parseInviteLink(url: string): InviteLink | null {
|
||||
const parts = linkSegments(url);
|
||||
if (parts === null) return null;
|
||||
|
||||
let host: string | undefined;
|
||||
if (parts.query !== "") {
|
||||
const h = new URLSearchParams(parts.query).get("host")?.trim();
|
||||
if (h) host = h;
|
||||
}
|
||||
|
||||
const segments = parts.segments;
|
||||
// A message permalink is not a bare invite code.
|
||||
if (segments[0] === MESSAGE_ROUTE) return null;
|
||||
// `owncord://invite/<code>` or bare `owncord://<code>`.
|
||||
const codeSegment = segments[0] === "invite" ? segments[1] : segments[0];
|
||||
if (!codeSegment) return null;
|
||||
@@ -63,10 +115,12 @@ export function parseInviteLink(url: string): InviteLink | null {
|
||||
|
||||
/**
|
||||
* Wire owncord:// deep links. No-op outside Tauri. `onInvite` is called once per
|
||||
* recognized invite link, on both cold start and warm launches.
|
||||
* recognized invite link and `onMessage` once per message permalink, on both
|
||||
* cold start and warm launches.
|
||||
*/
|
||||
export async function initDeepLinks(
|
||||
onInvite: (code: string, host?: string) => void,
|
||||
onMessage?: (channelId: number, messageId: number) => void,
|
||||
): Promise<void> {
|
||||
let plugin: typeof import("@tauri-apps/plugin-deep-link");
|
||||
try {
|
||||
@@ -77,6 +131,12 @@ export async function initDeepLinks(
|
||||
|
||||
function dispatch(urls: readonly string[] | null): void {
|
||||
for (const url of urls ?? []) {
|
||||
const message = parseMessageLink(url);
|
||||
if (message !== null) {
|
||||
log.info("Deep-link message permalink received");
|
||||
onMessage?.(message.channelId, message.messageId);
|
||||
continue;
|
||||
}
|
||||
const invite = parseInviteLink(url);
|
||||
if (invite) {
|
||||
log.info("Deep-link invite received", { hasHost: invite.host !== undefined });
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
updateChannel,
|
||||
removeChannel,
|
||||
incrementUnread,
|
||||
incrementMention,
|
||||
} from "@stores/channels.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import {
|
||||
addMessage,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
bulkDeleteMessages,
|
||||
updateReaction,
|
||||
confirmSend,
|
||||
markSendFailed,
|
||||
@@ -51,14 +53,19 @@ import {
|
||||
removeDmChannel,
|
||||
updateDmLastMessage,
|
||||
updateDmLastMessagePreview,
|
||||
dmDisplayName,
|
||||
} from "@stores/dm.store";
|
||||
import type { DmChannel } from "@stores/dm.store";
|
||||
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
|
||||
import { setCustomEmoji } from "@stores/emoji.store";
|
||||
import type { DmChannelPayload } from "./types";
|
||||
import type { ApiClient } from "./api";
|
||||
import { invalidateReactionUsers } from "@components/message-list/reaction-tooltip";
|
||||
import { notifyIncomingMessage } from "./notifications";
|
||||
import { highlightsCurrentUser } from "./mentions";
|
||||
import { ensureIdentityKeyPublished } from "@lib/identity";
|
||||
import { createLogger } from "./logger";
|
||||
import { showToast } from "./toast";
|
||||
import { ServerMessageType as S } from "./protocolTypes";
|
||||
|
||||
const log = createLogger("dispatcher");
|
||||
@@ -70,20 +77,34 @@ function livekitSession(): Promise<typeof import("@lib/livekitSession")> {
|
||||
return import("@lib/livekitSession");
|
||||
}
|
||||
|
||||
/** Map one DM participant from the wire shape to the store's. */
|
||||
function mapDmUser(u: DmChannelPayload["recipient"]): DmChannel["recipient"] {
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
status: u.status,
|
||||
displayName: u.display_name ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a server DM channel payload to the client DmChannel type. */
|
||||
function mapDmPayload(p: DmChannelPayload): DmChannel {
|
||||
// A pre-group server sends only `recipient`, which for it *is* the whole
|
||||
// membership — so the fallback is a one-element list rather than an empty
|
||||
// one, and every group-aware call site keeps working against an old server.
|
||||
const participants = (p.recipients ?? [p.recipient]).map(mapDmUser);
|
||||
return {
|
||||
channelId: p.channel_id,
|
||||
recipient: {
|
||||
id: p.recipient.id,
|
||||
username: p.recipient.username,
|
||||
avatar: p.recipient.avatar,
|
||||
status: p.recipient.status,
|
||||
},
|
||||
recipient: participants[0] ?? mapDmUser(p.recipient),
|
||||
participants,
|
||||
name: p.name ?? "",
|
||||
isGroup: p.is_group ?? false,
|
||||
lastMessageId: p.last_message_id,
|
||||
lastMessage: p.last_message,
|
||||
lastMessageAt: p.last_message_at,
|
||||
unreadCount: p.unread_count,
|
||||
mentionCount: p.mention_count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +130,8 @@ export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () =>
|
||||
*/
|
||||
export function wireDispatcher(
|
||||
ws: WsClient,
|
||||
api?: Pick<ApiClient, "listBlocks"> & Partial<Pick<ApiClient, "updateProfile" | "getConfig">>,
|
||||
api?: Pick<ApiClient, "listBlocks"> &
|
||||
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji">>,
|
||||
): DispatcherCleanup {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
@@ -200,6 +222,17 @@ export function wireDispatcher(
|
||||
.catch((err) => log.warn("Failed to load block list", { error: String(err) }));
|
||||
}
|
||||
|
||||
// Custom emoji are not in the ready payload (they are server-wide and
|
||||
// change rarely, so they do not belong in the per-session dump). Load
|
||||
// them once here; `emoji_update` keeps them fresh from then on. A
|
||||
// failure is non-fatal — unresolved shortcodes stay plain text.
|
||||
if (api?.listEmoji !== undefined) {
|
||||
api
|
||||
.listEmoji()
|
||||
.then((list) => setCustomEmoji(list))
|
||||
.catch((err) => log.warn("Failed to load custom emoji", { error: String(err) }));
|
||||
}
|
||||
|
||||
log.info("Ready payload applied", {
|
||||
channels: payload.channels.length,
|
||||
members: payload.members.length,
|
||||
@@ -214,7 +247,21 @@ export function wireDispatcher(
|
||||
unsubs.push(
|
||||
ws.on(S.DM_CHANNEL_OPEN, (payload) => {
|
||||
log.info("DM channel opened", { channelId: payload.channel_id });
|
||||
addDmChannel(mapDmPayload(payload));
|
||||
const dm = mapDmPayload(payload);
|
||||
addDmChannel(dm);
|
||||
|
||||
// A DM's channels-store row is synthesised from the DM store, and this
|
||||
// event is also how a *membership* change arrives (group renamed, member
|
||||
// left). Without this the chat header would keep the name the DM had
|
||||
// when it was first opened, until the user navigated away and back.
|
||||
channelsStore.setState((prev) => {
|
||||
const existing = prev.channels.get(dm.channelId);
|
||||
const name = dmDisplayName(dm);
|
||||
if (existing === undefined || existing.name === name) return prev;
|
||||
const next = new Map(prev.channels);
|
||||
next.set(dm.channelId, { ...existing, name });
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -251,6 +298,15 @@ export function wireDispatcher(
|
||||
// applied here for defence-in-depth.
|
||||
if (payload.channel_id !== activeId && !isOwnMessage && !ws.isReplaying()) {
|
||||
incrementUnread(payload.channel_id);
|
||||
// A mention is an unread too — the mention badge just outranks it.
|
||||
if (
|
||||
highlightsCurrentUser(payload.content, {
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
})
|
||||
) {
|
||||
incrementMention(payload.channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update DM store last message if this message belongs to a DM channel.
|
||||
@@ -287,6 +343,12 @@ export function wireDispatcher(
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.CHAT_BULK_DELETED, (payload) => {
|
||||
bulkDeleteMessages(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.CHAT_SEND_OK, (payload, id) => {
|
||||
if (id) {
|
||||
@@ -301,6 +363,9 @@ export function wireDispatcher(
|
||||
ws.on(S.REACTION_UPDATE, (payload) => {
|
||||
const userId = authStore.getState().user?.id ?? 0;
|
||||
updateReaction(payload, userId);
|
||||
// The who-reacted tooltip caches the reactor list per message+emoji; any
|
||||
// add/remove on this message makes those lists stale.
|
||||
invalidateReactionUsers(payload.message_id);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -316,7 +381,10 @@ export function wireDispatcher(
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.PRESENCE, (payload) => {
|
||||
updatePresence(payload.user_id, payload.status);
|
||||
// custom_status is passed through verbatim, undefined included: the
|
||||
// store treats "field absent" as "leave the text alone", which is what
|
||||
// an older server's presence event means.
|
||||
updatePresence(payload.user_id, payload.status, payload.custom_status);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -381,22 +449,49 @@ export function wireDispatcher(
|
||||
}),
|
||||
);
|
||||
|
||||
// Roles changed server-side (created, edited, deleted or reordered). The
|
||||
// payload is the whole list, so the store is replaced rather than patched —
|
||||
// name colors, the member-list groups and every permission-gated affordance
|
||||
// re-derive from it without a reconnect.
|
||||
unsubs.push(
|
||||
ws.on(S.ROLES_UPDATE, (payload) => {
|
||||
log.info("Roles updated", { count: payload.roles?.length ?? 0 });
|
||||
setRoles(payload.roles ?? []);
|
||||
}),
|
||||
);
|
||||
|
||||
// Custom emoji changed server-side (uploaded or deleted). Whole set, like
|
||||
// roles_update: the store is replaced so a deleted emoji stops rendering in
|
||||
// messages, pickers and reaction pills without a reconnect.
|
||||
unsubs.push(
|
||||
ws.on(S.EMOJI_UPDATE, (payload) => {
|
||||
log.info("Custom emoji updated", { count: payload.emoji?.length ?? 0 });
|
||||
setCustomEmoji(payload.emoji ?? []);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.USER_UPDATE, (payload) => {
|
||||
log.info("User profile updated", { userId: payload.user_id, username: payload.username });
|
||||
updateMemberProfile(
|
||||
payload.user_id,
|
||||
payload.username,
|
||||
payload.avatar,
|
||||
payload.identity_public_key,
|
||||
);
|
||||
updateMemberProfile(payload.user_id, {
|
||||
username: payload.username,
|
||||
avatar: payload.avatar,
|
||||
displayName: payload.display_name,
|
||||
identityPublicKey: payload.identity_public_key,
|
||||
});
|
||||
|
||||
// Update auth store if the current user changed their own profile.
|
||||
const currentUser = authStore.getState().user;
|
||||
if (currentUser && payload.user_id === currentUser.id) {
|
||||
setAuth(
|
||||
authStore.getState().token ?? "",
|
||||
{ ...currentUser, username: payload.username, avatar: payload.avatar },
|
||||
{
|
||||
...currentUser,
|
||||
username: payload.username,
|
||||
avatar: payload.avatar,
|
||||
display_name: payload.display_name,
|
||||
about: payload.about,
|
||||
},
|
||||
authStore.getState().serverName ?? "",
|
||||
authStore.getState().motd ?? "",
|
||||
);
|
||||
@@ -411,12 +506,52 @@ export function wireDispatcher(
|
||||
updateVoiceState(payload);
|
||||
// Auto-join voice channel if the event is for the current user
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
if (payload.user_id === currentUserId) {
|
||||
joinVoiceChannel(payload.channel_id);
|
||||
if (payload.user_id !== currentUserId) return;
|
||||
joinVoiceChannel(payload.channel_id);
|
||||
// Honor a moderator's mute/deafen locally. Mute is also enforced at the
|
||||
// SFU, but deafen governs what WE play back, so the client is the only
|
||||
// place it can take effect. Both apply through one lazy import so the
|
||||
// two effects cannot land in different ticks.
|
||||
const voice = voiceStore.getState();
|
||||
const applyDeafen = payload.server_deafened === true && !voice.localDeafened;
|
||||
const applyMute = payload.server_muted === true && !voice.localMuted;
|
||||
if (applyDeafen || applyMute) {
|
||||
void livekitSession().then(({ setDeafened, setMuted }) => {
|
||||
if (applyDeafen) setDeafened(true);
|
||||
if (applyMute) setMuted(true);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// A moderator moved this client: tear the media session down and re-join the
|
||||
// destination through the ordinary join path (the server already removed us
|
||||
// from the old room and broadcast voice_leave).
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_MOVED, (payload) => {
|
||||
log.info("Moved to another voice channel by a moderator", {
|
||||
toChannelId: payload.to_channel_id,
|
||||
});
|
||||
void livekitSession().then(({ leaveVoice }) => {
|
||||
leaveVoice(false);
|
||||
leaveVoiceChannel();
|
||||
joinVoiceChannel(payload.to_channel_id);
|
||||
ws.send({ type: "voice_join", payload: { channel_id: payload.to_channel_id } });
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// A moderator disconnected this client from voice. voice_leave has already
|
||||
// cleared the store; this only surfaces the reason.
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_DISCONNECTED, (payload) => {
|
||||
log.info("Disconnected from voice by a moderator", { channelId: payload.channel_id });
|
||||
void livekitSession().then(({ leaveVoice }) => leaveVoice(false));
|
||||
leaveVoiceChannel();
|
||||
showToast(payload.reason || "You were disconnected from voice", "error");
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_LEAVE, (payload) => {
|
||||
removeVoiceUser(payload);
|
||||
@@ -484,6 +619,18 @@ export function wireDispatcher(
|
||||
reason: payload.reason,
|
||||
delaySeconds: payload.delay_seconds,
|
||||
});
|
||||
if (payload.reason === "shutdown") {
|
||||
// GracefulStop broadcast: the server is going down, not briefly
|
||||
// restarting in place. Kick back to the login screen instead of
|
||||
// spinning the reconnect loop against a dead host. clearAuth also
|
||||
// leaves voice — stopping any live camera/screenshare tracks and
|
||||
// resetting their toggles to off. "server_shutdown" keeps the saved
|
||||
// credential (the token is still valid), so auto-login can resume
|
||||
// when the server comes back.
|
||||
setTransientError("The server was shut down — you have been signed out.");
|
||||
clearAuth("server_shutdown");
|
||||
return;
|
||||
}
|
||||
setTransientError(`Server is restarting: ${payload.reason ?? "maintenance"}`);
|
||||
}),
|
||||
);
|
||||
@@ -533,6 +680,20 @@ export function wireDispatcher(
|
||||
markSendFailed(id, payload.code);
|
||||
return;
|
||||
}
|
||||
// Voice capacity refusals. The server owns the limits (voice_max_users /
|
||||
// voice_max_video) and refuses the join or the camera; the client never
|
||||
// pre-blocks the click, because its copy of the participant list can lag
|
||||
// and a refusal it invented would be uncorrectable. So the only job here
|
||||
// is to say what happened — without this the click was a silent no-op
|
||||
// with an explanation buried in the log.
|
||||
if (payload.code === "CHANNEL_FULL") {
|
||||
showToast(payload.message || "That voice channel is full", "error");
|
||||
return;
|
||||
}
|
||||
if (payload.code === "VIDEO_LIMIT") {
|
||||
showToast(payload.message || "That voice channel has reached its video limit", "error");
|
||||
return;
|
||||
}
|
||||
if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") {
|
||||
setTransientError(payload.message || "Server error");
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export type IconName =
|
||||
| "pause"
|
||||
| "check"
|
||||
| "external-link"
|
||||
| "link"
|
||||
| "loader"
|
||||
| "arrow-right"
|
||||
| "hash"
|
||||
@@ -169,6 +170,9 @@ const ICON_PATHS: Record<IconName, string> = {
|
||||
// External link arrow out of box
|
||||
"external-link": `<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>`,
|
||||
|
||||
// Chain link — message permalinks ("Copy Message Link")
|
||||
link: `<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>`,
|
||||
|
||||
// Loading spinner circle (partial arc with rotating convention)
|
||||
loader: `<path d="M21 12a9 9 0 1 1-6.219-8.56"/>`,
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Mention token parsing and resolution, shared by message rendering, the
|
||||
* unread/mention badges, and the notification gate so all three agree on what
|
||||
* counts as a mention.
|
||||
*
|
||||
* The server is the authority: `mentions` / `mentions_everyone` on the wire
|
||||
* decide the outcome whenever they are present. The local token parse only
|
||||
* stands in for servers that predate those fields.
|
||||
*/
|
||||
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
|
||||
/**
|
||||
* An @token that stands alone as a word. Group 1 is the preceding character,
|
||||
* which must be start-of-string or a non-word non-`@` rune, so "mail@example"
|
||||
* and "@@name" never match; group 3 captures a trailing "@" so address-shaped
|
||||
* text like "@bob@example.com" is rejected whole. Mirrors the server's
|
||||
* mentionTokenRe — a token the server would not resolve must not be
|
||||
* highlighted here either.
|
||||
*/
|
||||
export const MENTION_TOKEN_REGEX = /(^|[^\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{1,64})(@?)/gu;
|
||||
|
||||
/** A `#name` channel token. Same word-boundary rule as @tokens. */
|
||||
export const CHANNEL_TOKEN_REGEX = /(^|[^\p{L}\p{N}_#])#([\p{L}\p{N}_-]{1,64})/gu;
|
||||
|
||||
/** Reserved: a user literally named "everyone" is not reachable via @everyone. */
|
||||
export const EVERYONE_TOKEN = "everyone";
|
||||
export const HERE_TOKEN = "here";
|
||||
|
||||
/** Server-resolved mention state of one message, as carried on the wire. */
|
||||
export interface MentionInfo {
|
||||
/** Mentioned user IDs. Undefined = the server did not send them. */
|
||||
readonly mentions?: readonly number[];
|
||||
/** Whether an @everyone/@here cleared the sender's MENTION_EVERYONE gate. */
|
||||
readonly mentionsEveryone?: boolean;
|
||||
}
|
||||
|
||||
/** Whether `token` (without the leading @) is @everyone or @here. */
|
||||
export function isEveryoneToken(token: string): boolean {
|
||||
const lower = token.toLowerCase();
|
||||
return lower === EVERYONE_TOKEN || lower === HERE_TOKEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Username spellings a token may resolve to, in preference order. The trailing
|
||||
* `.`/`-` are dropped in the second spelling so "@bob." resolves to bob when no
|
||||
* user is literally named "bob." — same fallback the server applies.
|
||||
*/
|
||||
export function mentionSpellings(token: string): readonly string[] {
|
||||
const lower = token.toLowerCase();
|
||||
const trimmed = lower.replace(/[.-]+$/, "");
|
||||
return trimmed !== "" && trimmed !== lower ? [lower, trimmed] : [lower];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an @token to a user ID, or null when nothing owns that name.
|
||||
* Unresolvable tokens stay plain text — "@ hey" and "@nobody" must not read as
|
||||
* mentions.
|
||||
*
|
||||
* Two sources, in order: the user IDs the server resolved for this message
|
||||
* (preferred, so an ambiguous spelling lands on the user the server actually
|
||||
* notified), then the member list by username. A server-listed ID that the
|
||||
* member list cannot name — a user who has since left — stays unresolved and
|
||||
* therefore unhighlighted; nothing else can spell it.
|
||||
*/
|
||||
export function resolveMentionUserId(token: string, info?: MentionInfo): number | null {
|
||||
if (isEveryoneToken(token)) return null;
|
||||
const spellings = mentionSpellings(token);
|
||||
const members = membersStore.getState().members;
|
||||
const matches = (username: string): boolean => spellings.includes(username.toLowerCase());
|
||||
|
||||
for (const id of info?.mentions ?? []) {
|
||||
const member = members.get(id);
|
||||
if (member !== undefined && matches(member.username)) return id;
|
||||
}
|
||||
for (const member of members.values()) {
|
||||
if (matches(member.username)) return member.id;
|
||||
}
|
||||
// The signed-in user is not always in the member map (DM-only views), but a
|
||||
// mention of oneself must still highlight.
|
||||
const me = authStore.getState().user;
|
||||
if (me != null && matches(me.username)) return me.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** IDs of every @token in `content` that resolves to a known user. */
|
||||
export function resolveMentionsFromContent(content: string): number[] {
|
||||
const ids: number[] = [];
|
||||
for (const match of content.matchAll(MENTION_TOKEN_REGEX)) {
|
||||
if (match[3] === "@") continue;
|
||||
const token = match[2];
|
||||
if (token === undefined || isEveryoneToken(token)) continue;
|
||||
const id = resolveMentionUserId(token);
|
||||
if (id !== null && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this message mentions the signed-in user by name. @everyone/@here is
|
||||
* deliberately excluded — callers that treat it as a mention say so explicitly,
|
||||
* because the two are suppressed independently.
|
||||
*/
|
||||
export function mentionsCurrentUser(content: string, info?: MentionInfo): boolean {
|
||||
const me = authStore.getState().user;
|
||||
if (me == null) return false;
|
||||
if (info?.mentions !== undefined) return info.mentions.includes(me.id);
|
||||
return resolveMentionsFromContent(content).includes(me.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this message should highlight for the signed-in user: a direct
|
||||
* mention, or an @everyone/@here the server honoured. An @everyone token from
|
||||
* a sender without MENTION_EVERYONE carries no mention semantics, so it never
|
||||
* highlights.
|
||||
*/
|
||||
export function highlightsCurrentUser(content: string, info?: MentionInfo): boolean {
|
||||
if (info?.mentionsEveryone === true) return true;
|
||||
return mentionsCurrentUser(content, info);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Single entry point for "jump to this message", mirroring channel-navigation.
|
||||
*
|
||||
* Every affordance that can jump — a search hit, a pinned entry, a reply bar,
|
||||
* an `owncord://message/…` permalink pasted into chat, a permalink opened from
|
||||
* the OS — routes through jumpToMessage so they all share one implementation:
|
||||
* open the channel if needed, fetch the around-window when the target is not
|
||||
* loaded, scroll to it and flash it.
|
||||
*
|
||||
* The real implementation lives in the main page (it needs the API client and
|
||||
* the mounted MessageList), so it registers itself here at mount time. Before
|
||||
* registration — and in unit tests that never mount a page — jumping is a
|
||||
* logged no-op rather than a crash.
|
||||
*/
|
||||
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("message-nav");
|
||||
|
||||
export type MessageJumpHandler = (channelId: number, messageId: number) => void;
|
||||
|
||||
let handler: MessageJumpHandler | null = null;
|
||||
|
||||
/**
|
||||
* Install the jump implementation. Returns an unregister function; calling it
|
||||
* only clears the handler if it is still the one installed here, so a late
|
||||
* teardown cannot wipe a newer page's handler.
|
||||
*/
|
||||
export function setMessageJumpHandler(fn: MessageJumpHandler): () => void {
|
||||
handler = fn;
|
||||
return () => {
|
||||
if (handler === fn) handler = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Jump to a message. No-op (logged) when no page has registered a handler. */
|
||||
export function jumpToMessage(channelId: number, messageId: number): void {
|
||||
if (handler === null) {
|
||||
log.debug("Jump requested with no handler registered", { channelId, messageId });
|
||||
return;
|
||||
}
|
||||
handler(channelId, messageId);
|
||||
}
|
||||
|
||||
/** Whether a jump would currently reach a handler. Used by tests and guards. */
|
||||
export function hasMessageJumpHandler(): boolean {
|
||||
return handler !== null;
|
||||
}
|
||||
@@ -138,3 +138,97 @@ export function createModal(
|
||||
destroy: handleClose,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptModalOptions {
|
||||
readonly title: string;
|
||||
readonly label?: string;
|
||||
readonly initialValue?: string;
|
||||
readonly placeholder?: string;
|
||||
readonly maxLength?: number;
|
||||
readonly confirmLabel?: string;
|
||||
/** Called with the trimmed value. Not called when the user cancels. */
|
||||
readonly onSubmit: (value: string) => void;
|
||||
readonly onClose?: () => void;
|
||||
readonly testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-field prompt: title, text input, confirm/cancel.
|
||||
*
|
||||
* Exists because `window.prompt` is unavailable in the Tauri webview and
|
||||
* because a hand-rolled overlay per caller is three chances to forget Escape
|
||||
* handling. An empty value is a legitimate submission — clearing a group DM's
|
||||
* name is exactly how you say "go back to listing the members".
|
||||
*/
|
||||
export function createPromptModal(
|
||||
options: PromptModalOptions,
|
||||
container: Element = document.body,
|
||||
): ModalInstance {
|
||||
const content = createElement("div", { style: "padding:20px;min-width:280px;" });
|
||||
const heading = createElement("h3", {}, options.title);
|
||||
content.appendChild(heading);
|
||||
|
||||
if (options.label !== undefined) {
|
||||
content.appendChild(
|
||||
createElement(
|
||||
"p",
|
||||
{ style: "color:var(--text-secondary);font-size:0.85rem;margin:0 0 8px;" },
|
||||
options.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const input = createElement("input", {
|
||||
type: "text",
|
||||
class: "modal-prompt-input",
|
||||
placeholder: options.placeholder ?? "",
|
||||
maxlength: String(options.maxLength ?? 100),
|
||||
"data-testid": options.testId ?? "prompt-input",
|
||||
style: "width:100%;",
|
||||
});
|
||||
input.value = options.initialValue ?? "";
|
||||
content.appendChild(input);
|
||||
|
||||
const row = createElement("div", {
|
||||
style: "display:flex;gap:8px;margin-top:12px;",
|
||||
});
|
||||
const confirm = createElement(
|
||||
"button",
|
||||
{ class: "btn btn-primary", style: "flex:1;", "data-testid": "prompt-confirm" },
|
||||
options.confirmLabel ?? "Save",
|
||||
);
|
||||
const cancel = createElement(
|
||||
"button",
|
||||
{ class: "btn btn-secondary", style: "flex:1;", "data-testid": "prompt-cancel" },
|
||||
"Cancel",
|
||||
);
|
||||
row.appendChild(confirm);
|
||||
row.appendChild(cancel);
|
||||
content.appendChild(row);
|
||||
|
||||
const instance = createModal(
|
||||
{ content, onClose: options.onClose, className: "modal-prompt" },
|
||||
container,
|
||||
);
|
||||
|
||||
const submit = (): void => {
|
||||
const value = input.value.trim();
|
||||
instance.close();
|
||||
options.onSubmit(value);
|
||||
};
|
||||
confirm.addEventListener("click", submit);
|
||||
cancel.addEventListener("click", () => instance.close());
|
||||
input.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
input.focus();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { loadPref } from "./preferences";
|
||||
import { notificationAllowed } from "./channel-mutes";
|
||||
import { loadUserStatus } from "./userStatus";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import type { ChatMessagePayload } from "./types";
|
||||
import { mentionsCurrentUser } from "./mentions";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("notifications");
|
||||
@@ -17,11 +19,6 @@ function isWindowFocused(): boolean {
|
||||
return document.hasFocus();
|
||||
}
|
||||
|
||||
/** Check if message content contains @everyone or @here. */
|
||||
function containsEveryone(content: string): boolean {
|
||||
return content.includes("@everyone") || content.includes("@here");
|
||||
}
|
||||
|
||||
/** Get the channel name for a given channel ID. */
|
||||
function getChannelName(channelId: number): string {
|
||||
const channels = channelsStore.getState().channels;
|
||||
@@ -47,11 +44,30 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
|
||||
const activeChannelId = channelsStore.getState().activeChannelId;
|
||||
if (isWindowFocused() && payload.channel_id === activeChannelId) return;
|
||||
|
||||
// Check @everyone suppression
|
||||
if (loadPref<boolean>("suppressEveryone", false) && containsEveryone(payload.content)) {
|
||||
const mentionInfo = {
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
};
|
||||
const directMention = mentionsCurrentUser(payload.content, mentionInfo);
|
||||
const everyoneMention = payload.mentions_everyone === true;
|
||||
|
||||
// "Suppress @everyone" now means exactly that: only a notification the
|
||||
// @everyone/@here caused is dropped. A message that also names the user is
|
||||
// theirs to see, and an @everyone the sender lacked the permission for never
|
||||
// reached mention status in the first place, so it is not suppressed either.
|
||||
if (loadPref<boolean>("suppressEveryone", false) && everyoneMention && !directMention) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mentioned = directMention || everyoneMention;
|
||||
|
||||
// A muted channel stops making noise entirely — popup, chime AND taskbar
|
||||
// flash, because a flashing taskbar is exactly the interruption the mute was
|
||||
// asked for. The unread badge is untouched (it is drawn from the store, not
|
||||
// from here) and just renders dimmed. A message that names the reader is
|
||||
// never silenced: see @lib/channel-mutes.
|
||||
if (!notificationAllowed(payload.channel_id, mentioned)) return;
|
||||
|
||||
// Do Not Disturb — the settings panel promises "You will not receive desktop
|
||||
// notifications", so honour it for the popup and the chime. The taskbar
|
||||
// flash stays: it's a passive hint, not a notification.
|
||||
@@ -66,7 +82,12 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
|
||||
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned;
|
||||
}
|
||||
|
||||
const title = sanitizeNotif(`${payload.user.username} in #${channelName}`, 80);
|
||||
const title = sanitizeNotif(
|
||||
mentioned
|
||||
? `${payload.user.username} mentioned you in #${channelName}`
|
||||
: `${payload.user.username} in #${channelName}`,
|
||||
80,
|
||||
);
|
||||
const body = sanitizeNotif(payload.content, 100);
|
||||
|
||||
// Desktop notification
|
||||
@@ -137,6 +158,7 @@ let notifAudioCtx: AudioContext | null = null;
|
||||
|
||||
/** Close and release the notification AudioContext. Call on logout/cleanup. */
|
||||
export function cleanupNotificationAudio(): void {
|
||||
stopRingChime();
|
||||
if (notifAudioCtx !== null) {
|
||||
notifAudioCtx.close().catch((err) => {
|
||||
log.warn("Failed to close notification AudioContext", err);
|
||||
@@ -145,6 +167,30 @@ export function cleanupNotificationAudio(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// The ring chime repeats until the call is answered, declined or times out —
|
||||
// unlike a message chime, which fires once. It reuses playNotificationSound so
|
||||
// a call sounds like the app rather than like a second app.
|
||||
let ringInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Start the repeating incoming-call chime. Idempotent. */
|
||||
export function startRingChime(): void {
|
||||
if (ringInterval !== null) return;
|
||||
// DND silences a call chime for the same reason it silences a message one:
|
||||
// the settings panel promises no notification sounds, and a ringing phone is
|
||||
// the loudest possible violation of that. The banner still appears.
|
||||
if (loadUserStatus() === "dnd") return;
|
||||
if (!loadPref<boolean>("notificationSounds", true)) return;
|
||||
playNotificationSound();
|
||||
ringInterval = setInterval(() => playNotificationSound(), 2000);
|
||||
}
|
||||
|
||||
/** Stop the repeating incoming-call chime. Idempotent. */
|
||||
export function stopRingChime(): void {
|
||||
if (ringInterval === null) return;
|
||||
clearInterval(ringInterval);
|
||||
ringInterval = null;
|
||||
}
|
||||
|
||||
/** Play a brief notification chime. */
|
||||
function playNotificationSound(): void {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Per-session acknowledgement of a channel's NSFW flag.
|
||||
*
|
||||
* The server treats `nsfw` as a label and nothing more — it stores it,
|
||||
* broadcasts it and audits an operator flipping it, but applies no content
|
||||
* behaviour of its own: no filtering, no age check, no restriction on who may
|
||||
* read or post. Everything a user experiences from the flag is decided here.
|
||||
*
|
||||
* What this client decides: the first time a session opens a flagged channel,
|
||||
* show a warning the reader must accept before its messages are rendered.
|
||||
*
|
||||
* Remembered in **sessionStorage**, deliberately, not localStorage: the promise
|
||||
* this makes is "once per session", so closing the app and coming back asks
|
||||
* again. It is a courtesy prompt, not a security control — a determined reader
|
||||
* clears the key, and the messages were never withheld by the server anyway.
|
||||
*/
|
||||
|
||||
const STORAGE_PREFIX = "owncord:nsfw-ack:";
|
||||
|
||||
function storageKey(channelId: number): string {
|
||||
return `${STORAGE_PREFIX}${channelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this session has already accepted the warning for `channelId`.
|
||||
*
|
||||
* A sessionStorage that throws (private modes, a sandboxed webview, a disabled
|
||||
* storage partition) is read as "not acknowledged": erring toward showing the
|
||||
* prompt again is the harmless direction, where erring the other way would
|
||||
* silently drop the gate the flag exists to produce.
|
||||
*/
|
||||
export function isNsfwAcknowledged(channelId: number): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(storageKey(channelId)) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that this session accepted the warning for `channelId`. */
|
||||
export function acknowledgeNsfw(channelId: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey(channelId), "1");
|
||||
} catch {
|
||||
// Storage unavailable — the gate simply asks again next time. Nothing to
|
||||
// recover from, and failing the channel open over it would be absurd.
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every stored acknowledgement (used by tests and by logout). */
|
||||
export function clearNsfwAcknowledgements(): void {
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key !== null && key.startsWith(STORAGE_PREFIX)) keys.push(key);
|
||||
}
|
||||
for (const key of keys) sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// Nothing stored means nothing to clear.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening `channel` should show the age gate: flagged, and not yet
|
||||
* acknowledged in this session.
|
||||
*/
|
||||
export function nsfwGateRequired(channel: { id: number; nsfw: boolean }): boolean {
|
||||
return channel.nsfw && !isNsfwAcknowledged(channel.id);
|
||||
}
|
||||
@@ -58,6 +58,45 @@ export function isAdministrator(userPerms: number): boolean {
|
||||
return (userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission mask for a role name, from the role list the server sends in
|
||||
* `ready`. Returns null when that list has no matching entry (pre-`ready`, or
|
||||
* an older server that sent none) so callers can distinguish "unknown role"
|
||||
* from "role with no bits" and fall back instead of hiding everything.
|
||||
*/
|
||||
export function permissionsForRole(roleName: string): number | null {
|
||||
const name = roleName.toLowerCase();
|
||||
const role = channelsStore.getState().roles.find((r) => r.name.toLowerCase() === name);
|
||||
return role?.permissions ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy owner/admin name check. Only meaningful as a fallback for servers
|
||||
* that send no role list; the permission mask is authoritative whenever one
|
||||
* is available.
|
||||
*/
|
||||
export function isLegacyAdminRole(roleName: string): boolean {
|
||||
const name = roleName.toLowerCase();
|
||||
return name === "owner" || name === "admin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `roleName` grants `perm`, from the role list the server sends in
|
||||
* `ready`. When that list has no matching entry (pre-`ready`, or an older
|
||||
* server that sent none) the legacy owner/admin name check stands in — a mask
|
||||
* of 0 would otherwise hide moderation from every actual admin.
|
||||
*
|
||||
* This is the single derivation every moderation affordance uses, so the
|
||||
* member-list gates and the voice moderation menu cannot drift apart. Drives
|
||||
* affordances only — the server is still the authority on every action, and
|
||||
* enforces the rank rule the client cannot evaluate.
|
||||
*/
|
||||
export function roleHasPermission(roleName: string, perm: Permission): boolean {
|
||||
const perms = permissionsForRole(roleName);
|
||||
if (perms === null) return isLegacyAdminRole(roleName);
|
||||
return hasPermission(perms, perm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective permission bits for the signed-in user, from the role list the
|
||||
* server sends in `ready`. Returns 0 when the role is unknown (pre-`ready`,
|
||||
@@ -66,10 +105,7 @@ export function isAdministrator(userPerms: number): boolean {
|
||||
export function currentUserPermissions(): number {
|
||||
const roleName = authStore.getState().user?.role;
|
||||
if (roleName === undefined || roleName === null) return 0;
|
||||
const role = channelsStore
|
||||
.getState()
|
||||
.roles.find((r) => r.name.toLowerCase() === roleName.toLowerCase());
|
||||
return role?.permissions ?? 0;
|
||||
return permissionsForRole(roleName) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,3 +120,29 @@ export function currentUserHasPermission(perm: Permission): boolean {
|
||||
export function canManageMessages(): boolean {
|
||||
return currentUserHasPermission(Permission.MANAGE_MESSAGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signed-in user's role holds MANAGE_CHANNELS — create, edit,
|
||||
* delete and reorder channels, all of which the server gates on the same bit
|
||||
* behind `/admin/api/channels*`.
|
||||
*
|
||||
* Routed through `roleHasPermission` rather than `currentUserHasPermission` so
|
||||
* a server that sent no role list still shows the affordances to owner/admin
|
||||
* instead of hiding channel management from everyone. The one derivation for
|
||||
* every channel-management affordance, so the category "+", the context menu
|
||||
* and the audit-log entry cannot disagree about who may manage channels.
|
||||
*/
|
||||
export function canManageChannels(): boolean {
|
||||
const roleName = authStore.getState().user?.role ?? "";
|
||||
return roleHasPermission(roleName, Permission.MANAGE_CHANNELS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signed-in user's role holds VIEW_AUDIT_LOG. Gates the desktop
|
||||
* entry point into the admin panel's audit log; the panel re-checks the bit
|
||||
* on every request.
|
||||
*/
|
||||
export function canViewAuditLog(): boolean {
|
||||
const roleName = authStore.getState().user?.role ?? "";
|
||||
return roleHasPermission(roleName, Permission.VIEW_AUDIT_LOG);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ServerMessageType = {
|
||||
CHAT_SEND_OK: "chat_send_ok",
|
||||
CHAT_EDITED: "chat_edited",
|
||||
CHAT_DELETED: "chat_deleted",
|
||||
CHAT_BULK_DELETED: "chat_bulk_deleted",
|
||||
REACTION_UPDATE: "reaction_update",
|
||||
TYPING: "typing",
|
||||
PRESENCE: "presence",
|
||||
@@ -30,16 +31,22 @@ export const ServerMessageType = {
|
||||
VOICE_TOKEN: "voice_token",
|
||||
VOICE_SPEAKERS: "voice_speakers",
|
||||
VOICE_LEAVE: "voice_leave", // broadcast (same string as client msg)
|
||||
VOICE_MOVED: "voice_moved",
|
||||
VOICE_DISCONNECTED: "voice_disconnected",
|
||||
MEMBER_JOIN: "member_join",
|
||||
MEMBER_LEAVE: "member_leave",
|
||||
MEMBER_UPDATE: "member_update",
|
||||
USER_UPDATE: "user_update",
|
||||
MEMBER_BAN: "member_ban",
|
||||
ROLES_UPDATE: "roles_update",
|
||||
EMOJI_UPDATE: "emoji_update",
|
||||
SERVER_RESTART: "server_restart",
|
||||
ERROR: "error",
|
||||
PONG: "pong",
|
||||
DM_CHANNEL_OPEN: "dm_channel_open",
|
||||
DM_CHANNEL_CLOSE: "dm_channel_close",
|
||||
CALL_INCOMING: "call_incoming",
|
||||
CALL_DECLINED: "call_declined",
|
||||
VOICE_E2EE_ANNOUNCE: "voice_e2ee_announce", // broadcast (same string as client msg)
|
||||
VOICE_E2EE_OFFER: "voice_e2ee_offer", // relay (same string as client msg)
|
||||
} as const;
|
||||
@@ -59,6 +66,7 @@ export const ClientMessageType = {
|
||||
REACTION_REMOVE: "reaction_remove",
|
||||
TYPING_START: "typing_start",
|
||||
CHANNEL_FOCUS: "channel_focus",
|
||||
MARK_READ: "mark_read",
|
||||
PRESENCE_UPDATE: "presence_update",
|
||||
VOICE_JOIN: "voice_join",
|
||||
VOICE_LEAVE: "voice_leave",
|
||||
@@ -66,10 +74,16 @@ export const ClientMessageType = {
|
||||
VOICE_DEAFEN: "voice_deafen",
|
||||
VOICE_CAMERA: "voice_camera",
|
||||
VOICE_SCREENSHARE: "voice_screenshare",
|
||||
VOICE_MOD_MUTE: "voice_mod_mute",
|
||||
VOICE_MOD_DEAFEN: "voice_mod_deafen",
|
||||
VOICE_MOD_MOVE: "voice_mod_move",
|
||||
VOICE_MOD_KICK: "voice_mod_kick",
|
||||
PING: "ping",
|
||||
VOICE_TOKEN_REFRESH: "voice_token_refresh",
|
||||
VOICE_E2EE_ANNOUNCE: "voice_e2ee_announce",
|
||||
VOICE_E2EE_OFFER: "voice_e2ee_offer",
|
||||
CALL_RING: "call_ring",
|
||||
CALL_DECLINE: "call_decline",
|
||||
} as const;
|
||||
|
||||
export type ClientMessageTypeValue = (typeof ClientMessageType)[keyof typeof ClientMessageType];
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Explicit mark-as-read, for affordances that clear a badge without opening
|
||||
* the channel (the channel context menu, "Mark All as Read").
|
||||
*
|
||||
* Opening a channel already marks it read via `channel_focus`. That message
|
||||
* also rebinds the connection's focused channel, so it is the wrong tool here:
|
||||
* marking a channel the user is *not* looking at must not move focus off the
|
||||
* one on screen. The server has a dedicated `mark_read` for exactly this.
|
||||
*/
|
||||
|
||||
import { channelsStore, clearUnread } from "@stores/channels.store";
|
||||
import { dmStore, clearDmUnread } from "@stores/dm.store";
|
||||
|
||||
/** Sends one `mark_read` over the socket. */
|
||||
export type MarkReadSender = (channelId: number) => void;
|
||||
|
||||
let sender: MarkReadSender | null = null;
|
||||
|
||||
/**
|
||||
* Register the socket sender. Called once from MainPage with the live WsClient,
|
||||
* mirroring how the attachment renderer is given the server host. Until it is
|
||||
* set, marking read still clears the local badges — the next `ready` re-asserts
|
||||
* the server's view, so a dropped send self-corrects rather than lying forever.
|
||||
*/
|
||||
export function setMarkReadSender(next: MarkReadSender | null): void {
|
||||
sender = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one channel read: advance the server read state and drop the local
|
||||
* unread/mention badges. Works for DMs too — the badge lives in dm.store for
|
||||
* those, and clearing the other store is a no-op.
|
||||
*
|
||||
* No-op for a channel this client does not know, so a stale menu cannot ask the
|
||||
* server to advance a read state for something that is not in the user's list.
|
||||
*/
|
||||
export function markChannelRead(channelId: number): void {
|
||||
const known =
|
||||
channelsStore.getState().channels.has(channelId) ||
|
||||
dmStore.getState().channels.some((c) => c.channelId === channelId);
|
||||
if (!known) return;
|
||||
|
||||
sender?.(channelId);
|
||||
clearUnread(channelId);
|
||||
clearDmUnread(channelId);
|
||||
}
|
||||
|
||||
/** Whether a channel currently shows an unread or mention badge — what decides
|
||||
* if "Mark as Read" is offered as an enabled action. */
|
||||
export function hasUnread(channelId: number): boolean {
|
||||
const ch = channelsStore.getState().channels.get(channelId);
|
||||
if (ch !== undefined && (ch.unreadCount > 0 || ch.mentionCount > 0)) return true;
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
return dm !== undefined && (dm.unreadCount > 0 || dm.mentionCount > 0);
|
||||
}
|
||||
|
||||
/** Ids of every channel and DM that currently shows a badge. */
|
||||
export function unreadChannelIds(): readonly number[] {
|
||||
const ids = new Set<number>();
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.unreadCount > 0 || ch.mentionCount > 0) ids.add(ch.id);
|
||||
}
|
||||
for (const dm of dmStore.getState().channels) {
|
||||
if (dm.unreadCount > 0 || dm.mentionCount > 0) ids.add(dm.channelId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every unread channel and DM read. Returns how many were marked, so the
|
||||
* caller can stay silent when there was nothing to do.
|
||||
*/
|
||||
export function markAllRead(): number {
|
||||
const ids = unreadChannelIds();
|
||||
for (const id of ids) markChannelRead(id);
|
||||
return ids.length;
|
||||
}
|
||||
@@ -8,8 +8,15 @@
|
||||
// Common / Shared Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Status values allowed by the protocol. */
|
||||
export type UserStatus = "online" | "idle" | "dnd" | "offline";
|
||||
/**
|
||||
* Status values allowed by the protocol.
|
||||
*
|
||||
* "invisible" is a real, settable status since phase 6: the server stores it
|
||||
* as chosen and maps it to "offline" for every OTHER user, so the owner keeps
|
||||
* seeing their own true state. "offline" is what the server broadcasts for a
|
||||
* user with no live session — it is no longer something a user picks.
|
||||
*/
|
||||
export type UserStatus = "online" | "idle" | "dnd" | "invisible" | "offline";
|
||||
|
||||
/** Channel types supported by the server. */
|
||||
export type ChannelType = "text" | "voice" | "announcement" | "dm";
|
||||
@@ -71,12 +78,21 @@ export interface MessageUser {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
/** Nickname to render instead of `username`. Absent/null = use `username`.
|
||||
* Mentions still resolve against `username`, which is the unique handle. */
|
||||
readonly display_name?: string | null;
|
||||
}
|
||||
|
||||
/** User object with role, used in auth_ok and member_join. */
|
||||
export interface UserWithRole extends MessageUser {
|
||||
readonly role: string;
|
||||
readonly totp_enabled?: boolean;
|
||||
/** The signed-in user's own profile text. Null = unset. */
|
||||
readonly about?: string | null;
|
||||
readonly custom_status?: string | null;
|
||||
/** The signed-in user's OWN true status, "invisible" included. Only ever
|
||||
* present on their own auth_ok / REST me payload. */
|
||||
readonly status?: UserStatus;
|
||||
/** Long-term E2EE identity public key (base64), pinned by peers on first
|
||||
* sight (F3 TOFU). Omitted/null when the user has not published one. */
|
||||
readonly identity_public_key?: string | null;
|
||||
@@ -110,6 +126,8 @@ export interface ReadyChannel {
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
/** Channel topic ("" = none). Absent from older servers. */
|
||||
readonly topic?: string;
|
||||
readonly position: number;
|
||||
readonly unread_count?: number;
|
||||
readonly last_message_id?: number;
|
||||
@@ -125,6 +143,26 @@ export interface ReadyChannel {
|
||||
* slow-mode countdown; the server still enforces. Absent from older servers.
|
||||
*/
|
||||
readonly slow_mode?: number;
|
||||
/**
|
||||
* Unread messages in this channel that mention the current user (directly or
|
||||
* via @everyone/@here). Always ≤ unread_count. Absent from older servers.
|
||||
*/
|
||||
readonly mention_count?: number;
|
||||
/**
|
||||
* Whether the channel is flagged as possibly carrying sensitive content.
|
||||
* A pure label: the server stores and ships it but applies no content
|
||||
* behaviour of its own, so what it means is entirely this client's choice
|
||||
* (a one-time-per-session age gate and a sidebar marker). Absent from older
|
||||
* servers, which is read as "not flagged".
|
||||
*/
|
||||
readonly nsfw?: boolean;
|
||||
/**
|
||||
* Voice capacity limits (0 = unlimited), the same values the server enforces
|
||||
* on join with CHANNEL_FULL / VIDEO_LIMIT. Shipped so the sidebar can show
|
||||
* "3/5"; the client enforces nothing. Absent from older servers.
|
||||
*/
|
||||
readonly voice_max_users?: number;
|
||||
readonly voice_max_video?: number;
|
||||
}
|
||||
|
||||
/** Member object in the ready payload. */
|
||||
@@ -134,6 +172,10 @@ export interface ReadyMember {
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
/** Nickname to render instead of `username`. Null = unset. */
|
||||
readonly display_name?: string | null;
|
||||
/** Free-text status line shown under the name. Null = unset. */
|
||||
readonly custom_status?: string | null;
|
||||
/** Long-term E2EE identity public key (base64) for voice TOFU (F3). */
|
||||
readonly identity_public_key?: string | null;
|
||||
}
|
||||
@@ -144,14 +186,26 @@ export interface ReadyVoiceState {
|
||||
readonly user_id: number;
|
||||
readonly muted: boolean;
|
||||
readonly deafened: boolean;
|
||||
/** Moderator-imposed; optional so an older server's payload still parses. */
|
||||
readonly server_muted?: boolean;
|
||||
readonly server_deafened?: boolean;
|
||||
}
|
||||
|
||||
/** Role object in the ready payload. */
|
||||
/** Role object in the ready payload and in roles_update. */
|
||||
export interface ReadyRole {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly color: string | null;
|
||||
readonly permissions: number;
|
||||
/**
|
||||
* Hierarchy rank — higher outranks lower. Optional because servers predating
|
||||
* role management shipped the list without it; nothing in the client sorts
|
||||
* on it yet, but role management makes positions mutable, so a stale copy
|
||||
* must be replaceable rather than inferred from list order.
|
||||
*/
|
||||
readonly position?: number;
|
||||
/** True for the fallback role members land on when their role is deleted. */
|
||||
readonly is_default?: boolean;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -172,6 +226,7 @@ export enum Permission {
|
||||
KICK_MEMBERS = 0x40000,
|
||||
BAN_MEMBERS = 0x80000,
|
||||
MUTE_MEMBERS = 0x100000,
|
||||
MENTION_EVERYONE = 0x200000,
|
||||
MANAGE_ROLES = 0x1000000,
|
||||
MANAGE_SERVER = 0x2000000,
|
||||
MANAGE_INVITES = 0x4000000,
|
||||
@@ -220,6 +275,18 @@ export interface ChatMessagePayload {
|
||||
readonly reply_to: number | null;
|
||||
readonly attachments: readonly Attachment[];
|
||||
readonly timestamp: string;
|
||||
/**
|
||||
* Server-resolved user IDs this message mentions, ordered by first
|
||||
* appearance. Absent from older servers — callers fall back to resolving
|
||||
* @tokens against the member list. Never contains @everyone/@here.
|
||||
*/
|
||||
readonly mentions?: readonly number[];
|
||||
/**
|
||||
* Whether an @everyone/@here in the content cleared the sender's
|
||||
* MENTION_EVERYONE gate. A token without the bit carries no mention
|
||||
* semantics at all. Absent from older servers.
|
||||
*/
|
||||
readonly mentions_everyone?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatSendOkPayload {
|
||||
@@ -232,6 +299,9 @@ export interface ChatEditedPayload {
|
||||
readonly channel_id: number;
|
||||
readonly content: string;
|
||||
readonly edited_at: string;
|
||||
/** Re-resolved mentions for the new content. An edit never re-notifies. */
|
||||
readonly mentions?: readonly number[];
|
||||
readonly mentions_everyone?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatDeletedPayload {
|
||||
@@ -239,6 +309,12 @@ export interface ChatDeletedPayload {
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
/** Bulk moderator delete (channel purge). `ids` is newest-first and never null. */
|
||||
export interface ChatBulkDeletedPayload {
|
||||
readonly channel_id: number;
|
||||
readonly ids: readonly number[];
|
||||
}
|
||||
|
||||
export interface ReactionUpdatePayload {
|
||||
readonly message_id: number;
|
||||
readonly channel_id: number;
|
||||
@@ -256,6 +332,10 @@ export interface TypingPayload {
|
||||
export interface PresencePayload {
|
||||
readonly user_id: number;
|
||||
readonly status: UserStatus;
|
||||
/** The user's current custom status line. Always present on the wire
|
||||
* (null = none), so a cleared text is distinguishable from an event that
|
||||
* simply does not mention it. */
|
||||
readonly custom_status?: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelCreatePayload {
|
||||
@@ -263,15 +343,33 @@ export interface ChannelCreatePayload {
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
readonly topic?: string;
|
||||
readonly position: number;
|
||||
readonly slow_mode?: number;
|
||||
/** See ReadyChannel.nsfw — a label the server never acts on. */
|
||||
readonly nsfw?: boolean;
|
||||
/** Voice capacity limits (0 = unlimited). See ReadyChannel. */
|
||||
readonly voice_max_users?: number;
|
||||
readonly voice_max_video?: number;
|
||||
}
|
||||
|
||||
export interface ChannelUpdatePayload {
|
||||
readonly id: number;
|
||||
readonly name?: string;
|
||||
readonly topic?: string;
|
||||
/**
|
||||
* The category the channel now sits under ("" = uncategorized). Moving a
|
||||
* channel between categories is an edit, so the broadcast carries it and
|
||||
* the sidebar regroups without a reconnect.
|
||||
*/
|
||||
readonly category?: string | null;
|
||||
readonly position?: number;
|
||||
readonly slow_mode?: number;
|
||||
/** See ReadyChannel.nsfw — a label the server never acts on. */
|
||||
readonly nsfw?: boolean;
|
||||
/** Voice capacity limits (0 = unlimited). See ReadyChannel. */
|
||||
readonly voice_max_users?: number;
|
||||
readonly voice_max_video?: number;
|
||||
}
|
||||
|
||||
export interface ChannelDeletePayload {
|
||||
@@ -287,6 +385,21 @@ export interface VoiceStatePayload {
|
||||
readonly speaking: boolean;
|
||||
readonly camera: boolean;
|
||||
readonly screenshare: boolean;
|
||||
/** Moderator-imposed; the user cannot lift these themselves. Optional so an
|
||||
* older server's payload still parses. */
|
||||
readonly server_muted?: boolean;
|
||||
readonly server_deafened?: boolean;
|
||||
}
|
||||
|
||||
/** Server -> Client: a moderator moved this client to another voice channel. */
|
||||
export interface VoiceMovedPayload {
|
||||
readonly to_channel_id: number;
|
||||
}
|
||||
|
||||
/** Server -> Client: a moderator removed this client from voice. */
|
||||
export interface VoiceDisconnectedPayload {
|
||||
readonly channel_id: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface VoiceLeavePayload {
|
||||
@@ -340,12 +453,38 @@ export interface VoiceE2EEOfferPayload {
|
||||
|
||||
export interface MemberJoinPayload {
|
||||
readonly user: UserWithRole;
|
||||
/** Viewer-safe presence the connecting user comes online as (broadcast
|
||||
* collapse of their real status — an invisible connector reports
|
||||
* "offline" here, never their true chosen status). Optional only for
|
||||
* compatibility with an older server that omits it; a caller MUST treat a
|
||||
* missing value as "offline", not assume "online", so a hidden user does
|
||||
* not render visible just because the field wasn't sent yet. */
|
||||
readonly status?: UserStatus;
|
||||
}
|
||||
|
||||
export interface MemberLeavePayload {
|
||||
readonly user_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full role list after any role mutation. The server sends the whole list
|
||||
* rather than a delta, so the store is replaced wholesale — a dropped
|
||||
* intermediate event can never leave a deleted role on screen.
|
||||
*/
|
||||
export interface RolesUpdatePayload {
|
||||
readonly roles: readonly ReadyRole[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `emoji_update` — the server's whole custom-emoji set after an upload or a
|
||||
* delete. Whole-set for the same reason roles_update is: the client replaces
|
||||
* its map rather than patching it, so a dropped event cannot leave a deleted
|
||||
* emoji rendering.
|
||||
*/
|
||||
export interface EmojiUpdatePayload {
|
||||
readonly emoji: readonly EmojiResponse[];
|
||||
}
|
||||
|
||||
export interface MemberUpdatePayload {
|
||||
readonly user_id: number;
|
||||
readonly role: string;
|
||||
@@ -355,6 +494,10 @@ export interface UserUpdatePayload {
|
||||
readonly user_id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
/** Always present (null = cleared): user_update replaces the client's copy
|
||||
* of the profile wholesale. */
|
||||
readonly display_name?: string | null;
|
||||
readonly about?: string | null;
|
||||
/** Updated E2EE identity public key (base64) — lets peers detect an
|
||||
* identity-key change (TOFU mismatch) as it happens (F3). */
|
||||
readonly identity_public_key?: string | null;
|
||||
@@ -374,25 +517,53 @@ export interface DmRecipient {
|
||||
readonly username: string;
|
||||
readonly avatar: string;
|
||||
readonly status: string;
|
||||
/** Chosen nickname, "" when unset. Absent from pre-phase-6 servers. */
|
||||
readonly display_name?: string;
|
||||
}
|
||||
|
||||
/** DM channel object in ready payload and dm_channel_open event. */
|
||||
export interface DmChannelPayload {
|
||||
readonly channel_id: number;
|
||||
/**
|
||||
* The other participant of a 1:1 DM. Retained for backward compatibility;
|
||||
* for a group it carries the first of `recipients` so an older payload
|
||||
* shape still renders something. Prefer `recipients`.
|
||||
*/
|
||||
readonly recipient: DmRecipient;
|
||||
/**
|
||||
* Every participant except the current user. Absent from pre-group servers,
|
||||
* where `recipient` is the whole membership.
|
||||
*/
|
||||
readonly recipients?: readonly DmRecipient[];
|
||||
/** Optional group name. "" (or absent) for a 1:1 DM. */
|
||||
readonly name?: string;
|
||||
/** True for a group DM. Absent from pre-group servers, which had none. */
|
||||
readonly is_group?: boolean;
|
||||
readonly last_message_id: number | null;
|
||||
readonly last_message: string;
|
||||
readonly last_message_at: string;
|
||||
readonly unread_count: number;
|
||||
/**
|
||||
* Unread messages in this DM that mention the current user. Absent from
|
||||
* older servers, which shipped no DM mention state at all — treat as 0.
|
||||
*/
|
||||
readonly mention_count?: number;
|
||||
}
|
||||
|
||||
export interface DmChannelOpenPayload {
|
||||
/** dm_channel_open carries the same shape as a ready-payload DM entry. */
|
||||
export type DmChannelOpenPayload = DmChannelPayload;
|
||||
|
||||
/** call_incoming / call_declined. Ephemeral: there is no call id because a
|
||||
* call is presence in the DM's voice channel, not a server-side record. */
|
||||
export interface CallSignalPayload {
|
||||
readonly channel_id: number;
|
||||
readonly from_user: number;
|
||||
readonly username: string;
|
||||
}
|
||||
|
||||
/** call_ring / call_decline (client → server). */
|
||||
export interface CallSignalRequestPayload {
|
||||
readonly channel_id: number;
|
||||
readonly recipient: DmRecipient;
|
||||
readonly last_message_id: number | null;
|
||||
readonly last_message: string;
|
||||
readonly last_message_at: string;
|
||||
readonly unread_count: number;
|
||||
}
|
||||
|
||||
export interface DmChannelClosePayload {
|
||||
@@ -452,8 +623,20 @@ export interface ChannelFocusPayload {
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* mark_read — advance the read state for a channel the user is *not* viewing.
|
||||
* Same shape as channel_focus, deliberately a different message: focus also
|
||||
* rebinds the connection's focused channel, which would be wrong here.
|
||||
*/
|
||||
export interface MarkReadPayload {
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
export interface PresenceUpdatePayload {
|
||||
readonly status: UserStatus;
|
||||
/** Omitted = leave the stored text alone (what the auto-idle timer sends);
|
||||
* "" = clear it. */
|
||||
readonly custom_status?: string;
|
||||
}
|
||||
|
||||
export interface VoiceJoinPayload {
|
||||
@@ -479,6 +662,29 @@ export interface VoiceScreensharePayload {
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
/** Client -> Server: moderator sets another user's server mute. channel_id is
|
||||
* the channel the moderator sees them in; the server refuses a mismatch. */
|
||||
export interface VoiceModMutePayload {
|
||||
readonly channel_id: number;
|
||||
readonly user_id: number;
|
||||
readonly muted: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceModDeafenPayload {
|
||||
readonly channel_id: number;
|
||||
readonly user_id: number;
|
||||
readonly deafened: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceModMovePayload {
|
||||
readonly user_id: number;
|
||||
readonly to_channel_id: number;
|
||||
}
|
||||
|
||||
export interface VoiceModKickPayload {
|
||||
readonly user_id: number;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Discriminated Union: Server → Client Messages
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -491,6 +697,7 @@ export type ServerMessage =
|
||||
| (WsEnvelope<ChatSendOkPayload> & { readonly type: "chat_send_ok" })
|
||||
| (WsEnvelope<ChatEditedPayload> & { readonly type: "chat_edited" })
|
||||
| (WsEnvelope<ChatDeletedPayload> & { readonly type: "chat_deleted" })
|
||||
| (WsEnvelope<ChatBulkDeletedPayload> & { readonly type: "chat_bulk_deleted" })
|
||||
| (WsEnvelope<ReactionUpdatePayload> & { readonly type: "reaction_update" })
|
||||
| (WsEnvelope<TypingPayload> & { readonly type: "typing" })
|
||||
| (WsEnvelope<PresencePayload> & { readonly type: "presence" })
|
||||
@@ -502,6 +709,8 @@ export type ServerMessage =
|
||||
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
|
||||
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
|
||||
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
|
||||
| (WsEnvelope<VoiceMovedPayload> & { readonly type: "voice_moved" })
|
||||
| (WsEnvelope<VoiceDisconnectedPayload> & { readonly type: "voice_disconnected" })
|
||||
| (WsEnvelope<VoiceE2EEAnnouncePayload> & { readonly type: "voice_e2ee_announce" })
|
||||
| (WsEnvelope<VoiceE2EEOfferPayload> & { readonly type: "voice_e2ee_offer" })
|
||||
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
|
||||
@@ -509,8 +718,12 @@ export type ServerMessage =
|
||||
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
|
||||
| (WsEnvelope<UserUpdatePayload> & { readonly type: "user_update" })
|
||||
| (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" })
|
||||
| (WsEnvelope<RolesUpdatePayload> & { readonly type: "roles_update" })
|
||||
| (WsEnvelope<EmojiUpdatePayload> & { readonly type: "emoji_update" })
|
||||
| (WsEnvelope<DmChannelOpenPayload> & { readonly type: "dm_channel_open" })
|
||||
| (WsEnvelope<DmChannelClosePayload> & { readonly type: "dm_channel_close" })
|
||||
| (WsEnvelope<CallSignalPayload> & { readonly type: "call_incoming" })
|
||||
| (WsEnvelope<CallSignalPayload> & { readonly type: "call_declined" })
|
||||
| (WsEnvelope<ServerRestartPayload> & { readonly type: "server_restart" })
|
||||
| (WsEnvelope<ErrorPayload> & { readonly type: "error" });
|
||||
|
||||
@@ -527,6 +740,7 @@ export type ClientMessage =
|
||||
| (WsEnvelope<ReactionRemovePayload> & { readonly type: "reaction_remove" })
|
||||
| (WsEnvelope<TypingStartPayload> & { readonly type: "typing_start" })
|
||||
| (WsEnvelope<ChannelFocusPayload> & { readonly type: "channel_focus" })
|
||||
| (WsEnvelope<MarkReadPayload> & { readonly type: "mark_read" })
|
||||
| (WsEnvelope<PresenceUpdatePayload> & { readonly type: "presence_update" })
|
||||
| (WsEnvelope<VoiceJoinPayload> & { readonly type: "voice_join" })
|
||||
| (WsEnvelope<VoiceLeaveClientPayload> & { readonly type: "voice_leave" })
|
||||
@@ -534,13 +748,19 @@ export type ClientMessage =
|
||||
| (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" })
|
||||
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
|
||||
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
|
||||
| (WsEnvelope<VoiceModMutePayload> & { readonly type: "voice_mod_mute" })
|
||||
| (WsEnvelope<VoiceModDeafenPayload> & { readonly type: "voice_mod_deafen" })
|
||||
| (WsEnvelope<VoiceModMovePayload> & { readonly type: "voice_mod_move" })
|
||||
| (WsEnvelope<VoiceModKickPayload> & { readonly type: "voice_mod_kick" })
|
||||
| (WsEnvelope<Record<string, never>> & { readonly type: "voice_token_refresh" })
|
||||
| (WsEnvelope<{ public_key: string; signature?: string }> & {
|
||||
readonly type: "voice_e2ee_announce";
|
||||
})
|
||||
| (WsEnvelope<{ target_user_id: number; encrypted_key: string; iv: string }> & {
|
||||
readonly type: "voice_e2ee_offer";
|
||||
});
|
||||
})
|
||||
| (WsEnvelope<CallSignalRequestPayload> & { readonly type: "call_ring" })
|
||||
| (WsEnvelope<CallSignalRequestPayload> & { readonly type: "call_decline" });
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// REST API Response Types
|
||||
@@ -590,6 +810,9 @@ export interface MessageResponse {
|
||||
readonly edited_at: string | null;
|
||||
readonly deleted: boolean;
|
||||
readonly timestamp: string;
|
||||
/** Server-resolved mentioned user IDs. Absent from older servers. */
|
||||
readonly mentions?: readonly number[];
|
||||
readonly mentions_everyone?: boolean;
|
||||
}
|
||||
|
||||
/** Paginated messages response. */
|
||||
@@ -598,6 +821,43 @@ export interface MessagesResponse {
|
||||
readonly has_more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of history centred on one message, from
|
||||
* `GET /channels/{id}/messages/around/{messageId}`.
|
||||
*
|
||||
* Unlike {@link MessagesResponse}, `messages` is **oldest-first** — it is
|
||||
* already in render order and must not be reversed. `has_more_after` true
|
||||
* means the window is detached from the live tail.
|
||||
*/
|
||||
export interface MessagesAroundResponse {
|
||||
readonly messages: readonly MessageResponse[];
|
||||
readonly has_more_before: boolean;
|
||||
readonly has_more_after: boolean;
|
||||
}
|
||||
|
||||
/** One reactor in the who-reacted list. `avatar` is `""` when unset. */
|
||||
export interface ReactionUser {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who reacted to a message with one emoji, from
|
||||
* `GET /channels/{id}/messages/{messageId}/reactions/{emoji}/users`.
|
||||
* Ordered oldest reaction first and capped at 100 by the server.
|
||||
*/
|
||||
export interface ReactionUsersResponse {
|
||||
readonly users: readonly ReactionUser[];
|
||||
}
|
||||
|
||||
/** Result of a channel purge — the ids actually soft-deleted, newest-first. */
|
||||
export interface PurgeResponse {
|
||||
readonly channel_id: number;
|
||||
readonly ids: readonly number[];
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
/** Member object from REST API. */
|
||||
export interface MemberResponse {
|
||||
readonly id: number;
|
||||
@@ -605,6 +865,9 @@ export interface MemberResponse {
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
readonly display_name?: string | null;
|
||||
readonly about?: string | null;
|
||||
readonly custom_status?: string | null;
|
||||
}
|
||||
|
||||
/** Search result item. */
|
||||
@@ -628,13 +891,17 @@ export interface ApiError {
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Single emoji object from GET /api/emoji. */
|
||||
/**
|
||||
* Single custom emoji from GET/POST /api/v1/emoji.
|
||||
*
|
||||
* `url` is server-relative and behind the session token — it is fetched the
|
||||
* same authenticated, cert-pinned way attachments are, never assigned straight
|
||||
* to an <img src>.
|
||||
*/
|
||||
export interface EmojiResponse {
|
||||
readonly id: number;
|
||||
readonly shortcode: string;
|
||||
readonly filename: string;
|
||||
readonly uploaded_by: number;
|
||||
readonly created_at: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/** Single sound object from GET /api/sounds. */
|
||||
@@ -709,6 +976,10 @@ export interface CreateDmResponse {
|
||||
readonly created: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/v1/dms/group and PATCH /api/v1/dms/{id} both answer with the
|
||||
* same DM summary shape the list and the ready payload use. */
|
||||
export type GroupDmResponse = DmChannelPayload;
|
||||
|
||||
/** GET /api/v1/blocks response. */
|
||||
export interface BlockedUsersResponse {
|
||||
readonly blocked_user_ids: readonly number[];
|
||||
|
||||
@@ -5,14 +5,37 @@
|
||||
* and write through here, so they can't drift apart, and consumers such as the
|
||||
* notification service can ask "is the user in Do Not Disturb?" without
|
||||
* reaching into a store that only tracks *other* members' presence.
|
||||
*
|
||||
* Since phase 6 this also records *who* chose the status. The auto-idle timer
|
||||
* needs to flip a user to idle after ten quiet minutes and back to online when
|
||||
* they return — but it must never undo a status the user picked by hand. A
|
||||
* manually chosen Idle stays idle when they start typing again, and a manually
|
||||
* chosen Do Not Disturb or Invisible is never touched at all. Storing the
|
||||
* origin alongside the value is what makes those two cases distinguishable;
|
||||
* the timer alone cannot tell them apart.
|
||||
*/
|
||||
|
||||
import type { UserStatus } from "./types";
|
||||
import { loadPref, savePref } from "./preferences";
|
||||
|
||||
export const USER_STATUS_PREF_KEY = "userStatus";
|
||||
/** Where the current status came from. Separate key so an older client's
|
||||
* saved status keeps working (absent = treated as a manual choice). */
|
||||
export const USER_STATUS_ORIGIN_PREF_KEY = "userStatusOrigin";
|
||||
|
||||
const VALID_STATUSES: readonly UserStatus[] = ["online", "idle", "dnd", "offline"];
|
||||
/** Who chose the current status. */
|
||||
export type StatusOrigin = "manual" | "auto";
|
||||
|
||||
/**
|
||||
* Statuses a user can pick.
|
||||
*
|
||||
* "offline" is deliberately absent: it used to be the "appear offline" option,
|
||||
* and "invisible" replaced it in phase 6 precisely because the server cannot
|
||||
* tell a chosen "offline" from a dropped connection. A stored "offline" from
|
||||
* an older client is migrated to "invisible" on read, which is what the user
|
||||
* meant when they picked it.
|
||||
*/
|
||||
const VALID_STATUSES: readonly UserStatus[] = ["online", "idle", "dnd", "invisible"];
|
||||
|
||||
function isUserStatus(value: string): value is UserStatus {
|
||||
return (VALID_STATUSES as readonly string[]).includes(value);
|
||||
@@ -21,11 +44,24 @@ function isUserStatus(value: string): value is UserStatus {
|
||||
/** The status the user last selected, defaulting to "online". */
|
||||
export function loadUserStatus(): UserStatus {
|
||||
const raw = loadPref<string>(USER_STATUS_PREF_KEY, "online");
|
||||
// Migration: "offline" was this client's old spelling of "appear offline".
|
||||
if (raw === "offline") return "invisible";
|
||||
return isUserStatus(raw) ? raw : "online";
|
||||
}
|
||||
|
||||
/** Persist the selected status and notify same-window listeners. */
|
||||
export function saveUserStatus(status: UserStatus): void {
|
||||
/** Whether the current status was set by the user or by the idle timer. */
|
||||
export function loadUserStatusOrigin(): StatusOrigin {
|
||||
return loadPref<string>(USER_STATUS_ORIGIN_PREF_KEY, "manual") === "auto" ? "auto" : "manual";
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the selected status and notify same-window listeners.
|
||||
* `origin` defaults to "manual" — everything that is not the idle timer is a
|
||||
* deliberate choice, and defaulting the other way would let a UI surface
|
||||
* silently mark a real choice as revocable.
|
||||
*/
|
||||
export function saveUserStatus(status: UserStatus, origin: StatusOrigin = "manual"): void {
|
||||
savePref(USER_STATUS_ORIGIN_PREF_KEY, origin);
|
||||
savePref(USER_STATUS_PREF_KEY, status);
|
||||
}
|
||||
|
||||
@@ -47,3 +83,25 @@ export function onUserStatusChange(
|
||||
window.removeEventListener("owncord:pref-change", handler);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom status text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CUSTOM_STATUS_PREF_KEY = "customStatus";
|
||||
|
||||
/** Server-side cap on users.custom_status. Mirrored here so the input can
|
||||
* bound itself instead of learning about it from a rejected send. */
|
||||
export const MAX_CUSTOM_STATUS_LEN = 128;
|
||||
|
||||
/** The custom status line the user last set, "" when none. */
|
||||
export function loadCustomStatus(): string {
|
||||
const raw = loadPref<string>(CUSTOM_STATUS_PREF_KEY, "");
|
||||
return typeof raw === "string" ? raw.slice(0, MAX_CUSTOM_STATUS_LEN) : "";
|
||||
}
|
||||
|
||||
/** Persist the custom status line locally. The server is the authority; this
|
||||
* is only so the input renders the right text before `ready` arrives. */
|
||||
export function saveCustomStatus(text: string): void {
|
||||
savePref(CUSTOM_STATUS_PREF_KEY, text.slice(0, MAX_CUSTOM_STATUS_LEN));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { initLogPersistence, flushLogs } from "@lib/logPersistence";
|
||||
import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials";
|
||||
import { initWindowState } from "@lib/window-state";
|
||||
import { initDeepLinks } from "@lib/deep-link";
|
||||
import { jumpToMessage } from "@lib/message-navigation";
|
||||
import { createCertMismatchModal, createCertFirstUseModal } from "@components/CertMismatchModal";
|
||||
import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
import type { CertTofuEvent } from "@lib/ws";
|
||||
@@ -621,9 +622,11 @@ authStore.subscribeSelector(
|
||||
ws.disconnect();
|
||||
lastConnectToken = "";
|
||||
lastConnectHost = "";
|
||||
// Clear stored credential on logout
|
||||
// Clear stored credential on logout — but keep it when the server
|
||||
// kicked us by shutting down: the token is still valid, and deleting
|
||||
// the credential would break auto-login every time the server restarts.
|
||||
const host = api.getConfig().host;
|
||||
if (host) {
|
||||
if (host && authStore.getState().logoutReason !== "server_shutdown") {
|
||||
void deleteCredential(host);
|
||||
}
|
||||
router.navigate("connect");
|
||||
@@ -662,7 +665,14 @@ function handleInviteDeepLink(code: string, host?: string): void {
|
||||
pendingInviteLink = null;
|
||||
}
|
||||
}
|
||||
void initDeepLinks(handleInviteDeepLink);
|
||||
// Route owncord://message/<channelId>/<messageId> permalinks to the main
|
||||
// page's jumper. Before the main page mounts (or when the channel isn't
|
||||
// visible to this user) the jump is a logged no-op — a link into a server the
|
||||
// user is not signed into has nothing to open.
|
||||
function handleMessageDeepLink(channelId: number, messageId: number): void {
|
||||
jumpToMessage(channelId, messageId);
|
||||
}
|
||||
void initDeepLinks(handleInviteDeepLink, handleMessageDeepLink);
|
||||
|
||||
// Initialize log persistence to disk (fire-and-forget)
|
||||
void initLogPersistence();
|
||||
|
||||
@@ -228,6 +228,7 @@ export function createConnectPage(
|
||||
onClose: () => closeSettings(),
|
||||
onChangePassword: () => Promise.resolve(),
|
||||
onUpdateProfile: () => Promise.resolve(),
|
||||
onUploadAvatar: () => Promise.reject(new Error("Not authenticated")),
|
||||
onLogout: () => {},
|
||||
onDeleteAccount: () => Promise.resolve(),
|
||||
onStatusChange: () => {},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { createRateLimiterSet } from "@lib/rate-limiter";
|
||||
@@ -20,9 +21,11 @@ import { authStore, clearAuth, updateUser } from "@stores/auth.store";
|
||||
import { closeSettings, uiStore } from "@stores/ui.store";
|
||||
import { updatePresence } from "@stores/members.store";
|
||||
import { loadUserStatus } from "@lib/userStatus";
|
||||
import { startAutoIdle, type AutoIdleController } from "@lib/autoIdle";
|
||||
import { channelsStore, getActiveChannel } from "@stores/channels.store";
|
||||
import { dmStore } from "@stores/dm.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { clearCustomEmoji } from "@stores/emoji.store";
|
||||
import {
|
||||
cleanupAll as voiceCleanupAll,
|
||||
setOnRemoteVideo,
|
||||
@@ -33,6 +36,12 @@ import {
|
||||
setOnError as setVoiceOnError,
|
||||
} from "@lib/livekitSession";
|
||||
import { setServerHost } from "@components/message-list/renderers";
|
||||
import { clearAttachmentCaches } from "@components/message-list/attachments";
|
||||
import {
|
||||
setReactionUsersFetcher,
|
||||
clearReactionUsersCache,
|
||||
} from "@components/message-list/reaction-tooltip";
|
||||
import { setMarkReadSender } from "@lib/read-state";
|
||||
import { createQuickSwitcherManager } from "./main-page/OverlayManagers";
|
||||
import { attachGlobalKeybinds } from "./main-page/GlobalKeybinds";
|
||||
import { createVoiceWidgetCallbacks } from "./main-page/VoiceCallbacks";
|
||||
@@ -47,6 +56,12 @@ import type { ChannelController } from "./main-page/ChannelController";
|
||||
import { createUpdateNotifier } from "@components/UpdateNotifier";
|
||||
import { createDmProfileSidebar } from "@components/DmProfileSidebar";
|
||||
import type { DmProfileSidebarComponent } from "@components/DmProfileSidebar";
|
||||
import { createIncomingCallBanner } from "@components/IncomingCallBanner";
|
||||
import type { IncomingCallBannerComponent } from "@components/IncomingCallBanner";
|
||||
import { createRingController } from "@lib/call-ring";
|
||||
import type { RingController } from "@lib/call-ring";
|
||||
import { startRingChime, stopRingChime } from "@lib/notifications";
|
||||
import { createSidebarVoiceCallbacks } from "./main-page/VoiceCallbacks";
|
||||
import { createSidebarArea } from "./main-page/SidebarArea";
|
||||
import { createChatArea } from "./main-page/ChatArea";
|
||||
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
|
||||
@@ -79,6 +94,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
setLiveKitServerHost(apiConfig.host);
|
||||
}
|
||||
|
||||
// "Mark as Read" affordances need the socket but are reached from deep inside
|
||||
// the sidebar; register the sender once instead of threading ws through.
|
||||
setMarkReadSender((channelId) => {
|
||||
ws.send({ type: "mark_read", payload: { channel_id: channelId } });
|
||||
});
|
||||
|
||||
// The who-reacted tooltip fetches on hover; give it the live REST client the
|
||||
// same way the attachment renderer is given the server host.
|
||||
clearReactionUsersCache();
|
||||
setReactionUsersFetcher(async (channelId, messageId, emoji) => {
|
||||
const res = await api.getReactionUsers(channelId, messageId, emoji);
|
||||
return res.users;
|
||||
});
|
||||
|
||||
const limiters = createRateLimiterSet();
|
||||
|
||||
let container: Element | null = null;
|
||||
@@ -102,6 +131,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
let reactionCtrl: ReactionController | null = null;
|
||||
let videoModeCtrl: VideoModeController | null = null;
|
||||
let channelCtrl: ChannelController | null = null;
|
||||
/** Inactivity watcher that flips the status to idle after ten quiet
|
||||
* minutes. Started once the socket is up, torn down with the page. */
|
||||
let autoIdle: AutoIdleController | null = null;
|
||||
|
||||
// Toast container for user-facing error feedback
|
||||
let toast: ToastContainer | null = null;
|
||||
@@ -110,6 +142,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
let dmProfileSidebar: DmProfileSidebarComponent | null = null;
|
||||
let dmProfileSlot: HTMLDivElement | null = null;
|
||||
|
||||
// DM calls: the banner draws a ring, the controller owns its lifetime.
|
||||
let callBanner: IncomingCallBannerComponent | null = null;
|
||||
let ringCtrl: RingController | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,13 +155,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-assert the status the user picked in settings. The server starts every
|
||||
* session as "online", so without this a saved "Do Not Disturb" would show
|
||||
* as selected in the panel while everyone else saw the user as online.
|
||||
* Re-assert the status the user picked, if the server disagrees.
|
||||
*
|
||||
* This used to fire on every connect, because the server stamped everyone
|
||||
* online at handshake and the client had to race to correct it — which is
|
||||
* what made a chosen Do Not Disturb (and "appear offline") flash online on
|
||||
* every reconnect. The server now reads the saved status and announces
|
||||
* *that*, so this is a no-op in the normal case and only speaks up when the
|
||||
* two genuinely differ (an older server, or a status changed while the
|
||||
* socket was down).
|
||||
*/
|
||||
function restoreSavedPresence(): void {
|
||||
const status = loadUserStatus();
|
||||
if (status === "online") return;
|
||||
const serverStatus = authStore.getState().user?.status;
|
||||
if (serverStatus === status) return;
|
||||
const userId = getCurrentUserId();
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status);
|
||||
@@ -135,15 +178,31 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve display name for a channel — for DMs, use recipient username from DM store. */
|
||||
/** Send a presence change and reflect it locally. Shared by the settings
|
||||
* tab, the user bar and the auto-idle timer so all three agree. */
|
||||
function applyPresence(status: UserStatus): void {
|
||||
const userId = getCurrentUserId();
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status);
|
||||
}
|
||||
if (limiters.presence.tryConsume()) {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a channel's display name. A DM is named by who is in it (or, for a
|
||||
* group, by its name), and the store is the authority on that — the channels
|
||||
* store carries a synthesised copy that can lag a rename or a departure.
|
||||
*/
|
||||
function resolveChannelName(
|
||||
channelId: number,
|
||||
channelName: string,
|
||||
channelType?: string,
|
||||
): string {
|
||||
if (channelType === "dm" && (!channelName || channelName === "")) {
|
||||
if (channelType === "dm") {
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
if (dm !== undefined) return dm.recipient.username;
|
||||
if (dm !== undefined) return dmDisplayName(dm);
|
||||
}
|
||||
return channelName;
|
||||
}
|
||||
@@ -192,6 +251,22 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
dmProfileSidebar.mount(dmProfileSlot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a call in the currently open DM: join its voice channel and ring the
|
||||
* other participants.
|
||||
*
|
||||
* Joining first is deliberate. A "call" is presence in the DM's voice
|
||||
* channel, so the ring is only truthful once the caller is actually there —
|
||||
* ringing first would offer an empty room to whoever accepts.
|
||||
*/
|
||||
function startCall(): void {
|
||||
const active = getActiveChannel();
|
||||
if (active === null || active.type !== "dm") return;
|
||||
createSidebarVoiceCallbacks(ws).onVoiceJoin(active.id);
|
||||
ws.send({ type: "call_ring", payload: { channel_id: active.id } });
|
||||
showToast("Calling…", "info");
|
||||
}
|
||||
|
||||
/** Close the DM profile sidebar if open. */
|
||||
function closeDmProfile(): void {
|
||||
if (dmProfileSidebar !== null) {
|
||||
@@ -241,10 +316,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
applyConnectionStatus(banner, uiStore.getState().connectionStatus);
|
||||
if (uiStore.getState().connectionStatus === "connected") restoreSavedPresence();
|
||||
|
||||
// Auto-idle. It only ever moves a status it is itself responsible for
|
||||
// (see @lib/autoIdle) — a manually chosen Idle, Do Not Disturb or
|
||||
// Invisible is never touched — so it is safe to leave running for the
|
||||
// whole session.
|
||||
autoIdle = startAutoIdle({ onStatusChange: (status) => applyPresence(status) });
|
||||
|
||||
unsubscribers.push(
|
||||
ws.on("server_restart", (payload) => {
|
||||
try {
|
||||
if (banner !== null) {
|
||||
// A "shutdown" broadcast kicks back to the login screen (handled in
|
||||
// the dispatcher) — no point starting a countdown on a page that is
|
||||
// about to unmount.
|
||||
if (banner !== null && payload.reason !== "shutdown") {
|
||||
banner.showRestart(payload.delay_seconds);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -281,6 +365,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
onToggleDmProfile: () => {
|
||||
toggleDmProfile();
|
||||
},
|
||||
onStartCall: () => {
|
||||
startCall();
|
||||
},
|
||||
});
|
||||
dmProfileSlot = chatAreaResult.dmProfileSlot;
|
||||
children.push(...chatAreaResult.children);
|
||||
@@ -315,10 +402,18 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onUpdateProfile: async (username) => {
|
||||
onUpdateProfile: async (patch) => {
|
||||
try {
|
||||
const updated = await api.updateProfile({ username });
|
||||
updateUser({ username: updated.username });
|
||||
// The username is required by the API but optional in the patch (the
|
||||
// profile form only edits the display name and about), so fill it in
|
||||
// from the current user rather than making every caller repeat it.
|
||||
const username = patch.username ?? authStore.getState().user?.username ?? "";
|
||||
const updated = await api.updateProfile({ ...patch, username });
|
||||
updateUser({
|
||||
username: updated.username,
|
||||
display_name: updated.display_name ?? null,
|
||||
about: updated.about ?? null,
|
||||
});
|
||||
showToast("Profile updated", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to update profile";
|
||||
@@ -326,6 +421,21 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onUploadAvatar: async (file) => {
|
||||
try {
|
||||
const uploaded = await api.uploadAvatar(file);
|
||||
// The server has already pointed the column at the served file and
|
||||
// broadcast a user_update; this keeps the local copy from lagging a
|
||||
// round-trip behind.
|
||||
updateUser({ avatar: uploaded.url });
|
||||
showToast("Avatar updated", "success");
|
||||
return uploaded.url;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to upload avatar";
|
||||
showToast(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onLogout: () => logout(api),
|
||||
onDeleteAccount: async (password) => {
|
||||
await api.deleteAccount(password);
|
||||
@@ -363,15 +473,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
const userId = getCurrentUserId();
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status);
|
||||
}
|
||||
if (limiters.presence.tryConsume()) {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
}
|
||||
},
|
||||
onStatusChange: (status) => applyPresence(status),
|
||||
});
|
||||
settingsOverlay.mount(root);
|
||||
children.push(settingsOverlay);
|
||||
@@ -400,6 +502,67 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
children.push(toast);
|
||||
initToast(toast);
|
||||
|
||||
// --- DM calls ---
|
||||
// The banner is mounted on the page root rather than inside the chat area
|
||||
// so a ring stays visible while the user is looking at another channel —
|
||||
// which is exactly when a call most needs to be answerable.
|
||||
ringCtrl = createRingController({
|
||||
onRingStateChange: (state) => callBanner?.setRing(state),
|
||||
onChime: (playing) => (playing ? startRingChime() : stopRingChime()),
|
||||
onAccept: (channelId) => {
|
||||
createSidebarVoiceCallbacks(ws).onVoiceJoin(channelId);
|
||||
},
|
||||
onDecline: (channelId) => {
|
||||
ws.send({ type: "call_decline", payload: { channel_id: channelId } });
|
||||
},
|
||||
});
|
||||
callBanner = createIncomingCallBanner({
|
||||
onAccept: () => ringCtrl?.accept(),
|
||||
onDecline: () => ringCtrl?.decline(),
|
||||
});
|
||||
callBanner.mount(root);
|
||||
children.push(callBanner);
|
||||
|
||||
unsubscribers.push(
|
||||
ws.on("call_incoming", (payload) => {
|
||||
try {
|
||||
// A call in the DM you are already sitting in still rings: the
|
||||
// channel being open does not mean the app has focus, and Discord
|
||||
// rings there too.
|
||||
ringCtrl?.incoming({
|
||||
channelId: payload.channel_id,
|
||||
fromUserId: payload.from_user,
|
||||
fromUsername: payload.username,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("call_incoming handler error", err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
unsubscribers.push(
|
||||
ws.on("call_declined", (payload) => {
|
||||
ringCtrl?.cancel(payload.channel_id);
|
||||
}),
|
||||
);
|
||||
// The ringer hanging up before anyone answered: their voice_leave is the
|
||||
// only signal there is that the call is over, because there is no call
|
||||
// record to close. Ringing for a room with nobody in it is worse than a
|
||||
// missed call, so a leave stops the ring for that channel.
|
||||
unsubscribers.push(
|
||||
ws.on("voice_leave", (payload) => {
|
||||
const ringing = ringCtrl?.current();
|
||||
if (ringing === null || ringing === undefined) return;
|
||||
if (payload.user_id === ringing.fromUserId) {
|
||||
ringCtrl?.cancel(ringing.channelId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
unsubscribers.push(() => {
|
||||
ringCtrl?.destroy();
|
||||
ringCtrl = null;
|
||||
callBanner = null;
|
||||
});
|
||||
|
||||
// Message loading controller
|
||||
msgCtrl = createMessageController({
|
||||
api,
|
||||
@@ -548,6 +711,16 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// Full voice cleanup — tears down room, callbacks, ws ref, serverHost.
|
||||
// Prevents stale module-level state persisting across logout/reconnect cycles.
|
||||
voiceCleanupAll();
|
||||
// Custom emoji belong to the server this page was connected to. The set
|
||||
// is module-global, so without this a switch to another server would keep
|
||||
// rendering the previous one's shortcodes until its own list arrived.
|
||||
clearCustomEmoji();
|
||||
// Image/video/audio caches are module-global too — without this every
|
||||
// clip viewed this session stays pinned (as a blob: URL or a cached
|
||||
// data: URI) past logout.
|
||||
clearAttachmentCaches();
|
||||
autoIdle?.destroy();
|
||||
autoIdle = null;
|
||||
channelCtrl?.destroyChannel();
|
||||
channelCtrl = null;
|
||||
|
||||
|
||||
@@ -15,13 +15,17 @@ import type { MessageListComponent } from "@components/MessageList";
|
||||
import { createMessageInput } from "@components/MessageInput";
|
||||
import type { MessageInputComponent } from "@components/MessageInput";
|
||||
import { createTypingIndicator } from "@components/TypingIndicator";
|
||||
import { createNsfwGate } from "@components/NsfwGate";
|
||||
import { nsfwGateRequired } from "@lib/nsfw-gate";
|
||||
import {
|
||||
getChannelMessages,
|
||||
setMessagePinned,
|
||||
addOptimisticMessage,
|
||||
markSendFailed,
|
||||
removeOptimistic,
|
||||
reattachToPresent,
|
||||
} from "@stores/messages.store";
|
||||
import { jumpToMessage } from "@lib/message-navigation";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import type { MessageUser } from "@lib/types";
|
||||
import type { MessageController } from "./MessageController";
|
||||
@@ -29,11 +33,11 @@ import type { PendingDeleteManager } from "./MessageController";
|
||||
import type { ReactionController } from "./ReactionController";
|
||||
import { updateChatHeaderForDm } from "./ChatHeader";
|
||||
import type { ChatHeaderRefs } from "./ChatHeader";
|
||||
import { dmStore } from "@stores/dm.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
import { canManageMessages } from "@lib/permissions";
|
||||
import { blocksStore, dmComposerBlockReason } from "@stores/blocks.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { channelsStore, setActiveChannel } from "@stores/channels.store";
|
||||
import { uiStore } from "@stores/ui.store";
|
||||
|
||||
const log = createLogger("channel-ctrl");
|
||||
@@ -97,6 +101,8 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
let messageList: MessageListComponent | null = null;
|
||||
let messageInput: MessageInputComponent | null = null;
|
||||
let typingIndicator: MountableComponent | null = null;
|
||||
// The age gate covering the message area of an NSFW channel, while it is up.
|
||||
let nsfwGate: MountableComponent | null = null;
|
||||
// Store/ws subscriptions that keep the composer's disabled state in sync.
|
||||
let composerGatingUnsubs: (() => void)[] = [];
|
||||
|
||||
@@ -111,6 +117,10 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
channelAbort = null;
|
||||
}
|
||||
|
||||
if (nsfwGate !== null) {
|
||||
nsfwGate.destroy?.();
|
||||
nsfwGate = null;
|
||||
}
|
||||
if (messageList !== null) {
|
||||
messageList.destroy?.();
|
||||
messageList = null;
|
||||
@@ -218,6 +228,20 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
void msgCtrl.loadMessages(channelId, channelAbort.signal);
|
||||
}
|
||||
},
|
||||
// A reply bar (and any other in-row jump) goes through the same jumper
|
||||
// as search hits and permalinks, so an out-of-window target fetches its
|
||||
// around-window instead of silently doing nothing.
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
jumpToMessage(channelId, msgId);
|
||||
},
|
||||
onJumpToPresent: () => {
|
||||
// Dropping the detached flag also clears "loaded", so loadMessages
|
||||
// refetches the live tail instead of short-circuiting.
|
||||
reattachToPresent(channelId);
|
||||
if (channelAbort !== null) {
|
||||
void msgCtrl.loadMessages(channelId, channelAbort.signal);
|
||||
}
|
||||
},
|
||||
onReplyClick: (msgId: number) => {
|
||||
const msgs = getChannelMessages(channelId);
|
||||
const msg = msgs.find((m) => m.id === msgId);
|
||||
@@ -321,10 +345,15 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
// composer disables (with a reason) when the socket is down or the user
|
||||
// may not post here, instead of accepting a click and failing. For DM
|
||||
// channels the reason also covers block state (channels-members-dms.md §3.2).
|
||||
const dmRecipientId =
|
||||
// Block gating is a 1:1 rule (Discord semantics, mirrored by the server's
|
||||
// requireDMNotBlocked): a group DM is a shared room, and gating one
|
||||
// member's composer over a block with one other member would leave the
|
||||
// group reading a conversation that person cannot join.
|
||||
const gatedDm =
|
||||
channelType === "dm"
|
||||
? (dmStore.getState().channels.find((c) => c.channelId === channelId)?.recipient.id ?? null)
|
||||
: null;
|
||||
? dmStore.getState().channels.find((c) => c.channelId === channelId)
|
||||
: undefined;
|
||||
const dmRecipientId = gatedDm !== undefined && !gatedDm.isGroup ? gatedDm.recipient.id : null;
|
||||
// Slow mode as affordance: after an accepted send the composer disables
|
||||
// itself for the channel's cooldown with a live countdown, instead of
|
||||
// taking a message the server will bounce with SLOW_MODE (UX spec §5,
|
||||
@@ -444,22 +473,62 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Age gate. Mounted over the message area — the channel is live underneath,
|
||||
// so accepting reveals it without a refetch, and declining leaves the
|
||||
// channel rather than pretending it is empty. Only the first open of a
|
||||
// flagged channel in a session shows it (see @lib/nsfw-gate).
|
||||
const storedChannel = channelsStore.getState().channels.get(channelId);
|
||||
if (storedChannel !== undefined && nsfwGateRequired(storedChannel)) {
|
||||
const gate = createNsfwGate({
|
||||
channelId,
|
||||
channelName,
|
||||
onContinue: () => {
|
||||
gate.destroy?.();
|
||||
if (nsfwGate === gate) nsfwGate = null;
|
||||
},
|
||||
onCancel: () => {
|
||||
// Leave the channel entirely: keeping the gate up over a channel the
|
||||
// reader declined would strand them on a screen with no way out that
|
||||
// is not also "continue".
|
||||
destroyChannel();
|
||||
setActiveChannel(null);
|
||||
},
|
||||
});
|
||||
gate.mount(slots.messagesSlot);
|
||||
nsfwGate = gate;
|
||||
}
|
||||
|
||||
// Update header
|
||||
if (chatHeaderRefs !== null && channelType === "dm") {
|
||||
// Look up the recipient's actual status from DM store or members store
|
||||
const dmChannel = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
let recipientStatus = "Offline";
|
||||
if (dmChannel !== undefined) {
|
||||
// A group has no single presence to show, so the subtitle lists who is
|
||||
// in it instead — that is the fact a group header is asked for, and a
|
||||
// first member's status presented as the group's would be a lie.
|
||||
let subtitle = "Offline";
|
||||
if (dmChannel !== undefined && dmChannel.isGroup) {
|
||||
const names = dmChannel.participants.map((p) => (p.displayName ?? "") || p.username);
|
||||
subtitle = `${names.length + 1} members: You, ${names.join(", ")}`;
|
||||
} else if (dmChannel !== undefined) {
|
||||
const member = membersStore.getState().members.get(dmChannel.recipient.id);
|
||||
recipientStatus = member?.status ?? dmChannel.recipient.status ?? "Offline";
|
||||
const status = member?.status ?? dmChannel.recipient.status ?? "Offline";
|
||||
subtitle = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
const displayStatus = recipientStatus.charAt(0).toUpperCase() + recipientStatus.slice(1);
|
||||
updateChatHeaderForDm(chatHeaderRefs, { username: channelName, status: displayStatus });
|
||||
const headerName = dmChannel !== undefined ? dmDisplayName(dmChannel) : channelName;
|
||||
updateChatHeaderForDm(chatHeaderRefs, { username: headerName, status: subtitle });
|
||||
} else if (chatHeaderRefs !== null) {
|
||||
updateChatHeaderForDm(chatHeaderRefs, null);
|
||||
if (chatHeaderName !== null) {
|
||||
setText(chatHeaderName, channelName);
|
||||
}
|
||||
// Show the channel topic and keep it live across channel_update events.
|
||||
const topicEl = chatHeaderRefs.topicEl;
|
||||
setText(topicEl, channelsStore.getState().channels.get(channelId)?.topic ?? "");
|
||||
composerGatingUnsubs.push(
|
||||
channelsStore.subscribeSelector(
|
||||
(s) => s.channels.get(channelId)?.topic ?? "",
|
||||
(topic) => setText(topicEl, topic),
|
||||
),
|
||||
);
|
||||
} else if (chatHeaderName !== null) {
|
||||
setText(chatHeaderName, channelName);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { ChatHeaderRefs } from "./ChatHeader";
|
||||
import { createPinnedPanelController, createSearchOverlayController } from "./OverlayManagers";
|
||||
import type { SearchOverlayController } from "./OverlayManagers";
|
||||
import type { ChannelController } from "./ChannelController";
|
||||
import { createMessageJumper } from "./MessageJump";
|
||||
import { setMessageJumpHandler } from "@lib/message-navigation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -26,6 +28,8 @@ export interface ChatAreaOptions {
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
readonly getChannelCtrl: () => ChannelController | null;
|
||||
readonly onToggleDmProfile?: () => void;
|
||||
/** Start a call in the current DM (join its voice channel + ring). */
|
||||
readonly onStartCall?: () => void;
|
||||
}
|
||||
|
||||
export interface ChatAreaResult {
|
||||
@@ -64,15 +68,26 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
const children: MountableComponent[] = [];
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
// --- Message jumping ---
|
||||
// One implementation for every jump affordance. Registering it globally lets
|
||||
// parts that have no handle on this page — permalink chips inside a rendered
|
||||
// message, owncord://message links from the OS — reach the same path.
|
||||
const jumper = createMessageJumper({ api, getChannelCtrl });
|
||||
unsubscribers.push(
|
||||
setMessageJumpHandler((channelId, messageId) => {
|
||||
void jumper.jumpTo(channelId, messageId);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Overlay controllers ---
|
||||
const pinnedCtrl = createPinnedPanelController({
|
||||
api,
|
||||
getRoot,
|
||||
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
const ctrl = getChannelCtrl();
|
||||
if (ctrl == null || ctrl.messageList == null) return false;
|
||||
return ctrl.messageList.scrollToMessage(msgId);
|
||||
const channelId = getChannelCtrl()?.currentChannelId;
|
||||
if (channelId == null) return;
|
||||
void jumper.jumpTo(channelId, msgId);
|
||||
},
|
||||
});
|
||||
unsubscribers.push(() => {
|
||||
@@ -83,10 +98,8 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
api,
|
||||
getRoot,
|
||||
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
|
||||
onJumpToMessage: (_channelId: number, msgId: number) => {
|
||||
const ctrl = getChannelCtrl();
|
||||
if (ctrl == null || ctrl.messageList == null) return false;
|
||||
return ctrl.messageList.scrollToMessage(msgId);
|
||||
onJumpToMessage: (channelId: number, msgId: number) => {
|
||||
void jumper.jumpTo(channelId, msgId);
|
||||
},
|
||||
});
|
||||
unsubscribers.push(() => {
|
||||
@@ -102,6 +115,7 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
searchCtrl.open();
|
||||
},
|
||||
onToggleDmProfile: opts.onToggleDmProfile,
|
||||
onStartCall: opts.onStartCall,
|
||||
});
|
||||
const chatHeaderName = chatHeader.refs.nameEl;
|
||||
|
||||
|
||||
@@ -13,12 +13,17 @@ export interface ChatHeaderRefs {
|
||||
readonly hashEl: HTMLSpanElement;
|
||||
readonly nameEl: HTMLSpanElement;
|
||||
readonly topicEl: HTMLSpanElement;
|
||||
/** The DM call button. Hidden outside DMs — a guild voice channel is joined
|
||||
* from the sidebar, and a text channel has nobody in particular to call. */
|
||||
readonly callBtn: HTMLButtonElement;
|
||||
}
|
||||
|
||||
export interface ChatHeaderOptions {
|
||||
readonly onTogglePins: () => void;
|
||||
readonly onSearchFocus?: () => void;
|
||||
readonly onToggleDmProfile?: () => void;
|
||||
/** Start a call in the current DM: join its voice channel and ring. */
|
||||
readonly onStartCall?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +63,19 @@ export function buildChatHeader(opts: ChatHeaderOptions): {
|
||||
const topicEl = createElement("span", { class: "ch-topic" }, "");
|
||||
|
||||
const tools = createElement("div", { class: "ch-tools" });
|
||||
const callBtn = createElement("button", {
|
||||
type: "button",
|
||||
class: "call-btn",
|
||||
title: "Start a call",
|
||||
"aria-label": "Start a call",
|
||||
"data-testid": "call-btn",
|
||||
});
|
||||
callBtn.appendChild(createIcon("phone", 18));
|
||||
callBtn.style.display = "none";
|
||||
if (opts.onStartCall !== undefined) {
|
||||
const start = opts.onStartCall;
|
||||
callBtn.addEventListener("click", () => start());
|
||||
}
|
||||
const pinBtn = createElement("button", {
|
||||
type: "button",
|
||||
class: "pin-btn",
|
||||
@@ -82,16 +100,23 @@ export function buildChatHeader(opts: ChatHeaderOptions): {
|
||||
searchInput.blur();
|
||||
});
|
||||
}
|
||||
appendChildren(tools, searchInput, pinBtn);
|
||||
appendChildren(tools, searchInput, callBtn, pinBtn);
|
||||
|
||||
appendChildren(header, nameGroup, divider, topicEl, tools);
|
||||
return { element: header, refs: { hashEl: hash, nameEl, topicEl } };
|
||||
return { element: header, refs: { hashEl: hash, nameEl, topicEl, callBtn } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM mode helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Put the header into DM mode (or, with null, back into channel mode).
|
||||
*
|
||||
* `subtitle` is what sits where a channel topic would: the other party's
|
||||
* presence for a 1:1 DM, and the member list for a group — a group has no
|
||||
* single status to show, and "who is in here" is the fact that matters.
|
||||
*/
|
||||
export function updateChatHeaderForDm(
|
||||
refs: ChatHeaderRefs,
|
||||
recipient: { username: string; status: string } | null,
|
||||
@@ -100,7 +125,9 @@ export function updateChatHeaderForDm(
|
||||
setText(refs.hashEl, "@");
|
||||
setText(refs.nameEl, recipient.username);
|
||||
setText(refs.topicEl, recipient.status);
|
||||
refs.callBtn.style.display = "";
|
||||
} else {
|
||||
setText(refs.hashEl, "#");
|
||||
refs.callBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/**
|
||||
* MemberPickerModal — a simple modal that lists server members for starting
|
||||
* a new DM conversation. Uses the shared modal factory for overlay behavior.
|
||||
* MemberPickerModal — lists server members for starting a DM.
|
||||
*
|
||||
* One picker covers both cases rather than two: selecting a single member
|
||||
* opens a 1:1 DM, selecting two or more creates a group. That is Discord's
|
||||
* model, and it is also the honest one — "new conversation" is one intent, and
|
||||
* making the user choose "DM" or "group DM" up front asks them to commit
|
||||
* before they have picked who is in it.
|
||||
*
|
||||
* Uses the shared modal factory for overlay behavior.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
@@ -9,14 +16,20 @@ import type { ModalInstance } from "@lib/modalFactory";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { MAX_GROUP_DM_PARTICIPANTS } from "@lib/constants";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemberPickerOptions {
|
||||
/** Called when the user selects a member. Receives the member's user ID. */
|
||||
/** One member picked — open a 1:1 DM. */
|
||||
readonly onSelect: (userId: number) => void;
|
||||
/**
|
||||
* Two or more picked — create a group DM. Optional: without it the picker
|
||||
* stays single-select and behaves exactly as it did before groups.
|
||||
*/
|
||||
readonly onSelectGroup?: (userIds: readonly number[], name: string) => void;
|
||||
/** Called when the modal is dismissed (cancel or overlay click). */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
@@ -31,28 +44,70 @@ export interface MemberPickerOptions {
|
||||
*/
|
||||
export function createMemberPickerModal(opts: MemberPickerOptions): MountableComponent {
|
||||
let modalInstance: ModalInstance | null = null;
|
||||
const selected = new Set<number>();
|
||||
const multi = opts.onSelectGroup !== undefined;
|
||||
|
||||
function mount(container: Element): void {
|
||||
const members = membersStore.getState().members;
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
|
||||
// Build the content that goes inside the modal
|
||||
const content = createElement("div", { style: "padding:20px;" });
|
||||
const title = createElement("h3", {}, "New Direct Message");
|
||||
const subtitle = createElement(
|
||||
"p",
|
||||
{ style: "color:var(--text-secondary);font-size:0.85rem;margin:0 0 8px;" },
|
||||
"Select a member to start a conversation",
|
||||
multi
|
||||
? `Select one member for a DM, or up to ${MAX_GROUP_DM_PARTICIPANTS - 1} for a group`
|
||||
: "Select a member to start a conversation",
|
||||
);
|
||||
const listContainer = createElement("div", {
|
||||
class: "dm-member-picker-list",
|
||||
style: "max-height:300px;overflow-y:auto;",
|
||||
});
|
||||
|
||||
// Group name field, revealed only once the selection is actually a group:
|
||||
// asking a 1:1 DM to be named would be asking for something that has no
|
||||
// effect (the server refuses to name a two-person DM).
|
||||
const nameInput = createElement("input", {
|
||||
class: "dm-group-name-input",
|
||||
type: "text",
|
||||
maxlength: "100",
|
||||
placeholder: "Group name (optional)",
|
||||
"data-testid": "dm-group-name",
|
||||
style: "width:100%;margin-top:10px;",
|
||||
});
|
||||
nameInput.style.display = "none";
|
||||
|
||||
const confirmBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn btn-primary",
|
||||
style: "margin-top:10px;width:100%;",
|
||||
"data-testid": "dm-picker-create",
|
||||
},
|
||||
"Create DM",
|
||||
);
|
||||
confirmBtn.style.display = "none";
|
||||
|
||||
const close = (): void => {
|
||||
modalInstance?.close();
|
||||
};
|
||||
|
||||
const refreshControls = (): void => {
|
||||
const isGroup = selected.size >= 2;
|
||||
// The name field appears only once the selection is actually a group:
|
||||
// the server refuses to name a two-person DM, so offering the field
|
||||
// there would be offering something that cannot take effect.
|
||||
nameInput.style.display = isGroup ? "" : "none";
|
||||
confirmBtn.style.display = selected.size >= 1 ? "" : "none";
|
||||
setText(confirmBtn, isGroup ? `Create Group DM (${selected.size + 1})` : "Create DM");
|
||||
};
|
||||
|
||||
for (const member of members.values()) {
|
||||
if (member.id === currentUserId) continue;
|
||||
const item = createElement("div", {
|
||||
class: "dm-member-picker-item channel-item",
|
||||
"data-testid": `dm-picker-member-${member.id}`,
|
||||
style: "cursor:pointer;padding:6px 8px;display:flex;align-items:center;gap:8px;",
|
||||
});
|
||||
const avatar = createElement("div", {
|
||||
@@ -60,8 +115,9 @@ export function createMemberPickerModal(opts: MemberPickerOptions): MountableCom
|
||||
style:
|
||||
"width:28px;height:28px;border-radius:50%;background:#5865F2;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:white;flex-shrink:0;",
|
||||
});
|
||||
setText(avatar, member.username.charAt(0).toUpperCase());
|
||||
const nameEl = createElement("span", {}, member.username);
|
||||
const label = (member.displayName ?? "") || member.username;
|
||||
setText(avatar, label.charAt(0).toUpperCase());
|
||||
const nameEl = createElement("span", {}, label);
|
||||
const statusEl = createElement(
|
||||
"span",
|
||||
{
|
||||
@@ -72,29 +128,55 @@ export function createMemberPickerModal(opts: MemberPickerOptions): MountableCom
|
||||
appendChildren(item, avatar, nameEl, statusEl);
|
||||
|
||||
item.addEventListener("click", () => {
|
||||
if (modalInstance !== null) {
|
||||
modalInstance.close();
|
||||
// Single-select mode keeps the pre-group behaviour: one click, one DM.
|
||||
if (!multi) {
|
||||
close();
|
||||
opts.onSelect(member.id);
|
||||
return;
|
||||
}
|
||||
opts.onSelect(member.id);
|
||||
if (selected.has(member.id)) {
|
||||
selected.delete(member.id);
|
||||
item.classList.remove("selected");
|
||||
refreshControls();
|
||||
return;
|
||||
}
|
||||
// The cap counts the creator too, so the picker allows one fewer.
|
||||
if (selected.size >= MAX_GROUP_DM_PARTICIPANTS - 1) return;
|
||||
selected.add(member.id);
|
||||
item.classList.add("selected");
|
||||
refreshControls();
|
||||
});
|
||||
|
||||
listContainer.appendChild(item);
|
||||
}
|
||||
|
||||
// One button for both outcomes, relabelled by the selection size. A
|
||||
// separate "make it a group" control would ask the user to declare their
|
||||
// intent before picking who is in it, when the picking is the declaration.
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const ids = [...selected];
|
||||
if (ids.length === 0) return;
|
||||
if (ids.length === 1) {
|
||||
close();
|
||||
opts.onSelect(ids[0]!);
|
||||
return;
|
||||
}
|
||||
const name = nameInput.value.trim();
|
||||
close();
|
||||
opts.onSelectGroup?.(ids, name);
|
||||
});
|
||||
|
||||
const cancelBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn btn-secondary",
|
||||
style: "margin-top:12px;width:100%;",
|
||||
style: "margin-top:8px;width:100%;",
|
||||
},
|
||||
"Cancel",
|
||||
);
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
if (modalInstance !== null) {
|
||||
modalInstance.close();
|
||||
}
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => close());
|
||||
|
||||
appendChildren(content, title, subtitle, listContainer, cancelBtn);
|
||||
appendChildren(content, title, subtitle, listContainer, nameInput, confirmBtn, cancelBtn);
|
||||
|
||||
modalInstance = createModal(
|
||||
{
|
||||
@@ -107,6 +189,7 @@ export function createMemberPickerModal(opts: MemberPickerOptions): MountableCom
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
selected.clear();
|
||||
if (modalInstance !== null) {
|
||||
modalInstance.destroy();
|
||||
modalInstance = null;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* MessageJump — the one implementation behind every "jump to this message"
|
||||
* affordance: search results, the pinned panel, reply bars, permalink chips in
|
||||
* chat, and `owncord://message/…` links opened from the OS.
|
||||
*
|
||||
* The interesting case is a target outside the loaded window. Rather than
|
||||
* telling the user "not in loaded history" and stopping there (which is what
|
||||
* the search and pinned panels used to do), the jumper fetches the server's
|
||||
* around-window, swaps it into the store, and scrolls to the target. That
|
||||
* leaves the channel *detached* from the live tail, which the MessageList
|
||||
* signals with its "Jump to Present" pill.
|
||||
*/
|
||||
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { ApiClientError } from "@lib/api";
|
||||
import { showToast } from "@lib/toast";
|
||||
import { findChannelById, navigateToChannel } from "@lib/channel-navigation";
|
||||
import { setAroundMessages, hasMessageLoaded } from "@stores/messages.store";
|
||||
import type { ChannelController } from "./ChannelController";
|
||||
|
||||
const log = createLogger("message-jump");
|
||||
|
||||
/** Window size requested when a jump target is not in the loaded page. */
|
||||
const AROUND_WINDOW = 50;
|
||||
|
||||
export interface MessageJumpOptions {
|
||||
readonly api: ApiClient;
|
||||
readonly getChannelCtrl: () => ChannelController | null;
|
||||
/**
|
||||
* Wait for the channel switch / re-render to hit the DOM. Defaults to a
|
||||
* requestAnimationFrame; tests inject a resolved promise instead.
|
||||
*/
|
||||
readonly nextFrame?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MessageJumper {
|
||||
/**
|
||||
* Open `channelId` if needed and scroll to `messageId`, fetching the
|
||||
* around-window when the message is not loaded. Resolves true when the row
|
||||
* was actually reached.
|
||||
*/
|
||||
jumpTo(channelId: number, messageId: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
function defaultNextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
export function createMessageJumper(opts: MessageJumpOptions): MessageJumper {
|
||||
const nextFrame = opts.nextFrame ?? defaultNextFrame;
|
||||
|
||||
/** Scroll the mounted list to a message, if that list is showing `channelId`. */
|
||||
function scrollIfMounted(channelId: number, messageId: number): boolean {
|
||||
const ctrl = opts.getChannelCtrl();
|
||||
if (ctrl === null || ctrl.messageList === null) return false;
|
||||
if (ctrl.currentChannelId !== channelId) return false;
|
||||
return ctrl.messageList.scrollToMessage(messageId);
|
||||
}
|
||||
|
||||
async function jumpTo(channelId: number, messageId: number): Promise<boolean> {
|
||||
// A permalink to a channel this user cannot see must degrade quietly
|
||||
// rather than blank the chat area on an unknown id.
|
||||
if (findChannelById(channelId) === null) {
|
||||
showToast("That channel isn't available", "info");
|
||||
return false;
|
||||
}
|
||||
|
||||
const ctrl = opts.getChannelCtrl();
|
||||
if (ctrl === null) return false;
|
||||
|
||||
if (ctrl.currentChannelId !== channelId) {
|
||||
navigateToChannel(channelId);
|
||||
// The channel switch mounts a fresh MessageList and kicks off its
|
||||
// history fetch; give it a frame before asking it to scroll.
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
if (scrollIfMounted(channelId, messageId)) return true;
|
||||
|
||||
// Not in the loaded window (or the fresh channel is still fetching) —
|
||||
// replace the window with one centred on the target.
|
||||
try {
|
||||
const resp = await opts.api.getMessagesAround(channelId, messageId, {
|
||||
limit: AROUND_WINDOW,
|
||||
});
|
||||
setAroundMessages(channelId, resp.messages, resp.has_more_before, resp.has_more_after);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError && err.status === 404) {
|
||||
showToast("That message no longer exists", "info");
|
||||
return false;
|
||||
}
|
||||
log.error("Failed to fetch the message window", { channelId, messageId, error: String(err) });
|
||||
showToast("Couldn't jump to that message", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasMessageLoaded(channelId, messageId)) {
|
||||
// The server answered but the centre is not in the window — nothing to
|
||||
// scroll to, and silently landing elsewhere would be worse.
|
||||
showToast("Couldn't jump to that message", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
// The store update re-renders the list; scroll on the next frame.
|
||||
await nextFrame();
|
||||
if (scrollIfMounted(channelId, messageId)) return true;
|
||||
|
||||
log.warn("Around-window loaded but the row did not render", { channelId, messageId });
|
||||
return false;
|
||||
}
|
||||
|
||||
return { jumpTo };
|
||||
}
|
||||
@@ -204,7 +204,12 @@ export function createPinnedPanelController(opts: {
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
|
||||
readonly getCurrentChannelId: () => number | null;
|
||||
readonly onJumpToMessage?: (messageId: number) => boolean;
|
||||
/**
|
||||
* Jump to a pinned message. Fire-and-forget: the jumper fetches the
|
||||
* around-window when the message is not loaded and reports its own failures,
|
||||
* so the panel simply closes and gets out of the way.
|
||||
*/
|
||||
readonly onJumpToMessage?: (messageId: number) => void;
|
||||
}): PinnedPanelController {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
@@ -230,16 +235,8 @@ export function createPinnedPanelController(opts: {
|
||||
channelId,
|
||||
pinnedMessages: pins,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
if (opts.onJumpToMessage !== undefined) {
|
||||
const found = opts.onJumpToMessage(msgId);
|
||||
if (found) {
|
||||
close();
|
||||
} else {
|
||||
showToast("Message not in loaded window", "info");
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
opts.onJumpToMessage?.(msgId);
|
||||
close();
|
||||
},
|
||||
onUnpin: (msgId: number) => {
|
||||
void opts.api
|
||||
@@ -280,7 +277,12 @@ export function createSearchOverlayController(opts: {
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
|
||||
readonly getCurrentChannelId: () => number | null;
|
||||
readonly onJumpToMessage?: (channelId: number, messageId: number) => boolean;
|
||||
/**
|
||||
* Jump to a search hit, in whichever channel it lives. Fire-and-forget — the
|
||||
* jumper opens the channel, fetches the around-window when needed, and
|
||||
* surfaces its own failures.
|
||||
*/
|
||||
readonly onJumpToMessage?: (channelId: number, messageId: number) => void;
|
||||
}): SearchOverlayController {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
@@ -311,16 +313,13 @@ export function createSearchOverlayController(opts: {
|
||||
}
|
||||
},
|
||||
onSelectResult: (result) => {
|
||||
setActiveChannel(result.channel_id);
|
||||
if (opts.onJumpToMessage !== undefined) {
|
||||
// Give the channel a frame to mount before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
const found = opts.onJumpToMessage!(result.channel_id, result.message_id);
|
||||
if (!found) {
|
||||
showToast("Message not in loaded history", "info");
|
||||
}
|
||||
});
|
||||
if (opts.onJumpToMessage === undefined) {
|
||||
setActiveChannel(result.channel_id);
|
||||
return;
|
||||
}
|
||||
// The jumper owns the channel switch too, so the fetch it may need to
|
||||
// do is sequenced after the switch rather than racing it.
|
||||
opts.onJumpToMessage(result.channel_id, result.message_id);
|
||||
},
|
||||
onClose: close,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* unified sidebar layout with a quick-switch overlay for server switching.
|
||||
*/
|
||||
|
||||
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
@@ -20,15 +20,23 @@ import { createUserBar } from "@components/UserBar";
|
||||
import { createVoiceWidget } from "@components/VoiceWidget";
|
||||
import { createQuickSwitchOverlay } from "@components/QuickSwitchOverlay";
|
||||
import type { QuickSwitchProfile } from "@components/QuickSwitchOverlay";
|
||||
import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./VoiceCallbacks";
|
||||
import {
|
||||
createVoiceWidgetCallbacks,
|
||||
createSidebarVoiceCallbacks,
|
||||
createVoiceModerationCallbacks,
|
||||
} from "./VoiceCallbacks";
|
||||
import { createSidebarMemberSection } from "./SidebarMemberSection";
|
||||
import { createInviteManagerController } from "./OverlayManagers";
|
||||
import {
|
||||
selectDmConversation,
|
||||
handleCreateDm,
|
||||
handleCreateGroupDm,
|
||||
buildDmConversations,
|
||||
type DmHelperDeps,
|
||||
} from "./SidebarDmHelpers";
|
||||
import { createMemberPickerModal } from "./MemberPickerModal";
|
||||
import { createPromptModal } from "@lib/modalFactory";
|
||||
import { toggleChannelMute } from "@lib/channel-mutes";
|
||||
import { createSidebarDmSection } from "./SidebarDmSection";
|
||||
import { uiStore, setSidebarMode, loadCollapsedCategories } from "@stores/ui.store";
|
||||
import { authStore, clearAuth } from "@stores/auth.store";
|
||||
@@ -36,6 +44,8 @@ import { membersStore, getOnlineMembers } from "@stores/members.store";
|
||||
import { channelsStore, setActiveChannel } from "@stores/channels.store";
|
||||
import { dmStore, removeDmChannel } from "@stores/dm.store";
|
||||
import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
import { openAdminPanel } from "@lib/admin-panel";
|
||||
import { canViewAuditLog } from "@lib/permissions";
|
||||
import type { ProfileManager } from "@lib/profiles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,6 +104,11 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
// Quick-switch overlay instance
|
||||
let quickSwitchInstance: MountableComponent | null = null;
|
||||
|
||||
// Re-render hook for the DM sidebar, set while DM mode is mounted. Mute state
|
||||
// lives in localStorage rather than a store, so toggling it has no subscriber
|
||||
// to wake — this is how the row redraws dimmed.
|
||||
let refreshDmSidebarRef: (() => void) | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar wrapper (replaces old channel-sidebar root)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,6 +158,59 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
headerInviteCtrl.cleanup();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The audit log itself stays in the admin panel — it is a paginated,
|
||||
// filterable table over an endpoint this client otherwise never calls, and a
|
||||
// second implementation would be a second thing to keep correct. What belongs
|
||||
// here is the way in, for the moderators who hold VIEW_AUDIT_LOG and would
|
||||
// otherwise have to know the panel's URL by heart.
|
||||
//
|
||||
// Rendered once per mount and kept in sync with the role list: `ready` may
|
||||
// land after this header is built, and a moderator whose role only becomes
|
||||
// known then would never see the entry otherwise.
|
||||
const auditBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "sidebar-audit-btn",
|
||||
title: "Open the audit log in the admin panel (opens in your browser)",
|
||||
"data-testid": "audit-log-btn",
|
||||
},
|
||||
"Audit Log",
|
||||
);
|
||||
auditBtn.addEventListener("click", () => {
|
||||
const host = api.getConfig().host ?? "";
|
||||
if (host === "") {
|
||||
getToast()?.show("Not connected to a server", "error");
|
||||
return;
|
||||
}
|
||||
void openAdminPanel(host, "audit").catch(() => {
|
||||
getToast()?.show("Could not open the admin panel", "error");
|
||||
});
|
||||
});
|
||||
|
||||
const syncAuditBtn = (): void => {
|
||||
auditBtn.style.display = canViewAuditLog() ? "" : "none";
|
||||
};
|
||||
syncAuditBtn();
|
||||
serverHeader.appendChild(auditBtn);
|
||||
// The permission is derived from the signed-in user's role plus the role
|
||||
// list, so both have to be watched.
|
||||
unsubscribers.push(
|
||||
authStore.subscribeSelector(
|
||||
(s) => s.user?.role ?? null,
|
||||
() => syncAuditBtn(),
|
||||
),
|
||||
);
|
||||
unsubscribers.push(
|
||||
channelsStore.subscribeSelector(
|
||||
(s) => s.roles,
|
||||
() => syncAuditBtn(),
|
||||
),
|
||||
);
|
||||
|
||||
sidebarWrapper.appendChild(serverHeader);
|
||||
|
||||
// Load per-server collapsed category state from localStorage
|
||||
@@ -186,6 +254,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
return createChannelSidebar({
|
||||
onVoiceJoin: sidebarVoice.onVoiceJoin,
|
||||
onVoiceLeave: sidebarVoice.onVoiceLeave,
|
||||
onVoiceModerate: createVoiceModerationCallbacks(ws),
|
||||
onWatchStream: opts.onWatchStream,
|
||||
onCreateChannel: (category) => {
|
||||
if (activeModal !== null) return;
|
||||
@@ -211,10 +280,20 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
},
|
||||
onEditChannel: (channel) => {
|
||||
if (activeModal !== null) return;
|
||||
// Pre-fill from the store rather than from the sidebar's row: the store
|
||||
// is what channel_update writes into, so the modal opens on the current
|
||||
// values even if the row was rendered before the last edit landed.
|
||||
const stored = channelsStore.getState().channels.get(channel.id);
|
||||
const modal = createEditChannelModal({
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
channelType: channel.type,
|
||||
channelTopic: stored?.topic ?? "",
|
||||
channelCategory: stored?.category ?? "",
|
||||
channelSlowMode: stored?.slowMode ?? 0,
|
||||
channelNsfw: stored?.nsfw ?? false,
|
||||
channelVoiceMaxUsers: stored?.voiceMaxUsers ?? 0,
|
||||
channelVoiceMaxVideo: stored?.voiceMaxVideo ?? 0,
|
||||
onSave: async (data) => {
|
||||
try {
|
||||
await api.adminUpdateChannel(channel.id, data);
|
||||
@@ -261,6 +340,22 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
void api.adminUpdateChannel(r.channelId, { position: r.newPosition });
|
||||
}
|
||||
},
|
||||
onPurgeChannel: async (channel, count) => {
|
||||
try {
|
||||
// The store is updated by the chat_bulk_deleted broadcast, so the
|
||||
// response is only used for the toast's honest count.
|
||||
const result = await api.purgeMessages(channel.id, count);
|
||||
getToast()?.show(
|
||||
result.count === 0
|
||||
? `No messages to purge in #${channel.name}`
|
||||
: `Purged ${result.count} message${result.count === 1 ? "" : "s"} from #${channel.name}`,
|
||||
result.count === 0 ? "info" : "success",
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to purge messages";
|
||||
getToast()?.show(msg, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,85 +372,29 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
},
|
||||
};
|
||||
|
||||
/** Show a simple member picker modal and call createDm on selection. */
|
||||
/**
|
||||
* Show the member picker. One selection opens a 1:1 DM; two or more create a
|
||||
* group — the picker itself decides which, so the sidebar does not need two
|
||||
* entry points for what the user experiences as one action.
|
||||
*/
|
||||
function showMemberPicker(): void {
|
||||
if (activeModal !== null) return;
|
||||
|
||||
const members = membersStore.getState().members;
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
|
||||
const overlay = createElement("div", { class: "modal-overlay visible" });
|
||||
const modal = createElement("div", {
|
||||
class: "modal dm-member-picker-modal",
|
||||
style: "padding:20px;",
|
||||
});
|
||||
const title = createElement("h3", {}, "New Direct Message");
|
||||
const subtitle = createElement(
|
||||
"p",
|
||||
{ style: "color:var(--text-secondary);font-size:0.85rem;margin:0 0 8px;" },
|
||||
"Select a member to start a conversation",
|
||||
);
|
||||
const listContainer = createElement("div", {
|
||||
class: "dm-member-picker-list",
|
||||
style: "max-height:300px;overflow-y:auto;",
|
||||
});
|
||||
|
||||
for (const member of members.values()) {
|
||||
if (member.id === currentUserId) continue;
|
||||
const item = createElement("div", {
|
||||
class: "dm-member-picker-item channel-item",
|
||||
style: "cursor:pointer;padding:6px 8px;display:flex;align-items:center;gap:8px;",
|
||||
});
|
||||
const avatar = createElement("div", {
|
||||
class: "dm-avatar",
|
||||
style:
|
||||
"width:28px;height:28px;border-radius:50%;background:#5865F2;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:white;flex-shrink:0;",
|
||||
});
|
||||
setText(avatar, member.username.charAt(0).toUpperCase());
|
||||
const nameEl = createElement("span", {}, member.username);
|
||||
const statusEl = createElement(
|
||||
"span",
|
||||
{
|
||||
style: `font-size:0.75rem;margin-left:auto;color:${member.status === "online" ? "var(--green)" : "var(--text-micro)"};`,
|
||||
},
|
||||
member.status,
|
||||
);
|
||||
appendChildren(item, avatar, nameEl, statusEl);
|
||||
|
||||
item.addEventListener("click", () => {
|
||||
const picker = createMemberPickerModal({
|
||||
onSelect: (userId) => {
|
||||
closePickerModal();
|
||||
void handleCreateDm(member.id, dmDeps);
|
||||
});
|
||||
listContainer.appendChild(item);
|
||||
}
|
||||
|
||||
const cancelBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn btn-secondary",
|
||||
style: "margin-top:12px;width:100%;",
|
||||
void handleCreateDm(userId, dmDeps);
|
||||
},
|
||||
onSelectGroup: (userIds, name) => {
|
||||
closePickerModal();
|
||||
void handleCreateGroupDm(userIds, name, dmDeps);
|
||||
},
|
||||
onClose: () => {
|
||||
activeModal = null;
|
||||
},
|
||||
"Cancel",
|
||||
);
|
||||
cancelBtn.addEventListener("click", () => closePickerModal());
|
||||
|
||||
appendChildren(modal, title, subtitle, listContainer, cancelBtn);
|
||||
overlay.appendChild(modal);
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) closePickerModal();
|
||||
});
|
||||
|
||||
const pickerComponent: MountableComponent = {
|
||||
mount: (container: Element) => {
|
||||
container.appendChild(overlay);
|
||||
},
|
||||
destroy: () => {
|
||||
overlay.remove();
|
||||
},
|
||||
};
|
||||
|
||||
activeModal = pickerComponent;
|
||||
pickerComponent.mount(document.body);
|
||||
activeModal = picker;
|
||||
picker.mount(document.body);
|
||||
}
|
||||
|
||||
function closePickerModal(): void {
|
||||
@@ -369,48 +408,91 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
// DM sidebar builder (dms mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Leave the DM list gracefully after a conversation goes away: fall back to
|
||||
* another DM if there is one, otherwise to the channel the user came from.
|
||||
*/
|
||||
function fallBackFromDm(): void {
|
||||
const remaining = dmStore.getState().channels;
|
||||
if (remaining.length > 0) {
|
||||
selectDmConversation(remaining[0]!, dmDeps);
|
||||
return;
|
||||
}
|
||||
setSidebarMode("channels");
|
||||
if (channelBeforeDm !== null) {
|
||||
setActiveChannel(channelBeforeDm);
|
||||
return;
|
||||
}
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a 1:1 DM or leave a group.
|
||||
*
|
||||
* The client does not decide which: the server's DELETE /dms/{id} is a hide
|
||||
* for a 1:1 and a leave for a group, and duplicating that branch here would
|
||||
* be a second place to get it wrong. Locally, both mean "drop it from the
|
||||
* list" — the row is removed optimistically because the request is a
|
||||
* fire-and-forget one whose failure the sidebar cannot usefully recover from
|
||||
* (the next `ready` restores the truth either way).
|
||||
*/
|
||||
function closeOrLeaveDm(channelId: number): void {
|
||||
const wasActive = channelsStore.getState().activeChannelId === channelId;
|
||||
removeDmChannel(channelId);
|
||||
void api.closeDm(channelId).catch(() => {
|
||||
getToast()?.show("Could not leave that conversation", "error");
|
||||
});
|
||||
if (wasActive) fallBackFromDm();
|
||||
}
|
||||
|
||||
/** Rename a group DM (participants only; the server refuses a 1:1). */
|
||||
function renameGroup(channelId: number): void {
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
if (dm === undefined || !dm.isGroup) return;
|
||||
createPromptModal({
|
||||
title: "Rename Group",
|
||||
label: "Leave it empty to go back to listing the members.",
|
||||
initialValue: dm.name,
|
||||
placeholder: "Group name",
|
||||
maxLength: 100,
|
||||
testId: "dm-rename-input",
|
||||
onSubmit: (name) => {
|
||||
// The store is updated by the dm_channel_open the server fans out to
|
||||
// every participant, so the response is only used for the error path.
|
||||
void api.renameGroupDm(channelId, name).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "Failed to rename group";
|
||||
getToast()?.show(msg, "error");
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildDmSidebar(): MountableComponent {
|
||||
const serverName = authStore.getState().serverName ?? "Server";
|
||||
const activeDmUserId = uiStore.getState().activeDmUserId;
|
||||
const activeChannelId = channelsStore.getState().activeChannelId;
|
||||
const dmChannels = dmStore.getState().channels;
|
||||
const conversations = buildDmConversations(activeDmUserId);
|
||||
const conversations = buildDmConversations(activeChannelId);
|
||||
|
||||
return createDmSidebar({
|
||||
conversations,
|
||||
onSelectConversation: (userId) => {
|
||||
const dmChannel = dmChannels.find((c) => c.recipient.id === userId);
|
||||
onSelectConversation: (channelId) => {
|
||||
const dmChannel = dmChannels.find((c) => c.channelId === channelId);
|
||||
if (dmChannel !== undefined) {
|
||||
selectDmConversation(dmChannel, dmDeps);
|
||||
}
|
||||
},
|
||||
onCloseDm: (userId) => {
|
||||
const dmChannel = dmChannels.find((c) => c.recipient.id === userId);
|
||||
if (dmChannel !== undefined) {
|
||||
const wasActive = channelsStore.getState().activeChannelId === dmChannel.channelId;
|
||||
removeDmChannel(dmChannel.channelId);
|
||||
void api.closeDm(dmChannel.channelId);
|
||||
|
||||
if (wasActive) {
|
||||
const remaining = dmStore.getState().channels;
|
||||
if (remaining.length > 0) {
|
||||
selectDmConversation(remaining[0]!, dmDeps);
|
||||
} else {
|
||||
setSidebarMode("channels");
|
||||
if (channelBeforeDm !== null) {
|
||||
setActiveChannel(channelBeforeDm);
|
||||
} else {
|
||||
const channels = channelsStore.getState().channels;
|
||||
for (const ch of channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onCloseDm: (channelId) => closeOrLeaveDm(channelId),
|
||||
onToggleMute: (channelId) => {
|
||||
toggleChannelMute(channelId);
|
||||
// Mute state is not in a store, so nothing re-renders on its own.
|
||||
refreshDmSidebarRef?.();
|
||||
},
|
||||
onRenameGroup: (channelId) => renameGroup(channelId),
|
||||
onNewDm: () => {
|
||||
showMemberPicker();
|
||||
},
|
||||
@@ -420,8 +502,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
setActiveChannel(channelBeforeDm);
|
||||
channelBeforeDm = null;
|
||||
} else {
|
||||
const channels = channelsStore.getState().channels;
|
||||
for (const ch of channels.values()) {
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
@@ -497,7 +578,13 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
// --- Member list (below DM section) ---
|
||||
// Same wiring lives in SidebarMemberSection; this used to be a private
|
||||
// copy of it, and a fix to one silently missed the other.
|
||||
const memberSection = createSidebarMemberSection({ api, getToast });
|
||||
const memberSection = createSidebarMemberSection({
|
||||
api,
|
||||
getToast,
|
||||
onMessageUser: (userId) => {
|
||||
void handleCreateDm(userId, dmDeps);
|
||||
},
|
||||
});
|
||||
contentSlot.appendChild(memberSection.element);
|
||||
channelModeExtras.push(memberSection.memberListComponent);
|
||||
channelModeUnsubs.push(memberSection.destroy);
|
||||
@@ -529,6 +616,11 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
contentSlot.appendChild(freshSlot);
|
||||
}
|
||||
|
||||
refreshDmSidebarRef = refreshDmSidebar;
|
||||
channelModeUnsubs.push(() => {
|
||||
refreshDmSidebarRef = null;
|
||||
});
|
||||
|
||||
// Re-render DM sidebar when DM store changes (new DMs, message updates)
|
||||
const unsubDmStore = dmStore.subscribeSelector(
|
||||
(s) => s.channels,
|
||||
@@ -538,9 +630,10 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
);
|
||||
channelModeUnsubs.push(unsubDmStore);
|
||||
|
||||
// Re-render DM sidebar when active DM user changes
|
||||
const unsubDmActive = uiStore.subscribeSelector(
|
||||
(s) => s.activeDmUserId,
|
||||
// Re-render DM sidebar when the active conversation changes. Keyed on the
|
||||
// active channel rather than activeDmUserId, which a group DM leaves null.
|
||||
const unsubDmActive = channelsStore.subscribeSelector(
|
||||
(s) => s.activeChannelId,
|
||||
() => {
|
||||
refreshDmSidebar();
|
||||
},
|
||||
|
||||
@@ -9,9 +9,11 @@ import type { DmConversation } from "@components/DmSidebar";
|
||||
import { setSidebarMode, setActiveDmUser } from "@stores/ui.store";
|
||||
import { channelsStore, setActiveChannel } from "@stores/channels.store";
|
||||
import type { Channel } from "@stores/channels.store";
|
||||
import { dmStore, clearDmUnread, addDmChannel } from "@stores/dm.store";
|
||||
import type { DmChannel } from "@stores/dm.store";
|
||||
import { dmStore, clearDmUnread, addDmChannel, dmDisplayName } from "@stores/dm.store";
|
||||
import type { DmChannel, DmUser } from "@stores/dm.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { isChannelMuted } from "@lib/channel-mutes";
|
||||
import type { DmChannelPayload } from "@lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -43,7 +45,10 @@ export function selectDmConversation(dmChannel: DmChannel, deps: DmHelperDeps):
|
||||
}
|
||||
}
|
||||
|
||||
setActiveDmUser(dmChannel.recipient.id);
|
||||
// A group has no single "DM user"; the sidebar's active marker keys on the
|
||||
// active channel instead. This is kept for the 1:1 case, where other parts
|
||||
// (the profile sidebar) still ask "who am I talking to".
|
||||
setActiveDmUser(dmChannel.isGroup ? null : dmChannel.recipient.id);
|
||||
setSidebarMode("dms");
|
||||
clearDmUnread(dmChannel.channelId);
|
||||
|
||||
@@ -60,22 +65,33 @@ export function selectDmConversation(dmChannel: DmChannel, deps: DmHelperDeps):
|
||||
export function addDmToChannelsStore(dmChannel: DmChannel): void {
|
||||
const existing = channelsStore.getState().channels.get(dmChannel.channelId);
|
||||
|
||||
// If the channel exists but has an empty name (server sends DMs with name=''),
|
||||
// update it with the recipient's username
|
||||
if (existing !== undefined && existing.name !== "") return;
|
||||
// Re-synthesise when the stored name has gone stale as well as when it is
|
||||
// empty: a group rename or a member leaving changes what the DM is called,
|
||||
// and the channels-store copy is what the chat header reads.
|
||||
if (existing !== undefined && existing.name === dmDisplayName(dmChannel)) return;
|
||||
|
||||
const newChannel: Channel = {
|
||||
id: dmChannel.channelId,
|
||||
name: dmChannel.recipient.username,
|
||||
name: dmDisplayName(dmChannel),
|
||||
type: "dm",
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: dmChannel.unreadCount,
|
||||
// The DM's own mention count, not a hardcoded 0: the ready payload now
|
||||
// carries it, so a DM mention badge survives a reconnect.
|
||||
mentionCount: dmChannel.mentionCount,
|
||||
lastMessageId: dmChannel.lastMessageId,
|
||||
// Channel-level permission is always true for DMs; block state is layered on
|
||||
// top by the composer via blocks.store (see ChannelController), not canSend.
|
||||
canSend: true,
|
||||
slowMode: 0,
|
||||
topic: "",
|
||||
// A DM is never age-gated and has no voice capacity: the flags exist on
|
||||
// guild channels, and a DM row is synthesised here rather than coming from
|
||||
// the server's channel list.
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
};
|
||||
channelsStore.setState((prev) => {
|
||||
const next = new Map(prev.channels);
|
||||
@@ -94,18 +110,24 @@ export async function handleCreateDm(recipientId: number, deps: DmHelperDeps): P
|
||||
const result = await deps.api.createDm(recipientId);
|
||||
const member = membersStore.getState().members.get(recipientId);
|
||||
|
||||
const recipient: DmUser = {
|
||||
id: result.recipient.id,
|
||||
username: result.recipient.username,
|
||||
avatar: result.recipient.avatar,
|
||||
status: result.recipient.status ?? member?.status ?? "offline",
|
||||
displayName: result.recipient.display_name ?? member?.displayName ?? "",
|
||||
};
|
||||
const dmChannel: DmChannel = {
|
||||
channelId: result.channel_id,
|
||||
recipient: {
|
||||
id: result.recipient.id,
|
||||
username: result.recipient.username,
|
||||
avatar: result.recipient.avatar,
|
||||
status: result.recipient.status ?? member?.status ?? "offline",
|
||||
},
|
||||
recipient,
|
||||
participants: [recipient],
|
||||
name: "",
|
||||
isGroup: false,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
};
|
||||
|
||||
addDmChannel(dmChannel);
|
||||
@@ -116,21 +138,98 @@ export async function handleCreateDm(recipientId: number, deps: DmHelperDeps): P
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dmChannelFromPayload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a server DM summary (the shape `POST /dms/group`, `PATCH /dms/{id}`,
|
||||
* `GET /dms` and `dm_channel_open` all share) into the store's DmChannel.
|
||||
*
|
||||
* The dispatcher has its own copy of this for the WS path; this one exists so
|
||||
* the REST responses land in exactly the same shape without importing the
|
||||
* dispatcher's internals into the sidebar.
|
||||
*/
|
||||
export function dmChannelFromPayload(p: DmChannelPayload): DmChannel {
|
||||
const participants: DmUser[] = (p.recipients ?? [p.recipient]).map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
status: u.status,
|
||||
displayName: u.display_name ?? "",
|
||||
}));
|
||||
return {
|
||||
channelId: p.channel_id,
|
||||
recipient: participants[0] ?? {
|
||||
id: p.recipient.id,
|
||||
username: p.recipient.username,
|
||||
avatar: p.recipient.avatar,
|
||||
status: p.recipient.status,
|
||||
displayName: p.recipient.display_name ?? "",
|
||||
},
|
||||
participants,
|
||||
name: p.name ?? "",
|
||||
isGroup: p.is_group ?? false,
|
||||
lastMessageId: p.last_message_id,
|
||||
lastMessage: p.last_message,
|
||||
lastMessageAt: p.last_message_at,
|
||||
unreadCount: p.unread_count,
|
||||
mentionCount: p.mention_count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleCreateGroupDm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a group DM with the given members and switch to it. */
|
||||
export async function handleCreateGroupDm(
|
||||
recipientIds: readonly number[],
|
||||
name: string,
|
||||
deps: DmHelperDeps,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await deps.api.createGroupDm(recipientIds, name);
|
||||
const dmChannel = dmChannelFromPayload(result);
|
||||
addDmChannel(dmChannel);
|
||||
selectDmConversation(dmChannel, deps);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to create group DM";
|
||||
deps.getToast()?.show(msg, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildDmConversations — helper for DM sidebar mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a readonly DmConversation array from DM store state. */
|
||||
export function buildDmConversations(activeDmUserId: number | null): readonly DmConversation[] {
|
||||
/**
|
||||
* Build a readonly DmConversation array from DM store state.
|
||||
*
|
||||
* Keyed on the channel, not on the recipient user: a group DM has no single
|
||||
* recipient, and a user can be in both a 1:1 and a group with the same person,
|
||||
* so a user id no longer identifies a row.
|
||||
*/
|
||||
export function buildDmConversations(activeChannelId: number | null): readonly DmConversation[] {
|
||||
const dmChannels = dmStore.getState().channels;
|
||||
return dmChannels.map((dm) => ({
|
||||
channelId: dm.channelId,
|
||||
userId: dm.recipient.id,
|
||||
username: dm.recipient.username,
|
||||
username: dmDisplayName(dm),
|
||||
avatar: dm.recipient.avatar || null,
|
||||
status: (dm.recipient.status as DmConversation["status"]) ?? "offline",
|
||||
isGroup: dm.isGroup,
|
||||
participants: dm.participants.map((p) => ({
|
||||
id: p.id,
|
||||
username: (p.displayName ?? "") || p.username,
|
||||
avatar: p.avatar || null,
|
||||
})),
|
||||
lastMessage: dm.lastMessage || "No messages yet",
|
||||
timestamp: dm.lastMessageAt,
|
||||
unread: dm.unreadCount > 0,
|
||||
active: dm.recipient.id === activeDmUserId,
|
||||
unread: dm.unreadCount > 0 || dm.mentionCount > 0,
|
||||
unreadCount: dm.unreadCount,
|
||||
mentionCount: dm.mentionCount,
|
||||
muted: isChannelMuted(dm.channelId),
|
||||
active: dm.channelId === activeChannelId,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
|
||||
import { dmStore } from "@stores/dm.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
import type { DmChannel } from "@stores/dm.store";
|
||||
import { setSidebarMode } from "@stores/ui.store";
|
||||
import { isChannelMuted } from "@lib/channel-mutes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -72,12 +73,16 @@ export function createSidebarDmSection(opts: SidebarDmSectionOptions): SidebarDm
|
||||
const dmChannels = dmStore.getState().channels;
|
||||
const displayChannels = dmChannels.slice(0, 3);
|
||||
for (const dm of displayChannels) {
|
||||
const muted = isChannelMuted(dm.channelId);
|
||||
const dmItem = createElement("div", {
|
||||
class: "channel-item",
|
||||
class: muted ? "channel-item muted" : "channel-item",
|
||||
"data-testid": "dm-entry",
|
||||
});
|
||||
const statusColor =
|
||||
dm.recipient.status === "online"
|
||||
// A group has no presence of its own, so it gets a neutral marker rather
|
||||
// than the first member's dot dressed up as the conversation's state.
|
||||
const statusColor = dm.isGroup
|
||||
? "var(--text-micro)"
|
||||
: dm.recipient.status === "online"
|
||||
? "var(--green)"
|
||||
: dm.recipient.status === "idle"
|
||||
? "var(--yellow)"
|
||||
@@ -85,17 +90,18 @@ export function createSidebarDmSection(opts: SidebarDmSectionOptions): SidebarDm
|
||||
? "var(--red)"
|
||||
: "var(--text-micro)";
|
||||
const statusDot = createElement("span", {
|
||||
style: `display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor};flex-shrink:0;`,
|
||||
style: `display:inline-block;width:8px;height:8px;border-radius:${dm.isGroup ? "2px" : "50%"};background:${statusColor};flex-shrink:0;`,
|
||||
});
|
||||
const name = createElement("span", { class: "ch-name" }, dm.recipient.username);
|
||||
const name = createElement("span", { class: "ch-name" }, dmDisplayName(dm));
|
||||
const parts: Element[] = [statusDot, name];
|
||||
if (dm.unreadCount > 0) {
|
||||
// Muted: the count still increments (it is a fact about the channel),
|
||||
// it just stops shouting. Only the colour changes.
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{
|
||||
class: "dm-unread-badge",
|
||||
style:
|
||||
"margin-left:auto;background:var(--red);color:white;border-radius:10px;padding:1px 6px;font-size:0.7rem;",
|
||||
class: muted ? "dm-unread-badge muted" : "dm-unread-badge",
|
||||
style: `margin-left:auto;background:${muted ? "var(--text-micro)" : "var(--red)"};color:white;border-radius:10px;padding:1px 6px;font-size:0.7rem;`,
|
||||
},
|
||||
String(dm.unreadCount),
|
||||
);
|
||||
@@ -116,8 +122,13 @@ export function createSidebarDmSection(opts: SidebarDmSectionOptions): SidebarDm
|
||||
viewAllBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// Update total unread badge on the DM header
|
||||
const totalUnread = dmChannels.reduce((sum, c) => sum + c.unreadCount, 0);
|
||||
// Update total unread badge on the DM header. Muted conversations are
|
||||
// excluded: the header badge is an interrupt, and a muted DM asked not to
|
||||
// be one. Its own row still shows its dimmed count.
|
||||
const totalUnread = dmChannels.reduce(
|
||||
(sum, c) => sum + (isChannelMuted(c.channelId) ? 0 : c.unreadCount),
|
||||
0,
|
||||
);
|
||||
if (totalUnread > 0) {
|
||||
setText(dmUnreadBadge, String(totalUnread));
|
||||
dmUnreadBadge.style.display = "";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createElement, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { createMemberList } from "@components/MemberList";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { setUserBlockedByMe } from "@stores/blocks.store";
|
||||
import { getRoleIdByName } from "@stores/channels.store";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
@@ -26,6 +27,8 @@ const LS_KEY_COLLAPSED = "owncord:member-list-collapsed";
|
||||
export interface SidebarMemberSectionOptions {
|
||||
readonly api: ApiClient;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
/** Start a DM with a user (profile popup's Message button). */
|
||||
readonly onMessageUser?: (userId: number) => void;
|
||||
}
|
||||
|
||||
export interface SidebarMemberSectionResult {
|
||||
@@ -44,7 +47,7 @@ export interface SidebarMemberSectionResult {
|
||||
export function createSidebarMemberSection(
|
||||
opts: SidebarMemberSectionOptions,
|
||||
): SidebarMemberSectionResult {
|
||||
const { api, getToast } = opts;
|
||||
const { api, getToast, onMessageUser } = opts;
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// --- Container ---
|
||||
@@ -147,24 +150,45 @@ export function createSidebarMemberSection(
|
||||
// --- Member list component ---
|
||||
const memberList = createMemberList({
|
||||
currentUserRole: authStore.getState().user?.role ?? "member",
|
||||
...(onMessageUser !== undefined ? { onMessageUser } : {}),
|
||||
// "Force Logout", not "Kick": the endpoint revokes the target's sessions
|
||||
// and nothing stops them signing back in — there is no membership to remove.
|
||||
onKick: async (userId, username) => {
|
||||
try {
|
||||
await api.adminKickMember(userId);
|
||||
getToast()?.show(`Kicked ${username}`, "success");
|
||||
getToast()?.show(`Forced ${username} to log out`, "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to kick member";
|
||||
const msg = err instanceof Error ? err.message : "Failed to force logout";
|
||||
getToast()?.show(msg, "error");
|
||||
}
|
||||
},
|
||||
onBan: async (userId, username, reason) => {
|
||||
onBan: async (userId, username, reason, durationHours) => {
|
||||
try {
|
||||
await api.adminBanMember(userId, reason);
|
||||
getToast()?.show(`Banned ${username}`, "success");
|
||||
await api.adminBanMember(userId, reason, durationHours);
|
||||
getToast()?.show(
|
||||
durationHours > 0 ? `Banned ${username} for ${durationHours}h` : `Banned ${username}`,
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to ban member";
|
||||
getToast()?.show(msg, "error");
|
||||
}
|
||||
},
|
||||
onToggleBlock: async (userId, username, block) => {
|
||||
try {
|
||||
if (block) {
|
||||
await api.blockUser(userId);
|
||||
} else {
|
||||
await api.unblockUser(userId);
|
||||
}
|
||||
setUserBlockedByMe(userId, block);
|
||||
getToast()?.show(block ? `Blocked ${username}` : `Unblocked ${username}`, "success");
|
||||
} catch (err) {
|
||||
const fallback = block ? "Failed to block user" : "Failed to unblock user";
|
||||
const msg = err instanceof Error ? err.message : fallback;
|
||||
getToast()?.show(msg, "error");
|
||||
}
|
||||
},
|
||||
onChangeRole: async (userId, username, newRole) => {
|
||||
const roleId = getRoleIdByName(newRole);
|
||||
if (roleId === undefined) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createLogger } from "@lib/logger";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import { voiceStore, joinVoiceChannel, leaveVoiceChannel } from "@stores/voice.store";
|
||||
import { uiStore } from "@stores/ui.store";
|
||||
import type { VoiceModerationCallbacks } from "@components/ChannelSidebar";
|
||||
import {
|
||||
leaveVoice as voiceSessionLeave,
|
||||
setMuted as voiceSessionSetMuted,
|
||||
@@ -69,6 +70,10 @@ export function createVoiceWidgetCallbacks(
|
||||
onMuteToggle: () => {
|
||||
if (!limiters.voice.tryConsume()) return;
|
||||
const state = voiceStore.getState();
|
||||
// A moderator-imposed mute is not ours to lift; the server refuses the
|
||||
// unmute, so don't spend the round-trip (keybinds reach here too, not
|
||||
// just the disabled button).
|
||||
if (state.localServerMuted === true) return;
|
||||
if (state.localMuted) {
|
||||
voiceSessionSetMuted(false);
|
||||
ws.send({ type: "voice_mute", payload: { muted: false } });
|
||||
@@ -84,6 +89,7 @@ export function createVoiceWidgetCallbacks(
|
||||
onDeafenToggle: () => {
|
||||
if (!limiters.voice.tryConsume()) return;
|
||||
const state = voiceStore.getState();
|
||||
if (state.localServerDeafened === true) return;
|
||||
if (state.localDeafened) {
|
||||
voiceSessionSetDeafened(false);
|
||||
ws.send({ type: "voice_deafen", payload: { deafened: false } });
|
||||
@@ -129,6 +135,40 @@ export function createVoiceWidgetCallbacks(
|
||||
// Sidebar Voice Callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Moderator voice actions. Fire-and-forget sends: the server answers with a
|
||||
* voice_state / voice_leave broadcast on success and an error frame on
|
||||
* refusal, so there is no optimistic local state to roll back. */
|
||||
export function createVoiceModerationCallbacks(ws: WsClient): VoiceModerationCallbacks {
|
||||
return {
|
||||
onServerMute: (channelId, userId, muted) => {
|
||||
if (!socketLive()) return;
|
||||
log.info("Server mute", { channelId, userId, muted });
|
||||
ws.send({
|
||||
type: "voice_mod_mute",
|
||||
payload: { channel_id: channelId, user_id: userId, muted },
|
||||
});
|
||||
},
|
||||
onServerDeafen: (channelId, userId, deafened) => {
|
||||
if (!socketLive()) return;
|
||||
log.info("Server deafen", { channelId, userId, deafened });
|
||||
ws.send({
|
||||
type: "voice_mod_deafen",
|
||||
payload: { channel_id: channelId, user_id: userId, deafened },
|
||||
});
|
||||
},
|
||||
onMove: (userId, toChannelId) => {
|
||||
if (!socketLive()) return;
|
||||
log.info("Move voice user", { userId, toChannelId });
|
||||
ws.send({ type: "voice_mod_move", payload: { user_id: userId, to_channel_id: toChannelId } });
|
||||
},
|
||||
onDisconnect: (userId) => {
|
||||
if (!socketLive()) return;
|
||||
log.info("Disconnect voice user", { userId });
|
||||
ws.send({ type: "voice_mod_kick", payload: { user_id: userId } });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSidebarVoiceCallbacks(ws: WsClient): SidebarVoiceCallbacks {
|
||||
return {
|
||||
onVoiceJoin: (channelId: number) => {
|
||||
|
||||
@@ -11,12 +11,22 @@ import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("auth.store");
|
||||
|
||||
/** Why the session ended. "user" covers every locally-initiated or
|
||||
* invalid-token path (logout, 401, auth_error, ban); "server_shutdown" is a
|
||||
* server-initiated kick whose token is still valid — the logout wiring keeps
|
||||
* the saved credential in that case so auto-login works when the server
|
||||
* comes back. */
|
||||
export type LogoutReason = "user" | "server_shutdown";
|
||||
|
||||
export interface AuthState {
|
||||
readonly token: string | null;
|
||||
readonly user: UserWithRole | null;
|
||||
readonly serverName: string | null;
|
||||
readonly motd: string | null;
|
||||
readonly isAuthenticated: boolean;
|
||||
/** Set by clearAuth; cleared again on the next setAuth. Optional so the
|
||||
* many inline AuthState test fixtures need not restate it. */
|
||||
readonly logoutReason?: LogoutReason | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AuthState = {
|
||||
@@ -41,9 +51,11 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m
|
||||
}
|
||||
|
||||
/** Reset auth state (logout / disconnect). Also cleans up the voice
|
||||
* session (WebRTC, AudioContext, streams) and clears voice store state.
|
||||
* Safe to call even if no voice session is active — leaveVoice is idempotent. */
|
||||
export function clearAuth(): void {
|
||||
* session (WebRTC, AudioContext, streams) and clears voice store state —
|
||||
* including camera/screenshare, whose tracks leaveVoice stops and whose
|
||||
* toggles it resets. Safe to call even if no voice session is active —
|
||||
* leaveVoice is idempotent. */
|
||||
export function clearAuth(reason: LogoutReason = "user"): void {
|
||||
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
|
||||
// lazily so it stays out of the startup path. Only import it when there is
|
||||
// actually a voice session to leave — otherwise a text-only user who never
|
||||
@@ -58,7 +70,7 @@ export function clearAuth(): void {
|
||||
}
|
||||
resetVoiceStore();
|
||||
cleanupNotificationAudio();
|
||||
authStore.setState(() => ({ ...INITIAL_STATE }));
|
||||
authStore.setState(() => ({ ...INITIAL_STATE, logoutReason: reason }));
|
||||
}
|
||||
|
||||
/** Shorthand selector for the current token. */
|
||||
|
||||
@@ -35,6 +35,17 @@ export function setBlockedByMe(userIds: readonly number[]): void {
|
||||
blocksStore.setState((prev) => ({ ...prev, blockedByMe: new Set(userIds) }));
|
||||
}
|
||||
|
||||
/** Mark (or unmark) a user as blocked by the local user (after PUT/DELETE /blocks). */
|
||||
export function setUserBlockedByMe(userId: number, blocked: boolean): void {
|
||||
blocksStore.setState((prev) => {
|
||||
if (prev.blockedByMe.has(userId) === blocked) return prev;
|
||||
const next = new Set(prev.blockedByMe);
|
||||
if (blocked) next.add(userId);
|
||||
else next.delete(userId);
|
||||
return { ...prev, blockedByMe: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark (or unmark) a recipient as having refused our DM. */
|
||||
export function setUserBlockedByThem(userId: number, blocked: boolean): void {
|
||||
blocksStore.setState((prev) => {
|
||||
|
||||
@@ -17,13 +17,37 @@ export interface Channel {
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
/** Channel topic ("" = none). */
|
||||
readonly topic: string;
|
||||
readonly position: number;
|
||||
readonly unreadCount: number;
|
||||
/**
|
||||
* Unread messages here that mention the current user (directly or via
|
||||
* @everyone/@here). Always a subset of unreadCount; drives the red mention
|
||||
* badge, which outranks the plain unread badge.
|
||||
*/
|
||||
readonly mentionCount: number;
|
||||
readonly lastMessageId: number | null;
|
||||
/** Whether the current user may post here (drives the composer affordance). */
|
||||
readonly canSend: boolean;
|
||||
/** Per-channel cooldown in seconds (0 = off). Drives the composer countdown. */
|
||||
readonly slowMode: number;
|
||||
/**
|
||||
* Flagged as possibly carrying sensitive content.
|
||||
*
|
||||
* The server stores and broadcasts this and does nothing else with it — no
|
||||
* filtering, no restriction on who may read or post — so every consequence
|
||||
* is this client's: a one-time-per-session age gate before the channel's
|
||||
* messages are shown, and a marker on the sidebar row.
|
||||
*/
|
||||
readonly nsfw: boolean;
|
||||
/**
|
||||
* Voice capacity limits (0 = unlimited). The server enforces both on join
|
||||
* (CHANNEL_FULL / VIDEO_LIMIT); these copies exist so the sidebar can show
|
||||
* "3/5" and the client never has to guess why a join was refused.
|
||||
*/
|
||||
readonly voiceMaxUsers: number;
|
||||
readonly voiceMaxVideo: number;
|
||||
}
|
||||
|
||||
export interface ChannelsState {
|
||||
@@ -40,8 +64,28 @@ const INITIAL_STATE: ChannelsState = {
|
||||
|
||||
export const channelsStore = createStore<ChannelsState>(INITIAL_STATE);
|
||||
|
||||
/**
|
||||
* How many unread messages each channel had at the moment it was last opened.
|
||||
*
|
||||
* Opening a channel clears its badge immediately, which destroys the only
|
||||
* record of where the reader had got to — so the value is snapshotted here
|
||||
* first. MessageList reads it to place the "NEW" divider above the first
|
||||
* message the reader has not seen. Kept outside the store state because it is
|
||||
* not reactive: it is read once when the list mounts, and a subscriber firing
|
||||
* on it would just re-render the list for no visible change.
|
||||
*/
|
||||
const unreadOnOpen = new Map<number, number>();
|
||||
|
||||
/** Unread count this channel had when it was last opened (0 = nothing new). */
|
||||
export function getUnreadOnOpen(channelId: number): number {
|
||||
return unreadOnOpen.get(channelId) ?? 0;
|
||||
}
|
||||
|
||||
/** Bulk set channels from the ready payload. Converts ReadyChannel[] to Map. */
|
||||
export function setChannels(channels: readonly ReadyChannel[]): void {
|
||||
// A fresh ready payload restates unread from the server; any snapshot from
|
||||
// the previous connection describes a read position that no longer applies.
|
||||
unreadOnOpen.clear();
|
||||
const map = new Map<number, Channel>();
|
||||
for (const ch of channels) {
|
||||
map.set(ch.id, {
|
||||
@@ -49,13 +93,20 @@ export function setChannels(channels: readonly ReadyChannel[]): void {
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
category: ch.category,
|
||||
topic: ch.topic ?? "",
|
||||
position: ch.position,
|
||||
unreadCount: ch.unread_count ?? 0,
|
||||
mentionCount: ch.mention_count ?? 0,
|
||||
lastMessageId: ch.last_message_id ?? null,
|
||||
// The current server always sends can_send; older servers omit it, in
|
||||
// which case we default permissive (no gating) rather than guessing.
|
||||
canSend: ch.can_send ?? true,
|
||||
slowMode: ch.slow_mode ?? 0,
|
||||
// Older servers omit these; "absent" reads as unflagged / unlimited,
|
||||
// which is also what an unconfigured channel sends.
|
||||
nsfw: ch.nsfw ?? false,
|
||||
voiceMaxUsers: ch.voice_max_users ?? 0,
|
||||
voiceMaxVideo: ch.voice_max_video ?? 0,
|
||||
});
|
||||
}
|
||||
channelsStore.setState((prev) => ({
|
||||
@@ -85,19 +136,31 @@ export function addChannel(channel: ChannelCreatePayload): void {
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
category: channel.category,
|
||||
topic: channel.topic ?? "",
|
||||
position: channel.position,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
// Broadcasts carry no per-user data; default permissive. The next ready
|
||||
// payload delivers the authoritative can_send. Server enforces regardless.
|
||||
canSend: true,
|
||||
slowMode: channel.slow_mode ?? 0,
|
||||
nsfw: channel.nsfw ?? false,
|
||||
voiceMaxUsers: channel.voice_max_users ?? 0,
|
||||
voiceMaxVideo: channel.voice_max_video ?? 0,
|
||||
});
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a channel's name and/or position immutably. */
|
||||
/**
|
||||
* Apply a channel_update broadcast immutably.
|
||||
*
|
||||
* Every field is optional and an absent one is left alone rather than reset:
|
||||
* the payload from an older server carries fewer keys than this one knows
|
||||
* about, and treating "absent" as "cleared" would blank a channel's topic or
|
||||
* drop its NSFW flag on the first update after connecting.
|
||||
*/
|
||||
export function updateChannel(update: ChannelUpdatePayload): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const existing = prev.channels.get(update.id);
|
||||
@@ -107,8 +170,15 @@ export function updateChannel(update: ChannelUpdatePayload): void {
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
...(update.name !== undefined ? { name: update.name } : {}),
|
||||
...(update.topic !== undefined ? { topic: update.topic } : {}),
|
||||
// "" is a real value here — it means "no category" — so only undefined
|
||||
// is treated as "not sent".
|
||||
...(update.category !== undefined ? { category: update.category } : {}),
|
||||
...(update.position !== undefined ? { position: update.position } : {}),
|
||||
...(update.slow_mode !== undefined ? { slowMode: update.slow_mode } : {}),
|
||||
...(update.nsfw !== undefined ? { nsfw: update.nsfw } : {}),
|
||||
...(update.voice_max_users !== undefined ? { voiceMaxUsers: update.voice_max_users } : {}),
|
||||
...(update.voice_max_video !== undefined ? { voiceMaxVideo: update.voice_max_video } : {}),
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(update.id, updated);
|
||||
@@ -143,17 +213,27 @@ export function removeChannel(id: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the active channel by id (or null to deselect). Clears unread count for the activated channel. */
|
||||
/**
|
||||
* Set the active channel by id (or null to deselect). Clears the unread and
|
||||
* mention counts for the activated channel — the server's channel_focus does
|
||||
* the same server-side, so the badges must not survive the visit locally.
|
||||
*/
|
||||
export function setActiveChannel(id: number | null): void {
|
||||
// Snapshot before clearing — this is the last moment the reader's position is
|
||||
// knowable (see unreadOnOpen). Done outside setState so the updater stays a
|
||||
// pure function of previous state.
|
||||
if (id !== null) {
|
||||
unreadOnOpen.set(id, channelsStore.getState().channels.get(id)?.unreadCount ?? 0);
|
||||
}
|
||||
channelsStore.setState((prev) => {
|
||||
if (id === null) {
|
||||
return { ...prev, activeChannelId: null };
|
||||
}
|
||||
const existing = prev.channels.get(id);
|
||||
if (existing === undefined || existing.unreadCount === 0) {
|
||||
if (existing === undefined || (existing.unreadCount === 0 && existing.mentionCount === 0)) {
|
||||
return { ...prev, activeChannelId: id };
|
||||
}
|
||||
const updated: Channel = { ...existing, unreadCount: 0 };
|
||||
const updated: Channel = { ...existing, unreadCount: 0, mentionCount: 0 };
|
||||
const next = new Map(prev.channels);
|
||||
next.set(id, updated);
|
||||
return { ...prev, activeChannelId: id, channels: next };
|
||||
@@ -170,6 +250,39 @@ export function getActiveChannel(): Channel | null {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The group header an uncategorized VOICE channel falls back to.
|
||||
*
|
||||
* Categories are free text and a channel of any type may carry any of them, so
|
||||
* a voice channel groups under whatever category it has — there is no magic
|
||||
* name that makes a category "the voice one". Only a voice channel with no
|
||||
* category at all needs somewhere to go, and mixing it into the unnamed group
|
||||
* next to uncategorized text channels reads as a bug, so it gets this group.
|
||||
*/
|
||||
export const UNCATEGORIZED_VOICE_CATEGORY = "Voice";
|
||||
|
||||
/** The category header a channel is displayed under. */
|
||||
export function displayCategoryOf(channel: Channel): string | null {
|
||||
if (channel.category !== null && channel.category !== "") {
|
||||
return channel.category;
|
||||
}
|
||||
return channel.type === "voice" ? UNCATEGORIZED_VOICE_CATEGORY : null;
|
||||
}
|
||||
|
||||
/** Every distinct category name currently in use, sorted, for suggestion lists. */
|
||||
export function getKnownCategories(): string[] {
|
||||
return channelsStore.select((s) => {
|
||||
const names = new Set<string>();
|
||||
for (const channel of s.channels.values()) {
|
||||
if (channel.type === "dm") continue;
|
||||
if (channel.category !== null && channel.category !== "") {
|
||||
names.add(channel.category);
|
||||
}
|
||||
}
|
||||
return [...names].toSorted((a, b) => a.localeCompare(b));
|
||||
});
|
||||
}
|
||||
|
||||
/** Group channels by category, sorted by position within each group. */
|
||||
export function getChannelsByCategory(): Map<string | null, Channel[]> {
|
||||
return channelsStore.select((s) => {
|
||||
@@ -177,11 +290,12 @@ export function getChannelsByCategory(): Map<string | null, Channel[]> {
|
||||
for (const channel of s.channels.values()) {
|
||||
// DM channels are shown in the DM sidebar, not the channel list
|
||||
if (channel.type === "dm") continue;
|
||||
const existing = grouped.get(channel.category);
|
||||
const category = displayCategoryOf(channel);
|
||||
const existing = grouped.get(category);
|
||||
if (existing !== undefined) {
|
||||
existing.push(channel);
|
||||
} else {
|
||||
grouped.set(channel.category, [channel]);
|
||||
grouped.set(category, [channel]);
|
||||
}
|
||||
}
|
||||
for (const channels of grouped.values()) {
|
||||
@@ -211,7 +325,31 @@ export function incrementUnread(channelId: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear unread count for a channel. */
|
||||
/**
|
||||
* Increment the mention count for a channel, unless it is the active channel.
|
||||
* Callers also call incrementUnread — a mention is always an unread too, and
|
||||
* the two counters are kept independent so the badge can outrank.
|
||||
*/
|
||||
export function incrementMention(channelId: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
if (prev.activeChannelId === channelId) {
|
||||
return prev;
|
||||
}
|
||||
const existing = prev.channels.get(channelId);
|
||||
if (existing === undefined) {
|
||||
return prev;
|
||||
}
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
mentionCount: existing.mentionCount + 1,
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(channelId, updated);
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the unread and mention counts for a channel — they clear together. */
|
||||
export function clearUnread(channelId: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const existing = prev.channels.get(channelId);
|
||||
@@ -221,6 +359,7 @@ export function clearUnread(channelId: number): void {
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(channelId, updated);
|
||||
|
||||
@@ -10,15 +10,35 @@ export interface DmUser {
|
||||
readonly username: string;
|
||||
readonly avatar: string;
|
||||
readonly status: string;
|
||||
/** Nickname to render instead of `username`. "" = unset. */
|
||||
readonly displayName?: string;
|
||||
}
|
||||
|
||||
export interface DmChannel {
|
||||
readonly channelId: number;
|
||||
/**
|
||||
* The other participant of a 1:1 DM. For a group it is the first of
|
||||
* `participants`; anything that must be correct for groups reads
|
||||
* `participants` instead. Kept because most 1:1 call sites want exactly one
|
||||
* user and would otherwise all index into an array.
|
||||
*/
|
||||
readonly recipient: DmUser;
|
||||
/** Everyone in the DM except the current user. Never empty for a live DM. */
|
||||
readonly participants: readonly DmUser[];
|
||||
/** Optional group name. "" for a 1:1 DM and for an unnamed group. */
|
||||
readonly name: string;
|
||||
/** True for a group DM (3+ participants at creation). */
|
||||
readonly isGroup: boolean;
|
||||
readonly lastMessageId: number | null;
|
||||
readonly lastMessage: string;
|
||||
readonly lastMessageAt: string;
|
||||
readonly unreadCount: number;
|
||||
/**
|
||||
* Unread messages in this DM that mention the current user. Kept independent
|
||||
* of unreadCount so the red mention badge can outrank the plain one, exactly
|
||||
* as it does for channels.
|
||||
*/
|
||||
readonly mentionCount: number;
|
||||
}
|
||||
|
||||
export interface DmState {
|
||||
@@ -34,11 +54,38 @@ export function setDmChannels(channels: readonly DmChannel[]): void {
|
||||
dmStore.setState(() => ({ channels }));
|
||||
}
|
||||
|
||||
/** Add or update a single DM channel (from dm_channel_open event). */
|
||||
/**
|
||||
* Add or update a single DM channel (from a `dm_channel_open` event).
|
||||
*
|
||||
* Local unread and mention counts survive the replace when the incoming
|
||||
* payload carries none. `dm_channel_open` is now also how a *membership*
|
||||
* change arrives — a group created, renamed, or left — and those payloads have
|
||||
* no unread state to report, so taking their zeroes literally would clear
|
||||
* everyone's badge every time somebody renamed a group. Between two `ready`s
|
||||
* the client's own count is the authoritative one (it is what the incoming
|
||||
* messages incremented), and a genuine reopen has nothing to lose: its local
|
||||
* count is zero too.
|
||||
*/
|
||||
export function addDmChannel(channel: DmChannel): void {
|
||||
dmStore.setState((prev) => {
|
||||
const existing = prev.channels.find((c) => c.channelId === channel.channelId);
|
||||
const filtered = prev.channels.filter((c) => c.channelId !== channel.channelId);
|
||||
return { channels: [channel, ...filtered] };
|
||||
const merged: DmChannel =
|
||||
existing === undefined
|
||||
? channel
|
||||
: {
|
||||
...channel,
|
||||
unreadCount: channel.unreadCount > 0 ? channel.unreadCount : existing.unreadCount,
|
||||
mentionCount: channel.mentionCount > 0 ? channel.mentionCount : existing.mentionCount,
|
||||
// Same reasoning for the preview: a rename does not know what the
|
||||
// last message was, and blanking it would leave the row emptier
|
||||
// than before the rename.
|
||||
lastMessageId: channel.lastMessageId ?? existing.lastMessageId,
|
||||
lastMessage: channel.lastMessage !== "" ? channel.lastMessage : existing.lastMessage,
|
||||
lastMessageAt:
|
||||
channel.lastMessageAt !== "" ? channel.lastMessageAt : existing.lastMessageAt,
|
||||
};
|
||||
return { channels: [merged, ...filtered] };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,9 +145,43 @@ export function updateDmLastMessagePreview(
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear unread count for a DM channel. */
|
||||
/** Clear the unread and mention counts for a DM channel — they clear together,
|
||||
* matching channels.store.clearUnread and the server's read-state advance. */
|
||||
export function clearDmUnread(channelId: number): void {
|
||||
dmStore.setState((prev) => ({
|
||||
channels: prev.channels.map((c) => (c.channelId === channelId ? { ...c, unreadCount: 0 } : c)),
|
||||
channels: prev.channels.map((c) =>
|
||||
c.channelId === channelId ? { ...c, unreadCount: 0, mentionCount: 0 } : c,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a DM renders under.
|
||||
*
|
||||
* One function so the sidebar row, the chat header, the quick switcher and the
|
||||
* notification title cannot disagree about what a conversation is called —
|
||||
* which for a group with no name they would, since each would pick its own
|
||||
* order and cut-off for the joined member list.
|
||||
*
|
||||
* A named group uses its name. An unnamed group joins its members' names, and
|
||||
* past three says "and N more" rather than growing without bound. A 1:1 DM is
|
||||
* named by the person on the other end.
|
||||
*/
|
||||
export function dmDisplayName(dm: DmChannel): string {
|
||||
if (dm.name !== "") return dm.name;
|
||||
const names = dm.participants.map((p) => (p.displayName ?? "") || p.username);
|
||||
if (names.length === 0) return dm.recipient.username;
|
||||
if (!dm.isGroup) return names[0]!;
|
||||
if (names.length <= 3) return names.join(", ");
|
||||
return `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`;
|
||||
}
|
||||
|
||||
/** Increment a DM's mention count. Callers also call updateDmLastMessage — a
|
||||
* mention is always an unread too. */
|
||||
export function incrementDmMention(channelId: number): void {
|
||||
dmStore.setState((prev) => ({
|
||||
channels: prev.channels.map((c) =>
|
||||
c.channelId === channelId ? { ...c, mentionCount: c.mentionCount + 1 } : c,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Emoji store — the server's custom-emoji set.
|
||||
*
|
||||
* Loaded once from GET /api/v1/emoji when the session goes ready, then
|
||||
* replaced wholesale on every `emoji_update` broadcast. The server sends the
|
||||
* full set rather than a delta for the same reason it does for roles: a
|
||||
* dropped intermediate event can never leave a deleted emoji rendering in
|
||||
* messages that name it.
|
||||
*
|
||||
* Everything downstream reads through `resolveEmoji`, which is the single
|
||||
* answer to "is `:name:` a real emoji here" — message rendering, the picker,
|
||||
* the composer autocomplete and reaction pills must all agree, and an
|
||||
* unresolved shortcode is plain text everywhere.
|
||||
*/
|
||||
|
||||
import { createStore } from "@lib/store";
|
||||
|
||||
/** One custom emoji as the server describes it. */
|
||||
export interface CustomEmoji {
|
||||
readonly id: number;
|
||||
/** Lowercase, colon-free, e.g. "wave". */
|
||||
readonly shortcode: string;
|
||||
/** Server-relative image path, e.g. "/api/v1/emoji/3/image". */
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface EmojiState {
|
||||
/** The set in server order (shortcode ascending). */
|
||||
readonly emoji: readonly CustomEmoji[];
|
||||
/** Lookup by lowercase shortcode. Rebuilt with the list, never mutated. */
|
||||
readonly byShortcode: ReadonlyMap<string, CustomEmoji>;
|
||||
}
|
||||
|
||||
const INITIAL: EmojiState = {
|
||||
emoji: [],
|
||||
byShortcode: new Map(),
|
||||
};
|
||||
|
||||
export const emojiStore = createStore<EmojiState>(INITIAL);
|
||||
|
||||
/**
|
||||
* The shortcode spelling the server accepts, mirrored here so the client never
|
||||
* treats `:not a code:` or `:x:` as a candidate. Deliberately identical to the
|
||||
* server's regexp: a token this rejects can never resolve, and a token this
|
||||
* accepts is one the server could have stored.
|
||||
*/
|
||||
export const SHORTCODE_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||
|
||||
/** Replace the whole set (from the REST list or an `emoji_update`). */
|
||||
export function setCustomEmoji(list: readonly CustomEmoji[]): void {
|
||||
const next: CustomEmoji[] = [];
|
||||
const byShortcode = new Map<string, CustomEmoji>();
|
||||
for (const e of list) {
|
||||
// Defensive: a malformed entry must not poison the lookup map that
|
||||
// message rendering consults for every `:token:` in every message.
|
||||
if (typeof e?.shortcode !== "string" || typeof e.url !== "string") continue;
|
||||
const shortcode = e.shortcode.toLowerCase();
|
||||
if (!SHORTCODE_PATTERN.test(shortcode)) continue;
|
||||
const entry: CustomEmoji = { id: e.id, shortcode, url: e.url };
|
||||
next.push(entry);
|
||||
// First spelling wins, so a duplicate cannot silently shadow the earlier
|
||||
// one after the list has already been rendered.
|
||||
if (!byShortcode.has(shortcode)) byShortcode.set(shortcode, entry);
|
||||
}
|
||||
emojiStore.setState(() => ({ emoji: next, byShortcode }));
|
||||
}
|
||||
|
||||
/** Drop every custom emoji (logout, or a switch to another server). */
|
||||
export function clearCustomEmoji(): void {
|
||||
emojiStore.setState((prev) => (prev.emoji.length === 0 ? prev : INITIAL));
|
||||
}
|
||||
|
||||
/**
|
||||
* The emoji owning `shortcode`, or null. Accepts the bare name or the `:name:`
|
||||
* spelling, so callers holding either form need no preprocessing.
|
||||
*/
|
||||
export function resolveEmoji(shortcode: string): CustomEmoji | null {
|
||||
if (typeof shortcode !== "string") return null;
|
||||
let name = shortcode.toLowerCase();
|
||||
if (name.startsWith(":") && name.endsWith(":") && name.length > 2) {
|
||||
name = name.slice(1, -1);
|
||||
}
|
||||
return emojiStore.getState().byShortcode.get(name) ?? null;
|
||||
}
|
||||
|
||||
/** The whole set, for the picker and the composer autocomplete. */
|
||||
export function listCustomEmoji(): readonly CustomEmoji[] {
|
||||
return emojiStore.getState().emoji;
|
||||
}
|
||||
@@ -12,6 +12,12 @@ export interface Member {
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
/** Nickname to render instead of `username`. Null = unset. Optional only so
|
||||
* the many inline Member test fixtures need not restate it. Mentions still
|
||||
* resolve against `username` — it is the unique handle. */
|
||||
readonly displayName?: string | null;
|
||||
/** Free-text status line shown under the name. Null = unset. */
|
||||
readonly customStatus?: string | null;
|
||||
/** Long-term E2EE identity public key (base64) for voice TOFU (F3). The store
|
||||
* always sets it (null when the user has not published one); optional only so
|
||||
* the many inline Member test fixtures need not restate it. */
|
||||
@@ -56,6 +62,8 @@ export function setMembers(members: readonly ReadyMember[]): void {
|
||||
avatar: m.avatar,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
displayName: m.display_name ?? null,
|
||||
customStatus: m.custom_status ?? null,
|
||||
identityPublicKey: m.identity_public_key ?? null,
|
||||
});
|
||||
}
|
||||
@@ -71,7 +79,11 @@ export function setMembers(members: readonly ReadyMember[]): void {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Add a member from a member_join event. */
|
||||
/** Add a member from a member_join event.
|
||||
* status comes from the payload's viewer-safe field, never assumed —
|
||||
* an invisible user's join broadcasts "offline", and a server old enough to
|
||||
* omit the field entirely must fail safe the same way rather than flash the
|
||||
* member online. */
|
||||
export function addMember(payload: MemberJoinPayload): void {
|
||||
membersStore.setState((prev) => {
|
||||
const next = new Map(prev.members);
|
||||
@@ -80,7 +92,11 @@ export function addMember(payload: MemberJoinPayload): void {
|
||||
username: payload.user.username,
|
||||
avatar: payload.user.avatar,
|
||||
role: payload.user.role,
|
||||
status: "online",
|
||||
status: payload.status ?? "offline",
|
||||
displayName: payload.user.display_name ?? null,
|
||||
// member_join carries no custom status; a presence event follows it and
|
||||
// is what fills this in.
|
||||
customStatus: prev.members.get(payload.user.id)?.customStatus ?? null,
|
||||
identityPublicKey: payload.user.identity_public_key ?? null,
|
||||
});
|
||||
return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };
|
||||
@@ -107,14 +123,43 @@ export function updateMemberRole(userId: number, role: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a member's profile (username, avatar, identity key) from a
|
||||
* user_update event. `identityPublicKey` is only applied when provided, so a
|
||||
* profile update that omits it doesn't clobber a pinned key. */
|
||||
export function updateMemberProfile(
|
||||
/** The profile fields a user_update event replaces. `identityPublicKey` and
|
||||
* `displayName` are only applied when provided, so an older server's payload
|
||||
* (or a partial one) doesn't clobber a pinned key or a nickname. */
|
||||
export interface MemberProfilePatch {
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
readonly displayName?: string | null;
|
||||
readonly identityPublicKey?: string | null;
|
||||
}
|
||||
|
||||
/** Update a member's profile from a user_update event. */
|
||||
export function updateMemberProfile(userId: number, patch: MemberProfilePatch): void {
|
||||
membersStore.setState((prev) => {
|
||||
const existing = prev.members.get(userId);
|
||||
if (!existing) return prev;
|
||||
const next = new Map(prev.members);
|
||||
next.set(userId, {
|
||||
...existing,
|
||||
username: patch.username,
|
||||
avatar: patch.avatar,
|
||||
displayName: patch.displayName === undefined ? existing.displayName : patch.displayName,
|
||||
identityPublicKey:
|
||||
patch.identityPublicKey === undefined
|
||||
? existing.identityPublicKey
|
||||
: patch.identityPublicKey,
|
||||
});
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a member's presence status, and the custom status line that rides
|
||||
* along with it. `customStatus` is only applied when the event carried the
|
||||
* field — a bare status flip must not blank the text. */
|
||||
export function updatePresence(
|
||||
userId: number,
|
||||
username: string,
|
||||
avatar: string | null,
|
||||
identityPublicKey?: string | null,
|
||||
status: UserStatus,
|
||||
customStatus?: string | null,
|
||||
): void {
|
||||
membersStore.setState((prev) => {
|
||||
const existing = prev.members.get(userId);
|
||||
@@ -122,24 +167,20 @@ export function updateMemberProfile(
|
||||
const next = new Map(prev.members);
|
||||
next.set(userId, {
|
||||
...existing,
|
||||
username,
|
||||
avatar,
|
||||
identityPublicKey:
|
||||
identityPublicKey === undefined ? existing.identityPublicKey : identityPublicKey,
|
||||
status,
|
||||
customStatus: customStatus === undefined ? existing.customStatus : customStatus,
|
||||
});
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a member's presence status. */
|
||||
export function updatePresence(userId: number, status: UserStatus): void {
|
||||
membersStore.setState((prev) => {
|
||||
const existing = prev.members.get(userId);
|
||||
if (!existing) return prev;
|
||||
const next = new Map(prev.members);
|
||||
next.set(userId, { ...existing, status });
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
/** The name to render for a member: display name when set, username otherwise.
|
||||
* The one place that answers it, so the member list, message rows and the
|
||||
* profile popup cannot disagree. */
|
||||
export function memberDisplayName(member: Pick<Member, "username" | "displayName">): string {
|
||||
const display = member.displayName;
|
||||
if (typeof display === "string" && display.trim().length > 0) return display;
|
||||
return member.username;
|
||||
}
|
||||
|
||||
/** Mark a user as typing in a channel. Auto-clears after 5 seconds. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ChatMessagePayload,
|
||||
ChatEditedPayload,
|
||||
ChatDeletedPayload,
|
||||
ChatBulkDeletedPayload,
|
||||
ReactionUpdatePayload,
|
||||
MessageUser,
|
||||
Attachment,
|
||||
@@ -49,6 +50,14 @@ export interface Message {
|
||||
readonly correlationId: string | null;
|
||||
/** Error code when status === "failed" (e.g. "SLOW_MODE", "FORBIDDEN"). */
|
||||
readonly errorCode: string | null;
|
||||
/**
|
||||
* Server-resolved mentioned user IDs. Optional so the many inline Message
|
||||
* fixtures need not restate it; undefined means "the server didn't say",
|
||||
* which sends rendering down the local @token resolution path.
|
||||
*/
|
||||
readonly mentions?: readonly number[];
|
||||
/** Whether an honoured @everyone/@here is present. Optional, as above. */
|
||||
readonly mentionsEveryone?: boolean;
|
||||
}
|
||||
|
||||
export interface MessagesState {
|
||||
@@ -65,6 +74,14 @@ export interface MessagesState {
|
||||
* never requested) — the message region then renders normally/empty.
|
||||
*/
|
||||
readonly historyLoadState: ReadonlyMap<number, "loading" | "error">;
|
||||
/**
|
||||
* Channels whose loaded window is an around-window detached from the live
|
||||
* tail: newer messages exist on the server below what is rendered. While a
|
||||
* channel is here the list shows a "Jump to Present" pill and incoming
|
||||
* broadcasts are *not* appended — they belong below the window, and
|
||||
* appending them would fake continuity across a gap.
|
||||
*/
|
||||
readonly detachedChannels: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -87,6 +104,8 @@ function chatPayloadToMessage(payload: ChatMessagePayload): Message {
|
||||
status: "sent",
|
||||
correlationId: null,
|
||||
errorCode: null,
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +125,8 @@ function messageResponseToMessage(response: MessageResponse): Message {
|
||||
status: "sent",
|
||||
correlationId: null,
|
||||
errorCode: null,
|
||||
mentions: response.mentions,
|
||||
mentionsEveryone: response.mentions_everyone,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +143,7 @@ const INITIAL_STATE: MessagesState = {
|
||||
loadedChannels: new Set(),
|
||||
hasMore: new Map(),
|
||||
historyLoadState: new Map(),
|
||||
detachedChannels: new Set(),
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -174,7 +196,11 @@ export function addMessage(payload: ChatMessagePayload): void {
|
||||
return { ...prev, messagesByChannel: updated };
|
||||
}
|
||||
|
||||
// 3. Append as a new message.
|
||||
// 3. Append as a new message — unless the channel is showing a detached
|
||||
// around-window, in which case the new message belongs to the live tail
|
||||
// below the gap and must wait for "Jump to Present".
|
||||
if (prev.detachedChannels.has(channelId)) return prev;
|
||||
|
||||
let updatedMsgs = [...existing, message];
|
||||
// Evict oldest messages if over the cap
|
||||
if (updatedMsgs.length > MAX_MESSAGES_PER_CHANNEL) {
|
||||
@@ -312,16 +338,94 @@ export function setMessages(
|
||||
const updatedLoadState = new Map(prev.historyLoadState);
|
||||
updatedLoadState.delete(channelId);
|
||||
|
||||
// Loading the plain tail always reattaches: this *is* the live end.
|
||||
const updatedDetached = new Set(prev.detachedChannels);
|
||||
updatedDetached.delete(channelId);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
messagesByChannel: updatedMessages,
|
||||
loadedChannels: updatedLoaded,
|
||||
hasMore: updatedHasMore,
|
||||
historyLoadState: updatedLoadState,
|
||||
detachedChannels: updatedDetached,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a channel's loaded window with an around-window centred on a jump
|
||||
* target. Unlike setMessages the payload is already oldest-first, so it is not
|
||||
* reversed.
|
||||
*
|
||||
* `hasMoreAfter` marks the window as detached from the live tail: the list
|
||||
* offers "Jump to Present" and live broadcasts stop being appended until
|
||||
* reattachToPresent (or a fresh setMessages) lands.
|
||||
*/
|
||||
export function setAroundMessages(
|
||||
channelId: number,
|
||||
messages: readonly MessageResponse[],
|
||||
hasMoreBefore: boolean,
|
||||
hasMoreAfter: boolean,
|
||||
): void {
|
||||
const converted = messages.map(messageResponseToMessage);
|
||||
// Defensive: the server caps a window at 100, so this never fires today.
|
||||
// If it ever does, keep the older head — dropping the newest end is what the
|
||||
// detached flag below already describes, whereas dropping the head would
|
||||
// silently move the window past the jump target.
|
||||
const trimmed =
|
||||
converted.length > MAX_MESSAGES_PER_CHANNEL
|
||||
? converted.slice(0, MAX_MESSAGES_PER_CHANNEL)
|
||||
: converted;
|
||||
messagesStore.setState((prev) => {
|
||||
const updatedMessages = new Map(prev.messagesByChannel);
|
||||
updatedMessages.set(channelId, trimmed);
|
||||
|
||||
const updatedLoaded = new Set(prev.loadedChannels);
|
||||
updatedLoaded.add(channelId);
|
||||
|
||||
const updatedHasMore = new Map(prev.hasMore);
|
||||
updatedHasMore.set(channelId, hasMoreBefore);
|
||||
|
||||
const updatedLoadState = new Map(prev.historyLoadState);
|
||||
updatedLoadState.delete(channelId);
|
||||
|
||||
const updatedDetached = new Set(prev.detachedChannels);
|
||||
// Trimming the tail of an oversized window also strands newer messages,
|
||||
// so the window is detached either way.
|
||||
if (hasMoreAfter || trimmed.length < converted.length) {
|
||||
updatedDetached.add(channelId);
|
||||
} else {
|
||||
updatedDetached.delete(channelId);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
messagesByChannel: updatedMessages,
|
||||
loadedChannels: updatedLoaded,
|
||||
hasMore: updatedHasMore,
|
||||
historyLoadState: updatedLoadState,
|
||||
detachedChannels: updatedDetached,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a channel's detached window so the next history fetch reloads the live
|
||||
* tail. Clears the loaded flag too — otherwise MessageController short-circuits
|
||||
* on "already loaded" and the stale window stays on screen.
|
||||
*/
|
||||
export function reattachToPresent(channelId: number): void {
|
||||
messagesStore.setState((prev) => {
|
||||
if (!prev.detachedChannels.has(channelId)) return prev;
|
||||
const updatedDetached = new Set(prev.detachedChannels);
|
||||
updatedDetached.delete(channelId);
|
||||
const updatedLoaded = new Set(prev.loadedChannels);
|
||||
updatedLoaded.delete(channelId);
|
||||
return { ...prev, detachedChannels: updatedDetached, loadedChannels: updatedLoaded };
|
||||
});
|
||||
}
|
||||
|
||||
/** Prepend older messages for infinite scroll.
|
||||
* The server returns messages newest-first; we reverse to chronological order. */
|
||||
export function prependMessages(
|
||||
@@ -361,7 +465,13 @@ export function editMessage(payload: ChatEditedPayload): void {
|
||||
|
||||
const updatedList = channelMessages.map((msg) =>
|
||||
msg.id === payload.message_id
|
||||
? { ...msg, content: payload.content, editedAt: payload.edited_at }
|
||||
? {
|
||||
...msg,
|
||||
content: payload.content,
|
||||
editedAt: payload.edited_at,
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
}
|
||||
: msg,
|
||||
);
|
||||
|
||||
@@ -387,6 +497,30 @@ export function deleteMessage(payload: ChatDeletedPayload): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete every id in one purge. Renders exactly like a single delete —
|
||||
* the rows stay as tombstones — but touches the channel's list once instead of
|
||||
* once per message.
|
||||
*/
|
||||
export function bulkDeleteMessages(payload: ChatBulkDeletedPayload): void {
|
||||
if (payload.ids.length === 0) return;
|
||||
messagesStore.setState((prev) => {
|
||||
const channelMessages = prev.messagesByChannel.get(payload.channel_id);
|
||||
if (!channelMessages) return prev;
|
||||
|
||||
const purged = new Set(payload.ids);
|
||||
if (!channelMessages.some((msg) => purged.has(msg.id) && !msg.deleted)) return prev;
|
||||
|
||||
const updatedList = channelMessages.map((msg) =>
|
||||
purged.has(msg.id) ? { ...msg, deleted: true } : msg,
|
||||
);
|
||||
|
||||
const updatedMessages = new Map(prev.messagesByChannel);
|
||||
updatedMessages.set(payload.channel_id, updatedList);
|
||||
return { ...prev, messagesByChannel: updatedMessages };
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle the pinned state of a message (optimistic update after API call). */
|
||||
export function setMessagePinned(channelId: number, messageId: number, pinned: boolean): void {
|
||||
messagesStore.setState((prev) => {
|
||||
@@ -457,12 +591,16 @@ export function clearChannelMessages(channelId: number): void {
|
||||
const updatedLoadState = new Map(prev.historyLoadState);
|
||||
updatedLoadState.delete(channelId);
|
||||
|
||||
const updatedDetached = new Set(prev.detachedChannels);
|
||||
updatedDetached.delete(channelId);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
messagesByChannel: updatedMessages,
|
||||
loadedChannels: updatedLoaded,
|
||||
hasMore: updatedHasMore,
|
||||
historyLoadState: updatedLoadState,
|
||||
detachedChannels: updatedDetached,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -531,3 +669,18 @@ export function hasMoreMessages(channelId: number): boolean {
|
||||
export function getHistoryLoadState(channelId: number): "loading" | "error" | null {
|
||||
return messagesStore.select((s) => s.historyLoadState.get(channelId) ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the channel's loaded window is an around-window detached from the
|
||||
* live tail — newer messages exist below what is rendered.
|
||||
*/
|
||||
export function isWindowDetached(channelId: number): boolean {
|
||||
return messagesStore.select((s) => s.detachedChannels.has(channelId));
|
||||
}
|
||||
|
||||
/** Whether a message id is present in a channel's loaded window. */
|
||||
export function hasMessageLoaded(channelId: number, messageId: number): boolean {
|
||||
return messagesStore.select(
|
||||
(s) => s.messagesByChannel.get(channelId)?.some((m) => m.id === messageId) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface VoiceUser {
|
||||
readonly speaking: boolean;
|
||||
readonly camera: boolean;
|
||||
readonly screenshare: boolean;
|
||||
/** Moderator-imposed (MUTE_MEMBERS). muted/deafened are always set alongside
|
||||
* these, so they only change how the row is presented. The store always sets
|
||||
* them; optional only so the inline VoiceUser test fixtures need not restate
|
||||
* them (same convention as VoiceState.peerVerifications). */
|
||||
readonly serverMuted?: boolean;
|
||||
readonly serverDeafened?: boolean;
|
||||
}
|
||||
|
||||
/** Observable voice-session lifecycle status, surfaced so the UI can
|
||||
@@ -60,6 +66,11 @@ export interface VoiceState {
|
||||
readonly voiceConfigs: ReadonlyMap<number, VoiceConfig>; // channelId -> VoiceConfig
|
||||
readonly localMuted: boolean;
|
||||
readonly localDeafened: boolean;
|
||||
/** Set from the local user's own voice_state: while true the widget refuses
|
||||
* to send an unmute, which the server would reject anyway. Always written by
|
||||
* the store; optional for the same fixture reason as peerVerifications. */
|
||||
readonly localServerMuted?: boolean;
|
||||
readonly localServerDeafened?: boolean;
|
||||
readonly localCamera: boolean;
|
||||
readonly localScreenshare: boolean;
|
||||
/** Epoch ms when the local user joined the current voice channel (for elapsed timer). */
|
||||
@@ -81,6 +92,8 @@ const INITIAL_STATE: VoiceState = {
|
||||
voiceConfigs: new Map(),
|
||||
localMuted: false,
|
||||
localDeafened: false,
|
||||
localServerMuted: false,
|
||||
localServerDeafened: false,
|
||||
localCamera: false,
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
@@ -99,6 +112,8 @@ export function resetVoiceStore(): void {
|
||||
voiceConfigs: new Map(),
|
||||
localMuted: false,
|
||||
localDeafened: false,
|
||||
localServerMuted: false,
|
||||
localServerDeafened: false,
|
||||
localCamera: false,
|
||||
localScreenshare: false,
|
||||
joinedAt: null,
|
||||
@@ -127,6 +142,8 @@ export function setVoiceStates(states: readonly ReadyVoiceState[]): void {
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
serverMuted: vs.server_muted ?? false,
|
||||
serverDeafened: vs.server_deafened ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,8 +170,13 @@ export function setVoiceStates(states: readonly ReadyVoiceState[]): void {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Update or add a user's voice state from a voice_state event. */
|
||||
/** Update or add a user's voice state from a voice_state event. When the event
|
||||
* describes the signed-in user it also mirrors the moderator-imposed flags
|
||||
* into localServerMuted/localServerDeafened, which gate the widget controls. */
|
||||
export function updateVoiceState(payload: VoiceStatePayload): void {
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
const serverMuted = payload.server_muted ?? false;
|
||||
const serverDeafened = payload.server_deafened ?? false;
|
||||
voiceStore.setState((prev) => {
|
||||
const nextChannels = new Map(prev.voiceUsers);
|
||||
const existingChannel = prev.voiceUsers.get(payload.channel_id);
|
||||
@@ -168,10 +190,20 @@ export function updateVoiceState(payload: VoiceStatePayload): void {
|
||||
speaking: payload.speaking,
|
||||
camera: payload.camera,
|
||||
screenshare: payload.screenshare,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
});
|
||||
|
||||
nextChannels.set(payload.channel_id, nextUsers);
|
||||
return { ...prev, voiceUsers: nextChannels };
|
||||
if (payload.user_id !== currentUserId) {
|
||||
return { ...prev, voiceUsers: nextChannels };
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
voiceUsers: nextChannels,
|
||||
localServerMuted: serverMuted,
|
||||
localServerDeafened: serverDeafened,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,13 +248,21 @@ export function joinVoiceChannel(channelId: number): void {
|
||||
export function leaveVoiceChannel(): void {
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
voiceStore.setState((prev) => {
|
||||
const cleared = {
|
||||
currentChannelId: null,
|
||||
joinedAt: null,
|
||||
voiceStatus: "idle" as const,
|
||||
// Server mute lives with the voice session; a new session starts clean.
|
||||
localServerMuted: false,
|
||||
localServerDeafened: false,
|
||||
};
|
||||
const channelId = prev.currentChannelId;
|
||||
if (channelId === null || currentUserId === 0) {
|
||||
return { ...prev, currentChannelId: null, joinedAt: null, voiceStatus: "idle" };
|
||||
return { ...prev, ...cleared };
|
||||
}
|
||||
const existingChannel = prev.voiceUsers.get(channelId);
|
||||
if (!existingChannel || !existingChannel.has(currentUserId)) {
|
||||
return { ...prev, currentChannelId: null, joinedAt: null, voiceStatus: "idle" };
|
||||
return { ...prev, ...cleared };
|
||||
}
|
||||
const nextChannels = new Map(prev.voiceUsers);
|
||||
const nextUsers = new Map(existingChannel);
|
||||
@@ -232,13 +272,7 @@ export function leaveVoiceChannel(): void {
|
||||
} else {
|
||||
nextChannels.set(channelId, nextUsers);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
currentChannelId: null,
|
||||
joinedAt: null,
|
||||
voiceStatus: "idle",
|
||||
voiceUsers: nextChannels,
|
||||
};
|
||||
return { ...prev, ...cleared, voiceUsers: nextChannels };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+4033
-1033
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Mocked E2E: custom-emoji shortcode autocomplete + render, and voice
|
||||
* moderation context-menu gating.
|
||||
*
|
||||
* Feature 1 (":shortcode" autocomplete + render) is distinct from
|
||||
* emoji-insertion.spec.ts, which covers the unicode emoji *picker* only.
|
||||
* Here the composer's inline ":" autocomplete (EmojiAutocomplete.ts) is
|
||||
* seeded with one custom emoji via GET /api/v1/emoji (the same REST route
|
||||
* dispatcher.ts calls once on `ready`; emoji.store.ts is the only place a
|
||||
* custom emoji can come from in this client).
|
||||
*
|
||||
* Feature 2 (voice moderation menu) reuses the ws_send capture pattern from
|
||||
* social.parity.spec.ts to assert the exact outgoing message, not just the
|
||||
* resulting DOM.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
buildTauriMockScript,
|
||||
mockTauriFullSessionWithVoice,
|
||||
navigateToMainPage,
|
||||
navigateToMainPageReady,
|
||||
emitWsMessageAndWait,
|
||||
MOCK_LOGIN_RESPONSE,
|
||||
MOCK_MESSAGES,
|
||||
MOCK_PINNED_MESSAGES,
|
||||
MOCK_ROLES,
|
||||
MOCK_CHANNELS_WITH_CATEGORIES,
|
||||
MOCK_MEMBERS_MULTI_ROLE,
|
||||
MOCK_VOICE_STATE,
|
||||
voiceWsHandlers,
|
||||
} from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call capture — records ws_send invocations (same pattern as
|
||||
// social.parity.spec.ts). Installed as a second init script, after the Tauri
|
||||
// mock sets up `invoke`, so it can see every outgoing call without touching
|
||||
// the shared helper file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CapturedCall {
|
||||
readonly cmd: string;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
function captureScript(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const internals = (window as any).__TAURI_INTERNALS__;
|
||||
const orig = internals.invoke;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).__capturedCalls = [];
|
||||
internals.invoke = async (cmd: string, args: unknown) => {
|
||||
if (cmd === "ws_send") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).__capturedCalls.push({ cmd, message: (args as any)?.message });
|
||||
}
|
||||
return orig(cmd, args);
|
||||
};
|
||||
}
|
||||
|
||||
async function getCapturedCalls(page: Page): Promise<CapturedCall[]> {
|
||||
return page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return ((window as any).__capturedCalls ?? []) as CapturedCall[];
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll until a captured call matches, and return it. Avoids racing the async send. */
|
||||
async function waitForCapturedCall(
|
||||
page: Page,
|
||||
predicate: (call: CapturedCall) => boolean,
|
||||
timeout = 5_000,
|
||||
): Promise<CapturedCall> {
|
||||
let found: CapturedCall | undefined;
|
||||
await expect(async () => {
|
||||
const calls = await getCapturedCalls(page);
|
||||
found = calls.find(predicate);
|
||||
expect(found).toBeDefined();
|
||||
}).toPass({ timeout });
|
||||
return found!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 1: custom-emoji ":shortcode" autocomplete + message-list render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One custom emoji, seeded through GET /api/v1/emoji (dispatcher.ts calls
|
||||
* this once on `ready`; emoji.store.ts is the sole source custom-emoji
|
||||
* autocomplete and rendering read from — see EmojiAutocomplete.ts and
|
||||
* message-list/custom-emoji.ts). */
|
||||
const SEEDED_CUSTOM_EMOJI = { id: 1, shortcode: "partyparrot", url: "/api/v1/emoji/1/image" };
|
||||
|
||||
async function mockSessionWithCustomEmoji(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
buildTauriMockScript({
|
||||
httpRoutes: [
|
||||
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
|
||||
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
|
||||
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
|
||||
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
|
||||
// Longer, more specific pattern first (buildTauriMockScript sorts by
|
||||
// pattern length, so this always outranks the shorter list route
|
||||
// below): the authenticated emoji image fetch custom-emoji.ts makes.
|
||||
{ pattern: "/api/v1/emoji/1/image", status: 200, body: "fake-emoji-bytes" },
|
||||
{ pattern: "/api/v1/emoji", status: 200, body: [SEEDED_CUSTOM_EMOJI] },
|
||||
],
|
||||
simulateWsFlow: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("@parity Custom emoji — shortcode autocomplete and render", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockSessionWithCustomEmoji(page);
|
||||
await page.goto("/");
|
||||
await navigateToMainPage(page);
|
||||
});
|
||||
|
||||
test('typing ":" + prefix opens the autocomplete and selecting the row inserts the shortcode token', async ({
|
||||
page,
|
||||
}) => {
|
||||
const textarea = page.locator("[data-testid='msg-textarea']");
|
||||
const popup = page.locator("[data-testid='emoji-autocomplete']");
|
||||
const row = page.locator("[data-testid='emoji-option-partyparrot']");
|
||||
|
||||
// GET /api/v1/emoji is fired once on `ready`, in parallel with the
|
||||
// channel-sidebar render `navigateToMainPage` already waited on, so the
|
||||
// response can still be in flight the instant this test starts typing.
|
||||
// Retry the whole type-and-check cycle (never a bare sleep) until the
|
||||
// store has caught up.
|
||||
await expect(async () => {
|
||||
await textarea.fill("");
|
||||
await textarea.fill(":party");
|
||||
await expect(popup).toBeVisible({ timeout: 1_000 });
|
||||
await expect(row).toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
|
||||
await expect(row.locator(".ma-name")).toHaveText(":partyparrot:");
|
||||
await expect(row.locator(".ma-detail")).toHaveText("Server emoji");
|
||||
// Custom emoji preview renders as an <img>, not a unicode character cell.
|
||||
await expect(row.locator(".ea-preview img.custom-emoji")).toBeAttached();
|
||||
|
||||
await row.click();
|
||||
|
||||
await expect(popup).not.toBeVisible();
|
||||
await expect(textarea).toHaveValue(":partyparrot: ");
|
||||
});
|
||||
|
||||
test("a chat_message containing the shortcode renders the custom-emoji image", async ({
|
||||
page,
|
||||
}) => {
|
||||
const messageId = 9001;
|
||||
await emitWsMessageAndWait(
|
||||
page,
|
||||
{
|
||||
type: "chat_message",
|
||||
payload: {
|
||||
id: messageId,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "otheruser", avatar: "" },
|
||||
content: "look at this :partyparrot: go",
|
||||
timestamp: "2026-03-15T11:00:00Z",
|
||||
edited_at: null,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
pinned: false,
|
||||
deleted: false,
|
||||
},
|
||||
},
|
||||
page.locator(`[data-testid='message-${messageId}'] img.custom-emoji`),
|
||||
);
|
||||
|
||||
const img = page.locator(`[data-testid='message-${messageId}'] img.custom-emoji`);
|
||||
await expect(img).toHaveAttribute("alt", ":partyparrot:");
|
||||
await expect(img).toHaveAttribute("data-shortcode", "partyparrot");
|
||||
// The literal ":partyparrot:" text is replaced by the image node, not
|
||||
// left behind as text alongside it.
|
||||
await expect(page.locator(`[data-testid='message-${messageId}'] .msg-text`)).not.toContainText(
|
||||
":partyparrot:",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 2: voice-moderation context menu gating
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("@parity Voice moderation menu — admin can moderate", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockTauriFullSessionWithVoice(page);
|
||||
await page.addInitScript(captureScript);
|
||||
await page.goto("/");
|
||||
await navigateToMainPageReady(page);
|
||||
});
|
||||
|
||||
test("offers Server Mute and Disconnect, and Server Mute fires voice_mod_mute", async ({
|
||||
page,
|
||||
}) => {
|
||||
// User 2 is a remote participant of "Voice Chat" (channel 10) in
|
||||
// MOCK_VOICE_STATE — the local user (admin, all permissions) may
|
||||
// moderate it.
|
||||
const row = page.locator(".voice-user-item[data-voice-uid='2']");
|
||||
await expect(row).toBeVisible({ timeout: 5_000 });
|
||||
await row.click({ button: "right" });
|
||||
|
||||
const menu = page.locator(".user-vol-menu");
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
const muteItem = menu.locator("[data-action='server-mute']");
|
||||
const disconnectItem = menu.locator("[data-action='voice-disconnect']");
|
||||
await expect(muteItem).toHaveText("Server Mute");
|
||||
await expect(disconnectItem).toHaveText("Disconnect");
|
||||
|
||||
await muteItem.click();
|
||||
|
||||
const call = await waitForCapturedCall(
|
||||
page,
|
||||
(c) => c.cmd === "ws_send" && (c.message ?? "").includes("voice_mod_mute"),
|
||||
);
|
||||
const parsed = JSON.parse(call.message ?? "{}") as {
|
||||
type: string;
|
||||
payload: { channel_id: number; user_id: number; muted: boolean };
|
||||
};
|
||||
expect(parsed.type).toBe("voice_mod_mute");
|
||||
expect(parsed.payload).toEqual({ channel_id: 10, user_id: 2, muted: true });
|
||||
});
|
||||
|
||||
test("Disconnect fires voice_mod_kick", async ({ page }) => {
|
||||
const row = page.locator(".voice-user-item[data-voice-uid='3']");
|
||||
await expect(row).toBeVisible({ timeout: 5_000 });
|
||||
await row.click({ button: "right" });
|
||||
|
||||
const menu = page.locator(".user-vol-menu");
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
await menu.locator("[data-action='voice-disconnect']").click();
|
||||
|
||||
const call = await waitForCapturedCall(
|
||||
page,
|
||||
(c) => c.cmd === "ws_send" && (c.message ?? "").includes("voice_mod_kick"),
|
||||
);
|
||||
const parsed = JSON.parse(call.message ?? "{}") as {
|
||||
type: string;
|
||||
payload: { user_id: number };
|
||||
};
|
||||
expect(parsed.type).toBe("voice_mod_kick");
|
||||
expect(parsed.payload).toEqual({ user_id: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
// The local user's role name comes from `auth_ok`, which
|
||||
// buildTauriMockScript hardcodes to "admin" (MOCK_AUTH_OK) — not
|
||||
// overridable via readyOverrides.members, so a member-role *user* can't be
|
||||
// simulated without editing the shared mock builder. What canModerateVoice()
|
||||
// actually reads is the *permission bits behind that role name*
|
||||
// (permissionsForRole("admin") against the `ready.roles` list), which
|
||||
// readyOverrides.roles does control. Stripping MUTE_MEMBERS from "admin"
|
||||
// there is a faithful stand-in for "local user's role lacks voice-moderation
|
||||
// permission" without touching helpers.ts.
|
||||
async function mockVoiceSessionWithoutModPermission(page: Page): Promise<void> {
|
||||
const rolesWithoutMute = MOCK_ROLES.map((r) =>
|
||||
r.name === "admin" ? { ...r, permissions: 0x3 } : r,
|
||||
);
|
||||
await page.addInitScript(
|
||||
buildTauriMockScript({
|
||||
httpRoutes: [
|
||||
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
|
||||
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
|
||||
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
|
||||
],
|
||||
simulateWsFlow: true,
|
||||
wsHandlers: voiceWsHandlers(),
|
||||
readyOverrides: {
|
||||
channels: MOCK_CHANNELS_WITH_CATEGORIES,
|
||||
members: MOCK_MEMBERS_MULTI_ROLE,
|
||||
voice_states: MOCK_VOICE_STATE,
|
||||
// buildTauriMockScript's typed `readyOverrides` doesn't list `roles`,
|
||||
// but buildReadyPayload (its implementation) does support it — this
|
||||
// cast bridges that gap without touching the shared helper file.
|
||||
roles: rolesWithoutMute,
|
||||
} as unknown as Parameters<typeof buildTauriMockScript>[0]["readyOverrides"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("@parity Voice moderation menu — gated without MUTE_MEMBERS", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockVoiceSessionWithoutModPermission(page);
|
||||
await page.goto("/");
|
||||
await navigateToMainPageReady(page);
|
||||
});
|
||||
|
||||
test("the moderation section is not offered when the local role lacks MUTE_MEMBERS", async ({
|
||||
page,
|
||||
}) => {
|
||||
const row = page.locator(".voice-user-item[data-voice-uid='2']");
|
||||
await expect(row).toBeVisible({ timeout: 5_000 });
|
||||
await row.click({ button: "right" });
|
||||
|
||||
// The per-user volume control (available to everyone) still opens...
|
||||
const menu = page.locator(".user-vol-menu");
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
await expect(menu.locator(".settings-slider")).toBeVisible();
|
||||
|
||||
// ...but the moderation section, which is gated on MUTE_MEMBERS, is gone.
|
||||
await expect(menu.locator("[data-action='server-mute']")).toHaveCount(0);
|
||||
await expect(menu.locator("[data-action='voice-disconnect']")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
buildTauriMockScript,
|
||||
mockTauriFullSession,
|
||||
navigateToMainPage,
|
||||
navigateToMainPageReady,
|
||||
emitWsMessage,
|
||||
MOCK_MESSAGES,
|
||||
MOCK_PINNED_MESSAGES,
|
||||
} from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom mocks — this spec needs ready payloads the shared helpers don't
|
||||
// build (an nsfw channel, a pre-seeded mention_count), so it constructs them
|
||||
// inline with buildTauriMockScript, mirroring mockTauriFullSession.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** MOCK_CHANNELS with #general (id 1) flagged nsfw. Channel 1 stays the
|
||||
* default-active channel, so login alone exercises "opening" the gated
|
||||
* channel — no extra click needed to trigger the mount. */
|
||||
const NSFW_CHANNELS = [
|
||||
{ id: 1, name: "general", type: "text", position: 0, category: null, nsfw: true },
|
||||
{ id: 2, name: "random", type: "text", position: 1, category: null },
|
||||
];
|
||||
|
||||
/** MOCK_CHANNELS with #random (id 2) pre-seeded with a mention count, to
|
||||
* cover the ready-payload render path independent of any WS traffic. */
|
||||
const MENTION_SEEDED_CHANNELS = [
|
||||
{ id: 1, name: "general", type: "text", position: 0, category: null },
|
||||
{ id: 2, name: "random", type: "text", position: 1, category: null, mention_count: 3 },
|
||||
];
|
||||
|
||||
async function mockTauriSessionWithChannels(page: Page, channels: unknown[]): Promise<void> {
|
||||
await page.addInitScript(
|
||||
buildTauriMockScript({
|
||||
httpRoutes: [
|
||||
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
|
||||
{
|
||||
pattern: "/api/v1/auth/login",
|
||||
status: 200,
|
||||
body: { token: "mock-session-token-abc123", requires_2fa: false },
|
||||
},
|
||||
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
|
||||
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
|
||||
],
|
||||
simulateWsFlow: true,
|
||||
readyOverrides: { channels },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1) NSFW age-gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("@parity NSFW age-gate", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockTauriSessionWithChannels(page, NSFW_CHANNELS);
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("opening the nsfw channel shows the gate over the message area", async ({ page }) => {
|
||||
// Channel 1 (nsfw) is auto-selected as the default active channel, so the
|
||||
// gate mounts as part of ordinary login — no extra navigation needed.
|
||||
await navigateToMainPage(page);
|
||||
|
||||
const gate = page.locator("[data-testid='nsfw-gate']");
|
||||
await expect(gate).toBeVisible({ timeout: 10_000 });
|
||||
await expect(gate).toContainText("general");
|
||||
});
|
||||
|
||||
test("continuing past the gate reveals the message area", async ({ page }) => {
|
||||
await navigateToMainPage(page);
|
||||
|
||||
const gate = page.locator("[data-testid='nsfw-gate']");
|
||||
await expect(gate).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.locator("[data-testid='nsfw-gate-continue']").click();
|
||||
|
||||
await expect(gate).not.toBeVisible();
|
||||
await expect(page.locator("[data-testid='message-101']")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("opening a normal channel shows no gate", async ({ page }) => {
|
||||
await navigateToMainPage(page);
|
||||
|
||||
// Dismiss the gate on the default (nsfw) channel first so the click below
|
||||
// is unambiguously about channel 2, not a leftover overlay from channel 1.
|
||||
await page.locator("[data-testid='nsfw-gate-continue']").click();
|
||||
await expect(page.locator("[data-testid='nsfw-gate']")).not.toBeVisible();
|
||||
|
||||
await page.locator("[data-testid='channel-2']").click();
|
||||
|
||||
await expect(page.locator("[data-testid='chat-header-name']")).toHaveText("random");
|
||||
await expect(page.locator("[data-testid='nsfw-gate']")).not.toBeVisible();
|
||||
await expect(page.locator(".messages-container")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) Mention badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("@parity mention badge", () => {
|
||||
test("a channel with mention_count>0 in the ready payload renders the mention badge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockTauriSessionWithChannels(page, MENTION_SEEDED_CHANNELS);
|
||||
await page.goto("/");
|
||||
await navigateToMainPageReady(page);
|
||||
|
||||
const channelTwo = page.locator("[data-testid='channel-2']");
|
||||
await expect(channelTwo).toHaveClass(/mentioned/);
|
||||
|
||||
const badge = page.locator("[data-testid='channel-mentions-2']");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText("3");
|
||||
});
|
||||
|
||||
test("an incoming @-mention on a non-active channel bumps its badge", async ({ page }) => {
|
||||
// Plain MOCK_CHANNELS (1 general, 2 random). Channel 1 is active by
|
||||
// default, so a mention delivered to channel 2 exercises the live WS path
|
||||
// through dispatcher's highlightsCurrentUser -> incrementMention.
|
||||
await mockTauriFullSession(page);
|
||||
await page.goto("/");
|
||||
await navigateToMainPageReady(page);
|
||||
|
||||
const channelTwo = page.locator("[data-testid='channel-2']");
|
||||
await expect(channelTwo).not.toHaveClass(/mentioned/);
|
||||
|
||||
await emitWsMessage(page, {
|
||||
type: "chat_message",
|
||||
payload: {
|
||||
id: 900,
|
||||
channel_id: 2,
|
||||
user: { id: 2, username: "otheruser", avatar: "" },
|
||||
content: "@testuser check this out",
|
||||
timestamp: new Date().toISOString(),
|
||||
edited_at: null,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
reply_to: null,
|
||||
pinned: false,
|
||||
deleted: false,
|
||||
mentions: [1],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(channelTwo).toHaveClass(/mentioned/, { timeout: 5_000 });
|
||||
const badge = page.locator("[data-testid='channel-mentions-2']");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText("1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3) Per-channel mute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("@parity per-channel mute", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockTauriFullSession(page);
|
||||
await page.goto("/");
|
||||
await navigateToMainPageReady(page);
|
||||
});
|
||||
|
||||
test("right-clicking a text channel opens a context menu with a Mute item", async ({ page }) => {
|
||||
const channelOne = page.locator("[data-testid='channel-1']");
|
||||
await channelOne.click({ button: "right" });
|
||||
|
||||
const menu = page.locator("[data-testid='channel-context-menu']");
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const muteItem = page.locator("[data-testid='ctx-mute-channel']");
|
||||
await expect(muteItem).toBeVisible();
|
||||
await expect(muteItem).toHaveText("Mute Channel");
|
||||
});
|
||||
|
||||
test("clicking Mute toggles the channel's muted state and persists it", async ({ page }) => {
|
||||
const channelOne = page.locator("[data-testid='channel-1']");
|
||||
await channelOne.click({ button: "right" });
|
||||
await page.locator("[data-testid='ctx-mute-channel']").click();
|
||||
|
||||
// The sidebar redraws on CHANNEL_MUTE_CHANGED, so re-query by testid.
|
||||
await expect(page.locator("[data-testid='channel-1']")).toHaveClass(/muted/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Persistence: the mute lives in localStorage under the settings prefix,
|
||||
// independent of any store/WS round-trip (see @lib/channel-mutes).
|
||||
const stored = await page.evaluate(() =>
|
||||
localStorage.getItem("owncord:settings:mutedChannels"),
|
||||
);
|
||||
expect(JSON.parse(stored ?? "[]")).toContain(1);
|
||||
|
||||
// Re-opening the menu reflects the flipped state.
|
||||
await page.locator("[data-testid='channel-1']").click({ button: "right" });
|
||||
const muteItem = page.locator("[data-testid='ctx-mute-channel']");
|
||||
await expect(muteItem).toHaveText("Unmute Channel");
|
||||
|
||||
// Toggle back off and confirm both the DOM class and storage clear.
|
||||
await muteItem.click();
|
||||
await expect(page.locator("[data-testid='channel-1']")).not.toHaveClass(/muted/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
const storedAfter = await page.evaluate(() =>
|
||||
localStorage.getItem("owncord:settings:mutedChannels"),
|
||||
);
|
||||
expect(JSON.parse(storedAfter ?? "[]")).not.toContain(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user