Commit Graph
100 Commits
Author SHA1 Message Date
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 30e63ffa83 Add an auto-provisioned system account for automated messages (#74)
The #72 welcome message was attributed to whichever admin added the
member, since there was no system/bot sender concept -- every Message
row requires a real user_id. Adds a lazily-created "system" bot
account (reusing the existing is_bot infrastructure) and switches the
welcome message to post as it instead. Excluded from the People
directory and @mention autocomplete for free: the directory already
filters is_bot users, and mention suggestions are sourced from room
membership, which the system account is never added to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:56:10 -06:00
ksmithandClaude Sonnet 5 019e10ac5c Post a welcome message when an admin adds someone to a room (#72)
Attributed to the admin doing the adding rather than a new system/bot
sender concept, since every message today requires a real user_id and
the admin is already a real, in-scope user for the request. Broadcasts
room_added before the welcome message itself, so the new member's
client learns the room exists before it sees an unread update for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:35:09 -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 cd6296d079 Shorten link preview cache TTL from 7 days to 5 minutes (#70)
Re-posting a URL whose title/content had genuinely changed kept
showing the stale first-fetch preview for up to a week. 5 minutes is
effectively "always fresh" for any realistic re-share cadence, while
still collapsing a burst of near-simultaneous fetches of the same URL
into one and not re-hammering a URL that just failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:50:16 -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 ad745c734e Fix WNS push 400s and improve push failure logging (#56)
WNS (Windows/Edge push) has required an X-WNS-Cache-Policy header
since April 2024; the production venv was likely still on the old
pywebpush 2.0.x installed when the app was first deployed, which
predates the library's fix. Floored the dependency at 2.4.0 (current
latest) so the next deploy picks it up.

Also logs response body and headers on any non-410/404 push failure,
not just body text -- WNS's own 400s carry their actual reason in a
header, which the old body-only logging would still have missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 21:04:04 -06:00
ksmithandClaude Sonnet 5 092f1de585 Fix stale custom emoji image after delete-and-reupload (#18)
The image-serve endpoint's Cache-Control: max-age=300 let a browser
keep serving an already-cached image for up to 5 minutes after a
delete-and-reupload swapped in a different file under the same
shortcode URL. Switched to no-cache, which forces revalidation on
every use -- still cheap, since FileResponse's own ETag/Last-Modified
make an unchanged file a 304, not a full re-transfer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:53:37 -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 aeaaef96ec Log every DM-email decision point (diagnostic follow-up to #66/#68)
A live "no emails arriving" report produced literally nothing in the
logs, not even the SMTP-not-configured line -- this app has no logging
config lowering the root level below Python's own WARNING default, so
every .debug()/.info() call has been silently invisible in production
all along. Bumped the SMTP-not-configured line to .warning, and added
one at each early-return in _maybe_email_dm_notification (no other
participant, recipient online, already has unread messages) plus a
confirmation right before actually sending -- next attempt will show
exactly which branch is being hit instead of nothing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 17:56:03 -06:00
ksmithandClaude Sonnet 5 1a05cf5515 Style outgoing emails instead of plain text (#68)
Every email went through one shared plain-text-only path. Redesigned
send_email/send_test_email around structured paragraphs + an optional
CTA button instead of one pre-formatted string, and render both a
proper styled HTML card (table-based, inline styles -- email clients
strip <style> blocks and don't support CSS variables) and a clean
plain-text fallback from the same input, sent as multipart/alternative.

The HTML is themed per recipient: an email to an existing user renders
in their own selected theme (dark/light/midnight/sunset, or their saved
custom palette), resolved server-side from User.theme/
active_custom_theme_id. Site invites have no account yet to read a
theme from, so they use the default DarkSingularity palette. All five
existing email triggers (site invite, room-added, password reset,
#66's DM notification, admin test email) updated to the new call shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 17:39:08 -06:00
ksmithandClaude Sonnet 5 7b44ce325c Email a DM's recipient when they're genuinely offline (#66)
Scoped to direct messages only, and deliberately narrower than the
existing push/desktop "offline" (not connected to this room's channel
right now, which fires on every message) -- email uses GlobalPresence
instead (no open connection anywhere, or appear_offline), since the
other participant could easily just be active in a different room.
Debounced to the first unread message in the conversation rather than
firing on every message in a burst, resetting once they mark it read.
Reuses the existing SMTP/send_email infrastructure from the invite
feature, so it silently no-ops if SMTP isn't configured, same as
everywhere else that already uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 17:19:01 -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 1d9fe25410 Fix hidden DMs not reappearing live, and the recurring idle-transaction leak
Two related fixes:

1. A hidden DM's un-hide-on-new-message path only cleared
   RoomMembership.hidden_at in the DB -- it never told an already-open
   client to refresh. The only existing signal for that room
   (unread_update) does setRooms(prev => prev.map(...)), which is a
   no-op for a room that isn't in `prev` at all -- exactly what a
   hidden DM is. Now broadcasts the same room_added signal a brand new
   DM gets (via UPDATE ... RETURNING to know exactly who was
   un-hidden), reusing the fix already established for that class of
   bug.

2. While debugging #1's test, found the actual root cause behind the
   deploy-blocking migrations from earlier this session: every
   WebSocket connection shares one AsyncSession for its entire
   lifetime, and SQLAlchemy opens a transaction implicitly on first
   use. Nothing ever committed it -- not the initial auth lookup, not
   any of the several read-then-continue branches in the message loop
   (join/message/edit/reaction all check membership this way). A
   connection that's just sitting open (which for a real user can be
   hours) was holding that transaction open the entire time, which is
   exactly what blocked ALTER TABLE twice in production this session
   (confirmed both times via pg_stat_activity -- idle in transaction
   for 30+ minutes on this exact query shape). Now commits once after
   connection setup and once after every frame via a try/finally
   wrapping the whole dispatch, so no exit path (including the many
   `continue`s) can leave a transaction open while idling on the next
   receive_json().

Verified end-to-end in the browser (a hidden DM reappears in an
already-open tab with zero reload when the other person messages
again) and via a new WS-level test reproducing the exact scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:20:00 -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 1d322d9516 Fix DM recipient never seeing the new conversation (#52 follow-up)
start_dm_endpoint created the room and membership correctly but never
sent the room_added signal every other "you're now in a room" path
(add_member) already sends -- without it, GET /rooms/mine is only
fetched once at app mount, so a DM started against an already-open
client stayed completely invisible until a manual reload. The
recipient still got an offline push/desktop notification (that path
is independent, via _notify_offline_members), just nothing to
actually open when they went looking in an already-loaded session.

Added a test mirroring the existing add_member broadcast test exactly
(recipient connected but never joined any room channel, proving the
signal alone is what tells their client the room exists) -- it failed
before this fix and passes now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:22:40 -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 8a461ebb13 Fix room deletion 500ing on attachments, integrations, or references (#55)
delete_room() only ever cleaned up Message and RoomMembership rows,
but none of the FK constraints referencing a room (or its messages)
are declared ON DELETE CASCADE at the DB level -- confirmed across
every migration that added one. Any room that ever had an image/file
attachment, an incoming webhook, an outgoing event subscription, or
was ever #referenced from a message in a *different* room (the one
that originally surfaced this as a message_room_references FK
violation in production) couldn't be deleted at all.

Now explicitly cleans up, in dependency order: message mentions,
reactions, and room-references (both the message-id and room-id
directions), the messages themselves, then room-scoped images/files
(including unlinking the actual stored files after a successful
commit, not just their DB rows) and incoming webhooks/event
subscriptions, before removing memberships and the room.

Added a test reproducing the full scenario -- attachments,
integrations, and a cross-room reference all on one room -- that
would have 500'd before this fix, plus a sanity check that deleting
the room doesn't touch the unrelated room that referenced it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:41:40 -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 752da74c5a Add screenshots to README.md
Real screenshots (Playwright, headless) from a clean demo room/users
created for this purpose and removed afterward: the main chat view
(Markdown, mentions, reactions), the custom theme builder mid-edit, the
same room under a custom theme, and a mobile-width view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 17:25:22 -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 5527b25e52 Fix the real cause of out-of-order messages: now() vs clock_timestamp() (#45)
Root cause, confirmed live against Postgres and reproduced end-to-end
through two real WebSocket connections: ws/chat.py shares one AsyncSession
for a whole connection's lifetime. A read-only action (e.g. a "join"
frame's membership check) can leave a transaction open with nothing to
commit it until the next write. Postgres's now()/CURRENT_TIMESTAMP
returns that transaction's *start* time in that case, not the actual
statement's -- so a reply sent after any idle/reading period got
timestamped to when the idle period started, sorting it before messages
that were genuinely sent earlier. This is independent of the two earlier
#45 fixes (missing ORDER BY tiebreakers, a stale-response race on
reload) -- both were real bugs, but this was the actual mechanism behind
"my message appears before theirs even though theirs was sent first."

Switched Message.created_at and MessageReaction.created_at from
func.now() to func.clock_timestamp(), which always reflects the actual
moment of execution regardless of how long the transaction has been
open. Migration is a plain column-default change -- no table rewrite, no
lock risk, round-trips cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:44:48 -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
ksmithandClaude Sonnet 5 dddda19238 Show a live mockup mapping each custom theme field to the real UI
The 12 color pickers (Background, Sidebar background, Surface, Border,
etc.) gave no indication of what each one actually affects without
trial and error. Adds a miniature, self-contained mockup of the real
chat UI above the picker grid -- a sidebar with room rows, a message
with an avatar/mention/role badge, a composer, a danger button -- styled
from inline styles bound to the draft colors (not the --ds-* custom
properties, since those reflect whatever theme is currently active, not
necessarily the one being edited).

Hovering or focusing either a color input or its matching element in
the mockup highlights both, using a fixed amber outline that stays
visible regardless of the theme's own palette -- makes the mapping
between the 12 fields and where they actually show up immediately
obvious in either direction, without needing to activate the theme and
go look around the rest of the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:13:53 -06:00
ksmithandClaude Sonnet 5 a0e1565097 Fix chat/room ordering that could differ between devices (#45)
Two independent gaps, both fixed since the report was ambiguous about
which "chats" meant:

- ChatPane.tsx concatenated history (REST-fetched) and live (WS-pushed)
  without sorting, so anything that could desync receipt order from
  send order -- a rejoin/resync racing a still-in-flight WS message,
  which opening the same room on another device triggers directly via
  a fresh socket connection -- could render messages out of
  chronological order. Now sorted by created_at (stable sort, so
  same-timestamp messages keep their relative order).
- list_member_rooms/list_open_rooms/list_recent_messages ordered by
  created_at alone, with no secondary tiebreaker. Postgres doesn't
  guarantee a stable order for tied rows across separate query
  executions, so two rooms/messages sharing an identical timestamp
  (a real possibility -- rapid sends, bulk-created rooms) could come
  back in a different order on two separate fetches, i.e. two devices.
  Added id as a secondary sort key everywhere this showed up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:52:29 -06:00
ksmithandClaude Sonnet 5 c99a07cae1 Add a download button to the image lightbox (#41)
MessageImage has no stored original filename, so this relies on a bare
`download` attribute (no explicit filename) rather than adding
filename support server-side to match the file-attachment pattern --
the image-serving response's existing Content-Type header is already
enough for the browser to infer a sensible extension on its own.

RoomInfoPanel's Files section reuses the same shared ImageLightbox
component, so its image rows get the same download button for free
with no separate change needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:37:24 -06:00
ksmithandClaude Sonnet 5 2466e76af1 Require password confirmation on account creation (#40)
Both account-creation surfaces now require the password twice:

- Web signup (invite-based self-service): SignupComplete gains a
  password_confirm field with a model_validator backstop server-side,
  plus a client-side match check in SignupPage.tsx for immediate
  feedback -- the client check is the primary UX, the server check is
  defense in depth so the guarantee doesn't rely on the client alone.
- CLI (python -m app.cli create-user): password is now an optional
  positional argument. If omitted, prompts interactively via getpass
  (hidden input) twice, retrying on mismatch -- matching what "entered
  twice and verified" actually means for a human typing blind. Passing
  the password directly as before still works unchanged, for scripted/
  automated provisioning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:48:23 -06:00
ksmithandClaude Sonnet 5 dbf9bfa902 Support dragging files/images onto the composer to attach them (#32)
Reuses the existing upload path unchanged: handleFileSelected's body is
now handleFile(file), called from both the file-input's onChange and a
new onDrop handler on the composer, so drag-and-drop and the "Attach a
file" button share the exact same size-check/branch-on-content-type/
error-surfacing logic rather than duplicating it.

Only the first dropped file, matching the existing single-attachment-
per-message limit. A dashed-border overlay appears while dragging over
the composer for discoverability; a nested dragenter/dragleave counter
keeps it from flickering as the drag crosses child element boundaries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:14:17 -06:00
ksmithandClaude Sonnet 5 4bac502b2c Add @-mention highlighting, sidebar badge, and push customization (#39)
@username tokens in a sent message are parsed against the room's actual
members (skipping fenced/inline code, so pasted code isn't misread) and
recorded as MessageMention rows, reusing #38's read-tracking and
offline-member broadcast infrastructure rather than building a parallel
notification path:

- Sidebar: a mentioned-and-unread room shows a distinct highlight-
  colored badge instead of (not alongside) the plain unread dot --
  computed the same way as has_unread, just scoped to messages that
  mention the caller, and cleared by the same last_read_at mark-read
  flow.
- Push notifications: a mentioned offline recipient gets "X mentioned
  you: ..." instead of the generic "X: ...", still per-recipient since
  the same message can page some room members and not others.
- Message rendering: a validated @username is highlighted inline,
  implemented by turning it into a `[@username](mention:username)` link
  before markdown parsing and overriding link rendering to style
  `mention:`-scheme links as a span instead of an anchor -- reuses
  markdown-to-jsx's existing parser rather than hand-rolling text-node
  splitting.
- Composer: typing @ opens an autocomplete dropdown of matching room
  members (arrow keys to navigate, Enter/Tab/click to insert, Escape or
  moving the cursor away to dismiss).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 08:56:40 -06:00
ksmithandClaude Sonnet 5 bd3e621e9f Support multiple named, saved custom themes per user
Replaces the single custom_theme_colors blob (one palette per user) with
a proper CustomTheme table -- users can now save, name, and switch
between as many custom palettes as they like, not just one.

Data model: users.active_custom_theme_id references whichever saved
CustomTheme (if any) is currently active; theme='custom' + that id
together determine what's rendered. The migration data-migrates any
already-saved single palette into a named CustomTheme row on upgrade,
and best-effort backfills the active one back into the old column shape
on downgrade.

New endpoints under /api/custom-themes: list, create, rename/recolor,
delete (falls back the user to a preset if the deleted theme was
active, so the two theme columns can never disagree), and activate.
UserRead.active_custom_theme is only populated when theme == 'custom'
even though the DB deliberately keeps the id set while a preset is
active, so switching to a preset and back doesn't lose the saved
palette.

ProfileModal now lists saved themes as swatches (click to activate,
pencil to edit -- active or not, trash to delete with a confirm), plus
a "+ New" button that creates, activates, and opens the editor for a
fresh theme immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:53:05 -06:00
ksmithandClaude Sonnet 5 73020fa39f Add PDF preview to the file preview modal (#23)
Extends the existing fetch-and-bypass-Content-Disposition pattern
already used for text/markdown previews: the PDF's bytes are fetched
into a Blob and handed to the browser's native viewer via a blob: URL,
which carries no HTTP headers of its own. That sidesteps
Content-Disposition: attachment the same way a script-initiated fetch()
already does for text, without needing an <iframe>/<embed> to navigate
to the real file URL directly (which would respect that header and
force a download) -- and without the backend allowlist endpoint this
issue's original scoping assumed would be necessary.

MIME type is forced to application/pdf explicitly rather than trusted
from the upload, since getPreviewKind gates on the .pdf extension alone
(matching its existing behavior for .md/.txt), so a mislabeled file
still renders instead of downloading or erroring. Object URLs are
revoked on unmount/file-change to avoid leaking memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:09:10 -06:00
ksmithandClaude Sonnet 5 53d16973fb Add custom theme colors, full per-token control (#30)
Adds a 5th "Custom" option to the theme swatch grid alongside the
existing 4 presets, opening a picker for all ~12 CSS custom properties
(backgrounds, borders, text, three accent tiers, highlight, danger) plus
a light/dark toggle for native control rendering.

Persisted as a new users.custom_theme_colors JSONB column, validated
server-side against exactly what a native <input type="color"> can ever
produce. Colors survive switching to a preset and back, since there's no
reason picking a preset for a moment should force redoing every color
pick. Applied at runtime as inline custom properties on :root (presets
stay static CSS) via a shared applyTheme() helper, which is also
responsible for clearing those inline overrides when switching away --
otherwise they'd silently keep winning over whatever preset's own
stylesheet values should apply next.

Live preview on every color change; persists only on explicit "Save
colors" (not per keystroke, since a color input fires continuously while
dragging), and closing the modal without saving reverts the preview back
to whatever's actually persisted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 06:15:49 -06:00
ksmithandClaude Sonnet 5 68e487e5ec Add unread message indicators for rooms (#38)
Shows a dot on rooms with unread messages in the sidebar, updated live
over WebSocket. Reuses the same offline-member audience computation
already used for push notifications: a member gets the real-time signal
whenever they aren't currently connected to that room's channel, which
correctly covers both "room not open" and "room open but tab
backgrounded" (the client leaves a room's channel while hidden).

Persisted server-side via a new room_memberships.last_read_at column so
state survives reload and stays consistent across devices, advanced by
an explicit mark-read call the frontend makes on room-open and on each
live message received while the room is genuinely visible -- gated on a
live visibility check, not a cached ref, so a backgrounded-but-open room
keeps accumulating unread instead of auto-marking-read the instant a
message arrives somewhere it can't be seen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:44:20 -06:00
ksmithandClaude Sonnet 5 974d92ab4d Unsubscribe from push notifications on logout
logout() cleared the session but never called unsubscribeFromPush(),
so a browser's push subscription (and its server-side row) outlived the
session indefinitely -- the logged-out account kept silently receiving
pushes for as long as that browser stayed open. Runs before the session
cookie is cleared since the unsubscribe call is authenticated, and is
best-effort so a failure there can't block logout itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:04:11 -06:00
ksmithandClaude Sonnet 5 5643df6bab Add a Files section to room info listing all sent attachments (#33)
Lists files and images actually attached to sent messages in a room,
newest first, with click-through to a lightbox, preview modal, or direct
download depending on type. Queries through messages.image_id/file_id
so an upload that was never sent doesn't show up as a phantom entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 19:57:14 -06:00
ksmithandClaude Sonnet 5 fd0b3863f0 Add a "Recently used" section to the emoji picker (#35)
Shows up to 8 most-recently-picked emoji, pinned above the regular
categories, whenever the picker isn't in search mode -- shared by both
the composer's insert-emoji button and message reactions, since both
go through the same EmojiPicker component. Stored in localStorage
(frontend/src/lib/recentEmoji.ts), per-browser rather than synced
across devices, matching this app's existing local-only preferences
(e.g. the resizable-panel widths).

Verified in-browser: no section when empty, a pick is recorded and
shows up on reopen, order is most-recent-first, re-picking an already-
recent emoji moves it to the front without duplicating, and the list
caps at 8 by dropping the oldest entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 19:25:50 -06:00
ksmithandClaude Sonnet 5 2cd44dc3f7 Fix push notifications permanently suppressed by silent background reloads
Root cause of "still not getting push notifications while backgrounded"
despite #31: joinRoom() was made to send unconditionally on the theory
that opening a room always implies genuine visibility (a real click
can't happen on a truly hidden tab). That's wrong for one real case --
mobile Chrome can silently discard and later reload a long-backgrounded
tab from memory, which re-mounts the room and calls joinRoom() again
with nobody actually looking at the screen. Each such reload re-joined
the room's presence with no matching "leave" (a discard skips normal
unmount cleanup), so a room could accumulate a stuck presence entry
that permanently suppressed push notifications for it -- confirmed
live via a user's server logs (repeated silent WS reconnects, and
their account still showing present in the room's Redis presence hash
while genuinely backgrounded).

joinRoom() and the reconnect replay now check document.visibilityState
live instead of trusting a cached ref or sending unconditionally: a
still-hidden reload correctly stays "left" (the room stays in
desiredRoomsRef, so the next genuine foreground transition still joins
it, just deferred instead of wrongly immediate), while a real
user-driven open still joins immediately as before.

Verified both directions in the browser: mounting a room while
genuinely hidden leaves the room's presence hash empty; a subsequent
real visibility transition to visible correctly triggers the deferred
join.

Note: this prevents new stuck entries but doesn't retroactively clear
any that already exist -- an affected user needs one real close
(not just backgrounding) to send a clean disconnect and reset the
stuck refcount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 19:14:05 -06:00
ksmithandClaude Sonnet 5 85dc83f4e5 Fix presence showing offline on non-chat pages, and a room-join bug it exposed
The WebSocket connection lived entirely inside ChatShellPage, so
navigating to /admin (which never opens its own connection) unmounted
it -- the server correctly marked the user offline since the
connection genuinely closed, even though they were still logged in
and using the app. New ChatSocketContext.tsx hoists the connection to
App.tsx, shared across every authenticated route via a single
provider (keyed by user id, so a logout/login as a different account
gets a clean reconnect rather than an old connection lingering under
a new identity) instead of living inside whichever page happens to be
mounted.

Verifying that fix surfaced a second, independent bug: #31's
visibility handling had gated the *explicit* joinRoom/leaveRoom calls
(fired when a room actually mounts/unmounts in the UI) on the same
isVisibleRef check meant for automatic background/foreground
transitions. That's wrong -- a room can only be opened by a real user
interaction, which can't happen on a genuinely backgrounded tab, so
gating it too meant a stale or momentarily-wrong visibility reading
at mount time could silently skip the join with nothing to ever retry
it. joinRoom/leaveRoom now always send immediately; only the
automatic hide/show transitions and the reconnect replay stay gated
on visibility, which is what #31 actually needed.

Verified both end-to-end in the browser: navigating to /admin via
real in-app navigation (not a reload) keeps the presence dot online,
confirmed via direct Redis inspection and the /api/users/online
endpoint; opening a room and sending a message works immediately
afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:37:40 -06:00
ksmithandClaude Sonnet 5 e9ad5d832b Show a reload banner when a new version has deployed (#28)
The service worker already auto-updates in the background (registerType:
'autoUpdate' + an unconditional self.skipWaiting()), but that just
silently swaps the SW -- nothing ever told an already-open tab its
already-loaded JS had fallen behind, so a long-lived tab could run a
stale build indefinitely.

Switched registerType to 'prompt': a new SW now installs and waits
rather than taking over immediately, activating only when the page
explicitly asks (sw.ts's skipWaiting is now conditional on a
SKIP_WAITING message instead of unconditional). UpdateBanner.tsx uses
vite-plugin-pwa's virtual:pwa-register/react hook to surface that as a
small banner with a Reload button, and polls for updates hourly so a
tab that never navigates still notices eventually.

Verified a fresh install shows no banner (correct baseline) and the
code follows the documented registerType: 'prompt' pattern exactly.
Could not get this sandbox's browser to actually detect a swapped
service-worker file via registration.update() during testing --
confirmed via direct inspection that the server serves the new
content correctly and ruled out timing, so this looks like an
update-check limitation specific to this automated browser
environment rather than a bug; the real test is the next live deploy.

Also adds a "frontend-preview" launch.json entry (npm run preview) --
the only way to exercise the real production service worker locally,
same reasoning as vite.config.ts's existing `preview.proxy` section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:13:05 -06:00
ksmithandClaude Sonnet 5 ccd92787d6 Send push notifications when the app is backgrounded, not just closed (#31)
Root cause (found earlier): the server only pushes to users it
believes are "connected" to a room, but that was a raw WebSocket-
connection check with no concept of whether the tab was actually
foregrounded -- a backgrounded-but-still-connected tab looked exactly
like someone actively watching, so the push got suppressed even
though nothing could surface on a hidden page.

No backend change needed: the server's presence tracking (and the
push-suppression logic built on it) was already correct for "not
joined to this room's channel" -- the gap was purely that the client
never told it about backgrounding. useChatSocket.ts now tracks
document.visibilityState and sends "leave" for every desired room
when hidden (without forgetting the app still wants them joined), and
"join" again when visible -- reusing the exact join/leave path a real
room switch already goes through, no new WS message type or backend
logic required.

Rejoining also triggers a message-history refetch in ChatPane (keyed
off the server's existing "joined" ack), so anything sent while
backgrounded gets backfilled instead of silently missing -- as a side
effect, this also fixes reconnect-after-a-dropped-connection never
backfilling either, which had the same gap.

Verified end-to-end: simulated backgrounding in the browser and
confirmed via direct Redis inspection that the room's presence hash
(what push-suppression actually reads) goes empty on hide and
repopulates with a message resync on show.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:58:39 -06:00
ksmithandClaude Sonnet 5 7ef6cfca65 Add presence indicators and a manual "appear offline" override (#36)
Every avatar in the app (chat messages, room member list, your own
avatar in the top bar/profile, the admin user list, the room-invite
search) now shows a green/red presence dot. Also adds a global
"Appear offline" toggle in the account menu, letting a user lurk in a
room undetected -- it overrides the real connection state everywhere,
not per-room.

Backend: new GlobalPresence (backend/app/ws/global_presence.py), a
cross-instance Redis-backed connection tracker parallel to the
existing per-room Presence, incremented/decremented on WS connect/
disconnect. A new users.appear_offline column (migration
f0f6e494454a) always wins over actual connection state when computing
displayed status. RoomMemberRead gained a computed `status` field;
add_member/change_member_role/list_room_members all compute it via a
shared _member_status() helper. Connect/disconnect and profile
updates (display_name, avatar, appear_offline) all broadcast
member_updated to every room the user belongs to, reusing the
broadcast infrastructure from the earlier avatar-staleness fix, so
chat surfaces update live with no new WS envelope type needed. A new
GET /api/users/online gives the admin list and user-search a snapshot
(deliberately not live -- see backend/app/routers/users.py) for
surfaces where "accurate as of page load" is good enough.

Frontend: UserAvatar renders an optional status dot; every call site
threads status/appear_offline through from whichever data source it
already has (room members, the current user, or the new online-ids
snapshot for admin/search).

4 new backend tests (backend/tests/test_presence.py); existing
broadcast-adjacent WS tests updated to tolerate the new member_updated
noise on connect. Verified end-to-end in the browser with two real
users: presence dot flips live on connect/disconnect via the existing
room-broadcast channel, and the lurk toggle correctly forces offline
while still connected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:39:56 -06:00
ksmithandClaude Sonnet 5 1c2d2e91c1 Fix stale room/message/member data after leaving and returning (#37)
The service worker cached /api/rooms/mine, /api/rooms, .../messages,
.../members, and /api/invites/mine with StaleWhileRevalidate: serve
the previous cached response immediately, refresh the cache in the
background for next time. That means every repeat visit showed
content one visit behind -- reopening a room after someone messaged
it, or checking a second device, both showed stale data until a
manual reload (which finally picked up the now-revalidated cache).

Only registers in a production build (vite dev never activates it),
which is why this didn't surface during in-browser testing this
session for #26/#27/#34.

Switched all five routes to NetworkFirst: always prefer a live
response, fall back to cache only when the network request itself
fails or times out (genuinely offline), keeping the "readable while
offline" behavior without the staleness.

Verified against a real production build (`vite preview`, the only
way the SW actually registers): sent a message from a second session
against an already-cached room, and the very next fetch showed it
immediately with no staleness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:55:30 -06:00
ksmithandClaude Sonnet 5 1567a96e8a Explicitly enable spellcheck on chat message textareas (#34)
Browsers already spellcheck a plain <textarea> by default (confirmed
in-browser: spellcheck read true on the composer with nothing
disabling it), but that's an implicit default rather than a guarantee
across every browser/PWA context. Set spellCheck explicitly on both
the composer and the message-edit textarea so it can't silently be
off somewhere it wasn't verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:33:52 -06:00
ksmithandClaude Sonnet 5 1b4d681ad0 Broadcast profile updates so member lists stay live (no reload needed)
Another user's new display name or avatar didn't show up until you
reloaded -- update_profile/upload_avatar/remove_avatar never told
anyone. Same root cause and fix shape as #26 (room_added): the
frontend's already-fetched member list had no way to hear about a
change, since nothing ever pushed one.

Reuses the existing per-room broadcast channel (not the per-user one
#26 added, since this only matters for rooms the affected user shares
with someone currently looking at them) -- publishes member_updated to
every room the user belongs to; ChatShellPage refetches members when
it arrives for the currently open room.

Verified end-to-end in the browser: one user's room-info member list
updated live when another user changed their display name from a
separate session, no reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:08:36 -06:00
ksmithandClaude Sonnet 5 c84c92446c Flip reaction emoji picker upward when it won't fit below
Reacting to a message near the bottom of the scrolled list opened a
picker that ran off-screen and couldn't be used -- placement was
hardcoded to "below" regardless of the trigger's actual position.
Now computed per-click from the trigger's bounding rect against
available viewport space, matching the composer's own picker (which
was already positioned dynamically, just always "above" since it's
pinned to the bottom of the screen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:01 -06:00
ksmithandClaude Sonnet 5 1adb4fcbe0 Add markdown :name: emoji shortcode support in messages (#27)
Typing a complete 😂/😉/etc. shortcode now renders as the emoji,
matching Slack/GitHub/Discord. Render-time only, alongside the existing
preserveLineBreaks preprocessing step -- stored/sent content keeps the
raw :name: text, same as markdown itself is never converted until
display. Fenced code blocks and inline code spans are skipped so
pasted code (a Ruby symbol, a dict key) isn't silently mangled.

frontend/src/lib/emojiShortcodes.ts is generated once from
emojibase-data's GitHub shortcode set (same one-time-generator approach
as #19's emojiNames.ts, never a runtime dependency) -- 928 of the
app's 936 emoji matched, 956 aliases, no collisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:38:09 -06:00
ksmithandClaude Sonnet 5 7dcc7104df Push a live signal when a user is added to a room (#26)
Previously GET /api/rooms/mine was only ever fetched once at app mount,
so a room added mid-session stayed invisible until a full page reload
-- add_member had no way to reach an already-open client at all.

Backend: ConnectionManager and Broadcaster (renamed from RoomBroadcaster)
now support per-user channels alongside the existing per-room ones, so a
signal can reach a user's socket even for a room they haven't joined
(and by definition can't have, until this fires). add_member publishes
a room_added event on the target user's channel.

Frontend: the WebSocket connection is no longer scoped to whichever
room is open -- ChatShellPage now owns one persistent connection for
the whole session (including while no room is open, which is exactly
when this bug showed), and ChatPane joins/leaves rooms on top of it.
A room_added event triggers a room-list refetch with no reload needed.

Verified end-to-end in the browser: a user sitting on the empty room
list saw a newly-added room appear live, then chatted in it normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:29:06 -06:00
ksmithandClaude Sonnet 5 c9d61c3d12 Fix composer send button clipped off-screen on mobile (#24)
The composer's message textarea lacked min-width: 0, so on browsers
that compute a larger default intrinsic width for flex-child form
controls (observed on Firefox for Android), the row could overflow
and push the fixed-width send button past the viewport edge instead
of shrinking the textarea to fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:09:49 -06:00
ksmithandClaude Sonnet 5 b451504d07 Fix mobile display: RoomInfoPanel inaccessible, emoji picker overflow
RoomInfoPanel was hard-gated behind !isMobile in ChatShellPage.tsx, but
its trigger button ("Room details" in ChatPane.tsx) still rendered and
toggled state unconditionally -- tapping it on mobile did nothing
visible, with no way to reach room info/members/settings at all. Fixed
by removing the gate and making the panel itself responsive: it renders
as a full-screen fixed overlay below the mobile breakpoint instead of
the desktop resizable aside (which stays exactly as before -- verified
in-browser at both viewport sizes).

Also found and fixed a second real bug during the mobile audit: the
emoji picker's fixed 320px width overflows a 375px-wide viewport by 5px
depending on trigger position (e.g. the composer's emoji button, near
the left edge). Shrunk it to 280px with a proportionally reduced column
count below 480px, rather than attempting dynamic position-aware sizing
for a 5px overflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:35:19 -06:00
ksmithandClaude Sonnet 5 9c275b1b86 Add emoji search by name
A search box in EmojiPicker filters the flat emoji list by name/keyword
instead of only browsing by category, shared by both the composer's
emoji trigger and the message reaction picker.

Names/keywords didn't exist anywhere in this codebase before (emoji.ts is
just raw unicode characters). Rather than hand-typing entries for all 928
unique emoji -- a lot of manual work, and unlike the codepoints themselves
a wrong name only means a bad search result, not a broken emoji, so the
accuracy argument for hand-typing didn't apply here -- generated
emojiNames.ts once from emojibase-data (MIT licensed), matched against
emoji.ts by codepoint (normalizing variation-selector differences between
the two sources). emojibase-data itself was never added to package.json;
it was only ever a one-time generation tool, so this ships with zero new
runtime dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:04:59 -06:00
ksmithandClaude Sonnet 5 caeff5d6f0 Fix sudoers rule not matching upgrade.sh's actual systemctl status call
The NOPASSWD rule from 3f only covered the bare `systemctl status
ds-chat` with no arguments, but deploy/upgrade.sh actually calls it with
`--no-pager -l`. Sudoers matches commands on the exact argument string
unless a wildcard is present, so the extra flags fell through to a
password prompt on every upgrade run -- one that can never actually be
satisfied, since ds-chat correctly has no password at all (a nologin
system account). Added a wildcarded pattern alongside the exact one so
upgrade.sh's real invocation matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:15:09 -06:00
ksmithandClaude Sonnet 5 691f597597 Fix SMTP sending forcing implicit TLS regardless of port
cfg.use_tls was passed straight through as aiosmtplib's use_tls kwarg,
which means implicit TLS -- encrypted from the first byte, port 465's
convention. Port 587, what most providers (including the one that
surfaced this: DreamHost) document as their primary submission port,
needs STARTTLS instead -- a plaintext connection that upgrades in-band.
Forcing implicit TLS against a STARTTLS-only port breaks the handshake
outright: [SSL: WRONG_VERSION_NUMBER], a client TLS ClientHello sent to a
server still expecting a plaintext SMTP greeting.

The "Use TLS" checkbox still means "encrypt this connection" -- the fix
infers which of the two negotiation modes to use from the port (465 ->
implicit, everything else -> STARTTLS), matching the convention every
mail client uses. start_tls is passed as an explicit requirement rather
than left to aiosmtplib's opportunistic default, so a server that turns
out not to support STARTTLS fails loudly instead of silently sending in
plaintext despite the admin asking for encryption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:09:39 -06:00
ksmithandClaude Sonnet 5 630b07d48f Fix deployment guide issues found during a live walkthrough
Three real, reproducible bugs in DEPLOYMENT.md's app-server setup steps,
each found by actually following the guide on a real box rather than
static review:

- useradd --system --create-home is unreliable on Debian in both
  directions: sometimes it silently skips creating the home directory,
  sometimes it creates it AND populates it from /etc/skel
  (.bashrc/.profile/.bash_logout). Either way it broke a later step --
  the skel files make git clone refuse to clone into a non-empty
  directory. Fixed by dropping --create-home and creating the directory
  ourselves, deferring anything else that goes in it (uploads/) until
  after the clone.
- Documented a personal/deployment-user access token as a first-class
  alternative to the SSH deploy key for cloning, alongside its plaintext-
  in-.git/config tradeoff.
- The create-user command nested a user-chosen password inside two layers
  of shell quoting (an outer bash -c '...' plus inner double quotes) --
  fragile for any password with a space or a literal '. Replaced with
  dropping into an authenticated interactive shell first, so there's only
  one layer of quoting to get right at an actual prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:09:27 -06:00
ksmithandClaude Sonnet 5 e20916bca9 Fix stale deployment docs found during a pre-launch review
DEPLOYMENT.md: two spots (§7 backups, §9 known gaps) said "images" when
they should say "images and file attachments" -- generic file attachments
(#13) share the exact same no-backup-coverage and orphaned-upload gaps as
images, but the wording was never updated when that feature shipped.

ARCHITECTURE.md §9.2 and the tech-stack table described a materially
different, outdated architecture: nginx running on the app server,
reverse-proxying to gunicorn over a Unix socket. The actual setup (which
DEPLOYMENT.md already correctly documents) has no nginx on the app server
at all -- gunicorn binds a TCP port directly, and TLS/reverse-proxying is
handled by an external, pre-existing Nginx Proxy Manager instance. Rewrote
both to match reality.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 22:10:39 -06:00
ksmithandClaude Sonnet 5 afdd573182 Add UI themes (Dark, Light, Midnight, Sunset)
Four preset color themes, switchable from Profile settings with instant
preview and server-side persistence (User.theme, applied via a data-theme
attribute the CSS custom-property overrides in themes.css key off). Dark
stays the existing DarkSingularity palette and default. Light is a genuine
new light-mode design; Midnight is a higher-contrast OLED-friendly dark
variant; Sunset swaps in a warm amber/coral accent family.

Custom theme building (pick-your-own-colors) is out of scope for this
pass -- presets only.

Also: fixed the PATCH /api/auth/me handler to only apply fields actually
present in the request body. It previously always overwrote display_name
unconditionally, which happened to be harmless when it was the only
field on ProfileUpdate but would have silently cleared it on any
theme-only update. And switched two hardcoded hex colors
(.btn-primary:hover, .role-badge-admin) to token-derived color-mix()
values so they adapt across themes instead of staying fixed to the
original cyan/violet palette.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 22:02:38 -06:00
ksmithandClaude Sonnet 5 d7e777cbd8 Add inline preview for markdown and plain-text file attachments
Clicking a .md/.txt attachment now opens a modal instead of downloading,
with an explicit download button still available inside it. Markdown
renders through the same XSS-safe renderer used for chat messages;
plain text renders as literal escaped content via <pre>.

No backend change needed: the preview content is read via fetch(), which
is unaffected by the Content-Disposition: attachment header the file-serve
endpoint always sends (that header only steers the browser's own
navigation/embed rendering, not a script-initiated body read) -- so the
existing download-forcing security behavior from #13 stays intact.
Non-previewable types (PDF, etc.) are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:30:29 -06:00
ksmithandClaude Sonnet 5 14dc340174 Rename project from KeepItTalking to DS Chat
Renames the app's display name everywhere (page titles, PWA manifest,
TopBar, email subject lines, HMAC signature header) and its internal
technical slug from chatapp to ds-chat/ds_chat: the Python package name
and console script, the systemd unit and its user/group/paths, the deploy
scripts, the Docker container names, and the Postgres database name.

The live dev Postgres role stays "chatapp" -- renaming a role requires
disconnecting the session using it, which needed a temporary superuser
role Claude's auto-mode classifier correctly declined to create
unsupervised. Functionally invisible (it's just a login credential), but
worth knowing about if this ever needs fully cleaning up by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:11:41 -06:00
ksmithandClaude Sonnet 5 62e4760c8a Add admin-configurable upload size limits
The 8MB image/file/avatar cap is now a site setting (UploadSettings,
single-row table like SmtpSettings) editable from the Admin Settings tab,
instead of a hardcoded constant. All three upload endpoints read the live
value and interpolate it into their 413 messages. A new GET
/api/uploads/limit endpoint (open to any authenticated user, unlike the
admin-only settings endpoints) lets the composer reject an oversized file
client-side before it ever hits the network, though the server still
enforces the same cap independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:44:05 -06:00
ksmithandClaude Sonnet 5 c78d7454b6 Add generic file attachments to chat messages
Messages can now carry an arbitrary file (MessageFile), parallel to the
existing MessageImage feature rather than a refactor of it. Files serve
with Content-Disposition: attachment to force a download and prevent an
uploaded HTML/SVG from executing same-origin. No content-type allowlist,
same 8MB cap as images for now (a separate size-limit redesign is tracked
as its own issue).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 08:02:55 -06:00
ksmith 89be497fdb Add markdown rendering for chat messages (issue #14)
Renders message content with markdown-to-jsx: bold/italic/strikethrough,
inline code, fenced code blocks, blockquotes, lists (incl. nested and
task lists), tables, footnotes, headings, and highlight (==text==).
Raw HTML in message content is parsed to escaped literal text rather
than rendered (disableParsingRawHTML), which is the XSS mitigation for
this being user-generated content -- verified against both <img
onerror> and <script> probes. Markdown image embeds degrade to a link
instead of an <img>, since the app already has a first-class image
upload and a second silent remote-image-embed path would duplicate it
and leak the viewer's IP to arbitrary URLs.

The inline message-edit control is upgraded from a single-line <input>
to a <textarea> so multi-line markdown can actually be edited without
losing newlines, mirroring the Composer's Enter-sends/Shift+Enter-
newlines convention.

Since CommonMark treats a single newline as a soft break (collapses to
a space) rather than a visible line break, added a small code-fence-
aware preprocessor that converts single newlines to hard breaks --
without it, existing multi-line messages sent via the Composer's
Shift+Enter would silently collapse onto one line.

Also fixes heading levels rendering at an identical capped size
(should still step down by level, just capped lower than default), and
adds CSS for markdown constructs the library already parsed but hadn't
been styled for the dark theme: table borders, highlight/mark color,
task-list checkbox accent, and footnote divider.
2026-08-14 21:42:57 -06:00
ksmith e31672719b Expand emoji picker from ~180 to ~940 curated emoji
The old list was a small hand-picked subset that was missing common
ones (party popper, facepalm, shrug, etc.). Reorganized into ten
categories (Smileys, People & Gestures, Hearts, Animals & Nature,
Food & Drink, Travel & Places, Activities & Sports, Objects, Symbols,
Flags) with much broader coverage in each. Widened the picker panel
and bumped the grid to 9 columns to fit the larger set comfortably.
2026-08-14 21:14:54 -06:00