38 Commits
Author SHA1 Message Date
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
116 changed files with 7133 additions and 555 deletions
+32 -5
View File
@@ -28,7 +28,7 @@ database and Redis; the other runs the application and serves the frontend.
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
| Reverse proxy / TLS | External Nginx Proxy Manager (pre-existing infra, not deployed by this project) | Terminates TLS, forwards REST + WebSocket traffic to the app server's one port. The app itself serves the built static frontend directly — no separate static-asset server needed |
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
| Auth | Session cookies (httpOnly, secure) | Simplest to carry through a WebSocket handshake automatically |
| Auth | Session cookies (httpOnly, secure), backed by a server-side session table | Cookie carries only an opaque session id — simplest way to carry auth through a WebSocket handshake automatically, while still allowing server-side revocation (see §4/§10) |
## 3. System architecture
@@ -85,17 +85,31 @@ users
id, username, email, password_hash, is_bot, is_site_admin, created_at
rooms
id, name, description, is_private, owner_id, created_at
id, name, description, is_private, is_dm, is_archived, owner_id, created_at
room_memberships
room_id, user_id, role (owner | admin | member), joined_at
room_id, user_id, role (owner | admin | member), joined_at, last_read_at,
hidden_at (DM-only: hides it from one participant's sidebar),
email_notifications (opt-in, non-DM rooms only)
room_invites
id, room_id, invited_by, token, target_user_id or target_email,
expires_at, status (pending | accepted | revoked)
messages
id, room_id, user_id, content, created_at, edited_at, deleted_at
id, room_id, user_id, content, image_id, file_id, created_at, edited_at,
deleted_at
message_reactions
id, message_id, user_id, emoji (unicode glyph or a custom emoji's
`:shortcode:`)
custom_emoji
id, shortcode (unique, site-wide), storage_filename, content_type,
uploaded_by, created_at
sessions
id, user_id, ip_address, user_agent, created_at, last_seen_at, revoked_at
push_subscriptions
id, user_id, endpoint, p256dh_key, auth_key, created_at
@@ -114,6 +128,13 @@ admin_audit_log
id, actor_id, action, target_type, target_id, metadata, created_at
```
Not shown above (present in the implementation, omitted here for brevity):
`message_images`, `message_files`, `message_mentions`,
`message_room_references`, `link_previews`, `custom_themes`,
`upload_settings`, `smtp_settings`, `site_invites`, `password_resets` — see
`backend/README.md` for the full, current model list and per-feature
detail on all of these.
## 5. Permission model
- **Room visibility**: `open` (any authenticated user can find and join) or
@@ -133,7 +154,13 @@ admin_audit_log
4. Every app instance subscribed to that channel forwards it to its own connected
members over WebSocket.
5. For members with no active connection, the server looks up
`push_subscriptions` and sends a Web Push notification via `pywebpush`.
`push_subscriptions` and sends a Web Push notification via `pywebpush`,
and a native desktop notification over the socket if the client is DS
Chat Desktop.
6. Separately, a genuinely-offline DM recipient always gets an email; a
regular room's member gets one too if they've opted in for that room
(first unread message, or any `@mention` regardless of debounce) — see
`backend/README.md`'s "Email notifications for missed messages".
## 7. Extension system: bots and AI agents
+9 -8
View File
@@ -345,11 +345,12 @@ producing a gzipped `pg_dump` in `/var/backups/ds-chat/` with 14-day local
rotation. Off-box shipping is a placeholder in that script (commented-out
rsync/S3 examples) — decide where those need to go and fill it in.
That script covers Postgres only. Uploaded chat images and file attachments
both live on the **app** server's disk (`/srv/ds-chat/uploads`, created in
§3a) — a separate machine from the data server this script runs on — and
currently have no backup mechanism at all. Whatever off-box destination you
pick above, include `/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
That script covers Postgres only. Uploaded chat images, file/video
attachments, avatars, and custom emoji all live on the **app** server's
disk (`/srv/ds-chat/uploads`, created in §3a) — a separate machine from
the data server this script runs on — and currently have no backup
mechanism at all. Whatever off-box destination you pick above, include
`/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
line run from the app server).
**Test a restore** (against a scratch database, never directly onto
@@ -389,13 +390,13 @@ Carried forward from earlier phases (see `backend/README.md`'s own "Notes /
scope decisions" for the full detail on each):
- No rate limiting on human or bot API traffic.
- No CSRF token (relies on `SameSite=Lax` cookies).
- No server-side session revocation (signed cookies only).
- SSRF protection on outgoing webhooks is creation-time only, not
re-validated per delivery (DNS-rebinding gap).
- Backup off-box shipping is a placeholder — decide a destination and fill
in `deploy/backup-postgres.sh`.
- Uploaded chat images and file attachments (`/srv/ds-chat/uploads` on the
app server) have no backup coverage at all yet, on-box or off — see §7.
- Uploaded chat images, file/video attachments, and custom emoji
(`/srv/ds-chat/uploads` on the app server) have no backup coverage at
all yet, on-box or off — see §7.
- Uploaded-but-never-sent images or files (a user attaches one, then never
hits Send) leak an orphaned file on disk — no cleanup job for this yet.
Not a security issue (still gated by room membership to view), just an
+18 -13
View File
@@ -18,21 +18,26 @@ build plan.
## Features
- **Auth & accounts** — session-based auth, invite-only signup (admin-issued
site invites or room invites, both delivered by email), password reset,
per-user light/dark/midnight/sunset presets plus a live theme builder for
fully custom, named, savable color themes.
- **Rooms** — open and private rooms, owner/admin/member roles, invites,
room browsing/search, file/image galleries per room.
- **Auth & accounts** — server-side, revocable sessions (see every device
you're logged in from and sign one out remotely), invite-only signup
(admin-issued site invites or room invites, both delivered by email),
password reset, per-user light/dark/midnight/sunset presets plus a live
theme builder for fully custom, named, savable color themes.
- **Rooms & direct messages** — open and private rooms, owner/admin/member
roles, invites (with resend), room browsing/search, file/image galleries
per room, plus 1:1 direct messages with a collapsible sidebar section.
- **Real-time chat** — WebSocket-based messaging with automatic reconnect
and backoff, Markdown rendering, @mentions with autocomplete and inline
highlighting, emoji reactions, message editing, image and file
attachments (drag-and-drop, paste, or picker) with inline previews for
images/PDFs/text/Markdown, unread indicators, and presence (online/away/
and backoff, Markdown rendering (headings with custom anchors, sub/
superscript, tables, and more), @mentions and #room-reference links with
autocomplete and inline highlighting, emoji reactions and shortcodes plus
site-wide custom/uploaded emoji, message editing and deletion, image/
video/file attachments (drag-and-drop, paste, or picker) with inline
previews and playback, unread indicators, and presence (online/away/
offline, with a manual "appear offline" override).
- **Notifications** — Web Push for offline/backgrounded members, with
per-type opt-in/out (mentions vs. all messages), plus in-app unread
badges.
- **Notifications** — Web Push and native desktop notifications for
offline/backgrounded members, plus email: always-on for direct messages,
opt-in per room (first unread message and every mention), all alongside
in-app unread badges.
- **PWA** — installable, offline-capable (cached room/message data, a
dedicated offline banner), with automatic update detection that prompts
a reload as soon as a new deploy goes live.
+59 -9
View File
@@ -7,15 +7,17 @@ attachments, notifications, and personalizing your account.
- [Getting started](#getting-started)
- [Rooms](#rooms)
- [Direct messages](#direct-messages)
- [Sending messages](#sending-messages)
- [Formatting](#formatting)
- [Mentions and room links](#mentions-and-room-links)
- [Attachments](#attachments)
- [Reactions](#reactions)
- [Editing a message](#editing-a-message)
- [Reactions and custom emoji](#reactions-and-custom-emoji)
- [Editing and deleting a message](#editing-and-deleting-a-message)
- [Presence and notifications](#presence-and-notifications)
- [Your profile](#your-profile)
- [Room details](#room-details)
- [Active sessions](#active-sessions)
- [Staying up to date](#staying-up-to-date)
## Getting started
@@ -45,6 +47,21 @@ search box at the top to filter by name.
- **Unread indicators**: a room with new activity shows a dot next to its
name — a plain dot for unread messages, a highlighted dot if you were
specifically @mentioned.
- **Collapsible sections**: the **Direct Messages** and **Rooms** headers in
the sidebar can be collapsed to hide their contents — click the header to
toggle. Your choice is remembered.
## Direct messages
Click **People** to see everyone on the site and start a 1:1 conversation
with someone — it opens (or reopens, if you've messaged them before)
under **Direct Messages** in the sidebar. There's only ever one
conversation per pair of people, however many times you start it.
You can hide a conversation you're done with (its own menu, or from its
**Room details** panel) without deleting anything — it drops out of your
sidebar but reappears automatically the moment the other person sends a
new message, or if you message them again yourself.
## Sending messages
@@ -61,7 +78,9 @@ Messages support Markdown:
- `-` or `1.` for bulleted/numbered lists
- `[link text](https://example.com)` — or just paste a bare URL and it
becomes clickable automatically
- `#`, `##`, `###` for headings
- `#`, `##`, `###` for headings — add `{#custom-id}` at the end of a
heading line to control its link anchor instead of the auto-generated one
- `~sub~` and `^sup^` for subscript and superscript
Pasting a link on its own often also generates a preview card underneath
your message, pulled from that page's title/description/image, when the
@@ -69,7 +88,9 @@ page provides one.
Emoji: click the 🙂 button in the composer to open the emoji picker, or
type a shortcode like `:tada:` and it's converted automatically once you
send. The picker remembers your recently-used emoji and has a search box.
send. The picker remembers your recently-used emoji and has a search box
see [Reactions and custom emoji](#reactions-and-custom-emoji) for uploading
your own.
## Mentions and room links
@@ -87,26 +108,39 @@ can see the message (and is a member of that room) straight to it.
Click the paperclip icon to attach a file, or just drag a file onto the
message box and drop it. Images show as an inline thumbnail — click one
to view it full-size. Other files show as a small card with the
filename and size; `.txt`, `.md`, and `.pdf` files open in a preview
without leaving the room, everything else downloads when clicked.
to view it full-size. Common video formats (MP4, WebM, Ogg) play inline
too, with a button to expand to a larger view; other files show as a
small card with the filename and size — `.txt`, `.md`, and `.pdf` files
open in a preview without leaving the room, everything else downloads
when clicked.
There's a server-configured maximum file size — if a file is too large,
you'll see an error before it uploads.
## Reactions
## Reactions and custom emoji
Hover over a message and click the 🙂 icon in its action row to react
with an emoji. Reactions from everyone appear as small pills under the
message with a count; click an existing pill to add or remove your own
reaction to it. Hovering a pill shows who reacted.
## Editing a message
Anyone can add a custom emoji: open the emoji picker (the 🙂 button, either
in the composer or on a message) and click **+ Add** in the **Custom**
section. Give it a short name and an image — it's then usable by everyone,
both as a reaction and inline in message text via `:your-name:`, right
alongside the built-in picker. You can remove a custom emoji you uploaded
(or any of them, if you're a site admin) from the same picker.
## Editing and deleting a message
You can edit any message you sent: hover it and click **Edit**, make your
changes, then press **Enter** to save or **Escape** to cancel. Clicking
away also saves. Edited messages are marked *(edited)*.
To delete a message you sent, hover it and click **Delete**. It's replaced
with a "message deleted" placeholder rather than disappearing outright, so
the conversation doesn't visibly shift for anyone else reading it.
## Presence and notifications
Everyone's avatar shows a small status dot — green for online, grey for
@@ -124,6 +158,12 @@ The same menu has a notifications toggle:
get a notification whenever the app is minimized *or* simply not the
focused window, even if it's still open somewhere on screen.
You'll also get an email if someone messages you in a direct conversation
while you're genuinely offline — no setup needed. For regular rooms,
email is opt-in per room: open a room's **Room details** panel and turn
on **Email notifications** to get emailed on that room's first unread
message and on every `@mention`, while you're offline.
## Your profile
Open your account menu and choose **Profile settings** to:
@@ -135,12 +175,22 @@ Open your account menu and choose **Profile settings** to:
live)
- Change your password
## Active sessions
Profile settings also lists **Active sessions** — every device/browser
currently logged into your account, with its approximate location (IP
address) and when it was last active. If you see one you don't
recognize, click **Revoke** to sign it out immediately. Revoking your own
current device signs you out too.
## Room details
Click the info icon in a room's header to open its details panel, where
you can:
- See who else is in the room and their role (member/admin/owner)
- Turn on **Email notifications** for that room (see
[Presence and notifications](#presence-and-notifications))
- Browse and re-download every file and image ever shared in the room,
without scrolling back through history
- Leave the room — unless you're the owner, in which case ownership has
+245 -54
View File
@@ -1,18 +1,22 @@
# DS Chat backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset)
# DS Chat backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset, direct messages, active sessions)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and direct
membership management, a WebSocket chat endpoint that fans out across
multiple app-server instances via Redis pub/sub, Web Push notifications for
offline room members, a site-admin portal (user/room/bot management + an
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
access, incoming and outgoing webhooks, message editing), image uploads and
generic file attachments in chat messages, emoji reactions on messages,
self-service user profiles
(display name, avatar), self-service password change and a token-based
forgot-password flow, and admin-issued email invites for new accounts
plus email notifications when a user is added to a room. See
`../ARCHITECTURE.md` for the full system design and the phased build plan.
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth
(server-side, revocable sessions — see Active sessions below), room CRUD
(open and private), room roles (owner/admin/member) and direct membership
management, direct messages, a WebSocket chat endpoint that fans out across
multiple app-server instances via Redis pub/sub, Web Push and email
notifications for offline room members (plus a native desktop-notification
bridge for DS Chat Desktop), a site-admin portal (user/room/bot management +
an audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
access, incoming and outgoing webhooks, message editing), image uploads,
inline-playable video attachments, and generic file attachments in chat
messages, message deletion, emoji reactions (built-in Unicode plus
site-wide custom/uploaded emoji, both usable in reactions and inline in
message text), self-service user profiles (display name, avatar),
self-service password change and a token-based forgot-password flow, and
admin-issued email invites for new accounts plus email notifications when a
user is added to a room. See `../ARCHITECTURE.md` for the full system
design and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below.
@@ -129,10 +133,14 @@ app/
on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions,
messages, message_images, message_files,
message_reactions, message_mentions,
message_room_references, link_previews,
custom_emoji, custom_themes, sessions,
site_invites, password_resets, smtp_settings,
push_subscriptions, admin_audit_log, api_tokens,
webhooks_incoming, event_subscriptions)
upload_settings, push_subscriptions,
admin_audit_log, api_tokens, webhooks_incoming,
event_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, users, signup, push, admin,
bots, webhooks, health
@@ -345,6 +353,36 @@ No `User`/`PushSubscription` schema change was needed for this feature —
the only backend change is the new `desktop_notification` envelope type,
covered by `backend/tests/test_desktop_notifications.py`.
## Email notifications for missed messages
Two related, separately-scoped email triggers layered on top of Web Push/
desktop notifications, both in `app/services/message_events.py`:
**DMs** (`_maybe_email_dm_notification`) — always on, no opt-in toggle.
Emails a DM's other participant when they're *genuinely* offline
(`GlobalPresence.is_online`, not just "not connected to this room's own
channel" the way the Web Push/desktop-notification audience is computed —
someone actively using the app in a different room shouldn't get emailed
for a DM) or have `appear_offline` set. Debounced to the first unread
message in the conversation, not one email per message in a burst, by
checking whether any other unread message already exists in the room
since the recipient's `last_read_at`.
**Regular rooms** (`_maybe_email_room_notifications`) — opt-in per
member, per room (`RoomMembership.email_notifications`, toggled via `PATCH
/api/rooms/{id}/notifications`; rejected for a DM — `CannotModifyDmError`
→ 400 — since DMs already get the always-on behavior above). Two triggers,
not one: the room's first unread message debounces the same way DMs do,
*but* a message that `@mentions` the subscriber always emails regardless
of that debounce — a mention is a stronger, individually-addressed signal
that shouldn't get silently absorbed by an earlier plain message in the
same burst already having used up the "first unread" email.
Both paths go through the same `email_service.send_email` used by
invites/room-membership notifications above, so they're silently skipped
if SMTP isn't configured and never block message delivery on an SMTP
outage.
## Room roles and membership (Phase 2)
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
@@ -365,6 +403,47 @@ since nothing meaningful was gained by making the target confirm first.
`GET /api/rooms/mine` lists every room (open + private) the current user
belongs to, alongside their role.
## Direct messages
A DM is a `Room` with `is_dm=True` and a deterministic, never-shown
internal `name` (`dm_room_name(user_a, user_b)` — the two user ids sorted
and joined, so it's the same string regardless of who initiates), not a
separate model — `POST /api/rooms/dm` (`find_or_create_dm`) looks up an
existing DM by that name and creates one (`is_private=True`, both
participants as plain `member`) only if none exists yet, so starting a DM
with the same person twice always resolves to the one conversation. The
frontend never renders `Room.name` for a DM; `MyRoomItem.dm_partner`
(`DmPartnerInfo`: the *other* participant's id/username/display
name/avatar/status) is precomputed server-side instead, batched per
request rather than N+1.
**Hiding a DM**: `RoomMembership.hidden_at` lets one participant remove a
DM from their own sidebar without touching the other participant's copy or
deleting anything — a DM has no sensible "leave" (it would violate
`find_or_create_dm`'s exactly-two-members assumption). `POST
/api/rooms/{id}/hide` sets it; it's cleared automatically (un-hiding the
DM) whenever a new message arrives in it or `find_or_create_dm` resolves
back to an already-hidden one — both count as the conversation being
active again, matching how a re-opened DM in Slack/Discord reappears on
its own rather than needing an explicit "unhide."
## Message deletion
`DELETE`-shaped over WS (`{"type": "delete", "room_id", "message_id"}`,
`message_service.delete_message`) — author-only (`NotMessageAuthorError`
error frame otherwise, no admin/moderator override yet). A real delete of
content, not a UI-only hide: `content`, `image_id`, `file_id`, and
`preview_url` are all cleared and any attached `MessageImage`/`MessageFile`
row (plus its on-disk file) is actually removed, only `deleted_at` (and
`id`/`room_id`/`user_id`/`created_at`, so the tombstone still occupies its
place in history) survives. The attachment's storage filename is read and
the DB row/file only unlinked *after* a successful commit — same ordering
`delete_room` already uses, so a rolled-back transaction never leaves an
already-destroyed file with no way back. Broadcasts
`{"type": "message_deleted", "id", "room_id"}`; the frontend renders a
"message deleted" placeholder rather than removing the row, so the
conversation doesn't visibly shift when someone deletes something above.
## Image uploads
A message can carry an image (`Message.image_id`, nullable), a caption
@@ -432,6 +511,18 @@ share them; `ImageTooLargeError` was likewise renamed to
Same orphaned-upload disk-space caveat as images applies here too.
**Inline video playback**: a browser-natively-playable video attachment
(`INLINE_SAFE_VIDEO_CONTENT_TYPES` in `app/storage.py` — a strict allowlist,
`video/mp4`/`video/webm`/`video/ogg`, deliberately not "every `video/*`
type") is served *without* the `filename=` param above, so it plays inline
in a `<video>` tag instead of forcing a download — the same reasoning
`MessageImage`'s own always-inline endpoint already relies on: these are
content types a browser only ever interprets as media, never as something
that could execute script, so the `Content-Disposition: attachment`
mitigation doesn't need to apply to them. Anything outside that allowlist
(e.g. `.mov`/`video/quicktime`) still forces a download like any other
file.
## Upload size limits
The 8 MB image/file/avatar cap is no longer hardcoded — it's an
@@ -462,14 +553,17 @@ returning `None`.
## Emoji & reactions
An emoji picker in the frontend composer is purely client-side (a static
curated unicode list, no backend involvement). Message **reactions** are
full-stack: `message_reactions` (`app/models/message_reaction.py`) has
The built-in emoji picker in the frontend composer is purely client-side (a
static curated unicode list, no backend involvement). Message **reactions**
are full-stack: `message_reactions` (`app/models/message_reaction.py`) has
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
backing toggle semantics — the same user reacting with the same emoji on
the same message twice removes it (Slack/Mattermost convention).
`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
no upsert needed.
no upsert needed. `emoji` is `String(32)`, sized to hold either a raw
unicode glyph or a custom emoji's `:shortcode:` reference (see Custom emoji
below) — the WS reaction envelope's own length check matches this exactly,
not an arbitrary smaller cap.
WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
reaction; the server broadcasts the message's **full recomputed** reaction
@@ -482,8 +576,42 @@ reload doesn't lose reaction state that only ever arrived over WS.
Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
in `webhook_service.py` is unchanged — same restraint as image uploads), no
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
only, curated client-side list in `frontend/src/lib/emoji.ts`).
reaction-count limit or rate limiting.
## Custom emoji
Site-wide (not room-scoped), uploadable by any authenticated user —
distinct from the built-in Unicode picker above. `CustomEmoji`
(`app/models/custom_emoji.py`): `shortcode` (unique, 30 chars max — sized
so a `:shortcode:` reference fits `MessageReaction.emoji`'s column
alongside its own colons with zero width change), `storage_filename`,
`content_type`, `uploaded_by`.
- `POST /api/custom-emoji` (multipart: `shortcode` form field + `file`) —
reuses `app/storage.py`'s upload primitives (`read_capped`,
`process_image(..., square=True, max_dimension=128)`, `save_file`), same
pattern as avatars. Shortcode format (`^[a-z0-9_-]{2,30}$`) and
uniqueness are checked *before* processing/saving the image, so a
rejected upload never orphans a file on disk.
- `GET /api/custom-emoji` — full list, any authenticated user.
- `DELETE /api/custom-emoji/{id}` — the uploader or a site admin only
(`NotEmojiOwnerError` → 403 otherwise).
- `GET /api/custom-emoji/{shortcode}/image` — serves the file,
`Cache-Control: private, no-cache` (not `immutable`, and deliberately
*not* a long `max-age` either — a shortcode can be deleted and
re-uploaded with different image data under the same URL, and a timed
cache let a browser keep serving the old image for its full duration
after that happened; `no-cache` 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).
A `:shortcode:` reference is stored/sent as literal text everywhere (message
content, reaction values) and resolved to an image only at render time on
the frontend — the same convention the built-in Unicode shortcode
autocomplete already used for glyphs, extended to a case with no unicode
codepoint to substitute. No server-side collision check against the ~950
built-in shortcode names (that list only exists in the frontend); the
upload UI warns about a colliding name but doesn't hard-block it.
## User profiles
@@ -530,15 +658,21 @@ the Admin portal; being added directly to a room (see Room roles and
membership above) sends a "you've been added" email too.
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
`send_email(db, to, subject, body)` is the fire-and-forget path used by
invite flows — if `SmtpSettings` isn't configured yet it logs at debug and
returns (same "silently skip if unconfigured" UX push notifications already
use for a missing VAPID key), and it never raises on delivery failure (an
SMTP outage must not block an invite/membership action that already
succeeded in the database). `send_test_email(db, to)` is the one exception —
used only by the admin "send test email" button, it raises so the UI can
show *why* it failed instead of a silent no-op. Plain-text bodies only, no
HTML templates, matching this codebase's existing minimalism.
`send_email(db, to, subject, paragraphs, *, cta_label=None, cta_url=None,
theme_user=None)` is the fire-and-forget path used by invite/notification
flows — if `SmtpSettings` isn't configured yet it logs (at `.warning`, not
`.debug` — this app has no logging config lowering the root level below
Python's own `WARNING` default, so anything below that is silently
invisible in production) and returns, and it never raises on delivery
failure (an SMTP outage must not block an invite/membership/notification
action that already succeeded in the database). `send_test_email(db, to)`
is the one exception — used only by the admin "send test email" button, it
raises so the UI can show *why* it failed instead of a silent no-op.
`paragraphs` (a `list[str]`, not a flat `body: str`) renders both an HTML
email — styled with `theme_user`'s own selected theme palette when given,
falling back to the default palette — and a plain-text fallback part from
the same source, rather than a single pre-formatted string that can't
cleanly become HTML without re-parsing it.
**SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s
`/settings/smtp` endpoints) lives in the database, not the env file — the
@@ -558,19 +692,25 @@ tokens use — it's a bearer secret looked up by itself). `POST /api/signup`
(`app/routers/signup.py`) is the first genuinely public,
unauthenticated endpoint in this app that creates a `User` row — it calls
the existing `auth_service.register_user` directly for identical
hashing/uniqueness handling, and logs the new user in immediately (same
session-cookie line `auth.py`'s `login()` uses) so they land in the app
already signed in. No new rate limiting on it — the unguessable, single-use,
expiring token is the actual protection, inheriting the same "no rate
limiting on human/bot traffic" gap already documented below, not a new one.
hashing/uniqueness handling, then `session_service.start_session` (see
Active sessions below) so they land in the app already signed in. No new
rate limiting on it — the unguessable, single-use, expiring token is the
actual protection, inheriting the same "no rate limiting on human/bot
traffic" gap already documented below, not a new one.
`POST /api/admin/invites/{id}/resend` (site-admin only,
`resend_site_invite`) issues a fresh token and resets the 7-day expiry
rather than re-sending the original link — the old link stops working the
moment this runs, and it means resending something close to expiring buys
the full week again, not just whatever was left. Only valid for a still-
`pending` invite (`SiteInviteNotPendingError` otherwise).
**Room-membership email**: `room_service.add_member` sends one email to
the target user after creating the `RoomMembership`, using the live
request's `base_url` for the link — no new "public URL" config needed.
Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers
it), no HTML email templates.
uploads/reactions).
## Self-service password change and reset
@@ -581,10 +721,11 @@ password, and a "forgot password" flow for someone locked out.
`current_password` + `new_password`; verifies the current one with
`security.verify_password` before setting `password_hash =
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
(profile update): mutate `current_user`, commit, done. No session
invalidation elsewhere (there's no server-side session table to invalidate
against — see Notes below), so other logged-in sessions for that account
stay valid until they expire naturally.
(profile update): mutate `current_user`, commit, done. Doesn't proactively
revoke any other logged-in session for that account — a session table now
exists (see Active sessions below), but changing your password doesn't
walk it and revoke everything else; if you suspect a specific device, use
Active sessions to revoke it directly instead.
**Forgot password** (`app/models/password_reset.py`,
`app/services/password_service.py`) — same hashed-token-with-expiry shape as
@@ -597,8 +738,8 @@ registered, so a miss is a silent no-op (no row created, no email sent) after
a single `SELECT`. `GET /api/auth/reset-password/validate` lets the frontend
show a "this link is invalid" state before rendering the password form.
`POST /api/auth/reset-password` completes it and — like signup — logs the
user in immediately (`request.session["user_id"]`), since they've just proven
they control the account's email.
user in immediately (`session_service.start_session`, see Active sessions
below), since they've just proven they control the account's email.
Scope cuts: no rate limiting on `/forgot-password` (inherits the same
documented gap as every other endpoint below, not a new one — the
@@ -606,6 +747,50 @@ unguessable expiring token is the actual protection once a request is made),
no cleanup job for expired/used `password_resets` rows (same as
`site_invites`, which has never had one either).
## Active sessions
Replaces the previously-stateless signed cookie (a bare `user_id`) with a
real server-side `Session` table (`app/models/session.py`) — the cookie
now only ever carries an opaque session id, resolved against this table
via `session_service.resolve_session` on *every* request (`get_current_user`
in `app/dependencies.py`, and the WS handshake in `app/ws/chat.py`), which
is the single choke point that makes revocation actually take effect on a
session's very next request rather than only once its cookie happens to
expire.
Each row records `ip_address` (`X-Forwarded-For`'s first entry, since
production sits behind Nginx Proxy Manager — falls back to the direct peer
address with nothing in front locally), `user_agent`, `created_at`, and a
throttled `last_seen_at` (only bumped if stale by more than 5 minutes —
`get_current_user` resolves a session on every authenticated request, so
writing on every single one would turn a read into a write storm for no
real benefit). `session_service.start_session` is the one place every
"log this browser in" call site (login, signup completion, password-reset
completion) creates the row and stashes its id in the cookie.
- `GET /api/auth/sessions` — every non-revoked session for the current
user, newest-last-seen first, with a parsed "Browser on OS" label
(`app/services/user_agent_service.py` — plain substring checks against
the User-Agent header, no new dependency; special-cases an `Electron/`
token as "DS Chat Desktop" rather than the underlying Chromium version)
and `is_current` (compares against `request.state.session_id`, set by
`get_current_user`) so the frontend can label "this device" and treat
revoking it as a self-logout.
- `DELETE /api/auth/sessions/{id}` — any of the current user's own
sessions, including their own current one (a remote sign-out of the
same device is a legitimate thing to do); 404 if it belongs to someone
else or is already revoked.
- `POST /api/auth/logout` also revokes the session row, not just clears
the cookie (`revoke_session_unchecked` — no ownership check needed,
since a session can only ever log itself out, and never fails even if
the row is already gone).
Scope cuts: changing your password doesn't proactively revoke other
sessions (see Self-service password change and reset above) — this is a
deliberate scope boundary, not an oversight, since it's a meaningfully
different feature (auto-revoke-everywhere-on-password-change) from
"let a user see and manually revoke what's logged in."
## Link previews
Slack/Discord-style unfurling: the first `http(s)://` URL found in a
@@ -637,10 +822,13 @@ preview card fetched from that page's Open Graph tags (`og:title`,
`Content-Type` is `text/html`.
- **Cached by URL, not by message** (`link_previews` table, unique on
`url`) — a URL posted by five different people in five different rooms
fetches once. A row also gets written on a *failed* fetch
(`fetch_failed=True`) so a URL that genuinely doesn't unfurl (SSRF
rejection, timeout, no usable title) isn't re-attempted on every message
that references it; both kinds expire after 7 days (`_CACHE_TTL`).
within the same short window fetches once. A row also gets written on a
*failed* fetch (`fetch_failed=True`) so a URL that genuinely doesn't
unfurl (SSRF rejection, timeout, no usable title) isn't re-attempted on
every message that references it; both kinds expire after 5 minutes
(`_CACHE_TTL`#70: was 7 days, confirmed live as far too long, a
re-posted URL whose title/content had genuinely changed kept showing
the stale first-fetch preview for up to a week).
- Parsed with stdlib `html.parser.HTMLParser`, not a new dependency — only
meta-tag scraping is needed, not general HTML parsing.
- Editing a message re-extracts the URL; if it changed or was removed, the
@@ -656,13 +844,16 @@ preview card fetched from that page's Open Graph tags (`og:title`,
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
provisioned with `python -m app.cli create-user` (see step 4 above), or via
a site invite (see Site invites & email below). This is separate from
a site invite (see Site invites & email above). This is separate from
adding an existing user to a private room — site accounts vs. room
membership.
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
through a WebSocket handshake). This means there's currently no way to force-
revoke a session server-side; that needs a real session table later.
- Sessions are backed by a real server-side table (`app/models/session.py`,
see Active sessions above) — the signed cookie (Starlette
`SessionMiddleware`) now only ever carries an opaque session id, resolved
against that table on every request, which is what makes revocation
possible. Carrying auth through the WebSocket handshake automatically is
still why it's cookie-based at all, per `ARCHITECTURE.md`'s original
rationale.
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
- Deleting a room explicitly deletes its messages/memberships first
@@ -0,0 +1,32 @@
"""room email notifications opt-in
Revision ID: 05b28b0a2261
Revises: e2fc4d65f93e
Create Date: 2026-08-28 19:34:23.507200
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '05b28b0a2261'
down_revision: Union[str, Sequence[str], None] = 'e2fc4d65f93e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('room_memberships', sa.Column('email_notifications', sa.Boolean(), server_default='false', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('room_memberships', 'email_notifications')
# ### end Alembic commands ###
@@ -0,0 +1,44 @@
"""add sessions table for active-sessions feature
Revision ID: 319c30e24cd9
Revises: 05b28b0a2261
Create Date: 2026-08-28 19:59:51.475384
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '319c30e24cd9'
down_revision: Union[str, Sequence[str], None] = '05b28b0a2261'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sessions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
op.drop_table('sessions')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add user text_scale preference
Revision ID: 339b78011a4f
Revises: a318850726ee
Create Date: 2026-08-30 18:03:16.159924
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '339b78011a4f'
down_revision: Union[str, Sequence[str], None] = 'a318850726ee'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('text_scale', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'text_scale')
# ### end Alembic commands ###
@@ -0,0 +1,35 @@
"""room is_dm flag for direct messages
Revision ID: 9ca717f837c2
Revises: 9484fbd1cb3a
Create Date: 2026-08-19 15:50:25.781735
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9ca717f837c2'
down_revision: Union[str, Sequence[str], None] = '9484fbd1cb3a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# server_default backfills existing rows; dropped right after since the
# model itself only sets a Python-side default (see is_archived above).
op.add_column(
'rooms', sa.Column('is_dm', sa.Boolean(), nullable=False, server_default=sa.false())
)
op.alter_column('rooms', 'is_dm', server_default=None)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rooms', 'is_dm')
# ### end Alembic commands ###
@@ -0,0 +1,43 @@
"""add custom emoji
Revision ID: a318850726ee
Revises: 319c30e24cd9
Create Date: 2026-08-28 20:34:27.293658
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a318850726ee'
down_revision: Union[str, Sequence[str], None] = '319c30e24cd9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('custom_emoji',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('shortcode', sa.String(length=30), nullable=False),
sa.Column('storage_filename', sa.String(length=64), nullable=False),
sa.Column('content_type', sa.String(length=50), nullable=False),
sa.Column('uploaded_by', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_custom_emoji_shortcode'), 'custom_emoji', ['shortcode'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_custom_emoji_shortcode'), table_name='custom_emoji')
op.drop_table('custom_emoji')
# ### end Alembic commands ###
@@ -0,0 +1,39 @@
"""allow deleted messages to clear content and attachments
Revision ID: e2fc4d65f93e
Revises: f3f255da9c96
Create Date: 2026-08-28 16:20:02.114630
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e2fc4d65f93e'
down_revision: Union[str, Sequence[str], None] = 'f3f255da9c96'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.drop_constraint('messages_content_or_attachment_required', 'messages', type_='check')
op.create_check_constraint(
'messages_content_or_attachment_required',
'messages',
'content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL '
'OR deleted_at IS NOT NULL',
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_constraint('messages_content_or_attachment_required', 'messages', type_='check')
op.create_check_constraint(
'messages_content_or_attachment_required',
'messages',
'content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL',
)
@@ -0,0 +1,32 @@
"""add user emoji_scale preference
Revision ID: e81c9bcc82b9
Revises: 339b78011a4f
Create Date: 2026-08-30 18:14:26.045186
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e81c9bcc82b9'
down_revision: Union[str, Sequence[str], None] = '339b78011a4f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('emoji_scale', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'emoji_scale')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""hide DM conversations per-participant
Revision ID: f3f255da9c96
Revises: 9ca717f837c2
Create Date: 2026-08-19 16:36:28.332581
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f3f255da9c96'
down_revision: Union[str, Sequence[str], None] = '9ca717f837c2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('room_memberships', sa.Column('hidden_at', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('room_memberships', 'hidden_at')
# ### end Alembic commands ###
+14 -3
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import RoomMembership, RoomRole, User
from app.services.bot_service import resolve_token
from app.services.session_service import resolve_session
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
@@ -29,16 +30,26 @@ async def get_current_user(
request.state.api_token = token
return user
user_id = request.session.get("user_id")
if not user_id:
session_id = request.session.get("session_id")
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
# #69: a session row, not a bare user_id -- resolve_session is also
# where a revoked session (this endpoint's own DELETE, or another
# device's "sign out") actually takes effect, since there's no other
# per-request check of that state.
session = await resolve_session(db, uuid.UUID(session_id))
if session is None:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
request.state.session_id = session.id
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
# be read without a MissingGreenlet -- selectinload skips the second
# query entirely when active_custom_theme_id is null (the common case),
# so this costs nothing for users who've never set a custom theme.
user = await db.get(
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
User, session.user_id, options=[selectinload(User.active_custom_theme)]
)
if user is None or not user.is_active:
request.session.clear()
+4
View File
@@ -15,6 +15,7 @@ from app.routers import (
admin,
auth,
bots,
custom_emoji,
custom_themes,
health,
push,
@@ -27,6 +28,7 @@ from app.routers import (
from app.ws.broadcaster import Broadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence
@@ -82,6 +84,7 @@ def create_app() -> FastAPI:
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
app.state.presence = Presence(app.state.redis)
app.state.global_presence = GlobalPresence(app.state.redis)
app.state.focus_presence = FocusPresence(app.state.redis)
app.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager)
app.include_router(health.router)
@@ -91,6 +94,7 @@ def create_app() -> FastAPI:
app.include_router(users.router)
app.include_router(push.router)
app.include_router(custom_themes.router)
app.include_router(custom_emoji.router)
app.include_router(uploads.router)
app.include_router(admin.router)
app.include_router(bots.router)
+4
View File
@@ -1,6 +1,7 @@
from app.models.admin_audit_log import AdminAuditLog
from app.models.api_token import ApiToken
from app.models.base import Base
from app.models.custom_emoji import CustomEmoji
from app.models.custom_theme import CustomTheme
from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus
@@ -15,6 +16,7 @@ from app.models.message_room_reference import MessageRoomReference
from app.models.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.session import Session
from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings
from app.models.upload_settings import UploadSettings
@@ -35,6 +37,7 @@ __all__ = [
"MessageRoomReference",
"InviteStatus",
"PasswordReset",
"Session",
"SiteInvite",
"SmtpSettings",
"UploadSettings",
@@ -44,5 +47,6 @@ __all__ = [
"WebhookIncoming",
"EventSubscription",
"CustomTheme",
"CustomEmoji",
"LinkPreview",
]
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class CustomEmoji(Base):
__tablename__ = "custom_emoji"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
# #18: site-wide, not room-scoped -- kept globally unique so a bare
# `:shortcode:` in any message/reaction is unambiguous without also
# knowing which room it was posted in. 30 chars, not 32 -- the stored
# *reference* in MessageReaction.emoji (String(32)) is the shortcode
# wrapped in colons, so this is sized to leave room for both without
# widening that column.
shortcode: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False)
storage_filename: Mapped[str] = mapped_column(String(64), nullable=False)
content_type: Mapped[str] = mapped_column(String(50), nullable=False)
uploaded_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
uploader = relationship("User")
+18 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
@@ -33,6 +33,23 @@ class RoomMembership(Base):
last_read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# #52 follow-up: lets a DM be hidden from one participant's own sidebar
# without touching the other participant's copy or deleting anything --
# a DM has no sensible "leave" (it would corrupt find_or_create_dm's
# exactly-two-members assumption), so this is deliberately a per-viewer
# display flag on their own membership row, not a membership deletion.
# Cleared automatically (see message_events.py) whenever a new message
# arrives in the room, or when find_or_create_dm resolves back to it --
# both count as the conversation being active again.
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# #67: opt-in, per-member -- email while offline on the room's first
# unread message, plus every mention regardless of that debounce (see
# message_events.py's _maybe_email_room_notifications). Deliberately
# separate from #66's DM emails (always-on, no toggle) rather than a
# shared flag, since DMs are explicitly out of scope for this setting.
email_notifications: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", nullable=False
)
room = relationship("Room", back_populates="memberships")
user = relationship("User")
+5 -1
View File
@@ -10,8 +10,12 @@ from app.models.base import Base
class Message(Base):
__tablename__ = "messages"
__table_args__ = (
# #53: a deleted message clears content/image_id/file_id entirely
# (see message_service.delete_message) -- the "must have something"
# rule only applies while the message is actually live.
CheckConstraint(
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL",
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL "
"OR deleted_at IS NOT NULL",
name="messages_content_or_attachment_required",
),
)
+4
View File
@@ -15,6 +15,10 @@ class Room(Base):
description: Mapped[str | None] = mapped_column(Text)
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# #52: a DM is a Room whose `name` is an internal, never-displayed
# deterministic token (see room_service.dm_room_name) rather than a
# user-chosen name -- see that function's docstring for the scheme.
is_dm: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
+43
View File
@@ -0,0 +1,43 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Session(Base):
__tablename__ = "sessions"
# #69: the row's own id doubles as the opaque value stored in the
# signed session cookie (see app/dependencies.py) -- no separate
# generate_token()/hash_token() pair like ApiToken needs. A bearer API
# token has to be looked up *by itself* from a plaintext string a bot
# pastes into an Authorization header (real leak risk, hence hashing
# it at rest); this id only ever travels inside itsdangerous's signed,
# tamper-proof cookie payload, so a plain UUID primary key carries the
# same security properties the stateless cookie already had before
# this table existed.
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
# 45 chars fits the longest possible IPv6 text representation.
ip_address: Mapped[str | None] = mapped_column(String(45))
user_agent: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Bumped (throttled, not on every request -- see session_service.py) so
# "active sessions" can be sorted/labeled by actual recent use, not just
# login time -- a session opened once a week ago and used constantly
# since should not look identical to one opened once and abandoned.
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Null while active. Set on explicit logout or a deliberate "sign out
# this device" from another session -- never deleted outright, so a
# revoked row still means something if anyone ever needs to ask "was
# this session valid at time X."
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user = relationship("User")
+11
View File
@@ -19,6 +19,17 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20))
# #71: null means "normal" (the pre-existing default before this
# setting existed) -- a preset name, not a raw scale factor, so it's
# validated/enumerable the same way `theme` already is rather than
# accepting an arbitrary float.
text_scale: Mapped[str | None] = mapped_column(String(20))
# #71: independent of text_scale above -- scales emoji rendered in
# message text specifically, not the whole UI (see
# frontend/src/components/MessageContent.tsx's --emoji-scale, scoped
# to message content only so it can't also inflate the emoji picker's
# grid or reaction pills).
emoji_scale: Mapped[str | None] = mapped_column(String(20))
# Only meaningful when theme == "custom" -- which of this user's saved
# CustomTheme rows (app/models/custom_theme.py) is currently active.
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
+18 -1
View File
@@ -38,6 +38,7 @@ from app.services.site_invite_service import (
SiteInviteNotPendingError,
create_site_invite,
list_site_invites,
resend_site_invite,
revoke_site_invite,
)
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings
@@ -322,6 +323,22 @@ async def revoke_site_invite_endpoint(
raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.post("/invites/{invite_id}/resend", response_model=SiteInviteRead)
async def resend_site_invite_endpoint(
invite_id: uuid.UUID,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await resend_site_invite(db, current_user, str(request.base_url), invite_id)
except SiteInviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except SiteInviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
async def get_smtp_settings_endpoint(
current_user: User = Depends(get_current_user),
@@ -376,7 +393,7 @@ async def test_smtp_settings_endpoint(
):
require_site_admin(current_user)
try:
await send_test_email(db, current_user.email)
await send_test_email(db, current_user.email, theme_user=current_user)
except SmtpNotConfiguredError:
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
except Exception as exc:
+53 -3
View File
@@ -1,3 +1,5 @@
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
from app.models import User
from app.schemas.auth import LoginRequest
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
from app.schemas.session import SessionRead
from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import (
AccountDeactivatedError,
@@ -22,7 +25,15 @@ from app.services.password_service import (
request_password_reset,
validate_reset_token,
)
from app.services.session_service import (
SessionNotFoundError,
list_sessions,
revoke_session,
revoke_session_unchecked,
start_session,
)
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.user_agent_service import describe_user_agent
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
InvalidImageError,
@@ -55,12 +66,15 @@ async def login(
except AccountDeactivatedError:
raise HTTPException(status_code=401, detail="Account is deactivated")
request.session["user_id"] = str(user.id)
await start_session(request, db, user.id)
return user
@router.post("/logout", status_code=204)
async def logout(request: Request) -> Response:
async def logout(request: Request, db: AsyncSession = Depends(get_db)) -> Response:
session_id = request.session.get("session_id")
if session_id:
await revoke_session_unchecked(db, uuid.UUID(session_id))
request.session.clear()
return Response(status_code=204)
@@ -86,6 +100,10 @@ async def update_profile(
current_user.display_name = display_name or None
if "theme" in updates:
current_user.theme = updates["theme"]
if "text_scale" in updates:
current_user.text_scale = updates["text_scale"]
if "emoji_scale" in updates:
current_user.emoji_scale = updates["emoji_scale"]
if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"]
await db.commit()
@@ -218,5 +236,37 @@ async def complete_reset_password_endpoint(
except PasswordResetInvalidError:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
request.session["user_id"] = str(user.id)
await start_session(request, db, user.id)
return user
@router.get("/sessions", response_model=list[SessionRead])
async def list_sessions_endpoint(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
sessions = await list_sessions(db, current_user.id)
return [
SessionRead(
id=s.id,
ip_address=s.ip_address,
device_label=describe_user_agent(s.user_agent),
created_at=s.created_at,
last_seen_at=s.last_seen_at,
is_current=s.id == request.state.session_id,
)
for s in sessions
]
@router.delete("/sessions/{session_id}", status_code=204)
async def revoke_session_endpoint(
session_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await revoke_session(db, current_user.id, session_id)
except SessionNotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
+139
View File
@@ -0,0 +1,139 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.custom_emoji import CustomEmojiRead
from app.services.custom_emoji_service import (
SHORTCODE_PATTERN,
CustomEmojiNotFoundError,
DuplicateShortcodeError,
InvalidShortcodeError,
NotEmojiOwnerError,
create_custom_emoji,
delete_custom_emoji,
get_custom_emoji_by_shortcode,
list_custom_emoji,
)
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
UPLOADS_DIR,
InvalidImageError,
UploadTooLargeError,
process_image,
read_capped,
save_file,
)
# Small and square -- these render inline in message text/reaction pills at
# roughly text size, nowhere near message-image or avatar dimensions.
CUSTOM_EMOJI_MAX_DIMENSION = 128
router = APIRouter(prefix="/api/custom-emoji", tags=["custom-emoji"])
@router.post("", response_model=CustomEmojiRead, status_code=201)
async def upload_custom_emoji_endpoint(
shortcode: str = Form(...),
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
shortcode = shortcode.strip().lower()
# Checked here, before any file processing/saving, so the common
# rejection cases (bad format, name taken) never leave an orphaned
# file on disk -- create_custom_emoji below still re-checks both
# (the actual source of truth, and the only thing that closes the
# TOCTOU race on the uniqueness check).
if not SHORTCODE_PATTERN.match(shortcode):
raise HTTPException(
status_code=400,
detail="Shortcode must be 2-30 characters: lowercase letters, numbers, hyphens, underscores",
)
if await get_custom_emoji_by_shortcode(db, shortcode) is not None:
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
upload_settings = await get_upload_settings(db)
try:
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
except UploadTooLargeError:
raise HTTPException(
status_code=413,
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
)
try:
data, ext = process_image(
data, file.content_type, square=True, max_dimension=CUSTOM_EMOJI_MAX_DIMENSION
)
except InvalidImageError:
raise HTTPException(status_code=400, detail="File is not a valid image")
storage_filename = save_file(data, ext)
try:
return await create_custom_emoji(
db, current_user.id, shortcode, storage_filename, file.content_type
)
except (InvalidShortcodeError, DuplicateShortcodeError):
# Already checked above -- only reachable via the uniqueness
# check's TOCTOU race (two uploads of the same new shortcode at
# once), not the common case.
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
@router.get("", response_model=list[CustomEmojiRead])
async def list_custom_emoji_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_custom_emoji(db)
@router.delete("/{emoji_id}", status_code=204)
async def delete_custom_emoji_endpoint(
emoji_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await delete_custom_emoji(db, emoji_id, current_user)
except CustomEmojiNotFoundError:
raise HTTPException(status_code=404, detail="Custom emoji not found")
except NotEmojiOwnerError:
raise HTTPException(status_code=403, detail="Only the uploader or a site admin can remove this")
@router.get("/{shortcode}/image")
async def get_custom_emoji_image_endpoint(
shortcode: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
emoji = await get_custom_emoji_by_shortcode(db, shortcode)
if emoji is None:
raise HTTPException(status_code=404, detail="Custom emoji not found")
return FileResponse(
UPLOADS_DIR / emoji.storage_filename,
media_type=emoji.content_type,
# #18 follow-up: `max-age=300` (the avatar endpoint's own
# convention) meant a browser that had already fetched this
# shortcode's image kept serving it from cache for up to 5 minutes
# after a delete-and-reupload under the same name swapped in a
# genuinely different file underneath the same URL -- confirmed
# live, re-adding an emoji with a just-deleted shortcode showed the
# old image. `no-cache` (despite the name, still cacheable) forces
# a revalidation round trip on every use instead of trusting a
# timed cache -- FileResponse already sets ETag/Last-Modified from
# the file's own mtime+size (see Starlette's set_stat_headers), so
# an unchanged file still gets served as a cheap 304 and only an
# actually-different one (any re-upload) returns fresh bytes.
headers={"Cache-Control": "private, no-cache"},
)
+134 -3
View File
@@ -17,6 +17,7 @@ from app.schemas.message import LinkPreviewInfo, MessageFileInfo, MessageRead
from app.schemas.message_file import MessageFileCreated
from app.schemas.message_image import MessageImageCreated
from app.schemas.room import (
DmPartnerInfo,
MyRoomItem,
RoomAttachmentRead,
RoomCreate,
@@ -24,8 +25,10 @@ from app.schemas.room import (
RoomMemberAdd,
RoomMemberRead,
RoomMemberRoleUpdate,
RoomNotificationSettingsUpdate,
RoomRead,
RoomUpdate,
StartDmRequest,
TransferOwnershipRequest,
)
from app.schemas.webhook import (
@@ -36,19 +39,24 @@ from app.schemas.webhook import (
WebhookIncomingRead,
)
from app.services.link_preview_service import get_link_previews_for_urls
from app.services.message_events import broadcast_room_added
from app.services.message_events import broadcast_new_message, broadcast_room_added
from app.services.message_service import (
create_message,
get_reactions_for_messages,
list_recent_messages,
list_room_attachments,
)
from app.services.system_user_service import get_or_create_system_user
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.room_service import (
AlreadyMemberError,
CannotDmSelfError,
CannotModifyDmError,
CannotRemoveOwnerError,
DuplicateRoomError,
InsufficientRoleError,
MembershipNotFoundError,
NotADmError,
OwnerMustTransferError,
RoomIsPrivateError,
RoomNotFoundError,
@@ -57,7 +65,9 @@ from app.services.room_service import (
change_member_role,
create_room,
delete_room,
find_or_create_dm,
get_room,
hide_dm,
join_room,
leave_room,
list_member_rooms,
@@ -65,6 +75,7 @@ from app.services.room_service import (
list_room_members,
mark_room_read,
remove_member,
set_room_email_notifications,
transfer_ownership,
update_room,
)
@@ -82,6 +93,7 @@ from app.services.webhook_service import (
from app.services.ssrf import UnsafeUrlError
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
INLINE_SAFE_VIDEO_CONTENT_TYPES,
UPLOADS_DIR,
InvalidImageError,
UploadTooLargeError,
@@ -105,6 +117,28 @@ async def create_room_endpoint(
raise HTTPException(status_code=409, detail="A room with this name already exists")
@router.post("/dm", response_model=RoomRead, status_code=201)
async def start_dm_endpoint(
data: StartDmRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await find_or_create_dm(db, current_user.id, data.other_user_id)
except CannotDmSelfError:
raise HTTPException(status_code=400, detail="Cannot start a DM with yourself")
except TargetUserNotFoundError:
raise HTTPException(status_code=404, detail="No user with that ID")
# Same signal add_member sends -- without it, the other participant's
# already-open client has no way to know this DM exists until they
# reload: GET /rooms/mine is only fetched once at app mount. Sent
# unconditionally (not just on genuine creation) since re-finding an
# existing DM and refreshing their room list again is harmless.
await broadcast_room_added(request.app.state.broadcaster, data.other_user_id, room)
return room
@router.get("", response_model=list[RoomListItem])
async def list_rooms_endpoint(
current_user: User = Depends(get_current_user),
@@ -117,6 +151,8 @@ async def list_rooms_endpoint(
name=room.name,
description=room.description,
is_private=room.is_private,
is_dm=room.is_dm,
is_archived=room.is_archived,
owner_id=room.owner_id,
created_at=room.created_at,
is_member=is_member,
@@ -127,23 +163,40 @@ async def list_rooms_endpoint(
@router.get("/mine", response_model=list[MyRoomItem])
async def list_my_rooms_endpoint(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rooms = await list_member_rooms(db, current_user.id)
partner_ids = [partner.id for *_, partner in rooms if partner is not None]
online_ids = await request.app.state.global_presence.online_user_ids(partner_ids)
return [
MyRoomItem(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
is_dm=room.is_dm,
is_archived=room.is_archived,
owner_id=room.owner_id,
created_at=room.created_at,
role=role,
has_unread=has_unread,
has_mention=has_mention,
email_notifications=email_notifications,
dm_partner=(
DmPartnerInfo(
user_id=partner.id,
username=partner.username,
display_name=partner.display_name,
avatar_filename=partner.avatar_filename,
status=_member_status(partner, online_ids),
)
for room, role, has_unread, has_mention in rooms
if partner is not None
else None
),
)
for room, role, has_unread, has_mention, email_notifications, partner in rooms
]
@@ -167,6 +220,8 @@ async def update_room_endpoint(
raise HTTPException(status_code=404, detail="Room not found")
except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists")
except CannotModifyDmError:
raise HTTPException(status_code=400, detail="DMs can't be edited")
@router.delete("/{room_id}", status_code=204)
@@ -215,6 +270,24 @@ async def leave_room_endpoint(
raise HTTPException(status_code=404, detail="Not a member of this room")
@router.post("/{room_id}/hide", status_code=204)
async def hide_dm_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
try:
room = await get_room(db, room_id)
await hide_dm(db, room, current_user.id)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except NotADmError:
raise HTTPException(status_code=400, detail="Only DMs can be hidden")
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="Not a member of this room")
@router.post("/{room_id}/read", status_code=204)
async def mark_room_read_endpoint(
room_id: uuid.UUID,
@@ -225,6 +298,20 @@ async def mark_room_read_endpoint(
await mark_room_read(db, room_id, current_user.id)
@router.patch("/{room_id}/notifications", status_code=204)
async def update_room_notifications_endpoint(
room_id: uuid.UUID,
data: RoomNotificationSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
try:
await set_room_email_notifications(db, room_id, current_user.id, data.email_notifications)
except CannotModifyDmError:
raise HTTPException(status_code=400, detail="Email notifications aren't available for DMs")
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
# appear_offline always wins, regardless of actual connection -- that's
# the whole point of the override (lurking in a room undetected).
@@ -366,6 +453,7 @@ async def get_room_messages_endpoint(
reactions=reactions_by_message.get(m.id, []),
created_at=m.created_at,
edited_at=m.edited_at,
deleted_at=m.deleted_at,
)
for m in messages
]
@@ -480,12 +568,25 @@ async def get_room_file_endpoint(
message_file = await db.get(MessageFile, file_id)
if message_file is None or message_file.room_id != room_id:
raise HTTPException(status_code=404, detail="File not found")
# #65: a browser-playable video is served inline (no filename=) so a
# <video> tag can actually play it instead of triggering a download --
# gated to a strict allowlist (INLINE_SAFE_VIDEO_CONTENT_TYPES), 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, so the attachment-disposition
# mitigation below doesn't need to apply to them.
if message_file.content_type in INLINE_SAFE_VIDEO_CONTENT_TYPES:
return FileResponse(
UPLOADS_DIR / message_file.storage_filename,
media_type=message_file.content_type,
headers={"Cache-Control": "private, max-age=31536000, immutable"},
)
# `filename=` makes Starlette set Content-Disposition: attachment,
# forcing a download instead of an inline render regardless of
# content-type -- the mitigation for a same-origin-served, user-
# uploaded file (e.g. .html/.svg) executing script in this app's own
# origin if opened directly. No content-type allowlist needed on top
# of this; see backend/README.md.
# of this beyond the video carve-out above; see backend/README.md.
return FileResponse(
UPLOADS_DIR / message_file.storage_filename,
media_type=message_file.content_type,
@@ -556,7 +657,37 @@ async def add_member_endpoint(
raise HTTPException(status_code=404, detail="No user with that ID")
except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member")
# room_added first -- the new member's client needs to know this room
# exists before it can make sense of an unread_update for it, which the
# welcome message below would otherwise trigger out of order.
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
# #74: posted as the auto-provisioned System account, not the admin who
# did the adding -- "Welcome, bob!" reads as coming from the room/app
# itself, not as something the admin personally typed.
system_user = await get_or_create_system_user(db)
welcome_name = membership.user.display_name or membership.user.username
welcome_message = await create_message(
db, room.id, system_user.id, f"Welcome to #{room.name}, {welcome_name}!"
)
# Same "sending implies having seen the room" reasoning as ws/chat.py's
# own live-message path -- the admin is the one who caused this message,
# and is presumably already looking at this room's member management, so
# without this their own client would show it as unread regardless.
await mark_room_read(db, room.id, current_user.id)
await broadcast_new_message(
db,
request.app.state.broadcaster,
request.app.state.presence,
request.app.state.focus_presence,
request.app.state.global_presence,
str(request.base_url),
room.id,
welcome_message,
system_user,
)
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
return RoomMemberRead(
user_id=membership.user_id,
+2 -1
View File
@@ -5,6 +5,7 @@ from app.database import get_db
from app.schemas.site_invite import SignupComplete, SignupValidateRead
from app.schemas.user import UserRead
from app.services.auth_service import DuplicateUserError
from app.services.session_service import start_session
from app.services.site_invite_service import (
SiteInviteInvalidError,
complete_signup,
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
except DuplicateUserError:
raise HTTPException(status_code=409, detail="That username or email is already taken")
request.session["user_id"] = str(user.id)
await start_session(request, db, user.id)
return user
+16 -2
View File
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.webhook import IncomingWebhookPost
from app.services.message_events import broadcast_new_message
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
from app.services.webhook_service import RoomArchivedError, WebhookNotFoundError, post_via_webhook
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
@@ -22,7 +22,21 @@ async def incoming_webhook_endpoint(
message, room, sender = await post_via_webhook(db, token, data.content)
except WebhookNotFoundError:
raise HTTPException(status_code=404, detail="Unknown webhook")
except RoomArchivedError:
raise HTTPException(status_code=403, detail="This room has been archived and is read-only")
broadcaster = request.app.state.broadcaster
presence = request.app.state.presence
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
focus_presence = request.app.state.focus_presence
global_presence = request.app.state.global_presence
await broadcast_new_message(
db,
broadcaster,
presence,
focus_presence,
global_presence,
str(request.base_url),
room.id,
message,
sender,
)
+13
View File
@@ -0,0 +1,13 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CustomEmojiRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
shortcode: str
uploaded_by: uuid.UUID
created_at: datetime
+6
View File
@@ -44,3 +44,9 @@ class MessageRead(BaseModel):
reactions: list[ReactionSummary]
created_at: datetime
edited_at: datetime | None
# #53: null for a live message; set once deleted, at which point
# content/image_id/file/link_preview are all already cleared
# server-side (see message_service.delete_message). `reactions` isn't
# cleared server-side -- the frontend just doesn't render them once
# deleted_at is set, same as it doesn't render the rest of a tombstone.
deleted_at: datetime | None
+33
View File
@@ -26,6 +26,12 @@ class RoomRead(BaseModel):
name: str
description: str | None
is_private: bool
is_dm: bool
# #57: previously only exposed on the admin-only AdminRoom schema, so a
# member of an archived room had no way to even know it was archived --
# the flag was set server-side but had no effect on their own view of
# the room at all.
is_archived: bool
owner_id: uuid.UUID
created_at: datetime
@@ -34,6 +40,14 @@ class RoomListItem(RoomRead):
is_member: bool
class DmPartnerInfo(BaseModel):
user_id: uuid.UUID
username: str
display_name: str | None
avatar_filename: str | None
status: Literal["online", "offline"]
class MyRoomItem(RoomRead):
role: RoomRole
# Whether this room has a message newer than the caller's last_read_at --
@@ -44,6 +58,25 @@ class MyRoomItem(RoomRead):
# over has_unread in the sidebar (see RoomRow.tsx), not shown alongside
# it.
has_mention: bool
# #52: populated only when is_dm is true -- the *other* participant,
# precomputed here so the sidebar can render a DM row (their name +
# avatar, not this room's internal `name`) without a second fetch per
# row. None for a regular room.
dm_partner: DmPartnerInfo | None = None
# #67: this viewer's own opt-in for email while offline -- the room's
# first unread message plus every mention (see message_events.py's
# _maybe_email_room_notifications for the exact debounce shape). Always
# false for a DM, deliberately out of scope -- DMs already get #66's
# automatic offline email.
email_notifications: bool = False
class StartDmRequest(BaseModel):
other_user_id: uuid.UUID
class RoomNotificationSettingsUpdate(BaseModel):
email_notifications: bool
class RoomMemberRead(BaseModel):
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class SessionRead(BaseModel):
id: uuid.UUID
ip_address: str | None
# Parsed from the stored user_agent by the router (see
# user_agent_service.describe_user_agent) -- not a stored column, so a
# future improvement to the parser applies retroactively to old rows
# too.
device_label: str
created_at: datetime
last_seen_at: datetime
# Whether this is the session the request making this call is itself
# authenticated with -- lets the UI mark "this device" and treat
# revoking it as a self-logout rather than just another row.
is_current: bool
+6
View File
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
is_site_admin: bool
display_name: str | None
theme: str | None
text_scale: str | None
emoji_scale: str | None
# Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
# #71: kept in sync with frontend/src/lib/theme.ts's TEXT_SCALE_PERCENT map.
text_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
# #71: kept in sync with MessageContent.tsx's EMOJI_SCALE_MULTIPLIER map.
emoji_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
appear_offline: bool | None = Field(default=None)
+5
View File
@@ -93,9 +93,14 @@ async def set_user_site_admin(
async def list_rooms_admin(db: AsyncSession) -> list[tuple[Room, int]]:
# #52: DMs are fully private, not just unlisted -- excluded here rather
# than merely omitted from the response, so there's no admin-portal
# surface (this list, or the audit log via anything that touches this
# query) that reveals a DM even exists between two users.
result = await db.execute(
select(Room, func.count(RoomMembership.user_id))
.outerjoin(RoomMembership, RoomMembership.room_id == Room.id)
.where(Room.is_dm.is_(False))
.group_by(Room.id)
.order_by(Room.created_at)
)
@@ -0,0 +1,80 @@
import re
import uuid
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import CustomEmoji, User
from app.storage import delete_file
# Deliberately stricter than the built-in Unicode shortcode set's charset
# (see frontend/src/lib/emojiShortcodes.ts, which also allows '+') -- this
# is validating a *new name being chosen*, not matching against an
# existing fixed list, so there's no reason to allow anything a person
# wouldn't naturally type. Max 30 chars matches CustomEmoji.shortcode's
# column width exactly (see that model's comment for why).
SHORTCODE_PATTERN = re.compile(r"^[a-z0-9_-]{2,30}$")
class InvalidShortcodeError(Exception):
pass
class DuplicateShortcodeError(Exception):
pass
class CustomEmojiNotFoundError(Exception):
pass
class NotEmojiOwnerError(Exception):
pass
async def create_custom_emoji(
db: AsyncSession,
uploaded_by: uuid.UUID,
shortcode: str,
storage_filename: str,
content_type: str,
) -> CustomEmoji:
if not SHORTCODE_PATTERN.match(shortcode):
raise InvalidShortcodeError()
emoji = CustomEmoji(
shortcode=shortcode,
storage_filename=storage_filename,
content_type=content_type,
uploaded_by=uploaded_by,
)
db.add(emoji)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise DuplicateShortcodeError() from exc
await db.refresh(emoji)
return emoji
async def list_custom_emoji(db: AsyncSession) -> list[CustomEmoji]:
result = await db.execute(select(CustomEmoji).order_by(CustomEmoji.shortcode))
return list(result.scalars().all())
async def get_custom_emoji_by_shortcode(db: AsyncSession, shortcode: str) -> CustomEmoji | None:
result = await db.execute(select(CustomEmoji).where(CustomEmoji.shortcode == shortcode))
return result.scalar_one_or_none()
async def delete_custom_emoji(db: AsyncSession, emoji_id: uuid.UUID, current_user: User) -> None:
emoji = await db.get(CustomEmoji, emoji_id)
if emoji is None:
raise CustomEmojiNotFoundError()
if emoji.uploaded_by != current_user.id and not current_user.is_site_admin:
raise NotEmojiOwnerError()
delete_file(emoji.storage_filename)
await db.delete(emoji)
await db.commit()
+185 -15
View File
@@ -1,3 +1,4 @@
import html
import logging
from email.message import EmailMessage
@@ -5,7 +6,7 @@ import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
from app.crypto import decrypt
from app.models import SmtpSettings
from app.models import CustomTheme, SmtpSettings, User
from app.services.smtp_settings_service import get_smtp_settings
logger = logging.getLogger(__name__)
@@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception):
pass
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
# #68: the 6 tokens actually used by the email template below, out of the
# full ~12-token palette themes.css defines per preset -- an email has no
# equivalent of --ds-surface-2/--ds-void-2/--ds-highlight/--ds-danger, it's
# one card on one background with one accent. Kept in sync by hand with
# frontend/src/styles/tokens.css (the default) and themes.css (the other
# three presets) -- there's no way to share the source of truth across the
# Python/CSS boundary, so if either changes the other needs updating too.
DEFAULT_PALETTE = {
"void": "#07080f",
"surface": "#101030",
"border": "#242478",
"text": "#fce4fc",
"muted": "#c0ccd8",
"accent": "#60d8fc",
}
_PRESET_PALETTES: dict[str, dict[str, str]] = {
"dark": DEFAULT_PALETTE,
"light": {
"void": "#f5f3fb",
"surface": "#ffffff",
"border": "#d8d2ee",
"text": "#1a1030",
"muted": "#675f80",
"accent": "#0891b2",
},
"midnight": {
"void": "#000000",
"surface": "#0a0a14",
"border": "#262640",
"text": "#ffffff",
"muted": "#a8b0c0",
"accent": "#00f0ff",
},
"sunset": {
"void": "#120a07",
"surface": "#241408",
"border": "#4a2c14",
"text": "#fce8d8",
"muted": "#c8b0a0",
"accent": "#fca050",
},
}
async def _resolve_palette(db: AsyncSession, theme_user: User | None) -> dict[str, str]:
"""#68: an email addressed to an existing user is styled with *their*
selected theme (mirroring the app itself), not a fixed look -- but
there's no such thing as "their theme" for someone who doesn't have an
account yet (site invites), so theme_user is None there and this falls
back to the default DarkSingularity palette, same as a logged-out page.
"""
if theme_user is None or theme_user.theme is None:
return DEFAULT_PALETTE
if theme_user.theme == "custom":
if theme_user.active_custom_theme_id is not None:
# A fresh PK fetch, not `theme_user.active_custom_theme` --
# that relationship is essentially never eager-loaded by
# whatever query got this User row in the first place, and
# touching it lazily here would raise MissingGreenlet in
# async SQLAlchemy.
custom = await db.get(CustomTheme, theme_user.active_custom_theme_id)
if custom is not None:
colors = custom.colors
return {key: colors[key] for key in DEFAULT_PALETTE}
return DEFAULT_PALETTE
return _PRESET_PALETTES.get(theme_user.theme, DEFAULT_PALETTE)
def _render_text(paragraphs: list[str], cta_label: str | None, cta_url: str | None) -> str:
body = "\n\n".join(paragraphs)
if cta_label and cta_url:
body += f"\n\n{cta_label}: {cta_url}"
return body
def _render_html(
palette: dict[str, str],
subject: str,
paragraphs: list[str],
cta_label: str | None,
cta_url: str | None,
) -> str:
# Table-based layout with everything inlined -- not the app's own CSS
# custom properties (email clients strip <style> blocks and don't
# support :root variables), just their resolved hex values baked in
# per send. Deliberately plain: one card, one accent color, no imagery
# that could get blocked by a client's "show images" gate and leave
# the email looking broken instead of just plain.
paragraphs_html = "".join(
f'<p style="margin:0 0 16px;color:{palette["muted"]};font-size:15px;'
f'line-height:1.6;">{html.escape(p)}</p>'
for p in paragraphs
)
cta_html = ""
if cta_label and cta_url:
cta_html = f"""
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">
<tr>
<td style="border-radius:8px;background:{palette["accent"]};">
<a href="{html.escape(cta_url)}" style="display:inline-block;padding:12px 22px;
font-size:15px;font-weight:700;color:{palette["void"]};text-decoration:none;
border-radius:8px;">{html.escape(cta_label)}</a>
</td>
</tr>
</table>
"""
return f"""<!doctype html>
<html>
<body style="margin:0;padding:0;background:{palette["void"]};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background:{palette["void"]};padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0"
style="max-width:480px;width:100%;background:{palette["surface"]};
border:1px solid {palette["border"]};border-radius:12px;padding:32px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
<tr>
<td>
<div style="font-size:13px;font-weight:800;letter-spacing:0.06em;
text-transform:uppercase;color:{palette["accent"]};margin:0 0 20px;">DS Chat</div>
<h1 style="margin:0 0 16px;font-size:20px;font-weight:800;
color:{palette["text"]};">{html.escape(subject)}</h1>
{paragraphs_html}
{cta_html}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
async def _deliver(
cfg: SmtpSettings, to_address: str, subject: str, html_body: str, text_body: str
) -> None:
"""Raises on failure -- internal helper only. Callers decide whether to
swallow (send_email) or surface (send_test_email) the error."""
message = EmailMessage()
message["From"] = cfg.from_address
message["To"] = to_address
message["Subject"] = subject
message.set_content(body)
# Plain-text part first, HTML as the alternative -- standard
# multipart/alternative ordering (least to most preferred), so a
# client with no HTML support (or a spam filter) still gets a normal
# readable email instead of raw markup.
message.set_content(text_body)
message.add_alternative(html_body, subtype="html")
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
@@ -55,29 +198,56 @@ async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str)
)
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
async def send_email(
db: AsyncSession,
to_address: str,
subject: str,
paragraphs: list[str],
*,
cta_label: str | None = None,
cta_url: str | None = None,
theme_user: User | None = None,
) -> None:
"""Best-effort -- used by invite/notification flows. Never raises: an
SMTP outage or missing configuration must never block an action (an
invite, a room membership) that already succeeded in the database."""
invite, a room membership) that already succeeded in the database.
`paragraphs` replaces the old flat `body: str` (#68) -- each entry
renders as its own paragraph in both the HTML and plain-text parts,
which a single pre-formatted string can't cleanly become HTML from
without re-parsing it. `theme_user`, when given, styles the email with
that user's own selected theme (default palette if they haven't picked
one, or don't have an account at all -- see _resolve_palette).
"""
cfg = await get_smtp_settings(db)
if cfg is None:
logger.debug("SMTP not configured; skipping email to %s", to_address)
# WARNING, not .debug -- this app has no logging config lowering
# the root level below Python's own WARNING default, so anything
# below that is silently invisible in production (confirmed live:
# a real "no emails arriving" report produced nothing in the logs
# at all, this line included, even though it was relevant).
logger.warning("SMTP not configured; skipping email to %s", to_address)
return
palette = await _resolve_palette(db, theme_user)
html_body = _render_html(palette, subject, paragraphs, cta_label, cta_url)
text_body = _render_text(paragraphs, cta_label, cta_url)
try:
await _deliver(cfg, to_address, subject, body)
await _deliver(cfg, to_address, subject, html_body, text_body)
except Exception:
logger.warning("Failed to send email to %s", to_address, exc_info=True)
async def send_test_email(db: AsyncSession, to_address: str) -> None:
async def send_test_email(db: AsyncSession, to_address: str, theme_user: User | None = None) -> None:
"""Used only by the admin 'send test email' button -- raises so the
admin UI can show why it failed instead of a silent no-op."""
admin UI can show why it failed instead of a silent no-op. theme_user
is the admin themselves (see routers/admin.py) -- the preview shows
them their own emails' real look, not a generic default."""
cfg = await get_smtp_settings(db)
if cfg is None:
raise SmtpNotConfiguredError()
await _deliver(
cfg,
to_address,
"DS Chat test email",
"This is a test email from DS Chat to confirm your SMTP settings are working.",
)
subject = "DS Chat test email"
paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
palette = await _resolve_palette(db, theme_user)
html_body = _render_html(palette, subject, paragraphs, None, None)
text_body = _render_text(paragraphs, None, None)
await _deliver(cfg, to_address, subject, html_body, text_body)
+11 -1
View File
@@ -27,8 +27,18 @@ _TRAILING_PUNCTUATION = ".,;:!?)'\">"
_FETCH_TIMEOUT_SECONDS = 5.0
_MAX_BYTES = 512 * 1024
_MAX_REDIRECTS = 3
# #70: was 7 days -- confirmed live as too long for how this app actually
# gets used: re-posting a URL whose title/content had genuinely changed
# kept showing the stale first-fetch preview for up to a week. Short
# enough that it's effectively "always fresh" for any realistic human
# posting cadence, while still doing the one thing a cache here is
# actually for -- collapsing a burst of near-simultaneous fetches of the
# same URL (several people pasting the same link within moments of each
# other, or the same person's message history being loaded repeatedly)
# into one, and not hammering a URL that just failed on every message
# that references it.
_USER_AGENT = "ds-chat-link-preview/1.0"
_CACHE_TTL = timedelta(days=7)
_CACHE_TTL = timedelta(minutes=5)
def extract_first_url(content: str | None) -> str | None:
+282 -11
View File
@@ -1,22 +1,30 @@
import asyncio
import logging
import uuid
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
from app.schemas.message import ReactionSummary
from app.services.email_service import send_email
from app.services.link_preview_service import fetch_and_broadcast_link_preview
from app.services.push_service import send_push_to_user
from app.services.room_service import list_dm_partner_ids
from app.services.webhook_service import dispatch_event
from app.ws.broadcaster import Broadcaster
from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence
logger = logging.getLogger(__name__)
async def _notify_offline_members(
db: AsyncSession,
broadcaster: Broadcaster,
presence: Presence,
focus_presence: FocusPresence,
room_id: uuid.UUID,
sender: User,
message: Message,
@@ -25,12 +33,28 @@ async def _notify_offline_members(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
)
member_ids = {row[0] for row in result.all()}
connected_ids = await presence.connected_user_ids(room_id)
# Subtract the sender explicitly rather than relying on them being
# "connected" (true for the WS path, since they just sent this over an
# active connection -- not true for the incoming-webhook REST path,
# which has no WS connection for the attributed sender at all).
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
if not offline_ids:
offline_ids = member_ids - connected_ids - {sender.id}
# #59: a desktop-mode member can be *connected* to this room's channel
# (it's open on screen, live messages are rendering) while their window
# sits unfocused behind something else -- still exactly the situation a
# desktop notification should fire for, same as #49's original intent.
# This used to be handled by the client faking "offline" (leaving the
# room's channel on blur), which also silently stopped live delivery to
# that room; FocusPresence is a separate signal so notification
# eligibility no longer has to ride on room-connection state at all.
connected_but_unfocused_ids = {
user_id
for user_id in connected_ids - {sender.id}
if await focus_presence.is_unfocused(user_id)
}
notify_ids = offline_ids | connected_but_unfocused_ids
if not notify_ids:
return
result = await db.execute(
@@ -38,12 +62,10 @@ async def _notify_offline_members(
)
mentioned_ids = {row[0] for row in result.all()}
# This is also exactly the right audience for "give this room an unread
# dot": presence.connected_user_ids(room_id) means "has this room's
# channel joined right now" -- which the client only does while the tab
# is genuinely foregrounded (see useChatSocket.ts's visibility-gated
# join/leave), so a backgrounded-but-open room correctly lands here too,
# not just rooms that aren't open at all.
# Unread-dot audience stays exactly offline_ids, not notify_ids: a
# connected-but-unfocused member still has the room open and rendering
# on screen right now, so it isn't actually "unread" for them the way a
# room they haven't got open at all is.
for user_id in offline_ids:
await broadcaster.publish_to_user(
user_id,
@@ -56,7 +78,7 @@ async def _notify_offline_members(
room = await db.get(Room, room_id)
title = f"#{room.name}" if room else "New message"
for user_id in offline_ids:
for user_id in notify_ids:
mentioned = user_id in mentioned_ids
if message.content:
prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: "
@@ -117,6 +139,10 @@ async def _message_payload(db: AsyncSession, message: Message, username: str) ->
"reactions": [],
"created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
# Always null here -- a message just being created can't already be
# deleted -- but included for wire-format parity with MessageRead
# and message_deleted (#53).
"deleted_at": None,
}
@@ -127,10 +153,199 @@ def _maybe_fetch_link_preview(broadcaster: Broadcaster, room_id: uuid.UUID, mess
)
async def _maybe_email_dm_notification(
db: AsyncSession,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID,
sender: User,
message: Message,
) -> None:
"""#66: DMs only, deliberately narrower than _notify_offline_members'
own "offline" -- that one means "not connected to this room's channel
right now," which fires on every message and is fine for a lightweight
channel (push/desktop). Email is heavier-weight and a DM's other
participant could easily be actively using the app in a different room,
so this uses GlobalPresence (genuinely no open connection anywhere)
instead -- the same "is this user actually offline" logic as
rooms.py's private _member_status (not importable from here), just
re-derived.
"""
# #68 follow-up: this whole function used to have zero logging on any
# of its early-return paths, which made "why didn't an email go out"
# completely undiagnosable from the outside -- confirmed live, a real
# report of "no emails" produced nothing in the logs at all, not even
# at the level that turned out to be the actual cause. logger.warning
# (not .info/.debug) is deliberate: this app has no logging config
# setting the root level below Python's own WARNING default, so
# anything logged lower than that is silently invisible in production
# regardless of what it's actually about -- these aren't really
# warnings, they're the only level guaranteed to reach journalctl
# today.
room = await db.get(Room, room_id)
if room is None or not room.is_dm:
return
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id != sender.id
)
)
membership = result.scalar_one_or_none()
if membership is None:
logger.warning("DM email skipped for room %s: no other participant found", room_id)
return
recipient = await db.get(User, membership.user_id)
if recipient is None:
logger.warning(
"DM email skipped for room %s: recipient user %s not found", room_id, membership.user_id
)
return
# appear_offline is a manual "always look offline" override -- treated
# the same as genuinely offline here, same as everywhere else it's
# checked in this codebase.
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
logger.warning(
"DM email skipped for room %s: recipient %s is online", room_id, recipient.id
)
return
# Debounced to the first unread message in this conversation, not
# every single one -- a burst of DMs while someone's asleep should be
# one email, not one per message.
already_unread = await db.execute(
select(Message.id)
.where(
Message.room_id == room_id,
Message.id != message.id,
Message.created_at > membership.last_read_at,
)
.limit(1)
)
if already_unread.scalar_one_or_none() is not None:
logger.warning(
"DM email skipped for room %s: recipient %s already has unread messages",
room_id,
recipient.id,
)
return
if message.content:
body_line = f"{sender.username}: {message.content[:200]}"
elif message.file_id:
body_line = f"{sender.username} sent a file"
else:
body_line = f"{sender.username} sent an image"
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
logger.warning("Sending DM email to %s for room %s", recipient.email, room_id)
await send_email(
db,
recipient.email,
f"New message from {sender.username}",
[body_line],
cta_label="Open conversation",
cta_url=link,
theme_user=recipient,
)
async def _maybe_email_room_notifications(
db: AsyncSession,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID,
sender: User,
message: Message,
) -> None:
"""#67: opt-in, per-room email -- deliberately scoped to regular rooms
only (see set_room_email_notifications: DMs already get #66's
always-on offline email, no separate toggle).
Two triggers, not one: the room's first unread message debounces the
same way #66 does (one email per unread burst, not one per message),
but a mention always emails regardless of that debounce -- a mention
is a stronger, individually-addressed signal that shouldn't get
silently swallowed just because an earlier plain message in the same
burst already used up the "first unread" email.
"""
room = await db.get(Room, room_id)
if room is None or room.is_dm:
return
result = await db.execute(
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
)
mentioned_ids = {row[0] for row in result.all()}
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id,
RoomMembership.user_id != sender.id,
RoomMembership.email_notifications.is_(True),
)
)
subscribed_memberships = result.scalars().all()
if not subscribed_memberships:
return
for membership in subscribed_memberships:
user_id = membership.user_id
recipient = await db.get(User, user_id)
if recipient is None:
continue
# appear_offline always wins here too, same as _maybe_email_dm_notification.
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
continue
mentioned = user_id in mentioned_ids
if not mentioned:
already_unread = await db.execute(
select(Message.id)
.where(
Message.room_id == room_id,
Message.id != message.id,
Message.created_at > membership.last_read_at,
)
.limit(1)
)
if already_unread.scalar_one_or_none() is not None:
continue
if mentioned:
subject = f"New mention in #{room.name}"
body_line = (
f"{sender.username} mentioned you: {message.content[:200]}"
if message.content
else f"{sender.username} mentioned you"
)
else:
subject = f"New message in #{room.name}"
if message.content:
body_line = f"{sender.username}: {message.content[:200]}"
elif message.file_id:
body_line = f"{sender.username} sent a file"
else:
body_line = f"{sender.username} sent an image"
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
await send_email(
db,
recipient.email,
subject,
[body_line],
cta_label="Open room",
cta_url=link,
theme_user=recipient,
)
async def broadcast_new_message(
db: AsyncSession,
broadcaster: Broadcaster,
presence: Presence,
focus_presence: FocusPresence,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID,
message: Message,
sender: User,
@@ -140,7 +355,30 @@ async def broadcast_new_message(
trigger identical fan-out/push/event behavior."""
payload = await _message_payload(db, message, sender.username)
await broadcaster.publish(room_id, payload)
await _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
# A no-op for a regular room (hidden_at is only ever set on a DM's
# membership row -- see RoomMembership.hidden_at) -- new activity
# un-hiding a DM someone closed matches find_or_create_dm's own
# un-hide-on-reopen behavior. `.returning` so we know exactly who was
# un-hidden -- their client needs the same room_added signal a brand
# new DM does (see broadcast_room_added's docstring): the room wasn't
# in their already-loaded room list at all, so unread_update's plain
# setRooms(prev => prev.map(...)) can't make it reappear -- there's
# nothing in `prev` for it to match.
unhidden_result = await db.execute(
update(RoomMembership)
.where(RoomMembership.room_id == room_id, RoomMembership.hidden_at.is_not(None))
.values(hidden_at=None)
.returning(RoomMembership.user_id)
)
unhidden_user_ids = list(unhidden_result.scalars().all())
await db.commit()
for unhidden_user_id in unhidden_user_ids:
await broadcaster.publish_to_user(
unhidden_user_id, {"type": "room_added", "room_id": str(room_id)}
)
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
await _maybe_email_room_notifications(db, global_presence, base_url, room_id, sender, message)
await dispatch_event(db, "message.created", room_id, payload)
_maybe_fetch_link_preview(broadcaster, room_id, message)
@@ -165,6 +403,16 @@ async def broadcast_message_update(
_maybe_fetch_link_preview(broadcaster, room_id, message)
async def broadcast_message_delete(broadcaster: Broadcaster, room_id: uuid.UUID, message_id: uuid.UUID) -> None:
# #53: no dispatch_event() call, deliberately -- same scope cut as
# broadcast_reaction_update's, and for the same reason (see
# backend/README.md): message.deleted isn't an outgoing-webhook event
# type here.
await broadcaster.publish(
room_id, {"type": "message_deleted", "id": str(message_id), "room_id": str(room_id)}
)
async def broadcast_reaction_update(
broadcaster: Broadcaster,
room_id: uuid.UUID,
@@ -199,6 +447,29 @@ async def broadcast_member_updated(db: AsyncSession, broadcaster: Broadcaster, u
)
async def broadcast_dm_presence_update(
db: AsyncSession, broadcaster: Broadcaster, user_id: uuid.UUID, online: bool
) -> None:
"""Tells every one of user_id's DM partners that their online/offline
status just changed (#63) -- on each partner's own per-user channel,
not the DM room's channel. The room channel alone doesn't reach the
sidebar: Presence gates room-channel delivery on actually having that
specific room's channel joined right now, which is only ever the one
room currently open in the UI -- so a DM sitting unopened in the
sidebar (which is the normal case; the sidebar shows every DM's status
at once) never saw its partner's status change until something else
forced a full room-list refetch."""
for partner_id in await list_dm_partner_ids(db, user_id):
await broadcaster.publish_to_user(
partner_id,
{
"type": "dm_presence_update",
"user_id": str(user_id),
"status": "online" if online else "offline",
},
)
async def broadcast_room_added(broadcaster: Broadcaster, user_id: uuid.UUID, room: Room) -> None:
"""The only signal a user's open client gets that they were just added
to a room -- without it, GET /rooms/mine is only ever fetched once at
+56 -2
View File
@@ -6,11 +6,19 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Message, MessageMention, MessageReaction, MessageRoomReference
from app.models import (
Message,
MessageFile,
MessageImage,
MessageMention,
MessageReaction,
MessageRoomReference,
)
from app.schemas.message import ReactionSummary
from app.services.link_preview_service import extract_first_url
from app.services.mention_service import extract_mentioned_user_ids
from app.services.room_reference_service import extract_referenced_room_ids
from app.storage import delete_file
class MessageNotFoundError(Exception):
@@ -56,7 +64,9 @@ async def edit_message(
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
) -> Message:
message = await db.get(Message, message_id)
if message is None:
# A deleted message might as well not exist for editing purposes --
# same MessageNotFoundError a genuinely missing id would raise.
if message is None or message.deleted_at is not None:
raise MessageNotFoundError()
if message.user_id != editor_id:
raise NotMessageAuthorError()
@@ -69,6 +79,50 @@ async def edit_message(
return message
async def delete_message(db: AsyncSession, message_id: uuid.UUID, deleter_id: uuid.UUID) -> Message:
message = await db.get(Message, message_id)
if message is None or message.deleted_at is not None:
raise MessageNotFoundError()
if message.user_id != deleter_id:
raise NotMessageAuthorError()
# Fetch the attachment's storage filename (if any) before clearing the
# message's own FK to it -- the file is only unlinked from disk after a
# successful commit below, mirroring delete_room's identical ordering:
# a rolled-back transaction should never leave us having destroyed
# something we couldn't get back.
image_filename: str | None = None
file_filename: str | None = None
if message.image_id is not None:
image = await db.get(MessageImage, message.image_id)
if image is not None:
image_filename = image.storage_filename
await db.delete(image)
if message.file_id is not None:
message_file = await db.get(MessageFile, message.file_id)
if message_file is not None:
file_filename = message_file.storage_filename
await db.delete(message_file)
# #53: a real delete, not just a UI hide -- content and any attachment
# are actually gone, not merely unlinked-but-still-fetchable. Only
# deleted_at (plus id/room_id/user_id/created_at, kept so the tombstone
# still occupies its place in history) survives.
message.content = None
message.image_id = None
message.file_id = None
message.preview_url = None
message.deleted_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(message)
for filename in (image_filename, file_filename):
if filename is not None:
delete_file(filename)
return message
async def list_recent_messages(
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
) -> list[Message]:
+7 -4
View File
@@ -46,10 +46,13 @@ async def request_password_reset(db: AsyncSession, email: str, base_url: str) ->
db,
email,
"Reset your DS Chat password",
f"Someone requested a password reset for this account.\n\n"
f"Reset it here:\n{reset_link}\n\n"
f"This link expires in 15 minutes. If you didn't request this, "
f"you can ignore this email.",
[
"Someone requested a password reset for this account.",
"This link expires in 15 minutes. If you didn't request this, you can ignore this email.",
],
cta_label="Reset password",
cta_url=reset_link,
theme_user=user,
)
+32 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import logging
import uuid
from urllib.parse import urlparse
from pywebpush import WebPushException, webpush
from sqlalchemy import delete, select
@@ -53,7 +54,23 @@ async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> No
await db.commit()
# #56 correction: bumping to pywebpush's latest release (2.4.0) turned out
# not to actually fix WNS -- checked the installed package's own source
# directly and it has no WNS-specific code anywhere; the upstream
# discussion (web-push-libs/pywebpush#162) apparently never shipped.
# Worked around here instead, using the `headers` param webpush() already
# exposes for exactly this: WNS (Windows/Edge push,
# *.notify.windows.com) has required this header since April 2024, or it
# 400s with no useful body -- "cache" for a non-zero TTL, "no-cache" for
# zero (this app never sets a TTL, so always the latter).
def _is_wns_endpoint(endpoint: str) -> bool:
return urlparse(endpoint).hostname is not None and urlparse(endpoint).hostname.endswith(
"notify.windows.com"
)
def _send_one(subscription: PushSubscription, payload: dict) -> None:
extra_headers = {"X-WNS-Cache-Policy": "no-cache"} if _is_wns_endpoint(subscription.endpoint) else None
webpush(
subscription_info={
"endpoint": subscription.endpoint,
@@ -62,6 +79,7 @@ def _send_one(subscription: PushSubscription, payload: dict) -> None:
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
headers=extra_headers,
)
@@ -95,4 +113,17 @@ async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict)
)
await db.commit()
else:
logger.warning("Push delivery failed for %s: %s", subscription.id, exc)
# #56: WNS's own 400s carry the actual reason in a response
# *header* ("Ttl value conflicts with X-WNS-Cache-Policy"),
# not the body -- pywebpush's own exception message only
# ever surfaces the body, so that specific bug still would
# have needed a full journalctl+DB-dump investigation to
# diagnose even with a body-only log line. Logging headers
# too is the difference between "something is broken" and
# this log line alone being enough next time, for any push
# provider's failure, not just WNS's.
response = exc.response
detail = ""
if response is not None:
detail = f" | response: {response.text!r} | headers: {dict(response.headers)!r}"
logger.warning("Push delivery failed for %s: %s%s", subscription.id, exc, detail)
+234 -11
View File
@@ -1,13 +1,27 @@
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy import delete, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Message, MessageMention, Room, RoomMembership, RoomRole, User
from app.models import (
EventSubscription,
Message,
MessageFile,
MessageImage,
MessageMention,
MessageReaction,
MessageRoomReference,
Room,
RoomMembership,
RoomRole,
User,
WebhookIncoming,
)
from app.schemas.room import RoomCreate, RoomUpdate
from app.services.email_service import send_email
from app.storage import delete_file
class DuplicateRoomError(Exception):
@@ -46,6 +60,50 @@ class AlreadyMemberError(Exception):
pass
class CannotDmSelfError(Exception):
pass
class CannotModifyDmError(Exception):
pass
class NotADmError(Exception):
pass
def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str:
"""Deterministic, internal-only name for the DM room between these two
users -- same canonical string regardless of argument order, so
find_or_create_dm can look up an existing DM with a single indexed
query (Room.name is already unique+indexed) instead of a membership-set
join. Never shown to a user -- the frontend renders a DM's dm_partner
info instead of its `name` (see MyRoomItem)."""
ids = sorted((str(user_a_id), str(user_b_id)))
return f"dm:{ids[0]}:{ids[1]}"
async def _unhide(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
membership = (
await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
).scalar_one_or_none()
if membership is not None and membership.hidden_at is not None:
membership.hidden_at = None
await db.commit()
async def hide_dm(db: AsyncSession, room: Room, user_id: uuid.UUID) -> None:
if not room.is_dm:
raise NotADmError()
membership = await _get_membership(db, room.id, user_id)
membership.hidden_at = func.now()
await db.commit()
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
room = Room(
name=data.name,
@@ -66,10 +124,53 @@ async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -
return room
async def find_or_create_dm(db: AsyncSession, user_id: uuid.UUID, other_user_id: uuid.UUID) -> Room:
if user_id == other_user_id:
raise CannotDmSelfError()
other = await db.get(User, other_user_id)
if other is None:
raise TargetUserNotFoundError()
name = dm_room_name(user_id, other_user_id)
result = await db.execute(select(Room).where(Room.name == name))
room = result.scalar_one_or_none()
if room is not None:
await _unhide(db, room.id, user_id)
return room
# is_private=True is belt-and-suspenders here -- list_open_rooms also
# excludes is_dm directly -- but it's also just semantically correct: a
# DM genuinely is a private room. Both participants get the plain
# `member` role (there's no meaningful owner/admin distinction for a
# 1:1 DM); `owner_id` still has to be someone to satisfy the column,
# but nothing reads it as meaningful for a DM.
room = Room(name=name, is_private=True, is_dm=True, owner_id=user_id)
db.add(room)
try:
await db.flush()
except IntegrityError:
# Lost a race with a concurrent find_or_create_dm for the same pair
# (e.g. both people click "message" on each other at once) -- the
# unique constraint on `name` is exactly what caught it, same
# pattern as create_room's DuplicateRoomError. The row that won the
# race is the room we actually want.
await db.rollback()
result = await db.execute(select(Room).where(Room.name == name))
room = result.scalar_one()
await _unhide(db, room.id, user_id)
return room
db.add(RoomMembership(room_id=room.id, user_id=user_id, role=RoomRole.member))
db.add(RoomMembership(room_id=room.id, user_id=other_user_id, role=RoomRole.member))
await db.commit()
await db.refresh(room)
return room
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
result = await db.execute(
select(Room)
.where(Room.is_private.is_(False), Room.is_archived.is_(False))
.where(Room.is_private.is_(False), Room.is_archived.is_(False), Room.is_dm.is_(False))
.options(selectinload(Room.memberships))
.order_by(Room.created_at, Room.id)
)
@@ -81,7 +182,7 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
async def list_member_rooms(
db: AsyncSession, user_id: uuid.UUID
) -> list[tuple[Room, RoomRole, bool, bool]]:
) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
last_message_at = (
select(func.max(Message.created_at))
.where(Message.room_id == Room.id)
@@ -104,10 +205,15 @@ async def list_member_rooms(
)
result = await db.execute(
select(
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
Room,
RoomMembership.role,
RoomMembership.last_read_at,
last_message_at,
has_unread_mention,
RoomMembership.email_notifications,
)
.join(RoomMembership, RoomMembership.room_id == Room.id)
.where(RoomMembership.user_id == user_id)
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
# A secondary key on the primary key -- without it, Postgres has no
# obligation to return two same-instant rooms (a plausible tie:
# bulk-created/migrated rooms, or just two created in quick
@@ -116,12 +222,55 @@ async def list_member_rooms(
# device's fetch and another's.
.order_by(Room.created_at, Room.id)
)
rows = result.all()
# #52: one batched follow-up query for every DM room's *other*
# participant, rather than a fetch per row -- a DM only ever has
# exactly two members, so "the other one" is unambiguous.
dm_room_ids = [room.id for room, *_ in rows if room.is_dm]
partners_by_room: dict[uuid.UUID, User] = {}
if dm_room_ids:
partner_result = await db.execute(
select(RoomMembership.room_id, User)
.join(User, User.id == RoomMembership.user_id)
.where(RoomMembership.room_id.in_(dm_room_ids), RoomMembership.user_id != user_id)
)
partners_by_room = {room_id: user for room_id, user in partner_result.all()}
return [
(room, role, last_message_at is not None and last_message_at > last_read_at, has_mention)
for room, role, last_read_at, last_message_at, has_mention in result.all()
(
room,
role,
last_message_at is not None and last_message_at > last_read_at,
has_mention,
email_notifications,
partners_by_room.get(room.id),
)
for room, role, last_read_at, last_message_at, has_mention, email_notifications in rows
]
async def list_dm_partner_ids(db: AsyncSession, user_id: uuid.UUID) -> list[uuid.UUID]:
"""Every user this user_id shares a DM with (#63) -- used to know who
needs telling about a global online/offline transition, since Presence
gates room-channel delivery on actually having that specific room
joined right now (only ever the one room currently open in the UI), so
a DM sitting unopened in the sidebar would otherwise never hear about
its partner's status changing at all."""
result = await db.execute(
select(RoomMembership.user_id)
.join(Room, Room.id == RoomMembership.room_id)
.where(
Room.is_dm.is_(True),
RoomMembership.user_id != user_id,
RoomMembership.room_id.in_(
select(RoomMembership.room_id).where(RoomMembership.user_id == user_id)
),
)
)
return [row[0] for row in result.all()]
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
room = await db.get(Room, room_id)
if room is None:
@@ -173,8 +322,10 @@ async def add_member(
db,
target.email,
f"You've been added to #{room.name}",
f"You've been added to the #{room.name} room on DS Chat.\n\n"
f"Open the app: {base_url.rstrip('/')}",
[f"You've been added to the #{room.name} room on DS Chat."],
cta_label="Open DS Chat",
cta_url=base_url.rstrip("/"),
theme_user=target,
)
result = await db.execute(
@@ -186,6 +337,14 @@ async def add_member(
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
# A DM's `name` is an internal token find_or_create_dm's lookup depends
# on being stable -- renaming it (even via the #48 site-admin bypass in
# the router) would silently orphan that invariant, not just leak a
# detail that's supposed to stay private. Blocked here, not just in the
# UI, since it's a correctness issue for every caller, not a permission
# one.
if room.is_dm:
raise CannotModifyDmError()
if data.name is not None:
room.name = data.name
if data.description is not None:
@@ -203,12 +362,60 @@ async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
async def delete_room(db: AsyncSession, room: Room) -> None:
# Explicit deletes rather than relying on ORM cascade + eager-loading —
# simpler and more predictable in async code.
# simpler and more predictable in async code. None of these FKs are
# declared ON DELETE CASCADE at the DB level (confirmed across every
# migration that added one), so every table referencing this room --
# directly, or indirectly via one of its messages -- has to be cleared
# explicitly, in dependency order, or the final room delete 500s on
# whichever one it happens to hit first (originally surfaced as a
# message_room_references FK violation, but every table below has the
# exact same gap).
room_message_ids = select(Message.id).where(Message.room_id == room.id).scalar_subquery()
# Message-child tables first -- these reference message_id, so they'd
# block deleting this room's own messages otherwise.
await db.execute(delete(MessageMention).where(MessageMention.message_id.in_(room_message_ids)))
await db.execute(delete(MessageReaction).where(MessageReaction.message_id.in_(room_message_ids)))
# Both directions: a reference *from* one of this room's own messages,
# and a reference *to* this room from a message in a completely
# different room (the case that originally surfaced this bug).
await db.execute(
delete(MessageRoomReference).where(
or_(
MessageRoomReference.message_id.in_(room_message_ids),
MessageRoomReference.room_id == room.id,
)
)
)
# Fetch attachment storage filenames before deleting their rows -- the
# actual files are only unlinked after a successful commit below, so a
# rolled-back transaction never leaves us having destroyed something we
# couldn't get back.
image_filenames = (
await db.execute(select(MessageImage.storage_filename).where(MessageImage.room_id == room.id))
).scalars().all()
file_filenames = (
await db.execute(select(MessageFile.storage_filename).where(MessageFile.room_id == room.id))
).scalars().all()
# Messages themselves, now that nothing still references them.
await db.execute(delete(Message).where(Message.room_id == room.id))
# Room-scoped attachments/integrations -- messages.image_id/file_id
# reference these, so they must come after the message delete above.
await db.execute(delete(MessageImage).where(MessageImage.room_id == room.id))
await db.execute(delete(MessageFile).where(MessageFile.room_id == room.id))
await db.execute(delete(WebhookIncoming).where(WebhookIncoming.room_id == room.id))
await db.execute(delete(EventSubscription).where(EventSubscription.room_id == room.id))
await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
await db.delete(room)
await db.commit()
for filename in (*image_filenames, *file_filenames):
delete_file(filename)
async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
result = await db.execute(
@@ -285,6 +492,22 @@ async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUI
await db.commit()
async def set_room_email_notifications(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, enabled: bool
) -> None:
"""#67: DMs are deliberately excluded -- they already get #66's
automatic offline email with no opt-in needed, and this setting only
makes sense for a regular room's mention-based notifications."""
room = await db.get(Room, room_id)
if room is None:
raise RoomNotFoundError()
if room.is_dm:
raise CannotModifyDmError()
membership = await _get_membership(db, room_id, user_id)
membership.email_notifications = enabled
await db.commit()
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
membership = await _get_membership(db, room_id, user_id)
if membership.role == RoomRole.owner:
+100
View File
@@ -0,0 +1,100 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Session
# #69: how stale last_seen_at has to be before a request bothers updating
# it. get_current_user resolves a session on *every* authenticated
# request (dozens per minute per active browser tab, between message
# polling, presence, etc.) -- writing+committing on every single one would
# turn a read into a write storm for no real benefit, since "active
# sessions" only needs last-seen accurate to within a few minutes, not to
# the second.
LAST_SEEN_THROTTLE = timedelta(minutes=5)
class SessionNotFoundError(Exception):
pass
def get_client_ip(request_or_websocket) -> str | None:
# X-Forwarded-For's first entry is the original client -- everything
# after it was appended by intermediate proxies. Production runs
# behind Nginx Proxy Manager (see backend/README.md's "Admin portal"
# section preamble), which sets this; local dev has nothing in front
# of the app, so this falls back to the direct peer address.
forwarded = request_or_websocket.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
client = request_or_websocket.client
return client.host if client else None
async def create_session(
db: AsyncSession, user_id: uuid.UUID, ip_address: str | None, user_agent: str | None
) -> Session:
session = Session(user_id=user_id, ip_address=ip_address, user_agent=user_agent)
db.add(session)
await db.commit()
await db.refresh(session)
return session
async def start_session(request: Request, db: AsyncSession, user_id: uuid.UUID) -> Session:
"""Every "log this browser in" call site (login, reset-password
completion, signup completion) needs the exact same three steps --
read the request's IP/UA, create the row, stash its id in the signed
cookie -- so this is the one place that combination lives."""
session = await create_session(db, user_id, get_client_ip(request), request.headers.get("user-agent"))
request.session["session_id"] = str(session.id)
return session
async def resolve_session(db: AsyncSession, session_id: uuid.UUID) -> Session | None:
"""Returns the session iff it exists and hasn't been revoked -- the
single choke point get_current_user and the WS handshake both go
through, so revoking a session (this endpoint or another device's
"sign out") takes effect on that session's very next request rather
than only once its signed cookie happens to expire."""
session = await db.get(Session, session_id)
if session is None or session.revoked_at is not None:
return None
now = datetime.now(timezone.utc)
if now - session.last_seen_at > LAST_SEEN_THROTTLE:
session.last_seen_at = now
await db.commit()
return session
async def list_sessions(db: AsyncSession, user_id: uuid.UUID) -> list[Session]:
result = await db.execute(
select(Session)
.where(Session.user_id == user_id, Session.revoked_at.is_(None))
.order_by(Session.last_seen_at.desc())
)
return list(result.scalars().all())
async def revoke_session(db: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None:
session = await db.get(Session, session_id)
if session is None or session.user_id != user_id or session.revoked_at is not None:
raise SessionNotFoundError()
session.revoked_at = datetime.now(timezone.utc)
await db.commit()
async def revoke_session_unchecked(db: AsyncSession, session_id: uuid.UUID) -> None:
"""Logout's own path -- no ownership check needed (a session can only
ever log itself out) and silently does nothing for a session that's
missing or already revoked, since "sign this browser out" should
never itself fail."""
session = await db.get(Session, session_id)
if session is None or session.revoked_at is not None:
return
session.revoked_at = datetime.now(timezone.utc)
await db.commit()
+51 -9
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import InviteStatus, SiteInvite, User
from app.models.site_invite import DEFAULT_SITE_INVITE_LIFETIME
from app.schemas.user import UserCreate
from app.security import hash_token
from app.services.audit import record_audit_log
@@ -26,6 +27,24 @@ class SiteInviteInvalidError(Exception):
pass
async def _send_invite_email(db: AsyncSession, inviter_username: str, base_url: str, email: str, raw_token: str) -> None:
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
# No theme_user -- the invitee doesn't have an account yet, so there's
# no theme of theirs to use (#68). Default palette, same as any
# logged-out page.
await send_email(
db,
email,
"You're invited to join DS Chat",
[
f"You've been invited to join DS Chat by {inviter_username}.",
"This link expires in 7 days.",
],
cta_label="Set up your account",
cta_url=signup_link,
)
async def create_site_invite(
db: AsyncSession, actor: User, base_url: str, email: str
) -> SiteInvite:
@@ -39,21 +58,19 @@ async def create_site_invite(
await db.commit()
await db.refresh(invite)
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
await send_email(
db,
email,
"You're invited to join DS Chat",
f"You've been invited to join DS Chat by {actor.username}.\n\n"
f"Set up your account here:\n{signup_link}\n\n"
f"This link expires in 7 days.",
)
await _send_invite_email(db, actor.username, base_url, email, raw_token)
return invite
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]:
# Pending only (#61) -- the admin UI's only consumer of this list labels
# it "Pending invites" and had no way to drop a row once it was accepted
# or revoked, since the backend returned every invite ever sent forever.
# An accepted/revoked invite has nothing further to act on here; its
# history already lives in the audit log ("user.invite"/"invite.revoke").
result = await db.execute(
select(SiteInvite)
.where(SiteInvite.status == InviteStatus.pending)
.options(selectinload(SiteInvite.inviter))
.order_by(SiteInvite.created_at.desc())
)
@@ -74,6 +91,31 @@ async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID
return invite
async def resend_site_invite(
db: AsyncSession, actor: User, base_url: str, invite_id: uuid.UUID
) -> SiteInvite:
invite = await db.get(SiteInvite, invite_id)
if invite is None:
raise SiteInviteNotFoundError()
if invite.status != InviteStatus.pending:
raise SiteInviteNotPendingError()
# A fresh token and a reset 7-day expiry, not just re-sending the same
# link -- the old link stops working the moment this runs (same
# "rotate, don't just repeat" instinct as a password-reset resend), and
# it means resending something close to expiring actually buys the
# full week again instead of whatever was left.
raw_token = secrets.token_urlsafe(32)
invite.token_hash = hash_token(raw_token)
invite.expires_at = datetime.now(timezone.utc) + DEFAULT_SITE_INVITE_LIFETIME
record_audit_log(db, actor, "invite.resend", "invite", invite.id, {"email": invite.email})
await db.commit()
await db.refresh(invite)
await _send_invite_email(db, actor.username, base_url, invite.email, raw_token)
return invite
async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
result = await db.execute(
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
@@ -0,0 +1,45 @@
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User
from app.security import generate_token, hash_password
# #74: one well-known, auto-provisioned bot account the app itself posts as
# for automated first-party messages (the #72 welcome message, and whatever
# comes next) -- distinct from bot_service.py's admin-created integration
# bots, which each need a human actor and audit-log entry for creating them.
# There's no actor here: this account is provisioned lazily, the first time
# something needs to post as it.
SYSTEM_USERNAME = "system"
async def get_or_create_system_user(db: AsyncSession) -> User:
result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME))
user = result.scalar_one_or_none()
if user is not None:
return user
# Same placeholder-email/discarded-password shape as bot_service.create_bot
# -- this account never logs in, email just satisfies the NOT NULL/unique
# column.
user = User(
username=SYSTEM_USERNAME,
email=f"{SYSTEM_USERNAME}@bots.example.com",
password_hash=hash_password(generate_token()),
is_bot=True,
)
db.add(user)
try:
await db.flush()
except IntegrityError:
# Two concurrent requests both found no existing row and raced to
# create one -- the loser just reads back the winner's row instead
# of erroring.
await db.rollback()
result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME))
return result.scalar_one()
await db.commit()
await db.refresh(user)
return user
@@ -0,0 +1,46 @@
def describe_user_agent(user_agent: str | None) -> str:
"""A short, human-readable "Browser on OS" label for the sessions list
-- no dependency pulled in for this (the app has stayed on a
zero-subdependency-when-possible diet, see MessageContent.tsx's
markdown-to-jsx choice), just substring checks against the handful of
tokens that actually distinguish the browsers/platforms this app's
users run. Order matters: Electron and Edge both also contain
"Chrome/", and Chrome-on-iOS/Safari-on-iOS both contain "Safari/", so
the more specific token has to be checked first.
"""
if not user_agent:
return "Unknown device"
if "Electron/" in user_agent:
browser = "DS Chat Desktop"
elif "Edg/" in user_agent:
browser = "Edge"
elif "OPR/" in user_agent:
browser = "Opera"
elif "Firefox/" in user_agent:
browser = "Firefox"
elif "Chrome/" in user_agent:
browser = "Chrome"
elif "CriOS/" in user_agent:
browser = "Chrome"
elif "Safari/" in user_agent:
browser = "Safari"
else:
browser = "Unknown browser"
if "Windows" in user_agent:
os_name = "Windows"
elif "Mac OS X" in user_agent and ("iPhone" in user_agent or "iPad" in user_agent):
os_name = "iOS"
elif "Mac OS X" in user_agent:
os_name = "macOS"
elif "Android" in user_agent:
os_name = "Android"
elif "Linux" in user_agent:
os_name = "Linux"
else:
os_name = "Unknown OS"
if browser == "DS Chat Desktop":
return f"{browser} ({os_name})"
return f"{browser} on {os_name}"
+9
View File
@@ -27,6 +27,10 @@ class SubscriptionNotFoundError(Exception):
pass
class RoomArchivedError(Exception):
pass
async def create_incoming_webhook(
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
) -> WebhookIncoming:
@@ -82,6 +86,11 @@ async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[
webhook = result.scalar_one_or_none()
if webhook is None:
raise WebhookNotFoundError()
# #57: same read-only rule as a human posting from the composer -- an
# archived room shouldn't gain new messages through a bot integration
# either.
if webhook.room.is_archived:
raise RoomArchivedError()
message = await create_message(db, webhook.room_id, webhook.created_by, content)
return message, webhook.room, webhook.creator
+13
View File
@@ -25,6 +25,19 @@ ALLOWED_IMAGE_CONTENT_TYPES: dict[str, tuple[str, str]] = {
"image/webp": (".webp", "WEBP"),
}
# #65: browser-natively-playable video formats -- used to decide whether a
# stored MessageFile gets served inline (a <video> tag can actually play
# it) or forced to download like every other non-image attachment (see
# rooms.py's file-serve endpoint). Deliberately a strict allowlist, not
# "every video/* type": .mov (video/quicktime) has spotty <video> support
# outside Safari, and more importantly this is the one thing standing
# between "serve with the browser trusting our declared Content-Type" and
# reopening the same-origin-script-execution risk Content-Disposition:
# attachment exists to close off for arbitrary uploads -- it must only
# ever contain types a <video> tag renders as media, never as something
# that could execute script.
INLINE_SAFE_VIDEO_CONTENT_TYPES = frozenset({"video/mp4", "video/webm", "video/ogg"})
class UploadTooLargeError(Exception):
pass
+134 -14
View File
@@ -6,10 +6,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
from app.models import ApiToken, Message, MessageFile, MessageImage, Room, RoomMembership, User
from app.services.bot_service import resolve_token
from app.services.message_events import (
broadcast_dm_presence_update,
broadcast_member_updated,
broadcast_message_delete,
broadcast_message_update,
broadcast_new_message,
broadcast_reaction_update,
@@ -18,10 +20,12 @@ from app.services.message_service import (
MessageNotFoundError,
NotMessageAuthorError,
create_message,
delete_message,
edit_message,
toggle_reaction,
)
from app.services.room_service import mark_room_read
from app.services.session_service import resolve_session
router = APIRouter(tags=["ws"])
@@ -36,6 +40,7 @@ class ClientEnvelope(BaseModel):
file_id: uuid.UUID | None = None
message_id: uuid.UUID | None = None
emoji: str | None = None
focused: bool | None = None
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
@@ -47,6 +52,11 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
return result.scalar_one_or_none() is not None
async def _is_room_archived(db: AsyncSession, room_id: uuid.UUID) -> bool:
room = await db.get(Room, room_id)
return room is not None and room.is_archived
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
return api_token is not None and scope not in api_token.scopes
@@ -66,11 +76,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
return
user, api_token = resolved
else:
user_id_raw = websocket.session.get("user_id")
if not user_id_raw:
session_id_raw = websocket.session.get("session_id")
if not session_id_raw:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, uuid.UUID(user_id_raw))
session = await resolve_session(db, uuid.UUID(session_id_raw))
if session is None:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, session.user_id)
if user is None or not user.is_active:
await websocket.close(code=WS_UNAUTHENTICATED)
return
@@ -79,8 +93,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager = websocket.app.state.connection_manager
presence = websocket.app.state.presence
global_presence = websocket.app.state.global_presence
focus_presence = websocket.app.state.focus_presence
broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set()
# Tracks this connection's last-reported focus state (see the "focus"
# envelope below) so the disconnect cleanup can release FocusPresence's
# refcount if the socket closes while still blurred -- mirroring how
# joined_rooms tracks per-connection room membership for its own cleanup.
is_blurred = False
manager.register_user(user.id, websocket)
# Only broadcast on a genuine offline->online transition (this user's
# first open connection), not for every extra tab -- broadcast_member_
@@ -89,10 +109,23 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
# visible.
if await global_presence.connect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
await broadcast_dm_presence_update(db, broadcaster, user.id, online=True)
# This session is shared for the connection's entire lifetime (which can
# be hours) -- SQLAlchemy opens a transaction implicitly on first use,
# and every read above (the auth lookup, broadcast_member_updated's own
# query) leaves it open with nothing to ever close it otherwise. Left
# uncommitted, that transaction sits "idle in transaction" holding locks
# for as long as the socket stays open -- confirmed in production
# blocking unrelated schema migrations on the same tables for 30+
# minutes. Committing here, and again after every frame below, means
# the connection is never sitting on an open transaction while merely
# waiting for the next one.
await db.commit()
try:
while True:
raw = await websocket.receive_json()
try:
try:
envelope = ClientEnvelope.model_validate(raw)
except ValidationError:
@@ -121,6 +154,25 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await presence.leave(envelope.room_id, user.id)
joined_rooms.discard(envelope.room_id)
elif envelope.type == "focus":
# Sent only by the desktop client (#59), independent of
# room join/leave -- see FocusPresence's docstring for
# why widening desktop_notification eligibility this
# way no longer needs to touch live room delivery at
# all, unlike the "leave the room's channel on blur"
# approach this replaced.
if envelope.focused is None:
await websocket.send_json({"type": "error", "detail": "focused required"})
continue
if envelope.focused:
if is_blurred:
await focus_presence.mark_focused(user.id)
is_blurred = False
else:
if not is_blurred:
await focus_presence.mark_blurred(user.id)
is_blurred = True
elif envelope.type == "message":
if envelope.room_id is None or (
not envelope.content
@@ -146,6 +198,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
{"type": "error", "detail": "Not a member of this room"}
)
continue
if await _is_room_archived(db, envelope.room_id):
# #57: history stays fully readable (joining/reading
# an archived room's channel is untouched above),
# this is the one gate that actually makes archiving
# do something for people who were already members.
await websocket.send_json(
{"type": "error", "detail": "This room has been archived and is read-only"}
)
continue
image_id = None
if envelope.image_id is not None:
image = await db.get(MessageImage, envelope.image_id)
@@ -163,16 +224,27 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
message = await create_message(
db, envelope.room_id, user.id, envelope.content, image_id, file_id
)
# Sending implies having seen the room as of now -- without
# this, GET /rooms/mine would show the sender's own room as
# unread the instant they send into it (last_read_at isn't
# otherwise bumped until the frontend's own message echo
# triggers a mark-read call, which is a real but avoidable
# race). Deliberately not done in create_message() itself:
# the incoming-webhook path also calls it, and a webhook's
# Sending implies having seen the room as of now --
# without this, GET /rooms/mine would show the sender's
# own room as unread the instant they send into it
# (last_read_at isn't otherwise bumped until the
# frontend's own message echo triggers a mark-read
# call, which is a real but avoidable race).
# Deliberately not done in create_message() itself: the
# incoming-webhook path also calls it, and a webhook's
# attributed sender may not actually be watching.
await mark_room_read(db, envelope.room_id, user.id)
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
await broadcast_new_message(
db,
broadcaster,
presence,
focus_presence,
global_presence,
str(websocket.base_url),
envelope.room_id,
message,
user,
)
elif envelope.type == "edit":
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
@@ -204,12 +276,46 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
continue
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
elif envelope.type == "delete":
if envelope.room_id is None or envelope.message_id is None:
await websocket.send_json(
{"type": "error", "detail": "room_id and message_id required"}
)
continue
if _missing_scope(api_token, "write:messages"):
await websocket.send_json(
{"type": "error", "detail": "Token missing required scope: write:messages"}
)
continue
if envelope.room_id not in joined_rooms or not await _is_room_member(
db, envelope.room_id, user.id
):
await websocket.send_json(
{"type": "error", "detail": "Not a member of this room"}
)
continue
try:
await delete_message(db, envelope.message_id, user.id)
except MessageNotFoundError:
await websocket.send_json({"type": "error", "detail": "Message not found"})
continue
except NotMessageAuthorError:
await websocket.send_json(
{"type": "error", "detail": "You can only delete your own messages"}
)
continue
await broadcast_message_delete(broadcaster, envelope.room_id, envelope.message_id)
elif envelope.type == "reaction":
if (
envelope.room_id is None
or envelope.message_id is None
or not envelope.emoji
or len(envelope.emoji) > 8
# #18: a raw unicode glyph never gets close to this,
# but a custom emoji reaction is stored as its
# literal `:shortcode:` text (see MessageReaction.emoji's
# String(32) column, which this matches exactly).
or len(envelope.emoji) > 32
):
await websocket.send_json(
{"type": "error", "detail": "room_id, message_id, and emoji required"}
@@ -228,7 +334,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
)
continue
target_message = await db.get(Message, envelope.message_id)
if target_message is None or target_message.room_id != envelope.room_id:
if (
target_message is None
or target_message.room_id != envelope.room_id
or target_message.deleted_at is not None
):
await websocket.send_json({"type": "error", "detail": "Message not found"})
continue
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)
@@ -240,6 +350,13 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await websocket.send_json(
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
)
finally:
# See the comment on the pre-loop commit above -- guarantees
# every single frame, on every exit path (including the
# many `continue`s above, which still run a `finally`
# before actually looping), leaves nothing open while this
# blocks on the next receive_json().
await db.commit()
except WebSocketDisconnect:
pass
@@ -248,5 +365,8 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager.unregister_user(user.id, websocket)
for room_id in joined_rooms:
await presence.leave(room_id, user.id)
if is_blurred:
await focus_presence.mark_focused(user.id)
if await global_presence.disconnect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
await broadcast_dm_presence_update(db, broadcaster, user.id, online=False)
+45
View File
@@ -0,0 +1,45 @@
import uuid
from redis.asyncio import Redis
class FocusPresence:
"""Cross-instance "is this desktop-mode user's window currently
unfocused," used only to widen desktop_notification/push eligibility
beyond plain room-connection state (#59). A Redis hash (field =
user_id, value = refcount of that user's currently-blurred desktop
connections), parallel to Presence/GlobalPresence.
Absence from this hash is the default and means "focused." That default
is also exactly right for every browser-tab connection: only the
desktop client ever sends focus/blur frames at all (see chat.py's
"focus" envelope handling), so a browser user never appears here --
their attention is already fully captured by Presence's room-connection
state, which stays visibility-gated with no separate focus signal.
Refcounted for the same multi-connection reason as Presence/
GlobalPresence, with the same known simplification: two desktop windows
for one user, one focused and one blurred, count as "unfocused" here
(refcount > 0) even though the user does have attention somewhere. That
errs toward notifying rather than silently missing one, which is the
safer failure mode for a notification.
"""
def __init__(self, redis: Redis) -> None:
self._redis = redis
def _key(self) -> str:
return "presence:unfocused"
async def mark_blurred(self, user_id: uuid.UUID) -> None:
await self._redis.hincrby(self._key(), str(user_id), 1)
async def mark_focused(self, user_id: uuid.UUID) -> None:
key = self._key()
field = str(user_id)
remaining = await self._redis.hincrby(key, field, -1)
if remaining <= 0:
await self._redis.hdel(key, field)
async def is_unfocused(self, user_id: uuid.UUID) -> bool:
return await self._redis.hexists(self._key(), str(user_id))
+9 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "ds-chat"
version = "1.0.0"
version = "2026.9.3"
description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11"
@@ -15,7 +15,14 @@ dependencies = [
"pydantic-settings>=2.6",
"argon2-cffi>=23.1",
"itsdangerous>=2.2",
"pywebpush>=2.0",
# #56: >=2.0 let the production venv sit on an old 2.0.x with no real
# downside to bumping the floor -- worth keeping current regardless.
# Doesn't by itself fix WNS (Windows/Edge push): despite
# web-push-libs/pywebpush#162's discussion, even the latest release
# (2.4.0) has no WNS-specific header handling in its own source. The
# actual fix is app/services/push_service.py adding the required
# X-WNS-Cache-Policy header itself via webpush()'s `headers` param.
"pywebpush>=2.4.0",
"redis>=5.0",
"httpx>=0.27",
"gunicorn>=23.0",
+121
View File
@@ -0,0 +1,121 @@
import uuid
from app.models import User
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from tests.conftest import register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
def _make_admin_ws(ws_client, user_id: str) -> None:
async def _promote():
async with ws_client.session_factory() as session:
user = await session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await session.commit()
ws_client.portal.call(_promote)
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
async def test_archived_room_flag_reaches_existing_members(client, db_session):
# #57: is_archived used to only ever reach the admin portal's own
# AdminRoom schema -- a member's own view of the room (GET /rooms/mine)
# had no way to know it was archived at all.
admin = await register_and_login(client, db_session, username=_unique("admin"))
await _make_admin(db_session, admin["id"])
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
resp = await client.post(f"/api/admin/rooms/{room['id']}/archive")
assert resp.status_code == 200
mine = (await client.get("/api/rooms/mine")).json()
entry = next(r for r in mine if r["id"] == room["id"])
assert entry["is_archived"] is True
# History stays fully readable for an existing member.
messages_resp = await client.get(f"/api/rooms/{room['id']}/messages")
assert messages_resp.status_code == 200
resp = await client.post(f"/api/admin/rooms/{room['id']}/unarchive")
assert resp.status_code == 200
mine = (await client.get("/api/rooms/mine")).json()
entry = next(r for r in mine if r["id"] == room["id"])
assert entry["is_archived"] is False
def test_ws_message_rejected_in_archived_room(ws_client_factory, db_session):
admin_ws = ws_client_factory()
admin = _register_ws(admin_ws, _unique("admin"))
_make_admin_ws(admin_ws, admin["id"])
room = admin_ws.post("/api/rooms", json={"name": _unique("general")}).json()
archive_resp = admin_ws.post(f"/api/admin/rooms/{room['id']}/archive")
assert archive_resp.status_code == 200
with admin_ws.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "should not send"})
received = ws.receive_json()
assert received["type"] == "error"
assert "archived" in received["detail"].lower()
def test_ws_message_allowed_again_after_unarchive(ws_client_factory):
admin_ws = ws_client_factory()
admin = _register_ws(admin_ws, _unique("admin"))
_make_admin_ws(admin_ws, admin["id"])
room = admin_ws.post("/api/rooms", json={"name": _unique("general")}).json()
admin_ws.post(f"/api/admin/rooms/{room['id']}/archive")
admin_ws.post(f"/api/admin/rooms/{room['id']}/unarchive")
with admin_ws.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "back online"})
received = ws.receive_json()
assert received["type"] == "message"
assert received["content"] == "back online"
async def test_incoming_webhook_rejected_in_archived_room(client, db_session):
admin = await register_and_login(client, db_session, username=_unique("admin"))
await _make_admin(db_session, admin["id"])
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
await client.post(f"/api/admin/rooms/{room['id']}/archive")
resp = await client.post(
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "should not post"}
)
assert resp.status_code == 403
assert "archived" in resp.json()["detail"].lower()
+69 -6
View File
@@ -8,21 +8,25 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "dm_presence_update"}
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
"""Reads the next frame, transparently discarding presence-change
broadcasts (member_updated, and #63's dm_presence_update) -- another
connection sharing a room or a DM going online/offline is real,
expected noise these tests aren't about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
if msg.get("type") not in _NOISE_TYPES:
return msg
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
async def fake(db, to, subject, paragraphs, **kwargs):
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls
@@ -146,6 +150,65 @@ def test_add_member_notifies_target_user_via_websocket(ws_client_factory, monkey
assert received == {"type": "room_added", "room_id": room["id"]}
def test_start_dm_notifies_other_participant_via_websocket(ws_client_factory):
# A production report: bob had no idea a DM existed until he reloaded --
# find_or_create_dm was creating the room/membership correctly but never
# sending this signal, unlike every other "you're now in a room" path
# (add_member, above). Same shape as that test: bob is only ever
# "connected," never "joined," proving the signal alone is what tells
# his client the room exists at all.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
with instance2.websocket_connect("/ws/chat") as bob_ws:
resp = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
assert resp.status_code == 201, resp.text
room = resp.json()
received = bob_ws.receive_json()
assert received == {"type": "room_added", "room_id": room["id"]}
def test_new_message_notifies_recipient_who_hid_the_dm_via_websocket(ws_client_factory):
# A second production report on the same underlying gap: hiding a DM
# correctly clears out of GET /rooms/mine, but when the other person
# messages again, the *only* existing signal for that (unread_update)
# does `setRooms(prev => prev.map(...))` -- a no-op for a room that
# isn't in `prev` at all, which a hidden DM by definition isn't. Needs
# the same room_added signal a brand new DM gets, not just a DB-level
# un-hide.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
resp = instance2.post(f"/api/rooms/{room['id']}/hide")
assert resp.status_code == 204
with instance2.websocket_connect("/ws/chat") as bob_ws:
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "you there?"})
alice_ws.receive_json()
# Sync barrier (see test_mentions.py's identical helper): the
# message ack only proves the room-level broadcast happened,
# not that broadcast_new_message's own continuation (which
# un-hides the room and publishes room_added) has finished --
# a second frame's own ack proves that before this connection
# closes underneath it.
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
received = _recv(bob_ws)
assert received == {"type": "room_added", "room_id": room["id"]}
def test_profile_update_notifies_room_members_via_websocket(ws_client_factory, monkeypatch):
# Only reaches clients that have the room's own channel joined --
# exactly the case where a stale avatar/display name would actually be
+275
View File
@@ -0,0 +1,275 @@
import io
import uuid
from PIL import Image
from app.models import User
from tests.conftest import register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _png_bytes(size: tuple[int, int] = (10, 10), color: tuple[int, int, int] = (255, 0, 0)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color=color).save(buf, format="PNG")
return buf.getvalue()
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def test_upload_custom_emoji_succeeds(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "party-parrot"},
files={"file": ("parrot.png", _png_bytes(), "image/png")},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["shortcode"] == "party-parrot"
assert "id" in body
assert "created_at" in body
async def test_upload_normalizes_shortcode_case(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": " PartyParrot "},
files={"file": ("parrot.png", _png_bytes(), "image/png")},
)
assert resp.status_code == 201, resp.text
assert resp.json()["shortcode"] == "partyparrot"
async def test_upload_rejects_invalid_shortcode(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "a"}, # too short
files={"file": ("x.png", _png_bytes(), "image/png")},
)
assert resp.status_code == 400
async def test_upload_rejects_duplicate_shortcode(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
first = await client.post(
"/api/custom-emoji",
data={"shortcode": "dupe-test"},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
assert first.status_code == 201, first.text
second = await client.post(
"/api/custom-emoji",
data={"shortcode": "dupe-test"},
files={"file": ("b.png", _png_bytes(), "image/png")},
)
assert second.status_code == 409
async def test_upload_rejects_non_image(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "not-an-image"},
files={"file": ("x.txt", b"hello", "text/plain")},
)
assert resp.status_code == 400
async def test_upload_rejects_oversized(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
oversized = b"0" * (9 * 1024 * 1024)
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "too-big"},
files={"file": ("huge.png", oversized, "image/png")},
)
assert resp.status_code == 413
async def test_list_custom_emoji(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("listed")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
resp = await client.get("/api/custom-emoji")
assert resp.status_code == 200
assert len(resp.json()) >= 1
async def test_serve_custom_emoji_image_by_shortcode(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
shortcode = _unique("served")
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": shortcode},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
assert upload.status_code == 201
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
async def test_serve_custom_emoji_image_forces_revalidation(client, db_session):
# A timed cache (the original `max-age=300`) meant a browser that had
# already fetched a shortcode's image kept serving those bytes for up
# to 5 minutes after a delete-and-reupload swapped in a different file
# under the same URL -- confirmed live: re-adding an emoji under a
# just-deleted shortcode showed the old image. `no-cache` forces
# revalidation on every use instead (still cheap: FileResponse's own
# ETag/Last-Modified make an actually-unchanged file a 304, not a full
# re-transfer).
await register_and_login(client, db_session, username=_unique("alice"))
shortcode = _unique("revalidated")
await client.post(
"/api/custom-emoji",
data={"shortcode": shortcode},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
assert "no-cache" in resp.headers["cache-control"]
assert "max-age" not in resp.headers["cache-control"]
async def test_reuploading_a_deleted_shortcode_serves_the_new_image(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
shortcode = _unique("reused")
first = await client.post(
"/api/custom-emoji",
data={"shortcode": shortcode},
files={"file": ("red.png", _png_bytes(color=(255, 0, 0)), "image/png")},
)
assert first.status_code == 201
await client.delete(f"/api/custom-emoji/{first.json()['id']}")
second = await client.post(
"/api/custom-emoji",
data={"shortcode": shortcode},
files={"file": ("blue.png", _png_bytes(color=(0, 0, 255)), "image/png")},
)
assert second.status_code == 201
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
served = Image.open(io.BytesIO(resp.content)).convert("RGB")
assert served.getpixel((0, 0)) == (0, 0, 255)
async def test_serve_unknown_shortcode_404s(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.get("/api/custom-emoji/no-such-emoji/image")
assert resp.status_code == 404
async def test_uploader_can_delete_own_emoji(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("deleteme")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
emoji_id = upload.json()["id"]
resp = await client.delete(f"/api/custom-emoji/{emoji_id}")
assert resp.status_code == 204
listed = (await client.get("/api/custom-emoji")).json()
assert emoji_id not in [e["id"] for e in listed]
async def test_non_uploader_non_admin_cannot_delete(client, app, db_session):
from httpx import ASGITransport, AsyncClient
await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("guarded")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
emoji_id = upload.json()["id"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
await register_and_login(bob_client, db_session, username=_unique("bob"))
resp = await bob_client.delete(f"/api/custom-emoji/{emoji_id}")
assert resp.status_code == 403
listed = (await client.get("/api/custom-emoji")).json()
assert emoji_id in [e["id"] for e in listed]
async def test_site_admin_can_delete_others_emoji(client, app, db_session):
from httpx import ASGITransport, AsyncClient
alice = await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("admin-deletable")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
emoji_id = upload.json()["id"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as admin_client:
admin = await register_and_login(admin_client, db_session, username=_unique("admin"))
await _make_admin(db_session, admin["id"])
resp = await admin_client.delete(f"/api/custom-emoji/{emoji_id}")
assert resp.status_code == 204
assert alice["id"]
def _register_ws(ws_client, username: str) -> dict:
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_reaction_accepts_custom_emoji_shortcode_reference(ws_client_factory):
instance = ws_client_factory()
_register_ws(instance, _unique("alice"))
room = instance.post("/api/rooms", json={"name": _unique("general")}).json()
with instance.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = ws.receive_json()
# A custom emoji reaction is stored as its literal `:shortcode:`
# text (14 chars here) -- well past the old 8-char cap that only
# ever needed to fit a raw unicode glyph.
ws.send_json(
{
"type": "reaction",
"room_id": room["id"],
"message_id": message["id"],
"emoji": ":party-parrot:",
}
)
reaction_update = ws.receive_json()
assert reaction_update["type"] == "reaction_update"
assert reaction_update["reactions"][0]["emoji"] == ":party-parrot:"
@@ -128,6 +128,87 @@ def test_desktop_notification_not_sent_to_room_member_who_is_present(ws_client_f
assert live_message["type"] == "message"
def test_desktop_notification_sent_to_connected_but_blurred_member(ws_client_factory):
# #59: bob keeps the room's channel joined (so live delivery to a room
# actually open on screen never stops) but reports his desktop window
# as unfocused via a "focus" frame -- the whole point of this fix is
# that notification eligibility no longer needs the client to fake
# "offline" by leaving the room's channel, which used to also break
# live delivery until the room was manually left and rejoined.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
bob_ws.send_json({"type": "focus", "focused": False})
# Sync barrier -- see test_mentions.py's identical pattern: a
# second (idempotent) join only acks once the prior "focus"
# frame's own handling (and commit) has completed.
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
message = _send_and_sync(alice_ws, room["id"], "you awake?")
assert message["type"] == "message"
# Live delivery still works -- bob's room channel was never left.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
assert live_message["content"] == "you awake?"
# ...and he's still notified, despite being "connected" to the room.
desktop_note = _recv(bob_ws)
assert desktop_note == {
"type": "desktop_notification",
"id": message["id"],
"room_id": room["id"],
"title": f"#{room['name']}",
"body": f"{alice['username']}: you awake?",
}
def test_desktop_notification_not_sent_after_refocus(ws_client_factory):
# Proves the "focus" signal is a live toggle, not one-way -- blurring
# and then refocusing before the message arrives must fully cancel out.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
bob_ws.send_json({"type": "focus", "focused": False})
bob_ws.send_json({"type": "focus", "focused": True})
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "hello again")
# Refocused before the message arrived -- only the live broadcast,
# same as test_desktop_notification_not_sent_to_room_member_who_is_present.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
def test_desktop_notification_not_sent_to_non_member(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
+342
View File
@@ -0,0 +1,342 @@
import uuid
from sqlalchemy import select
from app.models import Room, RoomMembership
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.services.room_service import dm_room_name
from tests.conftest import login_as, register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
async def test_start_dm_creates_private_room_with_both_members(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
assert resp.status_code == 201, resp.text
room = resp.json()
assert room["is_dm"] is True
assert room["is_private"] is True
# The internal name is never meant to be shown, but its scheme is part
# of the contract find_or_create_dm relies on -- pin it here so a
# future refactor can't silently change it without this test noticing.
assert room["name"] == dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"]))
result = await db_session.execute(
select(RoomMembership.user_id).where(RoomMembership.room_id == uuid.UUID(room["id"]))
)
member_ids = {str(row[0]) for row in result.all()}
assert member_ids == {alice["id"], bob["id"]}
async def test_start_dm_is_idempotent_regardless_of_who_initiates(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
resp1 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
assert resp1.status_code == 201
room_id = resp1.json()["id"]
# bob -> alice again should return the same room, not create a second one.
resp2 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
assert resp2.status_code == 201
assert resp2.json()["id"] == room_id
await client.post("/api/auth/logout")
await login_as(client, "alice")
# alice -> bob (reversed direction) should also find the same room.
resp3 = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
assert resp3.status_code == 201
assert resp3.json()["id"] == room_id
async def test_start_dm_rejects_self(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
assert resp.status_code == 400
async def test_start_dm_404s_for_unknown_user(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/rooms/dm", json={"other_user_id": str(uuid.uuid4())})
assert resp.status_code == 404
async def test_dm_excluded_from_browse_rooms(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
# A third user should never see the DM in the open-rooms listing, even
# though find_or_create_dm sets is_private=True (which alone would
# already exclude it) -- confirms the belt-and-suspenders is_dm filter
# in list_open_rooms is doing something, not just is_private.
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="carol")
resp = await client.get("/api/rooms")
assert resp.status_code == 200
assert all(not r["is_dm"] for r in resp.json())
async def test_dm_excluded_from_admin_room_list(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
await client.post("/api/auth/logout")
admin = await register_and_login(client, db_session, username="dave")
from app.models import User
user = await db_session.get(User, uuid.UUID(admin["id"]))
user.is_site_admin = True
await db_session.commit()
resp = await client.get("/api/admin/rooms")
assert resp.status_code == 200
names = [r["name"] for r in resp.json()]
assert dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"])) not in names
async def test_dm_appears_in_mine_with_partner_info(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob", password="password123")
# Give bob a display name so the partner payload's precedence is checked
# for something other than the fallback username.
await client.patch("/api/auth/me", json={"display_name": "Bobby"})
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
await client.post("/api/auth/logout")
await login_as(client, "alice")
mine = (await client.get("/api/rooms/mine")).json()
dm_entry = next(r for r in mine if r["id"] == dm["id"])
assert dm_entry["is_dm"] is True
assert dm_entry["dm_partner"]["user_id"] == bob["id"]
assert dm_entry["dm_partner"]["username"] == "bob"
assert dm_entry["dm_partner"]["display_name"] == "Bobby"
# A regular room's dm_partner is always null.
room = (await client.post("/api/rooms", json={"name": "general"})).json()
mine = (await client.get("/api/rooms/mine")).json()
room_entry = next(r for r in mine if r["id"] == room["id"])
assert room_entry["is_dm"] is False
assert room_entry["dm_partner"] is None
async def test_dm_cannot_be_updated(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
# bob is a plain 'member' of the DM (no admin/owner role exists for a
# DM), so this 403s on the ordinary role gate before ever reaching
# update_room's own is_dm guard.
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
assert resp.status_code == 403
# A site admin bypasses that role gate (see #48) -- confirms the
# explicit is_dm guard inside update_room itself is what stops this,
# not just incidental role-based protection.
await client.post("/api/auth/logout")
admin = await register_and_login(client, db_session, username="carol")
from app.models import User
user = await db_session.get(User, uuid.UUID(admin["id"]))
user.is_site_admin = True
await db_session.commit()
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
assert resp.status_code == 400
async def test_dm_rejects_add_member_and_join(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
await client.post("/api/auth/logout")
carol = await register_and_login(client, db_session, username="carol")
# Neither participant has admin/owner role in a DM, so adding a third
# person 403s on the existing role gate.
await client.post("/api/auth/logout")
await login_as(client, "bob")
resp = await client.post(f"/api/rooms/{dm['id']}/members", json={"user_id": carol["id"]})
assert resp.status_code == 403
# is_private=True on the DM already blocks the plain join endpoint too.
await client.post("/api/auth/logout")
await login_as(client, "carol")
resp = await client.post(f"/api/rooms/{dm['id']}/join")
assert resp.status_code == 400
async def test_hide_dm_removes_it_from_mine_for_that_user_only(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
resp = await client.post(f"/api/rooms/{dm['id']}/hide")
assert resp.status_code == 204
mine = (await client.get("/api/rooms/mine")).json()
assert all(r["id"] != dm["id"] for r in mine)
# Alice never hid it -- still sees it, proving this is per-viewer, not
# something that touched the room or bob's membership for everyone.
await client.post("/api/auth/logout")
await login_as(client, "alice")
mine = (await client.get("/api/rooms/mine")).json()
assert any(r["id"] == dm["id"] for r in mine)
async def test_hide_dm_rejects_regular_rooms(client, db_session):
await register_and_login(client, db_session, username="alice")
room = (await client.post("/api/rooms", json={"name": "general"})).json()
resp = await client.post(f"/api/rooms/{room['id']}/hide")
assert resp.status_code == 400
async def test_starting_a_dm_again_unhides_it(client, db_session):
alice = await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
await client.post(f"/api/rooms/{dm['id']}/hide")
mine = (await client.get("/api/rooms/mine")).json()
assert all(r["id"] != dm["id"] for r in mine)
# bob clicking alice in the People list again -- find_or_create_dm
# resolves to the same room and un-hides it for him.
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
assert resp.status_code == 201
assert resp.json()["id"] == dm["id"]
mine = (await client.get("/api/rooms/mine")).json()
assert any(r["id"] == dm["id"] for r in mine)
def test_new_message_unhides_dm_for_both_participants(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
bob = _register_ws(ws_client, _unique("bob")) # ws_client is now logged in as bob
dm = ws_client.post("/api/rooms/dm", json={"other_user_id": alice["id"]}).json()
ws_client.post(f"/api/rooms/{dm['id']}/hide")
assert all(r["id"] != dm["id"] for r in ws_client.get("/api/rooms/mine").json())
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": dm["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": dm["id"], "content": "you there?"})
ws.receive_json()
# Sync barrier (see test_mentions.py's identical helper): the
# message ack only proves the room-level broadcast happened, not
# that broadcast_new_message's own continuation (which un-hides
# the room) has finished -- a second frame's own ack proves that.
ws.send_json({"type": "join", "room_id": dm["id"]})
assert ws.receive_json()["type"] == "joined"
# bob never re-opened the DM himself -- alice's message alone unhid it.
ws_client.post(
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
)
assert any(r["id"] == dm["id"] for r in ws_client.get("/api/rooms/mine").json())
def test_dm_partner_presence_update_delivered_without_room_open(ws_client_factory):
# #63: alice never joins the DM's own room channel anywhere in this
# test -- exactly the normal state for a DM sitting in the sidebar that
# isn't the currently open room. member_updated's room-channel broadcast
# would never reach her in that state (Presence gates it on having that
# specific room joined); this signal has to arrive on her own per-user
# channel instead, same as room_added.
#
# Only the connect ("online") side is exercised here, not disconnect --
# see test_presence.py's module docstring for why the offline half
# isn't reliably testable via a `with websocket_connect(...)` block
# closing (TestClient cancels the server task rather than delivering a
# real disconnect, which can interrupt a `finally` block's own awaits
# in tests only, never in production).
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
with instance1.websocket_connect("/ws/chat") as alice_ws:
# Sync barrier: alice's own websocket_connect() returning only
# proves the handshake completed, not that chat.py's connection
# setup (register_user, in particular -- required before bob's
# connect can reach her per-user channel at all) has finished.
# Any reply -- even an error -- proves the connection has reached
# its main frame loop, which setup always completes before.
alice_ws.send_json({"type": "__sync_barrier__"})
assert alice_ws.receive_json()["type"] == "error"
with instance2.websocket_connect("/ws/chat"):
online_update = alice_ws.receive_json()
assert online_update == {
"type": "dm_presence_update",
"user_id": bob["id"],
"status": "online",
}
def test_dm_presence_update_not_sent_to_non_partner(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
instance3 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
_register_ws(instance3, _unique("outsider"))
instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
with instance3.websocket_connect("/ws/chat") as outsider_ws:
with instance2.websocket_connect("/ws/chat"):
pass
# Nothing should ever arrive for an outsider who shares no DM with
# bob. Prove the socket stayed quiet the same way
# test_desktop_notifications.py's non-member test does: a harmless
# self-targeted join, whose prompt "joined" ack proves nothing else
# was already queued ahead of it.
room = instance3.post("/api/rooms", json={"name": _unique("outsiders-room")}).json()
outsider_ws.send_json({"type": "join", "room_id": room["id"]})
joined = outsider_ws.receive_json()
assert joined == {"type": "joined", "room_id": room["id"]}
@@ -0,0 +1,207 @@
import uuid
from app.models import SmtpSettings
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _fake_smtp(monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append({"message": message, **kwargs})
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
# Monkeypatches get_smtp_settings directly rather than configuring it
# for real through the admin endpoint (like test_smtp_settings.py's own
# _configure_smtp does) -- ws_client_factory-based tests commit for
# real, no rollback, and SmtpSettings is a genuine single global row.
# Configuring it for real here previously leaked into every later test
# in the same run, breaking test_smtp_settings.py's "starts
# unconfigured" assumption. This never touches the DB at all.
fake_settings = SmtpSettings(
host="smtp.example.com",
port=587,
username="bot",
password_encrypted=None,
from_address="noreply@example.com",
use_tls=True,
)
async def fake_get_smtp_settings(db):
return fake_settings
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
return calls
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def _send_and_sync(ws, room_id: str, content: str) -> dict:
"""Sync barrier -- see test_mentions.py's identical helper. The
"message" ack fires the instant broadcaster.publish() runs, the very
first line of broadcast_new_message -- it proves nothing about whether
_maybe_email_dm_notification (awaited afterward, in the same handler)
has finished. A second, idempotent join's own ack only arrives once
the whole prior frame's handling -- including the email step -- is
done, since one connection processes frames strictly sequentially."""
ws.send_json({"type": "message", "room_id": room_id, "content": content})
message = ws.receive_json()
ws.send_json({"type": "join", "room_id": room_id})
assert ws.receive_json()["type"] == "joined"
return message
def test_dm_message_emails_globally_offline_recipient(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
# bob never connects via WS at all -- genuinely offline, not just
# absent from this room's own channel.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, dm["id"], "hey, you there?")
assert len(calls) == 1
email = calls[0]["message"]
assert email["To"] == bob["email"]
assert f"New message from {alice['username']}" in email["Subject"]
# #68: the email is now multipart/alternative (HTML + plain-text
# fallback) -- get_body(preferencelist=...) reaches a specific part,
# unlike get_content() which has no handler for the multipart itself.
body = email.get_body(preferencelist=("plain",)).get_content()
assert f"{alice['username']}: hey, you there?" in body
assert f"/rooms/{dm['id']}" in body
def test_dm_message_does_not_email_online_recipient(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
with instance2.websocket_connect("/ws/chat"):
# bob has an open connection -- genuinely online -- even though he
# never joins the DM's own room channel.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, dm["id"], "hey")
assert calls == []
def test_dm_message_emails_appear_offline_recipient_even_when_connected(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
resp = instance2.patch("/api/auth/me", json={"appear_offline": True})
assert resp.status_code == 200
with instance2.websocket_connect("/ws/chat"):
# bob is connected (genuinely online) but lurking -- appear_offline
# should still count as "email me," matching how it already
# overrides the presence dot everywhere else.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, dm["id"], "hey")
assert len(calls) == 1
assert calls[0]["message"]["To"] == bob["email"]
def test_regular_room_message_does_not_email_offline_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
# bob never connects -- genuinely offline, same as the DM case -- but
# this isn't a DM, so #66's email notification is out of scope here.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "hello room")
assert calls == []
def test_dm_email_debounced_to_first_unread_then_resets_after_read(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, dm["id"], "message one")
assert len(calls) == 1
# A second message while bob still hasn't read the first -- no
# second email for the same burst.
_send_and_sync(alice_ws, dm["id"], "message two")
assert len(calls) == 1
# bob "reads" the conversation via REST -- he never has to have been
# connected via WS for this to be meaningful, mark-read is independent
# of live connection state.
instance2.post(
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
)
read_resp = instance2.post(f"/api/rooms/{dm['id']}/read")
assert read_resp.status_code == 204
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, dm["id"], "message three")
assert len(calls) == 2
+37
View File
@@ -99,6 +99,43 @@ async def test_serve_file_forces_download(client, db_session):
assert "notes.txt" in disposition
async def test_serve_allowlisted_video_inline(client, db_session):
# #65: a <video> tag can't play something the browser is forced to
# download instead -- browser-playable video types are the one carve-
# out from test_serve_file_forces_download's rule above.
await register_and_login(client, db_session, username=_unique("alice"))
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
upload = await client.post(
f"/api/rooms/{room['id']}/files",
files={"file": ("clip.mp4", b"not a real mp4", "video/mp4")},
)
file_id = upload.json()["id"]
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
assert resp.status_code == 200
assert "content-disposition" not in resp.headers
assert resp.headers["content-type"] == "video/mp4"
async def test_serve_non_allowlisted_video_still_forces_download(client, db_session):
# video/quicktime (.mov) has spotty <video> support outside Safari, and
# more importantly this proves the carve-out is a strict allowlist, not
# "every video/* content type" -- the security-relevant boundary from
# test_serve_file_forces_download must still hold for anything not on
# INLINE_SAFE_VIDEO_CONTENT_TYPES.
await register_and_login(client, db_session, username=_unique("alice"))
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
upload = await client.post(
f"/api/rooms/{room['id']}/files",
files={"file": ("clip.mov", b"not a real mov", "video/quicktime")},
)
file_id = upload.json()["id"]
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
assert resp.status_code == 200
assert "attachment" in resp.headers["content-disposition"]
def _register_ws(ws_client, username: str) -> dict:
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
+67
View File
@@ -1,9 +1,13 @@
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
import pytest_asyncio
from sqlalchemy import select
from app.database import async_session_factory
from app.database import engine as _link_preview_engine
from app.models import LinkPreview
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.services.link_preview_service import extract_first_url
@@ -294,3 +298,66 @@ async def test_link_preview_reused_across_messages_with_same_url(client, db_sess
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
assert len(history) == 2
assert all(m["link_preview"]["title"] == "Example Article" for m in history)
async def test_link_preview_refetches_after_cache_expires(client, db_session, monkeypatch):
# #70: a real report -- re-posting a URL whose title had genuinely
# changed kept showing the stale first-fetch preview, because the
# cache TTL used to be 7 days. Simulates that expiry directly (rather
# than actually sleeping 5+ minutes) by backdating the cached row's
# fetched_at past the TTL, then confirms a second post of the same URL
# picks up new content instead of the stale cached title.
captured_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
def fake_create_task(coro):
task = real_create_task(coro)
captured_tasks.append(task)
return task
monkeypatch.setattr("app.services.message_events.asyncio.create_task", fake_create_task)
calls: list[str] = []
monkeypatch.setattr(
"app.services.link_preview_service.httpx.AsyncClient",
_fake_client_factory(call_log=calls),
)
url = _unique_url()
await register_and_login(client, db_session, username="alice")
room = (await client.post("/api/rooms", json={"name": "general"})).json()
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
resp1 = await client.post(
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"see {url}"}
)
assert resp1.status_code == 204
await asyncio.gather(*captured_tasks)
captured_tasks.clear()
async with async_session_factory() as session:
row = (
await session.execute(select(LinkPreview).where(LinkPreview.url == url))
).scalar_one()
row.fetched_at = datetime.now(timezone.utc) - timedelta(minutes=10)
await session.commit()
updated_html = _OG_HTML.replace(b"Example Article", b"Updated Article")
monkeypatch.setattr(
"app.services.link_preview_service.httpx.AsyncClient",
_fake_client_factory(html=updated_html, call_log=calls),
)
resp2 = await client.post(
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"again: {url}"}
)
assert resp2.status_code == 204
await asyncio.gather(*captured_tasks)
assert len(calls) == 2 # the expired cache forced a second real fetch
# Cached by URL, not by message (see link_preview_service.py) -- the
# row was refreshed in place, so *both* messages referencing this URL
# now show the new title on a history reload, not one each.
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
assert len(history) == 2
assert all(m["link_preview"]["title"] == "Updated Article" for m in history)
+222
View File
@@ -0,0 +1,222 @@
import io
import uuid
from PIL import Image
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding presence/offline-
notify noise -- see test_message_edit.py's identical helper."""
while True:
msg = ws.receive_json()
if msg.get("type") not in _NOISE_TYPES:
return msg
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def _png_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (10, 10), color=(255, 0, 0)).save(buf, format="PNG")
return buf.getvalue()
def test_ws_delete_clears_content_and_broadcasts(ws_client):
username = _unique("alice")
_register_ws(ws_client, username=username)
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = ws.receive_json()
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
deleted = ws.receive_json()
assert deleted == {"type": "message_deleted", "id": message["id"], "room_id": room["id"]}
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
history = resp.json()
tombstone = next(m for m in history if m["id"] == message["id"])
assert tombstone["content"] is None
assert tombstone["deleted_at"] is not None
assert tombstone["image_id"] is None
assert tombstone["file"] is None
def test_ws_delete_rejects_non_author(ws_client):
alice = _register_ws(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, username=_unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = alice_ws.receive_json()
ws_client.post(
"/api/auth/login",
json={"username_or_email": bob["username"], "password": "password123"},
)
with ws_client.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert _recv(bob_ws)["type"] == "joined"
bob_ws.send_json(
{"type": "delete", "room_id": room["id"], "message_id": message["id"]}
)
resp = bob_ws.receive_json()
assert resp["type"] == "error"
assert "own messages" in resp["detail"]
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
history = resp.json()
still_there = next(m for m in history if m["id"] == message["id"])
assert still_there["deleted_at"] is None
assert still_there["content"] == "hello"
def test_ws_delete_of_unknown_message_errors(ws_client):
_register_ws(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json(
{"type": "delete", "room_id": room["id"], "message_id": str(uuid.uuid4())}
)
resp = ws.receive_json()
assert resp == {"type": "error", "detail": "Message not found"}
def test_deleted_message_cannot_be_edited_or_reacted_to(ws_client):
_register_ws(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = ws.receive_json()
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
assert ws.receive_json()["type"] == "message_deleted"
ws.send_json(
{
"type": "edit",
"room_id": room["id"],
"message_id": message["id"],
"content": "resurrected",
}
)
assert ws.receive_json() == {"type": "error", "detail": "Message not found"}
ws.send_json(
{
"type": "reaction",
"room_id": room["id"],
"message_id": message["id"],
"emoji": "👍",
}
)
assert ws.receive_json() == {"type": "error", "detail": "Message not found"}
def test_delete_fans_out_across_instances(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert _recv(bob_ws)["type"] == "joined"
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
message = alice_ws.receive_json()
assert _recv(bob_ws)["type"] == "message"
alice_ws.send_json(
{"type": "delete", "room_id": room["id"], "message_id": message["id"]}
)
assert alice_ws.receive_json()["type"] == "message_deleted"
deleted = _recv(bob_ws)
assert deleted == {"type": "message_deleted", "id": message["id"], "room_id": room["id"]}
def test_delete_removes_underlying_image_from_disk(ws_client):
username = _unique("alice")
_register_ws(ws_client, username=username)
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
upload = ws_client.post(
f"/api/rooms/{room['id']}/images",
files={"file": ("test.png", _png_bytes(), "image/png")},
).json()
image_id = upload["id"]
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "image_id": image_id})
message = ws.receive_json()
assert message["image_id"] == image_id
# Confirm the image actually serves before deleting, so a false
# pass (it was never reachable to begin with) can't hide as a true
# one below.
get_resp = ws_client.get(f"/api/rooms/{room['id']}/images/{image_id}")
assert get_resp.status_code == 200
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
assert ws.receive_json()["type"] == "message_deleted"
# The image is gone -- both the DB row (via the now-404ing serve
# endpoint) and, per #53's "delete the file too" choice, the file
# actually unlinked from disk (not just detached and orphaned).
get_resp = ws_client.get(f"/api/rooms/{room['id']}/images/{image_id}")
assert get_resp.status_code == 404
+12 -6
View File
@@ -8,13 +8,19 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
"""Reads the next frame, transparently discarding presence/offline-
notify noise -- another connection in the same room going online/
offline, or a per-user-channel side effect of an earlier offline
member's own message, can legitimately arrive right as a connection is
established, before its own "joined" ack. Not what these tests are
about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
if msg.get("type") not in _NOISE_TYPES:
return msg
@@ -90,7 +96,7 @@ def test_ws_edit_rejects_non_author(ws_client):
)
with ws_client.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
assert _recv(bob_ws)["type"] == "joined"
bob_ws.send_json(
{
"type": "edit",
@@ -116,7 +122,7 @@ def test_edit_fans_out_across_instances(ws_client_factory):
with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
assert _recv(bob_ws)["type"] == "joined"
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
+14 -5
View File
@@ -10,8 +10,17 @@ from tests.conftest import register_and_login
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
calls.append(
{
"to": to,
"subject": subject,
"paragraphs": paragraphs,
"cta_label": cta_label,
"cta_url": cta_url,
"theme_user": theme_user,
}
)
monkeypatch.setattr("app.services.password_service.send_email", fake)
return calls
@@ -91,7 +100,7 @@ async def test_reset_password_flow_end_to_end(client, db_session, monkeypatch):
await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
token = _extract_token(calls[0]["body"])
token = _extract_token(calls[0]["cta_url"])
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
assert validate.status_code == 204
@@ -133,7 +142,7 @@ async def test_reset_password_expired_token_rejected(client, db_session, monkeyp
await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
token = _extract_token(calls[0]["body"])
token = _extract_token(calls[0]["cta_url"])
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
@@ -150,7 +159,7 @@ async def test_reset_password_used_token_cannot_be_reused(client, db_session, mo
await register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
token = _extract_token(calls[0]["body"])
token = _extract_token(calls[0]["cta_url"])
first = await client.post(
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
+56
View File
@@ -102,6 +102,62 @@ async def test_theme_custom_rejected_on_generic_profile_update(client, db_sessio
assert resp.status_code == 422
async def test_update_text_scale_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"text_scale": "large"})
assert resp.status_code == 200, resp.text
assert resp.json()["text_scale"] == "large"
me = await client.get("/api/auth/me")
assert me.json()["text_scale"] == "large"
async def test_invalid_text_scale_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"text_scale": "huge"})
assert resp.status_code == 422
async def test_updating_text_scale_does_not_clobber_theme(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"theme": "sunset"})
resp = await client.patch("/api/auth/me", json={"text_scale": "xlarge"})
assert resp.status_code == 200
assert resp.json()["theme"] == "sunset"
assert resp.json()["text_scale"] == "xlarge"
async def test_update_emoji_scale_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"emoji_scale": "xlarge"})
assert resp.status_code == 200, resp.text
assert resp.json()["emoji_scale"] == "xlarge"
me = await client.get("/api/auth/me")
assert me.json()["emoji_scale"] == "xlarge"
async def test_invalid_emoji_scale_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"emoji_scale": "huge"})
assert resp.status_code == 422
async def test_updating_emoji_scale_does_not_clobber_text_scale(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"text_scale": "large"})
resp = await client.patch("/api/auth/me", json={"emoji_scale": "small"})
assert resp.status_code == 200
assert resp.json()["text_scale"] == "large"
assert resp.json()["emoji_scale"] == "small"
async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
+112
View File
@@ -218,3 +218,115 @@ def test_expired_subscription_is_cleaned_up(ws_client, monkeypatch):
assert ws.receive_json()["type"] == "joined"
assert _fetch_subscriptions(ws_client, bob["id"]) == []
def test_non_gone_push_failure_logs_response_detail_and_keeps_subscription(ws_client, monkeypatch):
# #56: a real WNS 400 carries its actual reason in a response *header*,
# not the body -- str(WebPushException) alone (what used to be logged)
# would have shown neither, which is exactly why that bug took a DB
# dump + journalctl correlation to diagnose instead of one log line.
class FakeResponse:
status_code = 400
text = "Bad Request"
headers = {"X-WNS-Error-Description": "Ttl value conflicts with X-WNS-Cache-Policy"}
def fake_webpush(**kwargs):
raise WebPushException("Push failed: 400 Bad Request", response=FakeResponse())
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
# caplog's handler capture isn't reliable here -- the actual push send
# (and its logger.warning call) runs on ws_client_factory's background
# portal thread (see that fixture's own docstring), not pytest's main
# thread. Patching the logger call directly sidesteps that instead of
# depending on cross-thread log propagation.
calls = []
monkeypatch.setattr(
"app.services.push_service.logger.warning",
lambda msg, *args: calls.append(msg % args),
)
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
assert ws.receive_json()["type"] == "message"
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
# A 400 isn't "gone" (404/410) -- the subscription stays, unlike the
# expired-subscription case above.
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
assert len(calls) == 1
assert "Bad Request" in calls[0]
assert "Ttl value conflicts with X-WNS-Cache-Policy" in calls[0]
def test_wns_endpoint_gets_cache_policy_header(ws_client, monkeypatch):
calls = []
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post(
"/api/push/subscribe",
json={
"endpoint": f"https://wns2-by3p.notify.windows.com/w/{_unique('bob')}",
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
},
)
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
assert ws.receive_json()["type"] == "message"
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
assert len(calls) == 1
assert calls[0]["headers"] == {"X-WNS-Cache-Policy": "no-cache"}
def test_non_wns_endpoint_gets_no_extra_headers(ws_client, monkeypatch):
calls = []
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
assert ws.receive_json()["type"] == "message"
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
assert len(calls) == 1
assert calls[0]["headers"] is None
+11 -5
View File
@@ -9,13 +9,19 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
"""Reads the next frame, transparently discarding presence/offline-
notify noise -- another connection in the same room going online/
offline, or a per-user-channel side effect of an earlier offline
member's own message, can legitimately arrive right as a connection is
established, before its own "joined" ack. Not what these tests are
about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
if msg.get("type") not in _NOISE_TYPES:
return msg
@@ -122,7 +128,7 @@ def test_reaction_broadcasts_to_other_room_members(ws_client):
)
with ws_client.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
assert _recv(bob_ws)["type"] == "joined"
ws_client.post(
"/api/auth/login",
@@ -0,0 +1,255 @@
import uuid
from app.models import SmtpSettings
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _fake_smtp(monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append({"message": message, **kwargs})
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
# See test_dm_email_notifications.py's identical helper for why this
# monkeypatches get_smtp_settings directly instead of configuring a real
# row through the admin endpoint.
fake_settings = SmtpSettings(
host="smtp.example.com",
port=587,
username="bot",
password_encrypted=None,
from_address="noreply@example.com",
use_tls=True,
)
async def fake_get_smtp_settings(db):
return fake_settings
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
return calls
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def _send_and_sync(ws, room_id: str, content: str) -> dict:
"""See test_dm_email_notifications.py's identical helper -- the
"message" ack alone proves nothing about whether the email step
(awaited afterward in the same handler) has finished; a second,
idempotent join's ack only arrives once the whole frame is done."""
ws.send_json({"type": "message", "room_id": room_id, "content": content})
message = ws.receive_json()
ws.send_json({"type": "join", "room_id": room_id})
assert ws.receive_json()["type"] == "joined"
return message
def _subscribe(ws_client, room_id: str, username: str, password: str = "password123") -> None:
login = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": password}
)
assert login.status_code == 200, login.text
resp = ws_client.patch(f"/api/rooms/{room_id}/notifications", json={"email_notifications": True})
assert resp.status_code == 204, resp.text
def test_room_first_message_emails_offline_subscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
# bob never connects via WS -- genuinely offline.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "no mention here, just a plain message")
assert len(calls) == 1
email = calls[0]["message"]
assert email["To"] == bob["email"]
assert f"New message in #{room['name']}" in email["Subject"]
body = email.get_body(preferencelist=("plain",)).get_content()
assert alice["username"] in body
assert f"/rooms/{room['id']}" in body
def test_room_second_plain_message_does_not_reemail_before_read(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "message one")
assert len(calls) == 1
# A second plain message while bob still hasn't read the first --
# no second email for the same unread burst.
_send_and_sync(alice_ws, room["id"], "message two")
assert len(calls) == 1
def test_room_mention_always_emails_even_mid_unread_burst(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
# First unread message (plain) -- emails once, uses up the debounce.
_send_and_sync(alice_ws, room["id"], "hey everyone")
assert len(calls) == 1
# A mention arriving while that first message is still unread --
# must email anyway, unlike a second plain message.
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} specifically you")
assert len(calls) == 2
mention_email = calls[1]["message"]
assert mention_email["To"] == bob["email"]
assert f"New mention in #{room['name']}" in mention_email["Subject"]
body = mention_email.get_body(preferencelist=("plain",)).get_content()
assert f"{alice['username']} mentioned you" in body
def test_room_mention_does_not_email_unsubscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
# bob never opts in -- email_notifications defaults to False.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
assert calls == []
def test_room_message_does_not_email_online_subscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance2.websocket_connect("/ws/chat"):
# bob has an open connection -- genuinely online -- even though he
# never joins this room's own channel.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
assert calls == []
def test_room_notifications_reset_after_read(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "message one")
assert len(calls) == 1
instance2.post(
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
)
read_resp = instance2.post(f"/api/rooms/{room['id']}/read")
assert read_resp.status_code == 204
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "message two")
assert len(calls) == 2
def test_enabling_notifications_rejected_for_dm(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
resp = instance1.patch(f"/api/rooms/{dm['id']}/notifications", json={"email_notifications": True})
assert resp.status_code == 400
def test_notifications_setting_reflected_in_my_rooms(ws_client_factory):
instance1 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
rooms = instance1.get("/api/rooms/mine").json()
mine = next(r for r in rooms if r["id"] == room["id"])
assert mine["email_notifications"] is False
resp = instance1.patch(f"/api/rooms/{room['id']}/notifications", json={"email_notifications": True})
assert resp.status_code == 204
rooms = instance1.get("/api/rooms/mine").json()
mine = next(r for r in rooms if r["id"] == room["id"])
assert mine["email_notifications"] is True
+147 -3
View File
@@ -1,11 +1,52 @@
import io
import uuid
from PIL import Image
from sqlalchemy import select
from app.models import Room, RoomMembership, RoomRole, User
from app.models import (
EventSubscription,
MessageFile,
MessageImage,
MessageRoomReference,
Room,
RoomMembership,
RoomRole,
User,
WebhookIncoming,
)
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.storage import UPLOADS_DIR
from tests.conftest import login_as, register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _png_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (10, 10), color=(255, 0, 0)).save(buf, format="PNG")
return buf.getvalue()
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
async def test_create_room_requires_auth(client):
resp = await client.post("/api/rooms", json={"name": "general"})
assert resp.status_code == 401
@@ -210,6 +251,88 @@ async def test_delete_room_owner_only(client, db_session):
assert result.scalar_one_or_none() is None
def test_delete_room_with_attachments_integrations_and_cross_room_reference(ws_client):
# Reproduces #55: every table below has a room_id (or message_id, for a
# room being deleted) foreign key with no ON DELETE CASCADE at the DB
# level, so a room that's ever had an attachment, an integration, or
# been #referenced from another room's message used to 500 on delete.
# This sets up one of each and confirms delete_room cleans all of them
# up, not just whichever one originally surfaced the bug.
_register_ws(ws_client, _unique("alice"))
target_name = _unique("target-room")
other_name = _unique("other-room")
target = ws_client.post("/api/rooms", json={"name": target_name}).json()
other = ws_client.post("/api/rooms", json={"name": other_name}).json()
image_id = ws_client.post(
f"/api/rooms/{target['id']}/images",
files={"file": ("test.png", _png_bytes(), "image/png")},
).json()["id"]
file_upload = ws_client.post(
f"/api/rooms/{target['id']}/files",
files={"file": ("report.pdf", b"%PDF-1.4 not real", "application/pdf")},
).json()
file_id = file_upload["id"]
webhook = ws_client.post(f"/api/rooms/{target['id']}/webhooks/incoming", json={}).json()
sub = ws_client.post(
f"/api/rooms/{target['id']}/event-subscriptions",
json={"event_types": ["message.created"], "target_url": "http://8.8.8.8/hook"},
).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": target["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": target["id"], "image_id": image_id})
ws.receive_json()
ws.send_json({"type": "message", "room_id": target["id"], "file_id": file_id})
ws.receive_json()
ws.send_json({"type": "join", "room_id": other["id"]})
assert ws.receive_json()["type"] == "joined"
# References target-room from a message that belongs to a
# *different* room -- the direction that originally 500'd, since
# it's keyed by the referenced room's id, not the message's room.
ws.send_json({"type": "message", "room_id": other["id"], "content": f"check out #{target_name}"})
other_message = ws.receive_json()
async def _storage_filenames():
async with ws_client.session_factory() as session:
image = await session.get(MessageImage, uuid.UUID(image_id))
file = await session.get(MessageFile, uuid.UUID(file_id))
return image.storage_filename, file.storage_filename
image_filename, file_filename = ws_client.portal.call(_storage_filenames)
assert (UPLOADS_DIR / image_filename).exists()
assert (UPLOADS_DIR / file_filename).exists()
resp = ws_client.delete(f"/api/rooms/{target['id']}")
assert resp.status_code == 204, resp.text
async def _assert_cleaned_up():
async with ws_client.session_factory() as session:
target_id = uuid.UUID(target["id"])
for model in (MessageImage, MessageFile, WebhookIncoming, EventSubscription):
result = await session.execute(select(model).where(model.room_id == target_id))
assert result.scalar_one_or_none() is None, model.__name__
result = await session.execute(
select(MessageRoomReference).where(MessageRoomReference.room_id == target_id)
)
assert result.scalar_one_or_none() is None
ws_client.portal.call(_assert_cleaned_up)
# The physical files were unlinked too, not just the DB rows.
assert not (UPLOADS_DIR / image_filename).exists()
assert not (UPLOADS_DIR / file_filename).exists()
# Sanity: deleting target-room didn't touch the unrelated other-room or
# its message -- the cross-room reference cleanup is scoped correctly.
history = ws_client.get(f"/api/rooms/{other['id']}/messages").json()
assert any(m["id"] == other_message["id"] for m in history)
assert webhook["id"] and sub["id"] # created successfully, not otherwise asserted above
async def test_leave_room(client, db_session):
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
@@ -327,8 +450,8 @@ async def test_change_member_role_owner_only(client, db_session):
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
async def fake(db, to, subject, paragraphs, **kwargs):
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls
@@ -361,6 +484,27 @@ async def test_add_member_directly(client, db_session, monkeypatch):
assert "added" in calls[0]["subject"].lower()
async def test_add_member_posts_welcome_message(client, db_session, monkeypatch):
# #74: posted as the auto-provisioned "system" account, not the admin
# who added them -- mirrors test_add_member_directly's setup.
_fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]})
assert resp.status_code == 201, resp.text
history = (await client.get(f"/api/rooms/{room_id}/messages")).json()
welcome_messages = [m for m in history if m["username"] == "system"]
assert len(welcome_messages) == 1
assert welcome_messages[0]["content"] == "Welcome to #general, bob!"
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
_fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
+112
View File
@@ -0,0 +1,112 @@
import uuid
from httpx import ASGITransport, AsyncClient
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from tests.conftest import login_as, register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
async def test_login_creates_a_session(client, db_session):
username = _unique("alice")
await register_user(
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
)
resp = await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": "password123"},
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36"},
)
assert resp.status_code == 200, resp.text
sessions = (await client.get("/api/auth/sessions")).json()
assert len(sessions) == 1
assert sessions[0]["is_current"] is True
assert sessions[0]["device_label"] == "Chrome on Windows"
assert sessions[0]["ip_address"]
async def test_logout_revokes_the_session(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
assert len((await client.get("/api/auth/sessions")).json()) == 1
await client.post("/api/auth/logout")
# The important part is server-side: the session row itself is gone
# from the active list (this request also 401s since this client's own
# cookie was cleared, but that alone wouldn't prove the *row* is dead).
resp = await client.get("/api/auth/sessions")
assert resp.status_code == 401
async def test_x_forwarded_for_takes_priority_over_direct_peer(client, db_session):
username = _unique("alice")
await register_user(
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
)
await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": "password123"},
headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"},
)
sessions = (await client.get("/api/auth/sessions")).json()
assert sessions[0]["ip_address"] == "203.0.113.7"
async def test_revoking_another_session_logs_it_out(client, app, db_session):
username = _unique("alice")
await register_and_login(client, db_session, username=username)
# A second "device" -- a separate client hitting the same app instance
# (so it shares the DB-override wiring), its own independent cookie.
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other_device:
await login_as(other_device, username)
assert len((await other_device.get("/api/auth/sessions")).json()) == 2
mine = (await client.get("/api/auth/sessions")).json()
assert len(mine) == 2
not_current = next(s for s in mine if not s["is_current"])
revoke = await client.delete(f"/api/auth/sessions/{not_current['id']}")
assert revoke.status_code == 204
# The other device's own cookie is now dead.
resp = await other_device.get("/api/auth/sessions")
assert resp.status_code == 401
# And the revoked session no longer shows up for the account at all.
remaining = (await client.get("/api/auth/sessions")).json()
assert len(remaining) == 1
assert remaining[0]["is_current"] is True
async def test_cannot_revoke_another_users_session(client, app, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
my_session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
await register_and_login(bob_client, db_session, username=_unique("bob"))
resp = await bob_client.delete(f"/api/auth/sessions/{my_session_id}")
assert resp.status_code == 404
# Untouched -- still exactly one active session for alice.
assert len((await client.get("/api/auth/sessions")).json()) == 1
async def test_revoking_own_current_session_works_then_logs_this_client_out(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
# Revoking your own *current* session is allowed (a remote sign-out of
# this same device is a legitimate, if odd, thing to do).
first = await client.delete(f"/api/auth/sessions/{session_id}")
assert first.status_code == 204
# A second call with the same (now-dead) cookie can't even reach the
# revoke check -- get_current_user itself already 401s.
second = await client.delete(f"/api/auth/sessions/{session_id}")
assert second.status_code == 401
+137 -5
View File
@@ -43,6 +43,14 @@ def _extract_token(body: str) -> str:
return match.group(1)
def _plain_text(message) -> str:
# #68: the email is now multipart/alternative (HTML + plain-text
# fallback) -- .get_content() has no handler for a multipart message
# itself, get_body(preferencelist=...) is the standard way to reach a
# specific alternative part.
return message.get_body(preferencelist=("plain",)).get_content()
async def test_create_site_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
@@ -62,7 +70,7 @@ async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
assert invite["status"] == "pending"
assert len(calls) == 1
token = _extract_token(calls[0]["message"].get_content())
token = _extract_token(_plain_text(calls[0]["message"]))
validate = await client.get(f"/api/signup/validate?token={token}")
assert validate.status_code == 200
@@ -87,7 +95,7 @@ async def test_signup_rejects_mismatched_password_confirmation(client, db_sessio
await _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "typo@example.com"})
token = _extract_token(calls[0]["message"].get_content())
token = _extract_token(_plain_text(calls[0]["message"]))
complete = await client.post(
"/api/signup",
@@ -127,7 +135,7 @@ async def test_expired_token_rejected(client, db_session, monkeypatch):
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
invite_id = resp.json()["id"]
token = _extract_token(calls[0]["message"].get_content())
token = _extract_token(_plain_text(calls[0]["message"]))
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
@@ -147,7 +155,7 @@ async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
await _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "once@example.com"})
token = _extract_token(calls[0]["message"].get_content())
token = _extract_token(_plain_text(calls[0]["message"]))
first = await client.post(
"/api/signup",
@@ -170,7 +178,7 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
invite_id = resp.json()["id"]
token = _extract_token(calls[0]["message"].get_content())
token = _extract_token(_plain_text(calls[0]["message"]))
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200
@@ -183,6 +191,79 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
assert complete.status_code == 400
async def test_resend_site_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
assert resp.status_code == 403
async def test_resend_site_invite_unknown_id_404s(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
assert resp.status_code == 404
async def test_resend_site_invite_new_link_works_and_old_one_doesnt(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "resend-me@example.com"})
invite_id = resp.json()["id"]
original_expires_at = datetime.fromisoformat(resp.json()["expires_at"])
old_token = _extract_token(_plain_text(calls[0]["message"]))
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
assert resend.status_code == 200, resend.text
assert resend.json()["id"] == invite_id
assert resend.json()["status"] == "pending"
# A fresh 7-day window, not whatever was left on the original.
new_expires_at = datetime.fromisoformat(resend.json()["expires_at"])
assert new_expires_at > original_expires_at
assert len(calls) == 2
new_token = _extract_token(_plain_text(calls[1]["message"]))
assert new_token != old_token
# The old link is dead -- resending rotates the token, it doesn't just
# repeat it.
old_validate = await client.get(f"/api/signup/validate?token={old_token}")
assert old_validate.status_code == 400
new_validate = await client.get(f"/api/signup/validate?token={new_token}")
assert new_validate.status_code == 200
assert new_validate.json()["email"] == "resend-me@example.com"
complete = await client.post(
"/api/signup",
json={
"token": new_token,
"username": "resentuser",
"password": "password123",
"password_confirm": "password123",
},
)
assert complete.status_code == 200, complete.text
async def test_resend_site_invite_rejects_non_pending(client, db_session, monkeypatch):
_fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "already-revoked@example.com"})
invite_id = resp.json()["id"]
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
assert resend.status_code == 400
async def test_list_site_invites(client, db_session, monkeypatch):
_fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
@@ -194,3 +275,54 @@ async def test_list_site_invites(client, db_session, monkeypatch):
assert resp.status_code == 200
emails = [i["email"] for i in resp.json()]
assert "listed@example.com" in emails
async def test_list_site_invites_excludes_revoked_invite(client, db_session, monkeypatch):
# #61: the admin UI labels this list "Pending invites" -- a revoked
# invite has nothing left to act on and must actually drop out of it,
# not just get relabeled in place.
_fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "revoked-from-list@example.com"})
invite_id = resp.json()["id"]
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200
listed = await client.get("/api/admin/invites")
emails = [i["email"] for i in listed.json()]
assert "revoked-from-list@example.com" not in emails
async def test_list_site_invites_excludes_accepted_invite(client, db_session, monkeypatch):
# Same gap, the other trigger: completing signup accepts the invite
# out-of-band from the admin's own session, but it must still be gone
# from the pending list on the admin's next fetch.
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "accepted-from-list@example.com"})
token = _extract_token(_plain_text(calls[0]["message"]))
complete = await client.post(
"/api/signup",
json={
"token": token,
"username": "acceptedfromlist",
"password": "password123",
"password_confirm": "password123",
},
)
assert complete.status_code == 200, complete.text
# Signup logs the new user's session in on `client` -- switch back to
# the admin to check the list the way the admin actually would.
await login_as(client, "admin1")
listed = await client.get("/api/admin/invites")
emails = [i["email"] for i in listed.json()]
assert "accepted-from-list@example.com" not in emails
+13
View File
@@ -0,0 +1,13 @@
from app.services.system_user_service import SYSTEM_USERNAME, get_or_create_system_user
async def test_get_or_create_system_user_creates_bot_account(db_session):
user = await get_or_create_system_user(db_session)
assert user.username == SYSTEM_USERNAME
assert user.is_bot is True
async def test_get_or_create_system_user_is_idempotent(db_session):
first = await get_or_create_system_user(db_session)
second = await get_or_create_system_user(db_session)
assert first.id == second.id
+43 -23
View File
@@ -1,13 +1,15 @@
# DS Chat frontend
React 19 + TypeScript + Vite PWA. The full client for DS Chat: auth and
invite-based signup, room CRUD with roles/invites, real-time WebSocket chat
(Markdown, @mentions, reactions, image/file attachments with previews,
message editing), unread indicators and presence, per-user theming
(presets plus a custom theme builder), Web Push notifications, offline
caching and an auto-update banner via a custom service worker, and a
site-admin portal — wired to the backend's REST API and `/ws/chat` WebSocket
endpoint. See [`../README.md`](../README.md) and
invite-based signup, room CRUD with roles/invites, direct messages,
real-time WebSocket chat (Markdown, @mentions, reactions, built-in and
custom emoji, image/video/file attachments with previews, message editing
and deletion), unread indicators and presence, per-user theming (presets
plus a custom theme builder), Web Push/email notifications and a desktop-
notification bridge, an active-sessions view for managing where you're
logged in, offline caching and an auto-update banner via a custom service
worker, and a site-admin portal — wired to the backend's REST API and
`/ws/chat` WebSocket endpoint. See [`../README.md`](../README.md) and
[`../backend/README.md`](../backend/README.md) for full local setup.
## Dev
@@ -39,20 +41,24 @@ worker.
```
src/
main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password,
/rooms, /rooms/:roomId, /admin (AdminRoute-gated); mounts
UpdateBanner globally and ChatSocketProvider once authed
/rooms, /rooms/:roomId, /admin (AdminRoute-gated), /help;
mounts UpdateBanner globally, ChatSocketProvider and
CustomEmojiProvider once authed
types.ts shared request/response/WS-envelope types, mirroring the
backend's Pydantic schemas
api/ fetch wrappers, one file per backend resource: client
(base fetch/error handling), auth, signup, rooms, users,
bots, webhooks, push, admin, customThemes, uploads
(base fetch/error handling), auth (incl. active sessions),
signup, rooms (incl. DMs), users, bots, webhooks, push,
admin, customThemes, customEmoji, uploads
ws/useChatSocket.ts the WebSocket hook: connect/reconnect with backoff,
join/leave rooms, send/edit/react, visibility-gated
join/leave rooms, send/edit/delete/react, visibility-gated
presence, triggers an SW update check on reconnect
context/
AuthContext.tsx current-user state, hydrated via GET /api/auth/me
ChatSocketContext.tsx shares one useChatSocket instance across the app
CustomEmojiContext.tsx fetches the site's custom emoji once, exposes a
shortcode lookup + a refresh() called after upload/delete
lib/
avatar.ts deterministic accent-color cycling for avatars
@@ -61,7 +67,9 @@ src/
fileSize.ts human-readable byte formatting
lastUser.ts cached "who was I last logged in as" for offline shell render
messageGrouping.ts groups consecutive messages by sender/time, presence lookup
push.ts PushManager subscribe/unsubscribe, VAPID key conversion
push.ts PushManager subscribe/unsubscribe, VAPID key conversion,
timeout-guarded so a browser that never settles the
permission prompt can't leave the UI stuck forever
swUpdate.ts bridges the SW registration to useChatSocket's reconnect hook
theme.ts applies preset/custom themes as CSS custom properties
@@ -73,24 +81,36 @@ src/
components/
ProtectedRoute.tsx, AdminRoute.tsx auth/site-admin route guards
TopBar.tsx, Sidebar.tsx, RoomRow.tsx room list chrome
TopBar.tsx, Sidebar.tsx, RoomRow.tsx room list chrome (DMs and Rooms as
independently collapsible sections)
ChatPane.tsx, MessageList.tsx, Composer.tsx chat view: history+live merge,
message rendering, composer/attach/send
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering + @mention highlighting/autocomplete
ImageLightbox.tsx, FilePreviewModal.tsx attachment viewers (image/PDF/text/Markdown)
EmojiPicker.tsx reaction/composer emoji picker
RoomInfoPanel.tsx room details/members/roles panel
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member picking
message rendering (incl. deleted-message
tombstones), composer/attach/send
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering (mentions, room
links, custom emoji `:shortcode:`,
heading ids, sub/superscript) +
@mention highlighting/autocomplete
ImageLightbox.tsx, VideoLightbox.tsx,
FilePreviewModal.tsx attachment viewers (image/video/PDF/
text/Markdown)
EmojiPicker.tsx, CustomEmojiUploadModal.tsx reaction/composer emoji picker
(built-in + site's custom emoji) and
its upload dialog
RoomInfoPanel.tsx room details/members/roles/email-
notification-toggle panel
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member
picking (also how a DM starts)
ProfileModal.tsx, ThemeBuilderModal.tsx, CustomThemePreview.tsx
profile settings + the custom theme editor
(opened in its own wide dialog) with a live,
profile settings (incl. active-sessions
list) + the custom theme editor (opened
in its own wide dialog) with a live,
hoverable mockup of the real UI
RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot)
OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt
pages/
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
ChatShellPage.tsx, AdminPage.tsx
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.)
sw.ts custom service worker (injectManifest): app-shell
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DS Chat</title>
</head>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "2026.9.3",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

+3
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ChatSocketProvider } from './context/ChatSocketContext'
import { CustomEmojiProvider } from './context/CustomEmojiContext'
import { AdminRoute } from './components/AdminRoute'
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
import { ProtectedRoute } from './components/ProtectedRoute'
@@ -67,8 +68,10 @@ function AppRoutes() {
if (!user) return routes
return (
<ChatSocketProvider key={user.id}>
<CustomEmojiProvider>
<DesktopNotificationBridge />
{routes}
</CustomEmojiProvider>
</ChatSocketProvider>
)
}
+7
View File
@@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' })
}
// #60: same invite row, not a new one -- a fresh token/expiry, and the old
// link stops working the moment this is called (see the backend's own
// resend_site_invite for why).
export function resendSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}/resend`, { method: 'POST' })
}
export function getSmtpSettings(): Promise<SmtpSettings | null> {
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
}
+28 -1
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client'
import type { User } from '../types'
import type { EmojiScale, TextScale, User, UserSession } from '../types'
// No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not
@@ -16,6 +16,16 @@ export function logout(): Promise<void> {
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
}
// #69: every device/browser currently logged into this account, newest
// last-seen first -- see backend's app/schemas/session.py.
export function listSessions(): Promise<UserSession[]> {
return apiFetch<UserSession[]>('/api/auth/sessions')
}
export function revokeSession(sessionId: string): Promise<void> {
return apiFetch<void>(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' })
}
export function me(): Promise<User> {
return apiFetch<User>('/api/auth/me')
}
@@ -40,6 +50,23 @@ export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Pr
})
}
// #71: its own call, same reasoning as updateTheme above -- the backend
// only applies fields actually present in the request body, so this can't
// clobber theme (or vice versa).
export function updateTextScale(textScale: TextScale): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ text_scale: textScale }),
})
}
export function updateEmojiScale(emojiScale: EmojiScale): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ emoji_scale: emojiScale }),
})
}
export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
}
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch, ApiError, NetworkError } from './client'
import type { CustomEmoji } from '../types'
export function listCustomEmoji(): Promise<CustomEmoji[]> {
return apiFetch<CustomEmoji[]>('/api/custom-emoji')
}
export function getCustomEmojiUrl(shortcode: string): string {
return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image`
}
export function deleteCustomEmoji(id: string): Promise<void> {
return apiFetch<void>(`/api/custom-emoji/${id}`, { method: 'DELETE' })
}
// Raw fetch, not apiFetch -- same multipart-boundary reason as
// uploadAvatar/uploadRoomImage (a manually-set Content-Type header would
// omit the boundary the browser generates for FormData).
export async function uploadCustomEmoji(shortcode: string, file: File): Promise<CustomEmoji> {
const formData = new FormData()
formData.append('shortcode', shortcode)
formData.append('file', file)
let response: Response
try {
response = await fetch('/api/custom-emoji', {
method: 'POST',
credentials: 'include',
body: formData,
})
} catch {
throw new NetworkError()
}
if (!response.ok) {
let detail = response.statusText
try {
const body = await response.json()
detail = body.detail ?? detail
} catch {
// response had no JSON body
}
throw new ApiError(response.status, detail)
}
return (await response.json()) as CustomEmoji
}
+28
View File
@@ -29,6 +29,15 @@ export function createRoom(
})
}
// #52: find-or-create -- returns the existing DM with this person if one
// already exists, rather than always creating a new room.
export function startDm(otherUserId: string): Promise<Room> {
return apiFetch<Room>('/api/rooms/dm', {
method: 'POST',
body: JSON.stringify({ other_user_id: otherUserId }),
})
}
export function updateRoom(
roomId: string,
data: { name?: string; description?: string; is_private?: boolean },
@@ -51,6 +60,25 @@ export function leaveRoom(roomId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/leave`, { method: 'POST' })
}
// #52 follow-up: only for DMs -- hides it from this user's own sidebar
// without touching the other participant's copy. Reversible: messaging
// again (startDm, above) or a new message from the other person un-hides
// it automatically.
export function hideDm(roomId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
}
// #67: per-viewer opt-in for email on this room's first unread message
// and every mention. 400s for a DM (backend rejects it -- see
// set_room_email_notifications), so callers should only expose the
// toggle for a non-DM room.
export function updateRoomNotifications(roomId: string, emailNotifications: boolean): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/notifications`, {
method: 'PATCH',
body: JSON.stringify({ email_notifications: emailNotifications }),
})
}
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
}
+33 -3
View File
@@ -171,6 +171,21 @@ export function ChatPane({
setLive((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
)
} else if (envelope.type === 'message_deleted' && envelope.room_id === room.id) {
// Mirrors what the server already did to the row (see
// message_service.delete_message) -- content/image/file/preview
// cleared, deleted_at set. `reactions` is left alone; MessageList
// just doesn't render it once deleted_at is set, same as it
// doesn't render anything else here.
const tombstone = {
content: null,
image_id: null,
file: null,
link_preview: null,
deleted_at: new Date().toISOString(),
}
setHistory((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
setLive((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
} else if (envelope.type === 'error') {
setWsError(envelope.detail)
}
@@ -212,6 +227,10 @@ export function ChatPane({
(messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji),
[socket, room.id],
)
const sendDelete = useCallback(
(messageId: string) => socket.sendDelete(room.id, messageId),
[socket, room.id],
)
return (
<section className="chat-pane">
@@ -224,8 +243,16 @@ export function ChatPane({
</button>
)}
<div className="chat-pane-title-block">
<div className="chat-pane-title">#{room.name}</div>
<div className="chat-pane-subtitle">{members.length} member{members.length === 1 ? '' : 's'}</div>
<div className="chat-pane-title">
{room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : `#${room.name}`}
</div>
<div className="chat-pane-subtitle">
{room.dm_partner
? room.dm_partner.status === 'online'
? 'Online'
: 'Offline'
: `${members.length} member${members.length === 1 ? '' : 's'}`}
</div>
</div>
<button
type="button"
@@ -256,13 +283,16 @@ export function ChatPane({
myRooms={myRooms}
onEdit={sendEdit}
onReact={sendReaction}
onDelete={sendDelete}
/>
<Composer
roomId={room.id}
roomName={room.name}
roomName={room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : room.name}
isDm={room.is_dm}
members={members}
rooms={rooms}
disabled={!connected}
archived={room.is_archived}
onSend={send}
/>
</section>
+149 -9
View File
@@ -12,9 +12,13 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
import { formatFileSize } from '../lib/fileSize'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import type { MyRoomItem, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
import { MentionAutocomplete } from './MentionAutocomplete'
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
import './Composer.css'
@@ -22,6 +26,7 @@ import './Composer.css'
interface ComposerProps {
roomId: string
roomName: string
isDm?: boolean
members: RoomMember[]
// #47: rooms this user belongs to, for the #roomname autocomplete --
// deliberately the same list ChatPane already resolves message-display
@@ -29,6 +34,12 @@ interface ComposerProps {
// while typing and what actually renders as a link later agree.
rooms: MyRoomItem[]
disabled?: boolean
// #57: an archived room is permanently read-only, not just transiently
// disconnected -- kept as its own prop rather than folded into `disabled`
// so the placeholder/status text can say why, instead of the connecting/
// offline copy below (which would be actively misleading here: waiting
// won't ever re-enable this).
archived?: boolean
onSend: (content: string, imageId?: string, fileId?: string) => void
}
@@ -62,6 +73,19 @@ function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery |
return detectTriggerQuery(text, cursor, '#')
}
// #54: a dedicated scan rather than detectTriggerQuery(text, cursor, ':')
// -- shortcode names (see emojiShortcodes.ts) can contain '+'/'-' (':+1:',
// ':t-rex:') but never '.', the reverse of what the shared @/# charset
// allows, so it doesn't fit that helper's single fixed charset.
function detectEmojiQuery(text: string, cursor: number): TriggerQuery | null {
let i = cursor - 1
while (i >= 0 && /[a-zA-Z0-9_+-]/.test(text[i])) i--
if (i < 0 || text[i] !== ':') return null
const prevChar = text[i - 1]
if (prevChar && /\w/.test(prevChar)) return null
return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
}
interface AttachMenuProps {
onPickPhoto: () => void
onPickFile: () => void
@@ -89,7 +113,12 @@ function AttachMenu({ onPickPhoto, onPickFile, onClose }: AttachMenuProps) {
)
}
export function Composer({ roomId, roomName, members, rooms, disabled, onSend }: ComposerProps) {
export function Composer({ roomId, roomName, isDm, members, rooms, disabled, archived, onSend }: ComposerProps) {
// Every gate below (attach/emoji buttons, textarea, send button) reads
// this instead of the raw `disabled` prop -- an archived room must be
// just as unwritable as a disconnected one, it just says why differently
// (see the placeholder/status text further down).
const isDisabled = disabled || archived
const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
@@ -103,8 +132,11 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
const [emojiQuery, setEmojiQuery] = useState<TriggerQuery | null>(null)
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// #29: a separate input with an image/video accept hint, so mobile
@@ -135,6 +167,39 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
return rooms.filter((r) => r.name.toLowerCase().startsWith(q)).slice(0, 8)
}, [roomQuery, rooms])
const emojiMatches = useMemo((): EmojiShortcodeMatch[] => {
if (!emojiQuery) return []
const q = emojiQuery.text.toLowerCase()
// A bare ":" with nothing typed yet -- suggest recently-used emoji
// (already capped to 8, see recentEmoji.ts) rather than an arbitrary
// slice of the ~950 known shortcodes. A recent custom-emoji pick is
// stored as its literal `:shortcode:` (see recordEmojiUsed's call
// sites) -- resolved against the live registry the same way, so a
// since-deleted one just doesn't show up here.
if (!q) {
return getRecentEmoji()
.map((value) => {
const customMatch = /^:([a-z0-9_-]+):$/.exec(value)
if (customMatch && customEmojiByShortcode.has(customMatch[1])) {
return { shortcode: customMatch[1], glyph: null }
}
const shortcode = SHORTCODE_BY_GLYPH[value]
return shortcode ? { shortcode, glyph: value } : null
})
.filter((match): match is EmojiShortcodeMatch => match !== null)
}
// Custom emoji surface first -- a smaller, more specific set, and the
// whole reason this app has an upload feature at all is for them to be
// reachable as easily as the built-in set.
const customMatches: EmojiShortcodeMatch[] = [...customEmojiByShortcode.keys()]
.filter((shortcode) => shortcode.startsWith(q))
.map((shortcode) => ({ shortcode, glyph: null }))
const builtinMatches: EmojiShortcodeMatch[] = Object.keys(EMOJI_SHORTCODES)
.filter((shortcode) => shortcode.startsWith(q))
.map((shortcode) => ({ shortcode, glyph: EMOJI_SHORTCODES[shortcode] }))
return [...customMatches, ...builtinMatches].slice(0, 8)
}, [emojiQuery, customEmojiByShortcode])
useEffect(() => {
getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
@@ -158,6 +223,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
setValue('')
setMentionQuery(null)
setRoomQuery(null)
setEmojiQuery(null)
removePendingImage()
setPendingFile(null)
requestAnimationFrame(autoGrow)
@@ -195,6 +261,34 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
})
}
function selectEmojiShortcode(shortcode: string) {
const query = emojiQuery
if (!query) return
// A custom emoji has no unicode glyph to substitute -- its literal
// `:shortcode:` text is what actually gets stored/rendered (see
// MessageContent.tsx's convertCustomEmojiShortcodes), so that's what
// goes in the textarea instead of a glyph.
const isCustom = customEmojiByShortcode.has(shortcode)
const glyph = EMOJI_SHORTCODES[shortcode]
if (!isCustom && !glyph) return
const inserted = isCustom ? `:${shortcode}:` : glyph
// Matches EmojiPicker's own insertEmoji -- a shortcode-completed emoji
// counts as "used" the same as one picked from the picker, so it
// shows up there too next time.
recordEmojiUsed(inserted)
const el = textareaRef.current
const next = value.slice(0, query.start) + inserted + ' ' + value.slice(query.end)
setValue(next)
setEmojiQuery(null)
requestAnimationFrame(() => {
if (!el) return
el.focus()
const cursor = query.start + inserted.length + 1 // inserted text + trailing space
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionQuery && mentionMatches.length > 0) {
if (e.key === 'ArrowDown') {
@@ -240,6 +334,28 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
return
}
}
if (emojiQuery && emojiMatches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setEmojiActiveIndex((i) => (i + 1) % emojiMatches.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setEmojiActiveIndex((i) => (i - 1 + emojiMatches.length) % emojiMatches.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectEmojiShortcode(emojiMatches[emojiActiveIndex].shortcode)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setEmojiQuery(null)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
@@ -257,6 +373,8 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(el.value, cursor))
setRoomActiveIndex(0)
setEmojiQuery(detectEmojiQuery(el.value, cursor))
setEmojiActiveIndex(0)
}
async function handleFile(file: File) {
@@ -294,7 +412,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
function handleDragEnter(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
if (disabled) return
if (isDisabled) return
dragCounterRef.current++
setDragActive(true)
}
@@ -316,7 +434,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
e.preventDefault()
dragCounterRef.current = 0
setDragActive(false)
if (disabled) return
if (isDisabled) return
// Only the first dropped file, matching the existing single-attachment-
// per-message limit (the button-triggered file input isn't `multiple`
// either).
@@ -418,7 +536,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
type="button"
className="composer-attach"
onClick={() => setAttachMenuOpen((v) => !v)}
disabled={disabled || uploading}
disabled={isDisabled || uploading}
aria-label="Attach a photo or file"
aria-expanded={attachMenuOpen}
>
@@ -457,7 +575,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
type="button"
className="composer-emoji-trigger"
onClick={() => setEmojiPickerOpen((v) => !v)}
disabled={disabled}
disabled={isDisabled}
aria-label="Insert an emoji"
>
🙂
@@ -476,7 +594,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
ref={textareaRef}
rows={1}
value={value}
disabled={disabled}
disabled={isDisabled}
onChange={(e) => {
setValue(e.target.value)
autoGrow()
@@ -485,10 +603,20 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor))
setRoomActiveIndex(0)
setEmojiQuery(detectEmojiQuery(e.target.value, cursor))
setEmojiActiveIndex(0)
}}
onSelect={handleSelectionChange}
onKeyDown={handleKeyDown}
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
placeholder={
archived
? 'This room has been archived'
: disabled
? online
? 'Connecting…'
: "You're offline"
: `Message ${isDm ? roomName : `#${roomName}`}`
}
spellCheck
/>
{mentionQuery && mentionMatches.length > 0 && (
@@ -507,12 +635,20 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
onHover={setRoomActiveIndex}
/>
)}
{emojiQuery && emojiMatches.length > 0 && (
<EmojiShortcodeAutocomplete
matches={emojiMatches}
activeIndex={emojiActiveIndex}
onPick={selectEmojiShortcode}
onHover={setEmojiActiveIndex}
/>
)}
</div>
<button
type="button"
className="composer-send"
onClick={handleSend}
disabled={disabled || (!value.trim() && !pendingImage && !pendingFile)}
disabled={isDisabled || (!value.trim() && !pendingImage && !pendingFile)}
aria-label="Send message"
>
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
@@ -520,8 +656,12 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
</svg>
</button>
</div>
{disabled && (
{archived ? (
<div className="composer-status">This room has been archived and is read-only</div>
) : (
disabled && (
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
)
)}
</div>
)
@@ -40,6 +40,18 @@
color: var(--ds-accent);
}
.composer-autocomplete-emoji-glyph {
font-size: 1rem;
line-height: 1;
}
.composer-autocomplete-custom-emoji {
display: block;
width: 1rem;
height: 1rem;
object-fit: contain;
}
.composer-autocomplete-secondary {
font-size: 0.76rem;
color: var(--ds-muted);
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { deleteCustomEmoji } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import type { CustomEmoji } from '../types'
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
import { EmojiGlyph } from './MessageContent'
import './Modal.css'
interface CustomEmojiManageModalProps {
onClose: () => void
}
// Moved out of the reaction/composer emoji picker -- that grid packs items
// 9-to-a-row with a delete "x" overlapping the glyph itself, which on a
// touch screen is far too easy to hit by accident while just trying to
// react. A dedicated list with a normal-sized "Delete" button (plus the
// same confirm() every other destructive action in this app uses) needs a
// deliberate tap to actually delete something.
export function CustomEmojiManageModal({ onClose }: CustomEmojiManageModalProps) {
const { user } = useAuth()
const { list, refresh } = useCustomEmoji()
const [uploadOpen, setUploadOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
async function handleDelete(emoji: CustomEmoji) {
if (!confirm(`Delete :${emoji.shortcode}:? This can't be undone.`)) return
setDeletingId(emoji.id)
try {
await deleteCustomEmoji(emoji.id)
await refresh()
} finally {
setDeletingId(null)
}
}
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Custom emoji</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
<div className="modal-field-label">Site emoji</div>
{list.length === 0 ? (
<p className="modal-empty">No custom emoji yet.</p>
) : (
list.map((emoji) => {
const canDelete = user?.id === emoji.uploaded_by || user?.is_site_admin
return (
<div key={emoji.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">
<EmojiGlyph value={`:${emoji.shortcode}:`} /> :{emoji.shortcode}:
</div>
<div className="modal-list-row-sub">
Added {new Date(emoji.created_at).toLocaleDateString()}
</div>
</div>
{canDelete && (
<button
type="button"
className="modal-list-row-action"
disabled={deletingId === emoji.id}
onClick={() => handleDelete(emoji)}
>
{deletingId === emoji.id ? 'Deleting…' : 'Delete'}
</button>
)}
</div>
)
})
)}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Close
</button>
<button type="button" className="btn-primary" onClick={() => setUploadOpen(true)}>
Add emoji
</button>
</div>
</div>
{uploadOpen && (
<CustomEmojiUploadModal
onClose={() => setUploadOpen(false)}
onUploaded={() => {
refresh()
setUploadOpen(false)
}}
/>
)}
</div>
)
}
@@ -0,0 +1,108 @@
import { useState, type ChangeEvent, type FormEvent } from 'react'
import { uploadCustomEmoji } from '../api/customEmoji'
import { ApiError } from '../api/client'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './Modal.css'
interface CustomEmojiUploadModalProps {
onClose: () => void
onUploaded: () => void
}
// Mirrors the shortcode charset the backend actually enforces (see
// backend/app/services/custom_emoji_service.py's SHORTCODE_PATTERN) --
// checked here too so a bad name shows up immediately next to the field
// instead of only after a round trip.
const SHORTCODE_PATTERN = /^[a-z0-9_-]{2,30}$/
export function CustomEmojiUploadModal({ onClose, onUploaded }: CustomEmojiUploadModalProps) {
const [shortcode, setShortcode] = useState('')
const [file, setFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0] ?? null
if (previewUrl) URL.revokeObjectURL(previewUrl)
setFile(selected)
setPreviewUrl(selected ? URL.createObjectURL(selected) : null)
}
function handleClose() {
if (previewUrl) URL.revokeObjectURL(previewUrl)
onClose()
}
const normalizedShortcode = shortcode.trim().toLowerCase()
const shortcodeValid = SHORTCODE_PATTERN.test(normalizedShortcode)
// A built-in shortcode always wins when :name: is typed in a message
// (see MessageContent.tsx's convertShortcodes, which runs first) -- a
// custom emoji uploaded under a colliding name would still upload fine,
// but could never actually be *reached* by typing its shortcode. Not a
// hard block (site-admin-free upload means no server-side authority to
// enforce this against ~950 names), just steered away from here.
const collidesWithBuiltin = shortcodeValid && normalizedShortcode in EMOJI_SHORTCODES
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!file || !shortcodeValid) return
setSubmitting(true)
setError(null)
try {
await uploadCustomEmoji(normalizedShortcode, file)
onUploaded()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
setSubmitting(false)
}
}
return (
<div className="modal-scrim" onClick={handleClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add custom emoji</h2>
<button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-field-label">Shortcode</div>
<input
type="text"
value={shortcode}
onChange={(e) => setShortcode(e.target.value)}
placeholder="party-parrot"
autoFocus
/>
{shortcode && !shortcodeValid && (
<p className="modal-error">
2-30 characters: lowercase letters, numbers, hyphens, underscores
</p>
)}
{collidesWithBuiltin && (
<p className="modal-error">
:{normalizedShortcode}: is already a built-in emoji -- typing it will always show that
one instead of yours
</p>
)}
<div className="modal-field-label">Image</div>
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleFileSelected} />
{previewUrl && (
<img src={previewUrl} alt="Preview" className="custom-emoji-upload-preview" />
)}
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={handleClose}>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={submitting || !file || !shortcodeValid}>
{submitting ? 'Uploading…' : 'Add emoji'}
</button>
</div>
</form>
</div>
</div>
)
}
+44 -8
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { useEscapeKey } from '../hooks/useEscapeKey'
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
import { EMOJI_NAMES } from '../lib/emojiNames'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import { EmojiGlyph } from './MessageContent'
import './EmojiPicker.css'
interface EmojiPickerProps {
@@ -17,7 +19,17 @@ interface EmojiPickerProps {
// available viewport space) need this to know how much room to check for.
export const EMOJI_PICKER_MAX_HEIGHT = 380
function searchEmoji(query: string): string[] {
// Every emoji this picker deals with -- built-in or custom -- is just a
// string from here on: a raw unicode glyph, or a custom emoji's literal
// `:shortcode:` reference (see EmojiGlyph in MessageContent.tsx, which
// resolves either into the right thing to render). Keeping both kinds in
// the same list/search/recent machinery means there's exactly one grid
// rendering path instead of a parallel one for custom emoji.
function titleFor(value: string): string {
return EMOJI_NAMES[value]?.name ?? value
}
function searchEmoji(query: string, customShortcodes: string[]): string[] {
const q = query.trim().toLowerCase()
if (!q) return []
const seen = new Set<string>()
@@ -32,13 +44,18 @@ function searchEmoji(query: string): string[] {
results.push(emoji)
}
}
for (const shortcode of customShortcodes) {
if (shortcode.toLowerCase().includes(q)) results.push(`:${shortcode}:`)
}
return results
}
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose)
const { list: customEmoji } = useCustomEmoji()
const [query, setQuery] = useState('')
const searchResults = useMemo(() => searchEmoji(query), [query])
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
const searching = query.trim().length > 0
// A snapshot taken once when the picker opens, not live-updating as picks
// happen within this same session -- picking an emoji always closes the
@@ -75,10 +92,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button"
role="menuitem"
className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name}
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
{emoji}
<EmojiGlyph value={emoji} />
</button>
))}
</div>
@@ -87,6 +104,25 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
)
) : (
<>
{customEmoji.length > 0 && (
<div className="emoji-picker-category">
<div className="emoji-picker-category-label">Custom</div>
<div className="emoji-picker-grid">
{customEmoji.map((e) => (
<button
key={e.id}
type="button"
role="menuitem"
className="emoji-picker-item"
title={`:${e.shortcode}:`}
onClick={() => pick(`:${e.shortcode}:`)}
>
<EmojiGlyph value={`:${e.shortcode}:`} />
</button>
))}
</div>
</div>
)}
{recent.length > 0 && (
<div className="emoji-picker-category">
<div className="emoji-picker-category-label">Recently used</div>
@@ -97,10 +133,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button"
role="menuitem"
className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name}
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
{emoji}
<EmojiGlyph value={emoji} />
</button>
))}
</div>
@@ -116,10 +152,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button"
role="menuitem"
className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name}
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
{emoji}
<EmojiGlyph value={emoji} />
</button>
))}
</div>
@@ -0,0 +1,54 @@
import { getCustomEmojiUrl } from '../api/customEmoji'
import './ComposerAutocomplete.css'
export interface EmojiShortcodeMatch {
shortcode: string
// null for a custom emoji -- there's no unicode glyph to show, so the
// row renders its uploaded image instead (see getCustomEmojiUrl below).
glyph: string | null
}
interface EmojiShortcodeAutocompleteProps {
matches: EmojiShortcodeMatch[]
activeIndex: number
onPick: (shortcode: string) => void
onHover: (index: number) => void
}
export function EmojiShortcodeAutocomplete({
matches,
activeIndex,
onPick,
onHover,
}: EmojiShortcodeAutocompleteProps) {
return (
<div className="composer-autocomplete" role="listbox">
{matches.map((match, i) => (
<button
key={match.shortcode}
type="button"
role="option"
aria-selected={i === activeIndex}
className={`composer-autocomplete-item${i === activeIndex ? ' composer-autocomplete-item-active' : ''}`}
// Selecting must survive the textarea's blur (which would
// otherwise fire first and could dismiss the dropdown) --
// onMouseDown fires before blur, onClick fires after.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(match.shortcode)}
onMouseEnter={() => onHover(i)}
>
<span className="composer-autocomplete-emoji-glyph">
{match.glyph ?? (
<img
src={getCustomEmojiUrl(match.shortcode)}
alt=""
className="composer-autocomplete-custom-emoji"
/>
)}
</span>
<span className="composer-autocomplete-primary">:{match.shortcode}:</span>
</button>
))}
</div>
)
}
+12 -4
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import Markdown from 'markdown-to-jsx'
import { getRoomFileUrl } from '../api/rooms'
import { useEscapeKey } from '../hooks/useEscapeKey'
import type { MessageFileInfo } from '../types'
import { MARKDOWN_OPTIONS } from './MessageContent'
import { createMarkdownOptions, preprocessMarkdown } from './MessageContent'
import './FilePreviewModal.css'
export type PreviewKind = 'markdown' | 'text' | 'pdf'
@@ -33,6 +33,12 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileUrl = getRoomFileUrl(roomId, file.id)
// #21: subscript/superscript and heading-id support -- see MessageContent
// for why this needs to run before the Markdown component sees the text.
const markdownPreview = useMemo(
() => (content !== null ? preprocessMarkdown(content) : null),
[content],
)
useEffect(() => {
let cancelled = false
@@ -102,9 +108,11 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
<div className={`file-preview-body${kind === 'pdf' ? ' file-preview-body-pdf' : ''}`}>
{error && <p className="file-preview-error">{error}</p>}
{!error && kind !== 'pdf' && content === null && <p className="file-preview-loading">Loading</p>}
{!error && content !== null && kind === 'markdown' && (
{!error && kind === 'markdown' && markdownPreview && (
<div className="message-text file-preview-markdown">
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
<Markdown options={createMarkdownOptions(markdownPreview.headingIds)}>
{markdownPreview.text}
</Markdown>
</div>
)}
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
@@ -0,0 +1,28 @@
/* #18: em-relative, deliberately -- renders correctly inline in message
text, inside a reaction pill, and inside the emoji picker's grid without
a separate override per context, since each of those already sets its
own font-size and this just tracks it. Kept in this file (imported
directly by MessageContent.tsx) rather than MessageList.css so it's
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
included, not just the message list.
#71: also multiplied by --emoji-scale, the manual "make emoji bigger"
preference -- but that variable is only ever set on MessageContent's own
wrapper div (inline style, scoped to that element and its descendants),
never at :root, so var(..., 1) correctly falls back to a no-op multiplier
everywhere else this class is reused (the picker's grid, reaction pills)
instead of also inflating those and breaking their fixed-size layout. */
.message-custom-emoji {
height: calc(1.2em * var(--emoji-scale, 1));
width: calc(1.2em * var(--emoji-scale, 1));
object-fit: contain;
vertical-align: -0.25em;
}
/* #71: a raw unicode emoji wrapped by wrapEmojiGlyphs -- same --emoji-scale
multiplier as the custom-emoji image above, so "make emoji bigger"
applies uniformly regardless of which kind of emoji it is. */
.inline-emoji {
display: inline-block;
font-size: calc(1em * var(--emoji-scale, 1));
}
+243 -7
View File
@@ -1,7 +1,11 @@
import Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { getCustomEmojiUrl } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './MessageContent.css'
interface MessageContentProps {
content: string
@@ -49,8 +53,11 @@ interface MarkdownLinkProps {
// span instead of an actual anchor. highlightRoomReferences does the same
// trick for #roomname, but a room reference *is* meant to be navigable, so
// it becomes a real (client-side-routed) Link instead of an inert span.
// Everything else renders as a real external link, same as before mentions
// existed.
// convertSubSuperscript (#21) reuses the identical trick for `~sub~`/`^sup^`
// -- markdown-to-jsx has no plugin hook for new inline syntax, but a link is
// something it already parses correctly, so `sub:`/`sup:` "URLs" are just
// another carrier for meaning the parser was never told about. Everything
// else renders as a real external link, same as before mentions existed.
function MarkdownLink({ href, children }: MarkdownLinkProps) {
if (href?.startsWith('mention:')) {
return <span className="message-mention">{children}</span>
@@ -62,6 +69,31 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
</Link>
)
}
if (href === 'sub:') {
return <sub>{children}</sub>
}
if (href === 'sup:') {
return <sup>{children}</sup>
}
if (href?.startsWith('emoji:')) {
const shortcode = href.slice('emoji:'.length)
return (
<img
src={getCustomEmojiUrl(shortcode)}
alt={`:${shortcode}:`}
title={`:${shortcode}:`}
className="message-custom-emoji"
/>
)
}
// #71: a raw unicode emoji has no element of its own to size independently
// of the surrounding text -- it's just characters in a string. Wrapping
// each one individually (see wrapEmojiGlyphs below) gives it one, purely
// so the emoji-size preference can scale it via CSS the same way it
// already scales a custom emoji's <img>.
if (href === 'glyph:') {
return <span className="inline-emoji">{children}</span>
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
@@ -101,6 +133,137 @@ function convertShortcodes(text: string): string {
.join('\n')
}
// #21: single tilde/caret delimiters, no spaces inside, and not doubled --
// `~~text~~` is strikethrough (already natively supported) so a leading or
// trailing extra `~` excludes the match, matching markdownguide.org's
// extended syntax for both constructs. Converts a complete `~sub~`/`^sup^`
// span to `[sub](sub:)`/`[sup](sup:)` -- see MarkdownLink's comment for why
// a link is the carrier.
const SUBSCRIPT_PATTERN = /(?<!~)~([^~\s]+)~(?!~)/g
const SUPERSCRIPT_PATTERN = /\^([^^\s]+)\^/g
function convertSubSuperscript(text: string): string {
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) =>
i % 2 === 0
? part
.replace(SUBSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sub:)`)
.replace(SUPERSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sup:)`)
: part,
)
.join('')
})
.join('\n')
}
// #21: markdown-to-jsx has no option for an explicit heading anchor --
// every heading already gets an auto-generated slug from its own text
// (useful for linking within a message), and `{#custom-id}` is meant to
// *override* that slug, not add a second id next to it. There's no plugin
// hook for new block syntax either, so this strips the marker from the
// heading's own text (same fence-skipping convention as the functions
// above) and remembers the association by that now-bare text -- the one
// hook markdown-to-jsx *does* expose, `slugify` (see createMarkdownOptions
// below), gets called with exactly that text, letting the requested id
// stand in for the auto-generated one.
const HEADING_ID_PATTERN = /^(#{1,6}\s+.*?)\s*\{#([a-zA-Z0-9_-]+)\}\s*$/
function extractHeadingIds(text: string): { text: string; headingIds: Map<string, string> } {
const headingIds = new Map<string, string>()
const lines = text.split('\n')
let inFence = false
const nextLines = lines.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
const match = line.match(HEADING_ID_PATTERN)
if (!match) return line
const [, headingLine, customId] = match
headingIds.set(headingLine.replace(/^#{1,6}\s+/, ''), customId)
return headingLine
})
return { text: nextLines.join('\n'), headingIds }
}
// #18: a *complete* `:name:` that survived convertShortcodes above (it only
// replaces names it recognizes, so an unmatched one -- built-in or not --
// passes through untouched) and matches a shortcode this install actually
// has a custom emoji for. Turns it into `[:name:](emoji:name)`, the same
// link-trick MarkdownLink's other branches use -- deliberately reusing the
// exact fence/code-span-skip convention every other converter in this file
// follows, for the same reason (a pasted `:some_key:` in code shouldn't
// light up as an emoji any more than an unrelated one should).
const CUSTOM_EMOJI_PATTERN = /:([a-z0-9_-]+):/g
function convertCustomEmojiShortcodes(text: string, shortcodes: Set<string>): string {
if (shortcodes.size === 0) return text
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) =>
i % 2 === 0
? part.replace(CUSTOM_EMOJI_PATTERN, (match, name) =>
shortcodes.has(name) ? `[${match}](emoji:${name})` : match,
)
: part,
)
.join('')
})
.join('\n')
}
// Reaction pills and the "recently used" emoji row don't go through the
// markdown pipeline at all -- they render a single stored value directly.
// A custom emoji's value there is its literal `:shortcode:` (see
// backend's MessageReaction.emoji); this is the equivalent one-value
// resolution for those spots, so a deleted-since-reacted-with custom
// emoji degrades to plain `:shortcode:` text instead of a broken image.
interface EmojiGlyphProps {
value: string
}
export function EmojiGlyph({ value }: EmojiGlyphProps) {
const { byShortcode } = useCustomEmoji()
const match = /^:([a-z0-9_-]+):$/.exec(value)
const shortcode = match?.[1]
if (shortcode && byShortcode.has(shortcode)) {
return (
<img
src={getCustomEmojiUrl(shortcode)}
alt={value}
title={value}
className="message-custom-emoji"
/>
)
}
// Wrapped the same way wrapEmojiGlyphs wraps a raw emoji in message text
// (see .inline-emoji), so a --emoji-scale set on an ancestor (the
// reaction pill's own span in MessageList.tsx) scales this the same way
// it scales the .message-custom-emoji img above -- and falls back to a
// no-op 1x everywhere else (the picker) with no --emoji-scale set at all.
return <span className="inline-emoji">{value}</span>
}
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
// Turns a validated @username into `[@username](mention:username)` --
@@ -194,9 +357,13 @@ function preserveLineBreaks(text: string): string {
}
// Shared with FilePreviewModal so both render paths carry the exact same
// XSS mitigation (disableParsingRawHTML) -- duplicating this object would
// risk the two drifting out of sync if one gets edited later.
export const MARKDOWN_OPTIONS = {
// XSS mitigation (disableParsingRawHTML) and #21's heading-id/sub/superscript
// support -- duplicating this would risk the two drifting out of sync if
// one gets edited later. A function, not a plain constant, since `slugify`
// needs each render's own headingIds map (see extractHeadingIds above) --
// there's no per-render state to close over in a module-level object.
export function createMarkdownOptions(headingIds: Map<string, string>) {
return {
// The core XSS mitigation: raw HTML in message content is escaped
// and printed literally instead of being parsed into elements.
disableParsingRawHTML: true,
@@ -204,10 +371,79 @@ export const MARKDOWN_OPTIONS = {
a: { component: MarkdownLink },
img: { component: MarkdownImageLink },
},
slugify: (input: string, defaultFn: (input: string) => string) =>
headingIds.get(input) ?? defaultFn(input),
}
}
// #21: preprocessing shared by MessageContent and FilePreviewModal --
// subscript/superscript and heading-id overrides are general markdown
// features, not chat-specific like mentions/shortcodes/room-references, so
// a plain file preview gets them too.
export function preprocessMarkdown(text: string): { text: string; headingIds: Map<string, string> } {
return extractHeadingIds(convertSubSuperscript(text))
}
// #71: gives every individual unicode emoji its own element (see
// MarkdownLink's `glyph:` branch) purely so the emoji-size preference can
// scale it independently of the surrounding text -- a raw emoji is just
// characters in a string otherwise, with nothing CSS can address on its
// own. Runs after convertShortcodes so a built-in `:name:` that just
// became a glyph is wrapped too ("all emoji", not just ones typed as
// literal unicode); same fence/code-span skip convention as every other
// converter here.
const EMOJI_GLYPH_PATTERN = /\p{Extended_Pictographic}(?:\p{Emoji_Modifier}|\u200D\p{Extended_Pictographic}|\uFE0F)*/gu
function wrapEmojiGlyphs(text: string): string {
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) => (i % 2 === 0 ? part.replace(EMOJI_GLYPH_PATTERN, (match) => `[${match}](glyph:)`) : part))
.join('')
})
.join('\n')
}
// Exported so MessageList's reaction pills can apply the same viewer
// preference to their own EmojiGlyph -- reactions render outside the
// markdown pipeline entirely (see EmojiGlyph's own comment above), so they
// need this looked up independently rather than inheriting --emoji-scale
// from this component's wrapper div.
export const EMOJI_SCALE_MULTIPLIER: Record<string, number> = {
small: 0.8,
normal: 1,
large: 1.5,
xlarge: 2,
}
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
const { user } = useAuth()
const { byShortcode } = useCustomEmoji()
const customShortcodes = new Set(byShortcode.keys())
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withRoomRefs))}</Markdown>
const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
const { text, headingIds } = preprocessMarkdown(withEmojiGlyphs)
// #71: scoped to this element (not a :root-level variable) so it only
// ever affects emoji rendered in message text -- not the same
// .message-custom-emoji/EmojiGlyph markup reused by the emoji picker's
// grid, where a bigger image would just break its fixed-size layout
// instead of doing anything useful. Reaction pills DO scale too, but via
// their own inline --emoji-scale in MessageList.tsx, not by inheriting
// this one -- a pill isn't a descendant of this wrapper div.
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
return (
<div style={{ '--emoji-scale': emojiScale } as CSSProperties}>
<Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
</div>
)
}
+65 -3
View File
@@ -53,8 +53,13 @@
.message-image {
display: block;
max-width: min(320px, 100%);
max-height: 240px;
/* #71: rem, not px -- scales with the text-size setting (see
lib/theme.ts's applyTextScale), same as every other size in this app.
min(...) still caps against the viewport in absolute px, since a
percentage-of-viewport constraint isn't something a root font-size
change should affect. */
max-width: min(20rem, 100%);
max-height: 15rem;
object-fit: contain;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
@@ -62,6 +67,44 @@
margin-bottom: 4px;
}
.message-video-wrap {
position: relative;
display: inline-block;
max-width: min(20rem, 100%);
margin-bottom: 4px;
}
.message-video {
display: block;
width: 100%;
max-height: 15rem;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
background: var(--ds-void);
}
.message-video-expand {
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
background: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.message-video-wrap:hover .message-video-expand,
.message-video-expand:focus-visible {
opacity: 1;
}
.message-file-attachment {
display: inline-flex;
align-items: center;
@@ -73,7 +116,7 @@
margin-bottom: 4px;
color: var(--ds-text);
text-decoration: none;
max-width: min(320px, 100%);
max-width: min(20rem, 100%);
}
.message-file-attachment:hover {
@@ -304,6 +347,25 @@
border-color: var(--ds-accent);
}
.message-delete-link {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
color: var(--ds-muted);
font-size: 0.68rem;
cursor: pointer;
padding: 2px 8px;
border-radius: 6px;
}
.message-delete-link:hover {
color: var(--ds-danger);
border-color: var(--ds-danger);
}
.message-deleted-text {
color: var(--ds-muted);
}
.message-reaction-wrap {
position: relative;
}
+128 -8
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
@@ -8,8 +9,9 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { MessageContent } from './MessageContent'
import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox'
import './MessageList.css'
export function FileAttachmentIcon() {
@@ -60,6 +62,58 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
)
}
// #65: kept in sync with backend/app/storage.py's INLINE_SAFE_VIDEO_
// CONTENT_TYPES -- the server only ever serves these particular content
// types without a forced download, so a <video> tag pointed at anything
// else would just show a broken player instead of playing (or, worse,
// trigger a download the moment the browser tries to fetch it).
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
const GROUP_BREAK_MS = 5 * 60 * 1000
interface VideoAttachmentProps {
file: MessageFileInfo
roomId: string
onExpand: () => void
}
// Plays inline via the browser's own <video controls> (no custom overlay
// needed for play/pause/volume/seek) -- the one thing it doesn't give a
// small inline player is an obvious way to go bigger. The expand button
// opens a VideoLightbox (matching how images already expand) rather than
// calling the Fullscreen API directly on the video element -- that API is
// unreliable in embedded/packaged contexts (e.g. the Electron desktop
// build), where a rejected requestFullscreen() promise just does nothing
// with no visible error.
function VideoAttachment({ file, roomId, onExpand }: VideoAttachmentProps) {
return (
<div className="message-video-wrap">
<video src={getRoomFileUrl(roomId, file.id)} controls className="message-video" />
<button
type="button"
className="message-video-expand"
onClick={onExpand}
aria-label="Expand video"
>
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M7 3H3v4M13 3h4v4M3 13v4h4M17 13v4h-4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)
}
interface MessageListProps {
roomId: string
messages: (Message | ChatMessageEnvelope)[]
@@ -67,10 +121,23 @@ interface MessageListProps {
myRooms: Map<string, string>
onEdit: (messageId: string, content: string) => void
onReact: (messageId: string, emoji: string) => void
onDelete: (messageId: string) => void
}
export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) {
export function MessageList({
roomId,
messages,
members,
myRooms,
onEdit,
onReact,
onDelete,
}: MessageListProps) {
const { user } = useAuth()
// #71: same viewer preference MessageContent applies to in-text emoji,
// looked up separately here since a reaction pill isn't a descendant of
// that component's wrapper div (see EmojiGlyph's own comment).
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
const containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
// Whether the view should be pinned to the latest message -- true right
@@ -81,6 +148,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
@@ -133,6 +201,14 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
setEditingId(null)
}
function handleDelete(messageId: string) {
// Matches the confirm() pattern already used for other destructive
// actions in this app (RoomInfoPanel's leave/delete-room,
// ProfileModal's delete-theme) rather than a custom dialog.
if (!confirm("Delete this message? This can't be undone.")) return
onDelete(messageId)
}
return (
<div className="message-list" ref={containerRef}>
{messages.map((msg, i) => {
@@ -141,9 +217,15 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
// Mattermost-style grouping: every message shows who sent it, but
// consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages.
const isGroupStart = !prev || prev.user_id !== msg.user_id
// applies uniformly, including to your own messages. Also breaks on
// a long gap (see GROUP_BREAK_MS) so a message sent well after the
// rest of the run still gets its own visible timestamp.
const isGroupStart =
!prev ||
prev.user_id !== msg.user_id ||
new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS
const editing = editingId === msg.id
const deleted = !!msg.deleted_at
return (
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
@@ -166,7 +248,11 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
</span>
</div>
)}
{editing ? (
{deleted ? (
<div className="message-text message-deleted-text">
<em>This message was deleted</em>
</div>
) : editing ? (
<textarea
autoFocus
rows={Math.min(10, draft.split('\n').length)}
@@ -193,7 +279,19 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
onClick={() => setLightboxSrc(getRoomImageUrl(roomId, msg.image_id!))}
/>
)}
{msg.file && (
{msg.file && PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
<VideoAttachment
file={msg.file}
roomId={roomId}
onExpand={() =>
setVideoLightbox({
src: getRoomFileUrl(roomId, msg.file!.id),
filename: msg.file!.filename,
})
}
/>
)}
{msg.file && !PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
<FileAttachmentCard
file={msg.file}
roomId={roomId}
@@ -221,7 +319,12 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>{r.emoji}</span>
{/* No .inline-emoji here -- EmojiGlyph's own fallback branch
already applies it, and stacking it here too would double
the font-size multiplication for a custom-emoji img. */}
<span style={{ '--emoji-scale': emojiScale } as CSSProperties}>
<EmojiGlyph value={r.emoji} />
</span>
<span>{r.count}</span>
</button>
)
@@ -231,7 +334,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
</>
)}
</div>
{!editing && (
{!editing && !deleted && (
<div className="message-row-actions">
<div className="message-reaction-wrap">
<button
@@ -277,6 +380,16 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
Edit
</button>
)}
{mine && (
<button
type="button"
className="message-delete-link"
onClick={() => handleDelete(msg.id)}
aria-label="Delete message"
>
Delete
</button>
)}
</div>
)}
</div>
@@ -284,6 +397,13 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
})}
<div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{videoLightbox && (
<VideoLightbox
src={videoLightbox.src}
filename={videoLightbox.filename}
onClose={() => setVideoLightbox(null)}
/>
)}
{previewFile && (
<FilePreviewModal
roomId={roomId}
+91
View File
@@ -230,6 +230,45 @@
color: var(--ds-muted);
}
.text-scale-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-2);
margin-bottom: var(--sp-4);
}
.text-scale-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 10px 4px;
cursor: pointer;
}
.text-scale-option:hover {
border-color: var(--ds-accent);
}
.text-scale-option-selected {
border-color: var(--ds-accent);
box-shadow: 0 0 0 1px var(--ds-accent);
}
.text-scale-option-preview {
font-weight: 700;
color: var(--ds-text);
line-height: 1;
}
.text-scale-option-label {
font-size: 0.7rem;
color: var(--ds-muted);
}
.theme-swatch-preview-new {
background: transparent;
border-style: dashed;
@@ -389,6 +428,28 @@
border-bottom: none;
}
.modal-list-row-button {
width: 100%;
background: transparent;
border-left: none;
border-right: none;
border-top: none;
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
border-radius: var(--radius);
}
.modal-list-row-button:hover:not(:disabled) {
background: var(--ds-surface-2);
}
.modal-list-row-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.modal-list-row-body {
flex: 1;
min-width: 0;
@@ -407,6 +468,36 @@
color: var(--ds-muted);
}
.custom-emoji-upload-preview {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
margin-top: var(--sp-2);
background: var(--ds-surface-2);
border-radius: var(--radius);
border: 1px solid var(--ds-border);
}
.modal-list-row-action {
flex: none;
background: transparent;
border: none;
color: var(--ds-danger);
font-size: 0.76rem;
cursor: pointer;
padding: 4px 6px;
}
.modal-list-row-action:hover:not(:disabled) {
text-decoration: underline;
}
.modal-list-row-action:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.profile-modal-avatar-row {
display: flex;
align-items: center;
+35 -6
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { ApiError } from '../api/client'
import { startDm } from '../api/rooms'
import { getUserAvatarUrl, listOnlineUserIds, listUserDirectory } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import type { UserDirectoryEntry } from '../types'
import { UserAvatar } from './UserAvatar'
@@ -8,17 +10,20 @@ import './Modal.css'
interface PeopleModalProps {
onClose: () => void
onOpenRoom: (roomId: string) => void
}
// #25: a snapshot on open, not a live feed -- matches listOnlineUserIds'
// own documented contract (also used as-is by the admin user list and the
// room-invite search), rather than inventing a new live-updating design
// for this first pass.
export function PeopleModal({ onClose }: PeopleModalProps) {
export function PeopleModal({ onClose, onOpenRoom }: PeopleModalProps) {
const { user } = useAuth()
const [users, setUsers] = useState<UserDirectoryEntry[]>([])
const [onlineIds, setOnlineIds] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [startingId, setStartingId] = useState<string | null>(null)
useEffect(() => {
Promise.all([listUserDirectory(), listOnlineUserIds()])
@@ -33,18 +38,34 @@ export function PeopleModal({ onClose }: PeopleModalProps) {
// Online first (each group alphabetical, matching listUserDirectory's own
// username ordering) -- who's actually around right now is the more
// useful thing to see first in a list that can otherwise run to the
// entire site's user base.
// entire site's user base. Excludes the viewer themselves -- there's no
// "DM yourself" affordance.
const sorted = useMemo(
() =>
[...users].sort((a, b) => {
[...users]
.filter((u) => u.id !== user?.id)
.sort((a, b) => {
const aOnline = onlineIds.has(a.id)
const bOnline = onlineIds.has(b.id)
if (aOnline !== bOnline) return aOnline ? -1 : 1
return a.username.localeCompare(b.username)
}),
[users, onlineIds],
[users, onlineIds, user?.id],
)
async function handleStartDm(otherUserId: string) {
setStartingId(otherUserId)
setError(null)
try {
const room = await startDm(otherUserId)
onOpenRoom(room.id)
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
setStartingId(null)
}
}
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
@@ -65,7 +86,13 @@ export function PeopleModal({ onClose }: PeopleModalProps) {
sorted.map((u) => {
const online = onlineIds.has(u.id)
return (
<div key={u.id} className="modal-list-row">
<button
key={u.id}
type="button"
className="modal-list-row modal-list-row-button"
onClick={() => handleStartDm(u.id)}
disabled={startingId !== null}
>
<UserAvatar
username={u.username}
colorIndex={hashIndex(u.username)}
@@ -75,9 +102,11 @@ export function PeopleModal({ onClose }: PeopleModalProps) {
/>
<div className="modal-list-row-body">
<div className="modal-list-row-title">{u.display_name || u.username}</div>
<div className="modal-list-row-sub">{online ? 'Online' : 'Offline'}</div>
<div className="modal-list-row-sub">
{startingId === u.id ? 'Opening…' : online ? 'Online' : 'Offline'}
</div>
</div>
</button>
)
})
)}
+167 -4
View File
@@ -1,5 +1,16 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { changePassword, me, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth'
import {
changePassword,
listSessions,
me,
removeAvatar,
revokeSession,
updateEmojiScale,
updateProfile,
updateTextScale,
updateTheme,
uploadAvatar,
} from '../api/auth'
import { ApiError } from '../api/client'
import {
activateCustomTheme,
@@ -11,8 +22,8 @@ import {
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors } from '../types'
import { applyTextScale, applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors, EmojiScale, TextScale, UserSession } from '../types'
import { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
@@ -24,6 +35,26 @@ const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: st
{ name: 'sunset', label: 'Sunset' },
]
// #71: the "Aa" preview scales with each option's own size, the standard
// way a text-size picker shows what it does without a separate demo area.
const TEXT_SCALE_OPTIONS: { name: TextScale; label: string; previewSize: string }[] = [
{ name: 'small', label: 'Small', previewSize: '0.8rem' },
{ name: 'normal', label: 'Normal', previewSize: '1rem' },
{ name: 'large', label: 'Large', previewSize: '1.25rem' },
{ name: 'xlarge', label: 'Extra large', previewSize: '1.5rem' },
]
// #71: independent of text size -- only scales emoji rendered in message
// text (see MessageContent.tsx's --emoji-scale). The preview uses an
// actual emoji so it demonstrates itself the same way the text-size
// options do with "Aa".
const EMOJI_SCALE_OPTIONS: { name: EmojiScale; label: string; previewSize: string }[] = [
{ name: 'small', label: 'Small', previewSize: '1rem' },
{ name: 'normal', label: 'Normal', previewSize: '1.25rem' },
{ name: 'large', label: 'Large', previewSize: '1.6rem' },
{ name: 'xlarge', label: 'Extra large', previewSize: '2rem' },
]
const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; label: string }[] = [
{ key: 'void', label: 'Background' },
{ key: 'void_2', label: 'Sidebar background' },
@@ -44,13 +75,15 @@ interface ProfileModalProps {
}
export function ProfileModal({ onClose }: ProfileModalProps) {
const { user, updateUser } = useAuth()
const { user, updateUser, logout } = useAuth()
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [error, setError] = useState<string | null>(null)
const [savingName, setSavingName] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null)
const [textScaleError, setTextScaleError] = useState<string | null>(null)
const [emojiScaleError, setEmojiScaleError] = useState<string | null>(null)
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
@@ -66,6 +99,10 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [passwordSuccess, setPasswordSuccess] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [sessions, setSessions] = useState<UserSession[]>([])
const [sessionsError, setSessionsError] = useState<string | null>(null)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
useEffect(() => {
listCustomThemes()
.then(setCustomThemes)
@@ -73,6 +110,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
// Non-critical -- the saved-themes list just stays empty; presets
// and everything else in this modal still work fine.
})
listSessions()
.then(setSessions)
.catch(() => {
// Same non-critical treatment -- an empty list just means this
// section renders no rows rather than failing the whole modal.
})
}, [])
if (!user) return null
@@ -131,6 +174,32 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
}
}
async function handleSelectTextScale(scale: TextScale) {
// Same instant-apply-then-persist pattern as handleSelectPreset above.
applyTextScale(scale)
setTextScaleError(null)
try {
const updated = await updateTextScale(scale)
updateUser(updated)
} catch (err) {
applyTextScale(user?.text_scale ?? null)
setTextScaleError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleSelectEmojiScale(scale: EmojiScale) {
// No instant-apply DOM mutation here (unlike theme/text scale) -- it's
// just a value MessageContent reads from `user` on its next render, so
// persisting and updating that is the whole job.
setEmojiScaleError(null)
try {
const updated = await updateEmojiScale(scale)
updateUser(updated)
} catch (err) {
setEmojiScaleError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleActivateCustomTheme(theme: CustomTheme) {
applyTheme('custom', theme.colors)
setThemeError(null)
@@ -252,6 +321,27 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
}
}
async function handleRevokeSession(session: UserSession) {
setSessionsError(null)
setRevokingSessionId(session.id)
try {
if (session.is_current) {
// Revoking your own current session is really just "sign out" --
// go through the normal logout path so local auth state (and the
// rest of the app) clears immediately, instead of waiting for the
// next request to organically 401.
await logout()
return
}
await revokeSession(session.id)
setSessions((prev) => prev.filter((s) => s.id !== session.id))
} catch (err) {
setSessionsError(err instanceof ApiError ? err.message : String(err))
} finally {
setRevokingSessionId(null)
}
}
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
@@ -320,6 +410,48 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</div>
{themeError && <p className="modal-error">{themeError}</p>}
<div className="modal-field-label">Text size</div>
<div className="text-scale-options">
{TEXT_SCALE_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`text-scale-option${
(user.text_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
}`}
onClick={() => handleSelectTextScale(option.name)}
aria-pressed={(user.text_scale ?? 'normal') === option.name}
>
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
Aa
</span>
<span className="text-scale-option-label">{option.label}</span>
</button>
))}
</div>
{textScaleError && <p className="modal-error">{textScaleError}</p>}
<div className="modal-field-label">Emoji size</div>
<div className="text-scale-options">
{EMOJI_SCALE_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`text-scale-option${
(user.emoji_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
}`}
onClick={() => handleSelectEmojiScale(option.name)}
aria-pressed={(user.emoji_scale ?? 'normal') === option.name}
>
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
🎉
</span>
<span className="text-scale-option-label">{option.label}</span>
</button>
))}
</div>
{emojiScaleError && <p className="modal-error">{emojiScaleError}</p>}
<div className="modal-field-label">My custom themes</div>
<div className="theme-swatch-grid">
{customThemes.map((theme) => {
@@ -464,6 +596,37 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</button>
</div>
</form>
<hr className="modal-divider" />
<div className="modal-field-label">Active sessions</div>
{sessionsError && <p className="modal-error">{sessionsError}</p>}
{sessions.length === 0 ? (
<p className="modal-empty">No active sessions.</p>
) : (
sessions.map((session) => (
<div key={session.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">
{session.device_label}
{session.is_current && ' · This device'}
</div>
<div className="modal-list-row-sub">
{session.ip_address ?? 'Unknown location'} · last active{' '}
{new Date(session.last_seen_at).toLocaleString()}
</div>
</div>
<button
type="button"
className="modal-list-row-action"
disabled={revokingSessionId === session.id}
onClick={() => handleRevokeSession(session)}
>
{session.is_current ? 'Sign out' : 'Revoke'}
</button>
</div>
))
)}
</div>
</div>
)
+80 -3
View File
@@ -7,11 +7,13 @@ import {
deleteRoom,
getRoomFileUrl,
getRoomImageUrl,
hideDm,
leaveRoom,
listRoomAttachments,
removeMember,
transferOwnership,
updateRoom,
updateRoomNotifications,
} from '../api/rooms'
import {
createEventSubscription,
@@ -23,6 +25,7 @@ import {
} from '../api/webhooks'
import { useAuth } from '../context/AuthContext'
import { useResizableWidth } from '../hooks/useResizableWidth'
import { hashIndex } from '../lib/avatar'
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import { formatFileSize } from '../lib/fileSize'
import type {
@@ -104,6 +107,8 @@ export function RoomInfoPanel({
const [descDraft, setDescDraft] = useState(room.description ?? '')
const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private)
const [roomError, setRoomError] = useState<string | null>(null)
const [emailNotifications, setEmailNotifications] = useState(room.email_notifications)
const [notificationsError, setNotificationsError] = useState<string | null>(null)
const [integrationsOpen, setIntegrationsOpen] = useState(false)
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
@@ -117,13 +122,17 @@ export function RoomInfoPanel({
const canManage = myRole === 'admin' || myRole === 'owner'
// #48: room owner, room admin, or site admin (regardless of their role in
// *this* room) can edit room settings, including privacy -- matches the
// backend PATCH /api/rooms/{id} gate exactly (see rooms.py).
const canEditSettings = canManage || !!user?.is_site_admin
// backend PATCH /api/rooms/{id} gate exactly (see rooms.py). #52: never
// for a DM regardless of role -- mirrors update_room's own
// CannotModifyDmError guard, since a DM's `name` is an internal token,
// not something editable.
const canEditSettings = !room.is_dm && (canManage || !!user?.is_site_admin)
useEffect(() => {
setNameDraft(room.name)
setDescDraft(room.description ?? '')
setIsPrivateDraft(room.is_private)
setEmailNotifications(room.email_notifications)
if (canManage) {
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
@@ -133,7 +142,7 @@ export function RoomInfoPanel({
setEventSubscriptions([])
setDirectoryUsers([])
}
}, [room.id, room.name, room.description, room.is_private, canManage])
}, [room.id, room.name, room.description, room.is_private, room.email_notifications, canManage])
useEffect(() => {
// Fetched lazily (only once expanded), not alongside the section above
@@ -147,6 +156,19 @@ export function RoomInfoPanel({
.catch((err) => setAttachmentsError(err instanceof ApiError ? err.message : String(err)))
}, [filesOpen, room.id])
async function handleToggleEmailNotifications(enabled: boolean) {
const previous = emailNotifications
setEmailNotifications(enabled)
setNotificationsError(null)
try {
await updateRoomNotifications(room.id, enabled)
onRoomUpdated()
} catch (err) {
setEmailNotifications(previous)
setNotificationsError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleAddMember(target: UserDirectoryEntry) {
setInviteError(null)
try {
@@ -263,6 +285,15 @@ export function RoomInfoPanel({
onLeft()
}
async function handleHideDm() {
const name = room.dm_partner?.display_name || room.dm_partner?.username || 'this conversation'
if (!confirm(`Hide your conversation with ${name}? It'll come back if either of you sends a new message.`)) {
return
}
await hideDm(room.id)
onLeft()
}
async function handleSaveSettings(e: FormEvent) {
e.preventDefault()
setRoomError(null)
@@ -295,12 +326,32 @@ export function RoomInfoPanel({
</div>
<div className="room-info-summary">
{room.dm_partner ? (
<>
<UserAvatar
username={room.dm_partner.username}
colorIndex={hashIndex(room.dm_partner.username)}
size={56}
avatarUrl={
room.dm_partner.avatar_filename
? getUserAvatarUrl(room.dm_partner.user_id, room.dm_partner.avatar_filename)
: null
}
status={room.dm_partner.status}
/>
<div className="room-info-name">{room.dm_partner.display_name || room.dm_partner.username}</div>
<div className="room-info-sub">{room.dm_partner.status === 'online' ? 'Online' : 'Offline'}</div>
</>
) : (
<>
<RoomAvatar colorIndex={0} size={56} />
<div className="room-info-name">#{room.name}</div>
<div className="room-info-sub">
{members.length} member{members.length === 1 ? '' : 's'}
{room.is_private && ' · Private'}
</div>
</>
)}
</div>
<div className="room-info-section">
@@ -343,6 +394,26 @@ export function RoomInfoPanel({
})}
</div>
{!room.is_dm && (
<div className="room-info-section">
<div className="toggle-row">
<div className="toggle-label">
<span className="t">Email notifications</span>
<span className="d">New messages and mentions, while you're offline</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={emailNotifications}
onChange={(e) => handleToggleEmailNotifications(e.target.checked)}
/>
<span className="track" />
</label>
</div>
{notificationsError && <p className="room-info-error">{notificationsError}</p>}
</div>
)}
<div className="room-info-section">
<button type="button" className="room-info-settings-toggle" onClick={() => setFilesOpen((v) => !v)}>
<DisclosureChevron open={filesOpen} /> Files
@@ -572,6 +643,11 @@ export function RoomInfoPanel({
</div>
)}
{room.is_dm ? (
<button type="button" className="room-info-leave" onClick={handleHideDm}>
Hide conversation
</button>
) : (
<button
type="button"
className="room-info-leave"
@@ -581,6 +657,7 @@ export function RoomInfoPanel({
>
Leave room
</button>
)}
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{previewFile && (
+18 -3
View File
@@ -1,6 +1,9 @@
import { Link } from 'react-router-dom'
import { getUserAvatarUrl } from '../api/users'
import { hashIndex } from '../lib/avatar'
import type { MyRoomItem } from '../types'
import { RoomAvatar } from './RoomAvatar'
import { UserAvatar } from './UserAvatar'
import './RoomRow.css'
interface RoomRowProps {
@@ -10,13 +13,25 @@ interface RoomRowProps {
}
export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
const partner = room.dm_partner
return (
<Link to={`/rooms/${room.id}`} className={`room-row${active ? ' room-row-active' : ''}`}>
{partner ? (
<UserAvatar
username={partner.username}
colorIndex={hashIndex(partner.username)}
size={34}
avatarUrl={partner.avatar_filename ? getUserAvatarUrl(partner.user_id, partner.avatar_filename) : null}
status={partner.status}
/>
) : (
<RoomAvatar colorIndex={colorIndex} />
)}
<div className="room-row-body">
<div className="room-row-name">
{room.name}
{room.is_private && (
{partner ? partner.display_name || partner.username : room.name}
{!partner && room.is_private && (
<svg
className="room-row-lock"
width="12"
@@ -30,7 +45,7 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
</svg>
)}
</div>
{room.description && <div className="room-row-subtitle">{room.description}</div>}
{!partner && room.description && <div className="room-row-subtitle">{room.description}</div>}
</div>
{!active && room.has_mention && (
<span className="room-row-mention-dot" aria-label="You were mentioned" />
+23
View File
@@ -99,12 +99,35 @@
}
.sidebar-section-label {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px 16px 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--ds-muted);
text-transform: uppercase;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.sidebar-section-label:hover {
color: var(--ds-text);
}
.sidebar-section-chevron {
flex: none;
/* Points down (expanded) by default -- rotated to point right when the
section is collapsed, the standard disclosure-triangle convention. */
transition: transform 0.15s ease;
}
.sidebar-section-chevron-collapsed {
transform: rotate(-90deg);
}
.sidebar-offline-note {
+120 -3
View File
@@ -1,8 +1,64 @@
import { useState } from 'react'
import { useResizableWidth } from '../hooks/useResizableWidth'
import type { MyRoomItem } from '../types'
import { RoomRow } from './RoomRow'
import './Sidebar.css'
// #62: which of the two sections (keyed 'dm'/'rooms') are collapsed --
// persisted the same way sidebar-width already is (see useResizableWidth),
// a per-viewer cosmetic preference with no reason to live server-side.
const COLLAPSED_SECTIONS_KEY = 'sidebar-collapsed-sections'
function loadCollapsedSections(): Set<string> {
try {
const raw = localStorage.getItem(COLLAPSED_SECTIONS_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed.filter((s) => typeof s === 'string')) : new Set()
} catch {
return new Set()
}
}
function saveCollapsedSections(sections: Set<string>): void {
try {
localStorage.setItem(COLLAPSED_SECTIONS_KEY, JSON.stringify([...sections]))
} catch {
// storage unavailable (private browsing, quota) -- collapse state just
// won't persist this session, not fatal.
}
}
interface SidebarSectionHeaderProps {
label: string
collapsed: boolean
onToggle: () => void
}
function SidebarSectionHeader({ label, collapsed, onToggle }: SidebarSectionHeaderProps) {
return (
<button type="button" className="sidebar-section-label" onClick={onToggle} aria-expanded={!collapsed}>
<svg
className={`sidebar-section-chevron${collapsed ? ' sidebar-section-chevron-collapsed' : ''}`}
width="10"
height="10"
viewBox="0 0 10 10"
aria-hidden="true"
>
<path
d="M2 3.5 5 7 8 3.5"
stroke="currentColor"
strokeWidth="1.4"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{label}
</button>
)
}
interface SidebarProps {
rooms: MyRoomItem[]
activeRoomId: string | undefined
@@ -24,8 +80,38 @@ export function Sidebar({
onOpenPeople,
unavailableOffline,
}: SidebarProps) {
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(loadCollapsedSections)
function toggleSection(key: string) {
setCollapsedSections((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
saveCollapsedSections(next)
return next
})
}
const query = searchQuery.trim().toLowerCase()
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
// A DM's `name` is an internal token, never what a user would search for
// -- matched against the partner's display name/username instead.
function matchesQuery(room: MyRoomItem): boolean {
if (!query) return true
if (room.is_dm && room.dm_partner) {
return (
(room.dm_partner.display_name ?? '').toLowerCase().includes(query) ||
room.dm_partner.username.toLowerCase().includes(query)
)
}
return room.name.toLowerCase().includes(query)
}
// #57: archived rooms keep flowing through in `rooms` (so a member who
// still has one open via a direct link resolves fine -- see ChatPane),
// but they're a dead end going forward, so they don't belong in the list
// you'd browse/search from.
const filtered = rooms.filter((r) => !r.is_archived).filter(matchesQuery)
const directMessages = filtered.filter((r) => r.is_dm)
const regularRooms = filtered.filter((r) => !r.is_dm)
const { width, startResize } = useResizableWidth({
storageKey: 'sidebar-width',
@@ -83,9 +169,16 @@ export function Sidebar({
</p>
) : (
<>
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
{directMessages.length > 0 && (
<>
<SidebarSectionHeader
label="Direct Messages"
collapsed={collapsedSections.has('dm')}
onToggle={() => toggleSection('dm')}
/>
{!collapsedSections.has('dm') && (
<nav>
{filtered.map((room, i) => (
{directMessages.map((room, i) => (
<RoomRow
key={room.id}
room={room}
@@ -94,6 +187,30 @@ export function Sidebar({
/>
))}
</nav>
)}
</>
)}
{regularRooms.length > 0 && (
<>
<SidebarSectionHeader
label="Rooms"
collapsed={collapsedSections.has('rooms')}
onToggle={() => toggleSection('rooms')}
/>
{!collapsedSections.has('rooms') && (
<nav>
{regularRooms.map((room, i) => (
<RoomRow
key={room.id}
room={room}
colorIndex={i}
active={room.id === activeRoomId}
/>
))}
</nav>
)}
</>
)}
</>
)}
</div>
+13
View File
@@ -13,6 +13,7 @@ import {
} from '../lib/desktopBridge'
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
import { AboutModal } from './AboutModal'
import { CustomEmojiManageModal } from './CustomEmojiManageModal'
import { ProfileModal } from './ProfileModal'
import { UserAvatar } from './UserAvatar'
import './TopBar.css'
@@ -28,6 +29,7 @@ export function TopBar() {
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false)
const [customEmojiModalOpen, setCustomEmojiModalOpen] = useState(false)
const [aboutModalOpen, setAboutModalOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false)
@@ -124,6 +126,16 @@ export function TopBar() {
>
Profile settings
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setCustomEmojiModalOpen(true)
}}
>
Custom emoji
</button>
<button
type="button"
role="menuitem"
@@ -190,6 +202,7 @@ export function TopBar() {
)}
</div>
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
{customEmojiModalOpen && <CustomEmojiManageModal onClose={() => setCustomEmojiModalOpen(false)} />}
{aboutModalOpen && <AboutModal onClose={() => setAboutModalOpen(false)} />}
</header>
)

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