Commit Graph
101 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 62a25c6278 Point public deploy docs at the real GitHub repo
DEPLOYMENT.md's clone instructions and frontend/.env.example's
VITE_SOURCE_URL default now name github.com/ds-ksmith/DS-Chat directly
now that the project actually has a public home there, instead of
generic placeholders -- with a note for anyone deploying from their
own fork. The user's own production deploy is unaffected: it still
pulls from their private Gitea instance, which mirrors to GitHub.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:37:35 -06:00
ksmithandClaude Sonnet 5 f0e4c76ffd Prepare for public release: de-identify docs, add contributor terms
Removes personal infra baked into copy-paste instructions and the
app's own UI ahead of an eventual public release:

- AboutModal's "Source code" link is now a build-time env var
  (VITE_SOURCE_URL) instead of a hardcoded personal Gitea URL, and
  hides itself when unset rather than pointing somewhere wrong
- DEPLOYMENT.md's clone steps are genericized to any git host
- LICENSE gets its previously-blank copyright/description lines filled in
- CONTRIBUTING.md adds a lightweight contributor-terms note to keep a
  future dual-licensed offering possible once outside PRs start arriving

Deliberately out of scope for now: git commit history (still under the
real author identity) and the actual publish destination -- both still
undecided.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:01:33 -06:00
ksmithandClaude Sonnet 5 3be8d9d731 Bump to v2026.9.3
Switches to a date-based version scheme (YYYY.M.D, with .2/.3/etc.
appended for additional same-day releases) instead of semver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 21:00:56 -06:00
ksmithandClaude Sonnet 5 6e889b8ea4 Break message grouping on a long gap, not just sender change (#73)
Consecutive messages from the same sender only repeated the
avatar/name/timestamp header on the first one in the run, so a
message sent minutes later with nobody else posting in between still
hid under a stale timestamp. Adds a 5-minute gap threshold (Slack's
own cutoff) that starts a new group even for the same sender.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:02:01 -06:00
ksmithandClaude Sonnet 5 b4a104f8c6 Move custom emoji management out of the emoji picker
The picker's delete "x" overlaps the glyph in a tightly packed grid,
which is too easy to hit by accident on a touch screen. Adding and
deleting custom emoji now live in their own modal under the account
menu, with delete gated behind the same confirm() every other
destructive action in the app uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:02:21 -06:00
ksmithandClaude Sonnet 5 03cc16f236 Remove auto-large emoji-only messages, keep manual emoji size (#71)
The manual emoji-size preference already covers this well enough on
its own; the automatic 2.5x bump for emoji-only messages was extra
behavior on top of it that wasn't needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:33:50 -06:00
ksmithandClaude Sonnet 5 520b971247 Add adjustable text size and emoji size preferences (#71)
Text was too small on high-DPI screens with no in-app fix beyond
browser zoom. Adds a text-size setting (scales the whole app via a
root font-size percentage), auto-large rendering for emoji-only
messages, and an independent emoji-size preference that also scales
reaction pills without affecting the emoji picker's fixed-size grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:30:49 -06:00
ksmithandClaude Sonnet 5 f6c71753b5 Bump to v1.1.0 and bring all documentation current
Version bump in both pyproject.toml and package.json. Documentation
update covers everything shipped since v1.0.0 (direct messages,
message deletion, custom emoji, video attachments, DM/room email
notifications, active sessions, and more), and corrects claims that
had gone stale -- backend/README.md and DEPLOYMENT.md both still said
"no server-side session revocation" and backend/README.md said "no
custom/uploaded emoji," both now false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 21:27:52 -06:00
ksmithandClaude Sonnet 5 3e5f842df3 Actually fix WNS push, and stop the notification toggle hanging forever (#56)
The pywebpush version bump alone didn't fix WNS: even the latest
release (2.4.0) has no WNS-specific header handling in its own
source, confirmed by inspecting the installed package directly.
Adds the required X-WNS-Cache-Policy header ourselves via
webpush()'s own headers= param, gated to *.notify.windows.com
endpoints.

Also: subscribeToPush()'s permission request and service-worker-ready
wait had no timeout, so a browser that never settles either (seen
live on a fresh Windows/Edge install -- greyed out, no prompt, no
error) left the toggle stuck forever with no feedback. Both now time
out after 20s with an actionable message instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 21:16:24 -06:00
ksmithandClaude Sonnet 5 2e84ca42b7 Add custom emoji support (#18)
Site-wide, any user can upload -- usable both as reactions and inline
in message text via :shortcode:, alongside the existing built-in
Unicode picker. A :shortcode: reference is stored/sent as literal
text (same as the built-in shortcode convention) and resolved to an
image at render time, so it degrades to plain text if the emoji is
later deleted.

Backend: new custom_emoji table (shortcode unique, sized to fit
MessageReaction.emoji's existing column alongside its colons), upload/
list/delete endpoints (delete restricted to uploader or site admin).

Frontend: a CustomEmojiProvider context feeds a new "Custom" category
in the emoji picker (inline upload + hover-to-remove), extends the
composer's shortcode autocomplete, and a shared EmojiGlyph resolver
renders custom emoji wherever a value can appear -- message text,
reaction pills, and the picker itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:47:49 -06:00
ksmithandClaude Sonnet 5 b26643527d Add per-device active sessions with revocation (#69)
Replaces the stateless signed-cookie session (bare user_id) with a
real server-side sessions table -- the cookie now just carries an
opaque session id, resolved against the DB on every request. Each
session records IP address (respects X-Forwarded-For), a parsed
device label, and last-seen time (throttled updates, not written on
every request).

New GET/DELETE /api/auth/sessions endpoints and an "Active sessions"
section in Profile settings let a user see every device they're
logged in from and revoke one they don't recognize -- including their
own current session, which just signs them out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:22:50 -06:00
ksmithandClaude Sonnet 5 278f8bb995 Also email room subscribers on the first unread message, not just mentions (#67)
The previous pass only emailed on a mention. Corrected scope: the
room's first unread message triggers one debounced email (same shape
as #66), and every mention additionally emails regardless of that
debounce, since a mention shouldn't get silently absorbed by an
earlier plain message's already-sent notification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:51:02 -06:00
ksmithandClaude Sonnet 5 250bb862f6 Add per-room opt-in email notifications on mention (#67)
Lets a member subscribe to email when they're @mentioned in a room
while offline, alongside #66's always-on DM email. Debounced the same
way #66 is (one email per unread burst, not one per mention), and
deliberately scoped to regular rooms only -- DMs already have #66's
automatic offline email with no separate toggle needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:45:04 -06:00
ksmithandClaude Sonnet 5 3dabf0022c Update favicon to the new black hole app icon (#64)
Replaces the old vector "DS" mark with a PNG generated from the new
icon-512.png artwork, since a raster source has no clean SVG
equivalent to swap in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:29:22 -06:00
ksmithandClaude Sonnet 5 08a36248a0 Replace video expand's Fullscreen API with a lightbox (#65)
requestFullscreen() silently did nothing in the Electron desktop build
(it worked fine in a regular browser). Swap it for a VideoLightbox
component mirroring the existing ImageLightbox overlay, which has no
such platform dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:17:22 -06:00
ksmithandClaude Sonnet 5 5ec79e652f Play video attachments inline, with an expand option (#65)
A video file previously rendered as a generic downloadable file card,
same as any other attachment. The file-serve endpoint forces
Content-Disposition: attachment for every upload as an XSS mitigation
(a same-origin-served .html/.svg executing script), which also meant a
<video> tag pointed at it couldn't play -- the browser would just try
to download it.

Carve out a strict, server-side allowlist (video/mp4, video/webm,
video/ogg -- deliberately not "every video/* type") that skips the
forced download, the same reasoning MessageImage's own endpoint
already relies on: these are content types a browser only ever
interprets as media, never as something that could execute script.
Anything else, including other video formats like .mov, still forces
a download exactly as before.

On the frontend, a video attachment with one of those content types
renders as an inline <video controls> instead of the generic file
card, with a hover-revealed expand button that calls the browser's
native Fullscreen API on the video element directly rather than
building a second lightbox component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:08:25 -06:00
ksmithandClaude Sonnet 5 89d609f584 Add heading IDs, subscript, and superscript to markdown rendering (#21)
markdown-to-jsx has no plugin hook for new inline/block syntax, but it
does correctly parse ordinary links and exposes a slugify callback for
heading anchors -- both get reused the same way this app's own
@mention/#room-reference highlighting already works: ~sub~/^sup^ are
rewritten to a link before compiling (the "URL" is just a carrier for
meaning the parser was never told about), then re-rendered as
<sub>/<sup> instead of an anchor; a heading's {#custom-id} suffix is
stripped from its own text before compiling, and slugify substitutes
the requested id for the auto-generated one.

Applied everywhere markdown renders (chat messages, file previews, the
Help page), not just chat -- MARKDOWN_OPTIONS became a per-render
createMarkdownOptions() since slugify needs each render's own heading
ids.

Definition lists deliberately left unsupported -- no block-level
equivalent to the link-trick exists, and faking one would mean either
reopening the disableParsingRawHTML XSS mitigation or unreliably
misusing blockquote syntax. Documented on the issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 18:53:38 -06:00
ksmithandClaude Sonnet 5 51d0092bd3 Add collapse/expand for the DM and Room sidebar sections (#62)
Each section header is now a button with a disclosure chevron;
collapsed/expanded state persists per section in localStorage (same
pattern as the existing sidebar-width preference) and the two sections
toggle independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 18:41:22 -06:00
ksmithandClaude Sonnet 5 66e9c80422 Add the ability to resend unaccepted invites (#60)
Previously the only way to resend was to re-invite the same email from
scratch, creating a whole new invite row. Adds a "Resend" action next
to Revoke on each pending invite -- rotates the token and refreshes the
7-day expiry on the same row (the old link stops working the moment
it's used, same instinct as a password-reset resend), then re-sends
the invite email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 18:29:24 -06:00
ksmithandClaude Sonnet 5 ef615e1ef4 Add the ability to delete a message (#53)
Message.deleted_at has existed since the initial schema but was never
wired up -- no WS envelope, no permission check, no frontend concept of
it at all. Soft delete, author-only (mirrors the existing edit
permission exactly): content and any attached image/file are cleared
and the underlying MessageImage/MessageFile row and stored file are
actually removed, not just detached, so the message becomes a "This
message was deleted" tombstone with nothing left to recover through a
stale attachment URL. A deleted message can no longer be edited or
reacted to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 16:35:00 -06:00
ksmithandClaude Sonnet 5 157f1e30ac Harden the update-check pipeline against silent failures (#58)
checkForUpdate() was a bare `void registration?.update()` -- a failed
fetch (most plausible right when it's triggered by a WS reconnect, i.e.
the network just flapped from a backend restart) vanished with nothing
caught or logged, leaving only the hourly interval as a fallback. Now
logs the failure instead of swallowing it, and a third trigger checks
for an update whenever a backgrounded tab becomes visible again, so a
tab that misses both the reconnect-triggered check and the hourly timer
still gets a chance the moment someone actually looks at it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 16:14:38 -06:00
ksmithandClaude Sonnet 5 072405eb2d Make archiving a room actually affect existing members (#57)
is_archived was previously only exposed on the admin-only AdminRoom
schema and checked in one place (excluding a room from Browse rooms) --
for anyone already a member it was a complete no-op: still in their
sidebar, still fully postable, no indication anywhere it was archived.

Expose is_archived on the regular RoomRead/MyRoomItem schemas, drop
archived rooms from the sidebar list (while keeping them directly
reachable via URL so history stays readable), and reject new messages
in one -- both the WS "message" handler and incoming webhooks -- with a
clear "archived and read-only" response instead of silently no-op'ing
or a confusing membership error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:24:23 -06:00
ksmithandClaude Sonnet 5 2fd055f7d6 Add emoji shortcode autocomplete to the composer (#54)
Typing ":name" now shows a matching-shortcode dropdown (same
join/leave/arrow-key UX as the existing @mention and #room autocompletes),
selecting one inserts the actual glyph immediately rather than leaving
literal ":name:" text. A bare ":" with nothing typed yet suggests
recently-used emoji instead of an arbitrary slice of the ~950 known
shortcodes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:59:51 -06:00
ksmithandClaude Sonnet 5 4cc3823adf Fix DM presence indicator never updating live (#63)
The sidebar shows every DM's online/offline dot at once, but the only
existing signal for a presence change (member_updated) is broadcast to
a room's own channel, which Presence only delivers to a connection that
currently has that specific room joined -- never true for a DM sitting
unopened in the sidebar. Add a dedicated per-user broadcast
(dm_presence_update) sent to each of a user's DM partners on their own
per-user channel whenever their global online/offline state changes, so
the sidebar dot updates without needing that DM to be the open room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:41:57 -06:00
ksmithandClaude Sonnet 5 d42bf114dd Drop accepted/revoked invites from the pending invites list (#61)
list_site_invites returned every invite ever sent, so the admin UI's
"Pending invites" section kept showing accepted/revoked rows forever
(just relabeled with a status badge) instead of dropping them. Filter
the query to pending only, and have the revoke action remove its row
from local state immediately instead of leaving a relabeled one behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:13:23 -06:00
ksmithandClaude Sonnet 5 ed88eb0205 Fix desktop messages not appearing live until refocus (#59)
Desktop mode's focus gating (from #49) made losing OS focus send "leave"
for every open room, which stopped live message delivery to that room,
not just notification eligibility -- so a message wouldn't render until
the room was manually left and rejoined. Room join/leave is now gated on
visibility alone, matching the browser; notification eligibility gets its
own separate signal (a "focus"/"blur" WS frame tracked by a new
Redis-backed FocusPresence), so a connected-but-unfocused desktop member
still gets notified without losing live delivery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 18:08:04 -06:00
ksmithandClaude Sonnet 5 766883c992 Add the ability to hide a DM conversation (#52 follow-up)
Neither participant could get rid of a DM at all -- Leave/Delete were
both deliberately hidden for DMs during the initial build to sidestep
an edge case (removing a membership would break find_or_create_dm's
exactly-two-members assumption), but that left no way out whatsoever.

RoomMembership.hidden_at is a per-viewer display flag, not a
membership deletion: hiding a DM only sets it on your own membership
row, so it disappears from just your sidebar without touching the
other participant's copy or any messages. It's automatically cleared
(reappearing) in two cases: a new message arrives in the room
(broadcast_new_message), or find_or_create_dm resolves back to the
same room because either person re-opens it from the People list --
both count as the conversation being active again.

Also fixes two now-flaky tests (test_message_edit, test_reactions):
broadcast_new_message doing more work before returning shifted timing
enough to expose a pre-existing race where a per-user-channel frame
(desktop_notification/unread_update) could legitimately arrive before
a connection's own "joined" ack. Broadened their existing _recv()
noise-filtering helper (already used for member_updated) to cover
those types too, and used it at the two call sites that were reading
raw receive_json() instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:58:02 -06:00
ksmithandClaude Sonnet 5 f3f59ad822 Add direct messages (#52)
A DM is a Room with a new is_dm flag, not a separate model -- reuses
all the membership/message/WS plumbing Room already has instead of
duplicating it. The room's `name` (still required + globally unique)
is an internal, never-displayed token derived deterministically from
the two participants' sorted user IDs (dm_room_name), which makes
find-or-create a single indexed lookup and gets free race-condition
safety from the existing unique constraint -- a concurrent double-
start from both people just hits the same IntegrityError->retry-as-
lookup path create_room already established.

Both participants get the plain 'member' role (no owner/admin
distinction makes sense for a 1:1 DM), which incidentally reuses
every existing role gate to block add-member, room-settings edits,
and join-via-browse on a DM for free. update_room also gets an
explicit is_dm guard independent of that, since renaming a DM isn't
just a privacy concern -- it would silently corrupt the find-or-create
invariant. DMs are excluded from both Browse Rooms and the admin
portal's room listing (fully private, per scope).

GET /api/rooms/mine precomputes each DM's other participant (name,
avatar, presence) as dm_partner in one batched query, so the sidebar
can render a DM row without a fetch per row. Frontend: a new "Direct
Messages" sidebar section (searchable by partner name, not the
internal room name), clicking someone in the People list starts or
resumes a DM, and the chat header/composer/RoomInfoPanel all render
the partner's identity instead of a room name where it's a DM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:09:21 -06:00
ksmithandClaude Sonnet 5 222ca49355 Keep push notifications on screen until dismissed
Without requireInteraction, the OS default auto-dismiss (a few seconds
on most platforms) was closing notifications before they were
reliably noticed. Browser/PWA push only -- the Desktop bridge is a
separate codebase with its own native notification handling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:18:59 -06:00
ksmithandClaude Sonnet 5 0a91f4f347 Add an About section to the account menu (#51)
Shows the app version, links the AGPL-3.0-or-later license text, and
links the source repo -- AGPL's own suggested-usage text recommends
exactly this ("if your software can interact with users remotely...
its interface could display a 'Source' link"), not just a courtesy
credits screen.

Version comes from package.json at build time via a Vite `define`
(__APP_VERSION__), so it can't drift from what's actually released.
License text is served at /LICENSE via a frontend/public/ symlink to
the repo-root LICENSE, the same pattern already used for the user
guide. Also bumps both package manifests to 1.0.0 ahead of tagging
the first release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 18:59:50 -06:00
ksmithandClaude Sonnet 5 3fdfbd96e2 License the project under AGPL-3.0-or-later
Adds the verbatim license text as LICENSE, sets license metadata in
both package manifests, and links it from both README.md files.
Chosen specifically for the network-copyleft clause (AGPL §13): a
modified version run as a hosted service must offer its source to
that service's users, which plain GPL's distribution-only trigger
doesn't cover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 18:49:08 -06:00
ksmithandClaude Sonnet 5 5bd1716c94 Split the composer's attach button into Photo/video and File (#29)
On mobile, a file input with no accept hint (needed to allow arbitrary
file attachments) makes some Android browsers fall back to a generic
chooser -- Camera, Camera Video, Files -- with no direct Photos/Gallery
shortcut, confirmed via a screenshot showing exactly that. Android
can't reliably offer both "any file type" and a gallery shortcut from
a single input, so the attach button now opens a small menu: "Photo or
video" uses a new input with accept="image/*,video/*" (should surface
the OS media picker's gallery shortcut), "File" keeps today's
unrestricted picker. Upload routing (handleFile) is unchanged either
way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 18:36:49 -06:00
ksmithandClaude Sonnet 5 c094ce0975 Add a People list showing who's online (#25)
A "People" button next to "Browse rooms" opens a modal listing every
site user with an online/offline status dot, online users sorted
first. No backend changes needed -- GET /api/users (the user
directory) and GET /api/users/online (a snapshot of who's connected
anywhere in the app, backing every avatar's status dot already) both
already existed from other features, just never had a UI surface of
their own for regular members.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 18:07:37 -06:00
ksmithandClaude Sonnet 5 77809758a2 Fix chat view not landing on the latest message after switching rooms (#50)
MessageList auto-scrolled to the bottom in a useEffect keyed on
messages.length, but .message-image has no reserved width/height (only
max-width/max-height caps) -- unlike UserAvatar and LinkPreviewCard's
thumbnail, which both reserve fixed pixel dimensions. If a message near
the bottom of a room's history has an image attachment, that scroll ran
before the image loaded; the image then grew the container a moment
later, leaving the view scrolled short of the true bottom until the
user scrolled down manually.

Now tracks whether the view is pinned to the bottom (via a scroll
listener) and re-runs the scroll whenever any image inside the list
finishes loading, but only while still pinned -- a late-loading image
in history you've deliberately scrolled up to read won't yank you back
down. A single capture-phase 'load' listener on the container catches
every image (load doesn't bubble, but capture-phase listeners on an
ancestor still see it) without wiring an onLoad prop through each one.

Verified with a direct A/B comparison against the pre-fix code: same
scrolled-away state, same synthetic image load event -- old code never
calls scrollIntoView, new code does and lands back at the bottom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 17:55:06 -06:00
ksmithandClaude Sonnet 5 aadf620014 Add a user guide, reachable in-app from Help in the account menu
USER_GUIDE.md at the repo root is the single source of truth --
frontend/public/USER_GUIDE.md symlinks to it so the same file is both
readable directly in the repo and served by the app, rendered on a new
/help page reusing the existing markdown renderer. Scoped to regular
member features (messaging, rooms, attachments, notifications,
profile); room admin/site admin features are intentionally left out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:58:05 -06:00
ksmithandClaude Sonnet 5 344eb7ebf1 Fix desktop notifications not firing when Electron window is unfocused (#49)
Presence (which gates push, desktop notifications, and the unread dot)
only tracked document.visibilityState, which in Electron only flips on
minimize/hide -- not on losing OS focus, e.g. alt-tabbing away with the
window still open. That left the offline-audience computation treating
an unfocused-but-visible desktop window as "present," so notifications
never fired unless the app was actually minimized to tray.

Desktop mode now also requires document.hasFocus() before considering
a room joined; regular browser-tab behavior (visibility alone) is
unchanged. Verified in-browser: losing focus sends a leave frame,
regaining it sends join + gets acked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 12:31:48 -06:00
ksmithandClaude Sonnet 5 e0f85cec79 Add desktop notification bridge for DS Chat Desktop (#49)
Offline members now also get a desktop_notification WS envelope
alongside the existing Web Push send, since Electron has no push
delivery service configured. The client only acts on it when
window.dsDesktop is present and the user's local preference allows it,
so the server needs no awareness of which clients are Electron.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 10:27:20 -06:00
ksmithandClaude Sonnet 5 2a84a9c9bd Add composer autocomplete for #roomname (#47 follow-up)
Mirrors the @mention autocomplete exactly -- same trigger-detection
logic (factored into a shared detectTriggerQuery helper, parameterized
on '@' vs '#'), same arrow-key/Enter/Tab/Escape keyboard handling, same
dropdown. Suggests rooms the user belongs to, filtered by name prefix,
showing the room's description as a subtitle when it has one.

MentionAutocomplete.css is renamed to ComposerAutocomplete.css with
generic class names (composer-autocomplete-primary/-secondary instead of
-username/-display-name), now shared by both the mention and room-
reference dropdowns instead of being mention-specific.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:12:51 -06:00
ksmithandClaude Sonnet 5 12264b4d18 Add #roomname references in chat messages (#47)
Mirrors the existing @-mention system's shape: a regex finds #roomname
tokens, extract_referenced_room_ids validates them against rooms the
*sender* actually belongs to (mirrors mentions' "must be a real member"
rule -- referencing a private room the sender isn't in would otherwise
leak its existence), and a MessageRoomReference join row is stored per
match in create_message. No notification/unread layer, unlike mentions --
referencing a room has no "you were referenced" semantics.

Rendering is the same markdown-link rewrite trick MessageContent.tsx
already uses for mentions (#username -> [#username](mention:username)),
but resolved against the *viewer's* own room list (threaded down from
ChatShellPage's room state through ChatPane/MessageList) rather than the
stored server-side reference -- a reference to a room the current viewer
isn't in quietly renders as plain text instead of a link, same as an
@mention of someone outside the room does. The href scheme renders a
real react-router Link instead of mentions' inert span, since a room
reference is meant to be navigable.

mention_service.strip_code_spans (was _strip_code_spans) is now shared
between both extraction paths rather than private to one module.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:03:18 -06:00
ksmithandClaude Sonnet 5 8716fc5356 Replace +/- disclosure indicators with a rotating chevron in RoomInfoPanel
The Files/Integrations/Room settings section toggles used a trailing
"+"/"-" glyph -- confusing as a collapse/expand affordance. Replaced with
a small chevron placed before the label, pointing right when collapsed
and rotating 90deg clockwise (pointing down) when expanded, the more
conventional disclosure-triangle pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:40:14 -06:00
ksmithandClaude Sonnet 5 84dc99d1a1 Allow toggling room privacy after creation (#48)
is_private was previously only settable at room creation. RoomUpdate now
accepts it, update_room() applies it, and PATCH /api/rooms/{id} allows a
site admin to make the change even for a room they haven't joined (in
addition to the existing room owner/admin gate) -- require_room_role
normally 403s a non-member before the role check ever runs, so this is a
deliberate bypass for site admins specifically.

Flipping the flag has no effect on existing members either direction
(confirmed is_private is only ever checked at self-serve join time) --
it purely controls Browse Rooms visibility and future self-joins.

Frontend: RoomInfoPanel's "Room settings" section is now visible to room
owner, room admin, or site admin (was owner-only), with a privacy toggle
reusing NewRoomModal's existing toggle-switch UI. "Delete room" stays
owner-only, now nested inside that wider section rather than gating the
whole thing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:25:33 -06:00
ksmithandClaude Sonnet 5 54932c9c03 Expand direct image links instead of showing nothing (#43 follow-up)
A URL that points straight at an image file (Content-Type: image/*) has
no HTML to scrape Open Graph tags from, so the fetch found nothing and
the message showed no preview at all -- reported against
https://imgs.xkcd.com/comics/creepy.png.

link_preview_service now recognizes an allowed image content-type (same
list storage.py uses for uploads) before falling through to the HTML/og:
path, and returns the URL itself as the preview (LinkPreview.is_image).
No need to download the body -- the already-SSRF-validated URL is the
image. The frontend renders that case as a real expandable image
(message-image + lightbox, same as an actual attachment) instead of the
small title+description card, which would have nothing to show anyway.

Verified end-to-end against the reported URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:01:56 -06:00
ksmithandClaude Sonnet 5 760d2cf5dd Add URL previews for chat messages (#43)
Slack/Discord-style link unfurling: the first http(s) URL in a message's
content gets a small preview card (title/description/image/site name)
fetched from the page's Open Graph tags.

Backend:
- Message.preview_url (extracted at create/edit time, cheap regex, no
  I/O) points at a link_previews cache row keyed by URL -- the same URL
  posted in different messages/rooms fetches once, and a failed fetch is
  cached too so a dead URL isn't retried on every reference.
- The actual fetch runs in a background asyncio.create_task from
  broadcast_new_message/broadcast_message_update, on its own DB session,
  so a slow third-party site never delays message delivery. A separate
  "link_preview" WS envelope carries the result once it resolves.
- SSRF protection reuses app/services/ssrf.py's validate_target_url
  (renamed from UnsafeWebhookUrlError to UnsafeUrlError now that it's
  shared with webhooks), but re-validates before every hop of a redirect
  chain rather than once up front -- redirects are followed manually so
  each intermediate URL is checked before it's ever connected to.
- Parsed with stdlib html.parser -- no new dependency.

Frontend: a LinkPreviewCard rendered under message content when present,
patched into state live via the new WS envelope and included in message
history for reloads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 17:50:55 -06:00
ksmithandClaude Sonnet 5 8bb6bbe714 Expand README.md and frontend/README.md with fuller project description
Both were thin/stale for what the project has actually grown into (the
frontend README still framed things as "Phase 1-6" and listed maybe a
third of the current src/ tree). Added a Features section and tech-stack
summary to the root README, and refreshed the frontend README's layout
listing to match what's actually in src/ today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 17:09:11 -06:00
ksmithandClaude Sonnet 5 7022b63e9e Fix UpdateBanner pushing the composer off the bottom of the screen
Every top-level page independently hardcoded a full-viewport height
(.chat-shell: 100vh, .admin-page: 100%, .login-screen family: min-height
100vh), assuming it alone owned the whole viewport. UpdateBanner renders
globally above all of them (App.tsx), so its height just stacked on top
instead of the page shrinking to make room -- on ChatShellPage specifically
(overflow: hidden), that clipped the bottom of the screen and hid the
composer behind the visible edge.

Made #root a flex column shared by the banner and whichever page is
routed, with each page now using flex: 1; min-height: 0 to fill whatever
space is actually left instead of assuming the full viewport.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:53:47 -06:00
ksmithandClaude Sonnet 5 f2046efeaf Fix --sp-5 references to a nonexistent spacing token (#46 follow-up)
The spacing scale (tokens.css) jumps from --sp-4 (1rem) to --sp-6
(1.5rem) -- there's no --sp-5. Three places referenced it anyway, so
those margin declarations were invalid at compute time and silently
resolved to 0:

- ThemeBuilderModal.css's .custom-theme-preview margin-bottom -- the bug
  the user actually noticed, as a ~0px gap between the preview mockup and
  the color fields below it that read as a visual overlap.
- Modal.css's .modal-divider -- a shorthand `margin: var(--sp-5) 0
  var(--sp-4)`, where one invalid value invalidates the whole
  declaration, so every <hr class="modal-divider"> (ProfileModal's
  section separators, etc.) has had zero margin on both sides.
- AdminPage.css's .admin-invite-list margin-bottom.

All three now use --sp-6, matching the spacing tier that was clearly
intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:20:32 -06:00
ksmithandClaude Sonnet 5 8a9a4d6b7b Make the theme builder preview bigger, not just less cramped (#46 follow-up)
Splitting the builder into a preview column and a fields column still
capped the mockup at half the dialog's width. Give it the full width of
the modal instead (name field on top, preview below spanning the whole
dialog, fields and the native-controls toggle underneath), and scale up
CustomThemePreview's own fixed pixel dimensions (~1.4x: avatar, sidebar,
paddings, font sizes) so the extra room reads as a genuinely bigger
mockup rather than the same small one with more empty space around it.
Modal width bumped from 820px to 960px to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:14:45 -06:00
ksmithandClaude Sonnet 5 4cfe230c82 Open the custom theme builder in a wider dedicated dialog (#46)
The theme editor used to expand inline inside ProfileModal, whose .modal
is capped at min(380px, 100%) -- too narrow to comfortably see the live
CustomThemePreview mockup it's built around. Pulled the editor out into
a new ThemeBuilderModal (min(820px, 95vw), two-column layout above 680px)
opened on top of the profile modal, same stacked-dialog pattern already
used by ImageLightbox/FilePreviewModal. No changes to the theme data
model, activation, save, or delete behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:06:28 -06:00
ksmithandClaude Sonnet 5 58fa38610f Guard against out-of-order history responses overwriting each other (#45)
refreshHistory() fires twice in quick succession on a fresh load -- once
on mount, again when the WS 'joined' envelope arrives shortly after (for
the #37 rejoin-resync case) -- with nothing preventing a slower/stale
response (e.g. the service worker's NetworkFirst cache falling back on a
delayed request) from resolving last and overwriting a newer, correct
one. Track the latest-initiated request and drop any response that isn't
from it.

Confirmed via a production DB check that there are no duplicate
created_at timestamps, ruling out the timestamp-precision theory -- the
actual scrambling was two competing fetches racing, not a data problem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:47:37 -06:00
ksmithandClaude Sonnet 5 4b5fd3aab7 Trigger update checks off WS reconnect, not just the hourly poll (#42)
A backend restart during a deploy kills every open WebSocket, and the
chat socket's existing reconnect-with-backoff already re-fires onopen
within seconds -- reuse that as a reliable "the server just restarted"
signal to check for a new service worker version, instead of waiting up
to an hour for UpdateBanner's poll. The hourly poll stays as a fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:34:15 -06:00