43 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 b1c9a85ab3 Bump to v2026.9.4
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:38:02 -06:00
ksmithandClaude Sonnet 5 62a25c6278 Point public deploy docs at the real GitHub repo
DEPLOYMENT.md's clone instructions and frontend/.env.example's
VITE_SOURCE_URL default now name github.com/ds-ksmith/DS-Chat directly
now that the project actually has a public home there, instead of
generic placeholders -- with a note for anyone deploying from their
own fork. The user's own production deploy is unaffected: it still
pulls from their private Gitea instance, which mirrors to GitHub.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:37:35 -06:00
ksmithandClaude Sonnet 5 7a84d2f09f Deploy and upgrade only from tagged releases, not branch tip
deploy/upgrade.sh now fetches tags and checks out whichever sorts
newest (detached HEAD) instead of git pull --ff-only on a branch, now
that the project has a real release process (dated tags, e.g.
v2026.9.3) and a public mirror. Keeps production from ever landing on
an untagged commit. DEPLOYMENT.md's initial-clone steps and Upgrades
section updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:31:08 -06:00
ksmithandClaude Sonnet 5 d74527a29c Stop tracking .claude/ -- local tool config, not project source
launch.json is just the Browser preview tool's dev-server launch
config (command, port); nothing sensitive in it, but it never should
have been committed in the first place.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 21:00:56 -06:00
ksmithandClaude Sonnet 5 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
123 changed files with 7254 additions and 599 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
"port": 5173
},
{
"name": "frontend-preview",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "preview"],
"port": 4173
}
]
}
+1
View File
@@ -17,6 +17,7 @@ dist-ssr/
## Editors / OS ## Editors / OS
.vscode/ .vscode/
.idea/ .idea/
.claude/
.DS_Store .DS_Store
## Test / coverage ## Test / coverage
+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 | | 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 | | 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 | | 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 ## 3. System architecture
@@ -85,17 +85,31 @@ users
id, username, email, password_hash, is_bot, is_site_admin, created_at id, username, email, password_hash, is_bot, is_site_admin, created_at
rooms 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_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 room_invites
id, room_id, invited_by, token, target_user_id or target_email, id, room_id, invited_by, token, target_user_id or target_email,
expires_at, status (pending | accepted | revoked) expires_at, status (pending | accepted | revoked)
messages 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 push_subscriptions
id, user_id, endpoint, p256dh_key, auth_key, created_at 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 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 ## 5. Permission model
- **Room visibility**: `open` (any authenticated user can find and join) or - **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 4. Every app instance subscribed to that channel forwards it to its own connected
members over WebSocket. members over WebSocket.
5. For members with no active connection, the server looks up 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 ## 7. Extension system: bots and AI agents
+51
View File
@@ -0,0 +1,51 @@
# Contributing to DS Chat
Thanks for considering a contribution.
## Reporting bugs and requesting features
Open an issue on this project's issue tracker. Include steps to reproduce
for a bug, or the problem you're trying to solve for a feature request —
that's usually more useful than a proposed solution.
## Development setup
See the root [README.md](README.md)'s Quickstart, plus
[backend/README.md](backend/README.md) and
[frontend/README.md](frontend/README.md) for the full local dev setup
(Postgres, Redis, Python venv, migrations, the Vite dev server).
[ARCHITECTURE.md](ARCHITECTURE.md) covers the overall system design if
you're orienting yourself before a larger change.
## Before opening a pull request
- **Tests**: run `pytest` in `backend/` for any backend change, and add
tests for new behavior rather than just the happy path — see
`backend/README.md`'s "Run tests" section. For frontend changes, run
`npx tsc -b` in `frontend/` and confirm `npm run build` succeeds.
- **Style**: match the conventions already in the file you're editing
rather than introducing a new pattern — this codebase doesn't have a
separate style guide beyond "look at what's already there."
- **Scope**: smaller, focused PRs are easier to review than large ones
that mix unrelated changes.
## Contributor terms
By submitting a contribution (a pull request, patch, or similar), you
agree that:
1. Your contribution is licensed under the project's own license,
AGPL-3.0-or-later ([LICENSE](LICENSE)), and
2. You grant the project's maintainer(s) a perpetual, worldwide,
non-exclusive right to also relicense your contribution under different
terms — for example, as part of a separately-licensed commercial
offering built on this project.
This keeps the option of a future dual-licensed (open-source +
commercial) version of the project available, without requiring a
separate signed agreement for every contribution.
*This is a lightweight starting point, not a substitute for legal advice —
if you're contributing something substantial, or maintaining a fork with
your own commercial plans, it's worth having this reviewed by a lawyer
rather than relying on the paragraph above alone.*
+46 -23
View File
@@ -132,35 +132,45 @@ sudo -u ds-chat ssh-keygen -t ed25519 -f /srv/ds-chat/.ssh/id_ed25519 -N ""
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
``` ```
Add that public key as a **read-only deploy key** on the Gitea repo This project is hosted at
(Settings → Deploy Keys), then: **[github.com/ds-ksmith/DS-Chat](https://github.com/ds-ksmith/DS-Chat)**.
Add that public key there as a **read-only deploy key** (Settings → Deploy
Keys on the repo), then:
```bash ```bash
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts sudo -u ds-chat ssh-keyscan github.com >> /srv/ds-chat/.ssh/known_hosts
sudo -u ds-chat git clone git@git.darksingularity.org:DarkSingularity/ds-chat.git /srv/ds-chat sudo -u ds-chat git clone git@github.com:ds-ksmith/DS-Chat.git /srv/ds-chat
``` ```
(If your Gitea's SSH is on a non-default port, adjust the clone URL and (Deploying from your own fork instead? Substitute its clone URL — the same
`ssh-keyscan -p <port>` accordingly.) deploy-key/access-token steps work the same way on GitHub, GitLab, Gitea,
and most other git hosts.)
**Alternative: a personal/deployment-user access token instead of a deploy **Alternative: a personal/deployment-user access token instead of a deploy
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
and clone over HTTPS with the token embedded in the URL: and clone over HTTPS with the token embedded in the URL:
```bash ```bash
sudo -u ds-chat git clone https://<TOKEN>@git.darksingularity.org/DarkSingularity/ds-chat.git /srv/ds-chat sudo -u ds-chat git clone https://<TOKEN>@github.com/ds-ksmith/DS-Chat.git /srv/ds-chat
``` ```
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
remote -v` shows it) — readable by root and the `ds-chat` user, not by remote -v` shows it) — readable by root and the `ds-chat` user, not by
anyone else under normal file permissions. `deploy/upgrade.sh`'s later anyone else under normal file permissions. `deploy/upgrade.sh`'s later
`git pull`s reuse this same authenticated URL automatically, no extra `git fetch`es reuse this same authenticated URL automatically, no extra
setup needed. Fine as long as the token is scoped to read-only access on setup needed. Fine as long as the token is scoped to read-only access on
just this repo. just this repo.
Either way, now that the repo is cloned: Either way, now that the repo is cloned, check out the latest release tag
rather than deploying whatever the default branch's tip happens to be —
`deploy/upgrade.sh` follows the same rule on every later upgrade (see §6),
so this keeps the very first deploy consistent with all the ones after it:
```bash ```bash
cd /srv/ds-chat
sudo -u ds-chat git fetch --tags
LATEST_TAG="$(sudo -u ds-chat git tag --sort=-creatordate | head -n1)"
sudo -u ds-chat git checkout --detach "$LATEST_TAG"
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
``` ```
@@ -232,7 +242,16 @@ admin sets it up.
`/ws`) whenever that directory exists — that's what lets Nginx Proxy `/ws`) whenever that directory exists — that's what lets Nginx Proxy
Manager forward the whole domain to one port with no custom path routing. Manager forward the whole domain to one port with no custom path routing.
Before building, copy `frontend/.env.example` to `frontend/.env.production`
and set `VITE_SOURCE_URL` to wherever *your* copy of the repo lives — see
that file's own comment for why this matters (AGPL-3.0 source-availability
compliance). Vite bakes this in at build time, so it needs to be in place
before `npm run build` runs, and needs re-running after any future change
to it.
```bash ```bash
sudo -u ds-chat cp /srv/ds-chat/frontend/.env.example /srv/ds-chat/frontend/.env.production
sudo -u ds-chat nano /srv/ds-chat/frontend/.env.production # set VITE_SOURCE_URL
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build' sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
``` ```
@@ -320,12 +339,15 @@ This is config in NPM's own UI/database, not a file this repo ships:
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
``` ```
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`, Fetches tags and checks out whichever one sorts newest (`git tag
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to --sort=-creatordate`) — deliberately not the default branch's tip, so
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops running this between releases is a safe no-op rather than pulling in
before restarting anything if an earlier step — most importantly a failed whatever's mid-flight on `main`. Then reinstalls backend deps, runs
migration — errors out, so a bad deploy doesn't take down the previously `alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
working one. curls `/api/health` to confirm it came back up. Fails loudly
(`set -euo pipefail`) and stops before restarting anything if an earlier
step — most importantly a failed migration — errors out, so a bad deploy
doesn't take down the previously working one.
Active users get disconnected for a few seconds during the restart and Active users get disconnected for a few seconds during the restart and
reconnect automatically (same reconnect logic as §4's NPM-timeout note) — reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
@@ -345,11 +367,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 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. rsync/S3 examples) — decide where those need to go and fill it in.
That script covers Postgres only. Uploaded chat images and file attachments That script covers Postgres only. Uploaded chat images, file/video
both live on the **app** server's disk (`/srv/ds-chat/uploads`, created in attachments, avatars, and custom emoji all live on the **app** server's
§3a) — a separate machine from the data server this script runs on — and disk (`/srv/ds-chat/uploads`, created in §3a) — a separate machine from
currently have no backup mechanism at all. Whatever off-box destination you the data server this script runs on — and currently have no backup
pick above, include `/srv/ds-chat/uploads` in it too (e.g. a second `rsync` 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). line run from the app server).
**Test a restore** (against a scratch database, never directly onto **Test a restore** (against a scratch database, never directly onto
@@ -389,13 +412,13 @@ Carried forward from earlier phases (see `backend/README.md`'s own "Notes /
scope decisions" for the full detail on each): scope decisions" for the full detail on each):
- No rate limiting on human or bot API traffic. - No rate limiting on human or bot API traffic.
- No CSRF token (relies on `SameSite=Lax` cookies). - 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 - SSRF protection on outgoing webhooks is creation-time only, not
re-validated per delivery (DNS-rebinding gap). re-validated per delivery (DNS-rebinding gap).
- Backup off-box shipping is a placeholder — decide a destination and fill - Backup off-box shipping is a placeholder — decide a destination and fill
in `deploy/backup-postgres.sh`. in `deploy/backup-postgres.sh`.
- Uploaded chat images and file attachments (`/srv/ds-chat/uploads` on the - Uploaded chat images, file/video attachments, and custom emoji
app server) have no backup coverage at all yet, on-box or off — see §7. (`/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 - 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. 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 Not a security issue (still gated by room membership to view), just an
+2 -2
View File
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.> DS Chat, a self-hosted, real-time team chat service.
Copyright (C) <year> <name of author> Copyright (C) 2026 Keith Smith
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
+18 -13
View File
@@ -18,21 +18,26 @@ build plan.
## Features ## Features
- **Auth & accounts** — session-based auth, invite-only signup (admin-issued - **Auth & accounts** — server-side, revocable sessions (see every device
site invites or room invites, both delivered by email), password reset, you're logged in from and sign one out remotely), invite-only signup
per-user light/dark/midnight/sunset presets plus a live theme builder for (admin-issued site invites or room invites, both delivered by email),
fully custom, named, savable color themes. password reset, per-user light/dark/midnight/sunset presets plus a live
- **Rooms** — open and private rooms, owner/admin/member roles, invites, theme builder for fully custom, named, savable color themes.
room browsing/search, file/image galleries per room. - **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 - **Real-time chat** — WebSocket-based messaging with automatic reconnect
and backoff, Markdown rendering, @mentions with autocomplete and inline and backoff, Markdown rendering (headings with custom anchors, sub/
highlighting, emoji reactions, message editing, image and file superscript, tables, and more), @mentions and #room-reference links with
attachments (drag-and-drop, paste, or picker) with inline previews for autocomplete and inline highlighting, emoji reactions and shortcodes plus
images/PDFs/text/Markdown, unread indicators, and presence (online/away/ 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). offline, with a manual "appear offline" override).
- **Notifications** — Web Push for offline/backgrounded members, with - **Notifications** — Web Push and native desktop notifications for
per-type opt-in/out (mentions vs. all messages), plus in-app unread offline/backgrounded members, plus email: always-on for direct messages,
badges. 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 - **PWA** — installable, offline-capable (cached room/message data, a
dedicated offline banner), with automatic update detection that prompts dedicated offline banner), with automatic update detection that prompts
a reload as soon as a new deploy goes live. 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) - [Getting started](#getting-started)
- [Rooms](#rooms) - [Rooms](#rooms)
- [Direct messages](#direct-messages)
- [Sending messages](#sending-messages) - [Sending messages](#sending-messages)
- [Formatting](#formatting) - [Formatting](#formatting)
- [Mentions and room links](#mentions-and-room-links) - [Mentions and room links](#mentions-and-room-links)
- [Attachments](#attachments) - [Attachments](#attachments)
- [Reactions](#reactions) - [Reactions and custom emoji](#reactions-and-custom-emoji)
- [Editing a message](#editing-a-message) - [Editing and deleting a message](#editing-and-deleting-a-message)
- [Presence and notifications](#presence-and-notifications) - [Presence and notifications](#presence-and-notifications)
- [Your profile](#your-profile) - [Your profile](#your-profile)
- [Room details](#room-details) - [Room details](#room-details)
- [Active sessions](#active-sessions)
- [Staying up to date](#staying-up-to-date) - [Staying up to date](#staying-up-to-date)
## Getting started ## 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 - **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 name — a plain dot for unread messages, a highlighted dot if you were
specifically @mentioned. 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 ## Sending messages
@@ -61,7 +78,9 @@ Messages support Markdown:
- `-` or `1.` for bulleted/numbered lists - `-` or `1.` for bulleted/numbered lists
- `[link text](https://example.com)` — or just paste a bare URL and it - `[link text](https://example.com)` — or just paste a bare URL and it
becomes clickable automatically 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 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 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 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 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 ## 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 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 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 to view it full-size. Common video formats (MP4, WebM, Ogg) play inline
filename and size; `.txt`, `.md`, and `.pdf` files open in a preview too, with a button to expand to a larger view; other files show as a
without leaving the room, everything else downloads when clicked. 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, There's a server-configured maximum file size — if a file is too large,
you'll see an error before it uploads. 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 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 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 message with a count; click an existing pill to add or remove your own
reaction to it. Hovering a pill shows who reacted. 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 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 changes, then press **Enter** to save or **Escape** to cancel. Clicking
away also saves. Edited messages are marked *(edited)*. 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 ## Presence and notifications
Everyone's avatar shows a small status dot — green for online, grey for 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 get a notification whenever the app is minimized *or* simply not the
focused window, even if it's still open somewhere on screen. 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 ## Your profile
Open your account menu and choose **Profile settings** to: Open your account menu and choose **Profile settings** to:
@@ -135,12 +175,22 @@ Open your account menu and choose **Profile settings** to:
live) live)
- Change your password - 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 ## Room details
Click the info icon in a room's header to open its details panel, where Click the info icon in a room's header to open its details panel, where
you can: you can:
- See who else is in the room and their role (member/admin/owner) - 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, - Browse and re-download every file and image ever shared in the room,
without scrolling back through history without scrolling back through history
- Leave the room — unless you're the owner, in which case ownership has - 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 FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth
CRUD (open and private), room roles (owner/admin/member) and direct (server-side, revocable sessions — see Active sessions below), room CRUD
membership management, a WebSocket chat endpoint that fans out across (open and private), room roles (owner/admin/member) and direct membership
multiple app-server instances via Redis pub/sub, Web Push notifications for management, direct messages, a WebSocket chat endpoint that fans out across
offline room members, a site-admin portal (user/room/bot management + an multiple app-server instances via Redis pub/sub, Web Push and email
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket notifications for offline room members (plus a native desktop-notification
access, incoming and outgoing webhooks, message editing), image uploads and bridge for DS Chat Desktop), a site-admin portal (user/room/bot management +
generic file attachments in chat messages, emoji reactions on messages, an audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
self-service user profiles access, incoming and outgoing webhooks, message editing), image uploads,
(display name, avatar), self-service password change and a token-based inline-playable video attachments, and generic file attachments in chat
forgot-password flow, and admin-issued email invites for new accounts messages, message deletion, emoji reactions (built-in Unicode plus
plus email notifications when a user is added to a room. See site-wide custom/uploaded emoji, both usable in reactions and inline in
`../ARCHITECTURE.md` for the full system design and the phased build plan. 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. 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. 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 on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys` cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships, 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, site_invites, password_resets, smtp_settings,
push_subscriptions, admin_audit_log, api_tokens, upload_settings, push_subscriptions,
webhooks_incoming, event_subscriptions) admin_audit_log, api_tokens, webhooks_incoming,
event_subscriptions)
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
routers/ auth, rooms, users, signup, push, admin, routers/ auth, rooms, users, signup, push, admin,
bots, webhooks, health 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, the only backend change is the new `desktop_notification` envelope type,
covered by `backend/tests/test_desktop_notifications.py`. 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) ## Room roles and membership (Phase 2)
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or 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 `GET /api/rooms/mine` lists every room (open + private) the current user
belongs to, alongside their role. 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 ## Image uploads
A message can carry an image (`Message.image_id`, nullable), a caption 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. 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 ## Upload size limits
The 8 MB image/file/avatar cap is no longer hardcoded — it's an The 8 MB image/file/avatar cap is no longer hardcoded — it's an
@@ -462,14 +553,17 @@ returning `None`.
## Emoji & reactions ## Emoji & reactions
An emoji picker in the frontend composer is purely client-side (a static The built-in emoji picker in the frontend composer is purely client-side (a
curated unicode list, no backend involvement). Message **reactions** are static curated unicode list, no backend involvement). Message **reactions**
full-stack: `message_reactions` (`app/models/message_reaction.py`) has are full-stack: `message_reactions` (`app/models/message_reaction.py`) has
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three `message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
backing toggle semantics — the same user reacting with the same emoji on backing toggle semantics — the same user reacting with the same emoji on
the same message twice removes it (Slack/Mattermost convention). the same message twice removes it (Slack/Mattermost convention).
`message_service.toggle_reaction` is a plain select-then-delete-or-insert, `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 WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
reaction; the server broadcasts the message's **full recomputed** reaction 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` Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
in `webhook_service.py` is unchanged — same restraint as image uploads), no in `webhook_service.py` is unchanged — same restraint as image uploads), no
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode reaction-count limit or rate limiting.
only, curated client-side list in `frontend/src/lib/emoji.ts`).
## 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 ## 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. membership above) sends a "you've been added" email too.
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`): **Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
`send_email(db, to, subject, body)` is the fire-and-forget path used by `send_email(db, to, subject, paragraphs, *, cta_label=None, cta_url=None,
invite flows — if `SmtpSettings` isn't configured yet it logs at debug and theme_user=None)` is the fire-and-forget path used by invite/notification
returns (same "silently skip if unconfigured" UX push notifications already flows — if `SmtpSettings` isn't configured yet it logs (at `.warning`, not
use for a missing VAPID key), and it never raises on delivery failure (an `.debug` — this app has no logging config lowering the root level below
SMTP outage must not block an invite/membership action that already Python's own `WARNING` default, so anything below that is silently
succeeded in the database). `send_test_email(db, to)` is the one exception — invisible in production) and returns, and it never raises on delivery
used only by the admin "send test email" button, it raises so the UI can failure (an SMTP outage must not block an invite/membership/notification
show *why* it failed instead of a silent no-op. Plain-text bodies only, no action that already succeeded in the database). `send_test_email(db, to)`
HTML templates, matching this codebase's existing minimalism. 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 **SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s
`/settings/smtp` endpoints) lives in the database, not the env file — the `/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, (`app/routers/signup.py`) is the first genuinely public,
unauthenticated endpoint in this app that creates a `User` row — it calls unauthenticated endpoint in this app that creates a `User` row — it calls
the existing `auth_service.register_user` directly for identical the existing `auth_service.register_user` directly for identical
hashing/uniqueness handling, and logs the new user in immediately (same hashing/uniqueness handling, then `session_service.start_session` (see
session-cookie line `auth.py`'s `login()` uses) so they land in the app Active sessions below) so they land in the app already signed in. No new
already signed in. No new rate limiting on it — the unguessable, single-use, rate limiting on it — the unguessable, single-use, expiring token is the
expiring token is the actual protection, inheriting the same "no rate actual protection, inheriting the same "no rate limiting on human/bot
limiting on human/bot traffic" gap already documented below, not a new one. 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 **Room-membership email**: `room_service.add_member` sends one email to
the target user after creating the `RoomMembership`, using the live the target user after creating the `RoomMembership`, using the live
request's `base_url` for the link — no new "public URL" config needed. request's `base_url` for the link — no new "public URL" config needed.
Scope cuts: no outgoing-webhook event type for these (matching image Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers uploads/reactions).
it), no HTML email templates.
## Self-service password change and reset ## 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 `current_password` + `new_password`; verifies the current one with
`security.verify_password` before setting `password_hash = `security.verify_password` before setting `password_hash =
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me` hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
(profile update): mutate `current_user`, commit, done. No session (profile update): mutate `current_user`, commit, done. Doesn't proactively
invalidation elsewhere (there's no server-side session table to invalidate revoke any other logged-in session for that account — a session table now
against — see Notes below), so other logged-in sessions for that account exists (see Active sessions below), but changing your password doesn't
stay valid until they expire naturally. 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`, **Forgot password** (`app/models/password_reset.py`,
`app/services/password_service.py`) — same hashed-token-with-expiry shape as `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 a single `SELECT`. `GET /api/auth/reset-password/validate` lets the frontend
show a "this link is invalid" state before rendering the password form. show a "this link is invalid" state before rendering the password form.
`POST /api/auth/reset-password` completes it and — like signup — logs the `POST /api/auth/reset-password` completes it and — like signup — logs the
user in immediately (`request.session["user_id"]`), since they've just proven user in immediately (`session_service.start_session`, see Active sessions
they control the account's email. below), since they've just proven they control the account's email.
Scope cuts: no rate limiting on `/forgot-password` (inherits the same Scope cuts: no rate limiting on `/forgot-password` (inherits the same
documented gap as every other endpoint below, not a new one — the 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 no cleanup job for expired/used `password_resets` rows (same as
`site_invites`, which has never had one either). `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 ## Link previews
Slack/Discord-style unfurling: the first `http(s)://` URL found in a 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`. `Content-Type` is `text/html`.
- **Cached by URL, not by message** (`link_previews` table, unique on - **Cached by URL, not by message** (`link_previews` table, unique on
`url`) — a URL posted by five different people in five different rooms `url`) — a URL posted by five different people in five different rooms
fetches once. A row also gets written on a *failed* fetch within the same short window fetches once. A row also gets written on a
(`fetch_failed=True`) so a URL that genuinely doesn't unfurl (SSRF *failed* fetch (`fetch_failed=True`) so a URL that genuinely doesn't
rejection, timeout, no usable title) isn't re-attempted on every message unfurl (SSRF rejection, timeout, no usable title) isn't re-attempted on
that references it; both kinds expire after 7 days (`_CACHE_TTL`). 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 - Parsed with stdlib `html.parser.HTMLParser`, not a new dependency — only
meta-tag scraping is needed, not general HTML parsing. meta-tag scraping is needed, not general HTML parsing.
- Editing a message re-extracts the URL; if it changed or was removed, the - 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 - 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 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 adding an existing user to a private room — site accounts vs. room
membership. membership.
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side - Sessions are backed by a real server-side table (`app/models/session.py`,
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth see Active sessions above) — the signed cookie (Starlette
through a WebSocket handshake). This means there's currently no way to force- `SessionMiddleware`) now only ever carries an opaque session id, resolved
revoke a session server-side; that needs a real session table later. 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 - 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. proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
- Deleting a room explicitly deletes its messages/memberships first - 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.database import get_db
from app.models import RoomMembership, RoomRole, User from app.models import RoomMembership, RoomRole, User
from app.services.bot_service import resolve_token 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} _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 request.state.api_token = token
return user return user
user_id = request.session.get("user_id") session_id = request.session.get("session_id")
if not user_id: if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated") 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 # Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
# be read without a MissingGreenlet -- selectinload skips the second # be read without a MissingGreenlet -- selectinload skips the second
# query entirely when active_custom_theme_id is null (the common case), # 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. # so this costs nothing for users who've never set a custom theme.
user = await db.get( 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: if user is None or not user.is_active:
request.session.clear() request.session.clear()
+4
View File
@@ -15,6 +15,7 @@ from app.routers import (
admin, admin,
auth, auth,
bots, bots,
custom_emoji,
custom_themes, custom_themes,
health, health,
push, push,
@@ -27,6 +28,7 @@ from app.routers import (
from app.ws.broadcaster import Broadcaster from app.ws.broadcaster import Broadcaster
from app.ws.chat import router as ws_router from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence 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.redis = Redis.from_url(settings.redis_url, decode_responses=True)
app.state.presence = Presence(app.state.redis) app.state.presence = Presence(app.state.redis)
app.state.global_presence = GlobalPresence(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.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager)
app.include_router(health.router) app.include_router(health.router)
@@ -91,6 +94,7 @@ def create_app() -> FastAPI:
app.include_router(users.router) app.include_router(users.router)
app.include_router(push.router) app.include_router(push.router)
app.include_router(custom_themes.router) app.include_router(custom_themes.router)
app.include_router(custom_emoji.router)
app.include_router(uploads.router) app.include_router(uploads.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(bots.router) app.include_router(bots.router)
+4
View File
@@ -1,6 +1,7 @@
from app.models.admin_audit_log import AdminAuditLog from app.models.admin_audit_log import AdminAuditLog
from app.models.api_token import ApiToken from app.models.api_token import ApiToken
from app.models.base import Base from app.models.base import Base
from app.models.custom_emoji import CustomEmoji
from app.models.custom_theme import CustomTheme from app.models.custom_theme import CustomTheme
from app.models.event_subscription import EventSubscription from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus 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.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.room import Room from app.models.room import Room
from app.models.session import Session
from app.models.site_invite import SiteInvite from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings from app.models.smtp_settings import SmtpSettings
from app.models.upload_settings import UploadSettings from app.models.upload_settings import UploadSettings
@@ -35,6 +37,7 @@ __all__ = [
"MessageRoomReference", "MessageRoomReference",
"InviteStatus", "InviteStatus",
"PasswordReset", "PasswordReset",
"Session",
"SiteInvite", "SiteInvite",
"SmtpSettings", "SmtpSettings",
"UploadSettings", "UploadSettings",
@@ -44,5 +47,6 @@ __all__ = [
"WebhookIncoming", "WebhookIncoming",
"EventSubscription", "EventSubscription",
"CustomTheme", "CustomTheme",
"CustomEmoji",
"LinkPreview", "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 import uuid
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base from app.models.base import Base
@@ -33,6 +33,23 @@ class RoomMembership(Base):
last_read_at: Mapped[datetime] = mapped_column( last_read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False 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") room = relationship("Room", back_populates="memberships")
user = relationship("User") user = relationship("User")
+5 -1
View File
@@ -10,8 +10,12 @@ from app.models.base import Base
class Message(Base): class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"
__table_args__ = ( __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( 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", name="messages_content_or_attachment_required",
), ),
) )
+4
View File
@@ -15,6 +15,10 @@ class Room(Base):
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_archived: 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) owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False 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) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50)) display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20)) 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 # Only meaningful when theme == "custom" -- which of this user's saved
# CustomTheme rows (app/models/custom_theme.py) is currently active. # CustomTheme rows (app/models/custom_theme.py) is currently active.
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme # 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, SiteInviteNotPendingError,
create_site_invite, create_site_invite,
list_site_invites, list_site_invites,
resend_site_invite,
revoke_site_invite, revoke_site_invite,
) )
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings 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") 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) @router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
async def get_smtp_settings_endpoint( async def get_smtp_settings_endpoint(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -376,7 +393,7 @@ async def test_smtp_settings_endpoint(
): ):
require_site_admin(current_user) require_site_admin(current_user)
try: try:
await send_test_email(db, current_user.email) await send_test_email(db, current_user.email, theme_user=current_user)
except SmtpNotConfiguredError: except SmtpNotConfiguredError:
raise HTTPException(status_code=400, detail="SMTP is not configured yet") raise HTTPException(status_code=400, detail="SMTP is not configured yet")
except Exception as exc: 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 fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
from app.models import User from app.models import User
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
from app.schemas.session import SessionRead
from app.schemas.user import ProfileUpdate, UserRead from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import ( from app.services.auth_service import (
AccountDeactivatedError, AccountDeactivatedError,
@@ -22,7 +25,15 @@ from app.services.password_service import (
request_password_reset, request_password_reset,
validate_reset_token, 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.upload_settings_service import format_mb, get_upload_settings
from app.services.user_agent_service import describe_user_agent
from app.storage import ( from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES, ALLOWED_IMAGE_CONTENT_TYPES,
InvalidImageError, InvalidImageError,
@@ -55,12 +66,15 @@ async def login(
except AccountDeactivatedError: except AccountDeactivatedError:
raise HTTPException(status_code=401, detail="Account is deactivated") 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 return user
@router.post("/logout", status_code=204) @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() request.session.clear()
return Response(status_code=204) return Response(status_code=204)
@@ -86,6 +100,10 @@ async def update_profile(
current_user.display_name = display_name or None current_user.display_name = display_name or None
if "theme" in updates: if "theme" in updates:
current_user.theme = updates["theme"] 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: if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"] current_user.appear_offline = updates["appear_offline"]
await db.commit() await db.commit()
@@ -218,5 +236,37 @@ async def complete_reset_password_endpoint(
except PasswordResetInvalidError: except PasswordResetInvalidError:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired") 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 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_file import MessageFileCreated
from app.schemas.message_image import MessageImageCreated from app.schemas.message_image import MessageImageCreated
from app.schemas.room import ( from app.schemas.room import (
DmPartnerInfo,
MyRoomItem, MyRoomItem,
RoomAttachmentRead, RoomAttachmentRead,
RoomCreate, RoomCreate,
@@ -24,8 +25,10 @@ from app.schemas.room import (
RoomMemberAdd, RoomMemberAdd,
RoomMemberRead, RoomMemberRead,
RoomMemberRoleUpdate, RoomMemberRoleUpdate,
RoomNotificationSettingsUpdate,
RoomRead, RoomRead,
RoomUpdate, RoomUpdate,
StartDmRequest,
TransferOwnershipRequest, TransferOwnershipRequest,
) )
from app.schemas.webhook import ( from app.schemas.webhook import (
@@ -36,19 +39,24 @@ from app.schemas.webhook import (
WebhookIncomingRead, WebhookIncomingRead,
) )
from app.services.link_preview_service import get_link_previews_for_urls 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 ( from app.services.message_service import (
create_message,
get_reactions_for_messages, get_reactions_for_messages,
list_recent_messages, list_recent_messages,
list_room_attachments, 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.upload_settings_service import format_mb, get_upload_settings
from app.services.room_service import ( from app.services.room_service import (
AlreadyMemberError, AlreadyMemberError,
CannotDmSelfError,
CannotModifyDmError,
CannotRemoveOwnerError, CannotRemoveOwnerError,
DuplicateRoomError, DuplicateRoomError,
InsufficientRoleError, InsufficientRoleError,
MembershipNotFoundError, MembershipNotFoundError,
NotADmError,
OwnerMustTransferError, OwnerMustTransferError,
RoomIsPrivateError, RoomIsPrivateError,
RoomNotFoundError, RoomNotFoundError,
@@ -57,7 +65,9 @@ from app.services.room_service import (
change_member_role, change_member_role,
create_room, create_room,
delete_room, delete_room,
find_or_create_dm,
get_room, get_room,
hide_dm,
join_room, join_room,
leave_room, leave_room,
list_member_rooms, list_member_rooms,
@@ -65,6 +75,7 @@ from app.services.room_service import (
list_room_members, list_room_members,
mark_room_read, mark_room_read,
remove_member, remove_member,
set_room_email_notifications,
transfer_ownership, transfer_ownership,
update_room, update_room,
) )
@@ -82,6 +93,7 @@ from app.services.webhook_service import (
from app.services.ssrf import UnsafeUrlError from app.services.ssrf import UnsafeUrlError
from app.storage import ( from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES, ALLOWED_IMAGE_CONTENT_TYPES,
INLINE_SAFE_VIDEO_CONTENT_TYPES,
UPLOADS_DIR, UPLOADS_DIR,
InvalidImageError, InvalidImageError,
UploadTooLargeError, UploadTooLargeError,
@@ -105,6 +117,28 @@ async def create_room_endpoint(
raise HTTPException(status_code=409, detail="A room with this name already exists") 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]) @router.get("", response_model=list[RoomListItem])
async def list_rooms_endpoint( async def list_rooms_endpoint(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -117,6 +151,8 @@ async def list_rooms_endpoint(
name=room.name, name=room.name,
description=room.description, description=room.description,
is_private=room.is_private, is_private=room.is_private,
is_dm=room.is_dm,
is_archived=room.is_archived,
owner_id=room.owner_id, owner_id=room.owner_id,
created_at=room.created_at, created_at=room.created_at,
is_member=is_member, is_member=is_member,
@@ -127,23 +163,40 @@ async def list_rooms_endpoint(
@router.get("/mine", response_model=list[MyRoomItem]) @router.get("/mine", response_model=list[MyRoomItem])
async def list_my_rooms_endpoint( async def list_my_rooms_endpoint(
request: Request,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
rooms = await list_member_rooms(db, current_user.id) 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 [ return [
MyRoomItem( MyRoomItem(
id=room.id, id=room.id,
name=room.name, name=room.name,
description=room.description, description=room.description,
is_private=room.is_private, is_private=room.is_private,
is_dm=room.is_dm,
is_archived=room.is_archived,
owner_id=room.owner_id, owner_id=room.owner_id,
created_at=room.created_at, created_at=room.created_at,
role=role, role=role,
has_unread=has_unread, has_unread=has_unread,
has_mention=has_mention, 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") raise HTTPException(status_code=404, detail="Room not found")
except DuplicateRoomError: except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists") 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) @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") 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) @router.post("/{room_id}/read", status_code=204)
async def mark_room_read_endpoint( async def mark_room_read_endpoint(
room_id: uuid.UUID, room_id: uuid.UUID,
@@ -225,6 +298,20 @@ async def mark_room_read_endpoint(
await mark_room_read(db, room_id, current_user.id) 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: def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
# appear_offline always wins, regardless of actual connection -- that's # appear_offline always wins, regardless of actual connection -- that's
# the whole point of the override (lurking in a room undetected). # 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, []), reactions=reactions_by_message.get(m.id, []),
created_at=m.created_at, created_at=m.created_at,
edited_at=m.edited_at, edited_at=m.edited_at,
deleted_at=m.deleted_at,
) )
for m in messages for m in messages
] ]
@@ -480,12 +568,25 @@ async def get_room_file_endpoint(
message_file = await db.get(MessageFile, file_id) message_file = await db.get(MessageFile, file_id)
if message_file is None or message_file.room_id != room_id: if message_file is None or message_file.room_id != room_id:
raise HTTPException(status_code=404, detail="File not found") 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, # `filename=` makes Starlette set Content-Disposition: attachment,
# forcing a download instead of an inline render regardless of # forcing a download instead of an inline render regardless of
# content-type -- the mitigation for a same-origin-served, user- # content-type -- the mitigation for a same-origin-served, user-
# uploaded file (e.g. .html/.svg) executing script in this app's own # uploaded file (e.g. .html/.svg) executing script in this app's own
# origin if opened directly. No content-type allowlist needed on top # 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( return FileResponse(
UPLOADS_DIR / message_file.storage_filename, UPLOADS_DIR / message_file.storage_filename,
media_type=message_file.content_type, 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") raise HTTPException(status_code=404, detail="No user with that ID")
except AlreadyMemberError: except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member") 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) 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]) online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
return RoomMemberRead( return RoomMemberRead(
user_id=membership.user_id, 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.site_invite import SignupComplete, SignupValidateRead
from app.schemas.user import UserRead from app.schemas.user import UserRead
from app.services.auth_service import DuplicateUserError from app.services.auth_service import DuplicateUserError
from app.services.session_service import start_session
from app.services.site_invite_service import ( from app.services.site_invite_service import (
SiteInviteInvalidError, SiteInviteInvalidError,
complete_signup, complete_signup,
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
except DuplicateUserError: except DuplicateUserError:
raise HTTPException(status_code=409, detail="That username or email is already taken") 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 return user
+16 -2
View File
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.schemas.webhook import IncomingWebhookPost from app.schemas.webhook import IncomingWebhookPost
from app.services.message_events import broadcast_new_message 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"]) 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) message, room, sender = await post_via_webhook(db, token, data.content)
except WebhookNotFoundError: except WebhookNotFoundError:
raise HTTPException(status_code=404, detail="Unknown webhook") 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 broadcaster = request.app.state.broadcaster
presence = request.app.state.presence 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] reactions: list[ReactionSummary]
created_at: datetime created_at: datetime
edited_at: datetime | None 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 name: str
description: str | None description: str | None
is_private: bool 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 owner_id: uuid.UUID
created_at: datetime created_at: datetime
@@ -34,6 +40,14 @@ class RoomListItem(RoomRead):
is_member: bool 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): class MyRoomItem(RoomRead):
role: RoomRole role: RoomRole
# Whether this room has a message newer than the caller's last_read_at -- # 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 # over has_unread in the sidebar (see RoomRow.tsx), not shown alongside
# it. # it.
has_mention: bool 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): 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 is_site_admin: bool
display_name: str | None display_name: str | None
theme: 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 # Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts). # paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None active_custom_theme: CustomThemeRead | None
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
# ownership check; that's POST /api/custom-themes/{id}/activate, not a # ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at. # bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None) 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) 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]]: 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( result = await db.execute(
select(Room, func.count(RoomMembership.user_id)) select(Room, func.count(RoomMembership.user_id))
.outerjoin(RoomMembership, RoomMembership.room_id == Room.id) .outerjoin(RoomMembership, RoomMembership.room_id == Room.id)
.where(Room.is_dm.is_(False))
.group_by(Room.id) .group_by(Room.id)
.order_by(Room.created_at) .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 import logging
from email.message import EmailMessage from email.message import EmailMessage
@@ -5,7 +6,7 @@ import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.crypto import decrypt 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 from app.services.smtp_settings_service import get_smtp_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception):
pass 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 """Raises on failure -- internal helper only. Callers decide whether to
swallow (send_email) or surface (send_test_email) the error.""" swallow (send_email) or surface (send_test_email) the error."""
message = EmailMessage() message = EmailMessage()
message["From"] = cfg.from_address message["From"] = cfg.from_address
message["To"] = to_address message["To"] = to_address
message["Subject"] = subject 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 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 """Best-effort -- used by invite/notification flows. Never raises: an
SMTP outage or missing configuration must never block an action (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) cfg = await get_smtp_settings(db)
if cfg is None: 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 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: try:
await _deliver(cfg, to_address, subject, body) await _deliver(cfg, to_address, subject, html_body, text_body)
except Exception: except Exception:
logger.warning("Failed to send email to %s", to_address, exc_info=True) 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 """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) cfg = await get_smtp_settings(db)
if cfg is None: if cfg is None:
raise SmtpNotConfiguredError() raise SmtpNotConfiguredError()
await _deliver( subject = "DS Chat test email"
cfg, paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
to_address, palette = await _resolve_palette(db, theme_user)
"DS Chat test email", html_body = _render_html(palette, subject, paragraphs, None, None)
"This is a test email from DS Chat to confirm your SMTP settings are working.", 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 _FETCH_TIMEOUT_SECONDS = 5.0
_MAX_BYTES = 512 * 1024 _MAX_BYTES = 512 * 1024
_MAX_REDIRECTS = 3 _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" _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: def extract_first_url(content: str | None) -> str | None:
+282 -11
View File
@@ -1,22 +1,30 @@
import asyncio import asyncio
import logging
import uuid import uuid
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
from app.schemas.message import ReactionSummary 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.link_preview_service import fetch_and_broadcast_link_preview
from app.services.push_service import send_push_to_user 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.services.webhook_service import dispatch_event
from app.ws.broadcaster import Broadcaster 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 from app.ws.presence import Presence
logger = logging.getLogger(__name__)
async def _notify_offline_members( async def _notify_offline_members(
db: AsyncSession, db: AsyncSession,
broadcaster: Broadcaster, broadcaster: Broadcaster,
presence: Presence, presence: Presence,
focus_presence: FocusPresence,
room_id: uuid.UUID, room_id: uuid.UUID,
sender: User, sender: User,
message: Message, message: Message,
@@ -25,12 +33,28 @@ async def _notify_offline_members(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id) select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
) )
member_ids = {row[0] for row in result.all()} 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 # Subtract the sender explicitly rather than relying on them being
# "connected" (true for the WS path, since they just sent this over an # "connected" (true for the WS path, since they just sent this over an
# active connection -- not true for the incoming-webhook REST path, # active connection -- not true for the incoming-webhook REST path,
# which has no WS connection for the attributed sender at all). # which has no WS connection for the attributed sender at all).
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id} offline_ids = member_ids - connected_ids - {sender.id}
if not offline_ids:
# #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 return
result = await db.execute( result = await db.execute(
@@ -38,12 +62,10 @@ async def _notify_offline_members(
) )
mentioned_ids = {row[0] for row in result.all()} mentioned_ids = {row[0] for row in result.all()}
# This is also exactly the right audience for "give this room an unread # Unread-dot audience stays exactly offline_ids, not notify_ids: a
# dot": presence.connected_user_ids(room_id) means "has this room's # connected-but-unfocused member still has the room open and rendering
# channel joined right now" -- which the client only does while the tab # on screen right now, so it isn't actually "unread" for them the way a
# is genuinely foregrounded (see useChatSocket.ts's visibility-gated # room they haven't got open at all is.
# join/leave), so a backgrounded-but-open room correctly lands here too,
# not just rooms that aren't open at all.
for user_id in offline_ids: for user_id in offline_ids:
await broadcaster.publish_to_user( await broadcaster.publish_to_user(
user_id, user_id,
@@ -56,7 +78,7 @@ async def _notify_offline_members(
room = await db.get(Room, room_id) room = await db.get(Room, room_id)
title = f"#{room.name}" if room else "New message" 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 mentioned = user_id in mentioned_ids
if message.content: if message.content:
prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: " 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": [], "reactions": [],
"created_at": message.created_at.isoformat(), "created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None, "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( async def broadcast_new_message(
db: AsyncSession, db: AsyncSession,
broadcaster: Broadcaster, broadcaster: Broadcaster,
presence: Presence, presence: Presence,
focus_presence: FocusPresence,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID, room_id: uuid.UUID,
message: Message, message: Message,
sender: User, sender: User,
@@ -140,7 +355,30 @@ async def broadcast_new_message(
trigger identical fan-out/push/event behavior.""" trigger identical fan-out/push/event behavior."""
payload = await _message_payload(db, message, sender.username) payload = await _message_payload(db, message, sender.username)
await broadcaster.publish(room_id, payload) 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) await dispatch_event(db, "message.created", room_id, payload)
_maybe_fetch_link_preview(broadcaster, room_id, message) _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) _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( async def broadcast_reaction_update(
broadcaster: Broadcaster, broadcaster: Broadcaster,
room_id: uuid.UUID, 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: 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 """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 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.schemas.message import ReactionSummary
from app.services.link_preview_service import extract_first_url from app.services.link_preview_service import extract_first_url
from app.services.mention_service import extract_mentioned_user_ids from app.services.mention_service import extract_mentioned_user_ids
from app.services.room_reference_service import extract_referenced_room_ids from app.services.room_reference_service import extract_referenced_room_ids
from app.storage import delete_file
class MessageNotFoundError(Exception): class MessageNotFoundError(Exception):
@@ -56,7 +64,9 @@ async def edit_message(
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
) -> Message: ) -> Message:
message = await db.get(Message, message_id) 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() raise MessageNotFoundError()
if message.user_id != editor_id: if message.user_id != editor_id:
raise NotMessageAuthorError() raise NotMessageAuthorError()
@@ -69,6 +79,50 @@ async def edit_message(
return 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( async def list_recent_messages(
db: AsyncSession, room_id: uuid.UUID, limit: int = 50 db: AsyncSession, room_id: uuid.UUID, limit: int = 50
) -> list[Message]: ) -> list[Message]:
+7 -4
View File
@@ -46,10 +46,13 @@ async def request_password_reset(db: AsyncSession, email: str, base_url: str) ->
db, db,
email, email,
"Reset your DS Chat password", "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" "Someone requested a password reset for this account.",
f"This link expires in 15 minutes. If you didn't request this, " "This link expires in 15 minutes. If you didn't request this, you can ignore this email.",
f"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 json
import logging import logging
import uuid import uuid
from urllib.parse import urlparse
from pywebpush import WebPushException, webpush from pywebpush import WebPushException, webpush
from sqlalchemy import delete, select from sqlalchemy import delete, select
@@ -53,7 +54,23 @@ async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> No
await db.commit() 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: 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( webpush(
subscription_info={ subscription_info={
"endpoint": subscription.endpoint, "endpoint": subscription.endpoint,
@@ -62,6 +79,7 @@ def _send_one(subscription: PushSubscription, payload: dict) -> None:
data=json.dumps(payload), data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key, vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject}, 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() await db.commit()
else: 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 import uuid
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.schemas.room import RoomCreate, RoomUpdate
from app.services.email_service import send_email from app.services.email_service import send_email
from app.storage import delete_file
class DuplicateRoomError(Exception): class DuplicateRoomError(Exception):
@@ -46,6 +60,50 @@ class AlreadyMemberError(Exception):
pass 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: async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
room = Room( room = Room(
name=data.name, name=data.name,
@@ -66,10 +124,53 @@ async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -
return room 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]]: async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
result = await db.execute( result = await db.execute(
select(Room) 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)) .options(selectinload(Room.memberships))
.order_by(Room.created_at, Room.id) .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( async def list_member_rooms(
db: AsyncSession, user_id: uuid.UUID db: AsyncSession, user_id: uuid.UUID
) -> list[tuple[Room, RoomRole, bool, bool]]: ) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
last_message_at = ( last_message_at = (
select(func.max(Message.created_at)) select(func.max(Message.created_at))
.where(Message.room_id == Room.id) .where(Message.room_id == Room.id)
@@ -104,10 +205,15 @@ async def list_member_rooms(
) )
result = await db.execute( result = await db.execute(
select( 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) .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 # A secondary key on the primary key -- without it, Postgres has no
# obligation to return two same-instant rooms (a plausible tie: # obligation to return two same-instant rooms (a plausible tie:
# bulk-created/migrated rooms, or just two created in quick # 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. # device's fetch and another's.
.order_by(Room.created_at, Room.id) .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 [ 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: async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
room = await db.get(Room, room_id) room = await db.get(Room, room_id)
if room is None: if room is None:
@@ -173,8 +322,10 @@ async def add_member(
db, db,
target.email, target.email,
f"You've been added to #{room.name}", f"You've been added to #{room.name}",
f"You've been added to the #{room.name} room on DS Chat.\n\n" [f"You've been added to the #{room.name} room on DS Chat."],
f"Open the app: {base_url.rstrip('/')}", cta_label="Open DS Chat",
cta_url=base_url.rstrip("/"),
theme_user=target,
) )
result = await db.execute( result = await db.execute(
@@ -186,6 +337,14 @@ async def add_member(
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room: 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: if data.name is not None:
room.name = data.name room.name = data.name
if data.description is not None: 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: async def delete_room(db: AsyncSession, room: Room) -> None:
# Explicit deletes rather than relying on ORM cascade + eager-loading — # 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)) 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.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
await db.delete(room) await db.delete(room)
await db.commit() 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]: async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
result = await db.execute( 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() 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: async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
membership = await _get_membership(db, room_id, user_id) membership = await _get_membership(db, room_id, user_id)
if membership.role == RoomRole.owner: 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 sqlalchemy.orm import selectinload
from app.models import InviteStatus, SiteInvite, User 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.schemas.user import UserCreate
from app.security import hash_token from app.security import hash_token
from app.services.audit import record_audit_log from app.services.audit import record_audit_log
@@ -26,6 +27,24 @@ class SiteInviteInvalidError(Exception):
pass 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( async def create_site_invite(
db: AsyncSession, actor: User, base_url: str, email: str db: AsyncSession, actor: User, base_url: str, email: str
) -> SiteInvite: ) -> SiteInvite:
@@ -39,21 +58,19 @@ async def create_site_invite(
await db.commit() await db.commit()
await db.refresh(invite) await db.refresh(invite)
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}" await _send_invite_email(db, actor.username, base_url, email, 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.",
)
return invite return invite
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]: 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( result = await db.execute(
select(SiteInvite) select(SiteInvite)
.where(SiteInvite.status == InviteStatus.pending)
.options(selectinload(SiteInvite.inviter)) .options(selectinload(SiteInvite.inviter))
.order_by(SiteInvite.created_at.desc()) .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 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: async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
result = await db.execute( result = await db.execute(
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token)) 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 pass
class RoomArchivedError(Exception):
pass
async def create_incoming_webhook( async def create_incoming_webhook(
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
) -> WebhookIncoming: ) -> WebhookIncoming:
@@ -82,6 +86,11 @@ async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[
webhook = result.scalar_one_or_none() webhook = result.scalar_one_or_none()
if webhook is None: if webhook is None:
raise WebhookNotFoundError() 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) message = await create_message(db, webhook.room_id, webhook.created_by, content)
return message, webhook.room, webhook.creator 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"), "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): class UploadTooLargeError(Exception):
pass pass
+134 -14
View File
@@ -6,10 +6,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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.bot_service import resolve_token
from app.services.message_events import ( from app.services.message_events import (
broadcast_dm_presence_update,
broadcast_member_updated, broadcast_member_updated,
broadcast_message_delete,
broadcast_message_update, broadcast_message_update,
broadcast_new_message, broadcast_new_message,
broadcast_reaction_update, broadcast_reaction_update,
@@ -18,10 +20,12 @@ from app.services.message_service import (
MessageNotFoundError, MessageNotFoundError,
NotMessageAuthorError, NotMessageAuthorError,
create_message, create_message,
delete_message,
edit_message, edit_message,
toggle_reaction, toggle_reaction,
) )
from app.services.room_service import mark_room_read from app.services.room_service import mark_room_read
from app.services.session_service import resolve_session
router = APIRouter(tags=["ws"]) router = APIRouter(tags=["ws"])
@@ -36,6 +40,7 @@ class ClientEnvelope(BaseModel):
file_id: uuid.UUID | None = None file_id: uuid.UUID | None = None
message_id: uuid.UUID | None = None message_id: uuid.UUID | None = None
emoji: str | 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: 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 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: def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
return api_token is not None and scope not in api_token.scopes 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 return
user, api_token = resolved user, api_token = resolved
else: else:
user_id_raw = websocket.session.get("user_id") session_id_raw = websocket.session.get("session_id")
if not user_id_raw: if not session_id_raw:
await websocket.close(code=WS_UNAUTHENTICATED) await websocket.close(code=WS_UNAUTHENTICATED)
return 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: if user is None or not user.is_active:
await websocket.close(code=WS_UNAUTHENTICATED) await websocket.close(code=WS_UNAUTHENTICATED)
return return
@@ -79,8 +93,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager = websocket.app.state.connection_manager manager = websocket.app.state.connection_manager
presence = websocket.app.state.presence presence = websocket.app.state.presence
global_presence = websocket.app.state.global_presence global_presence = websocket.app.state.global_presence
focus_presence = websocket.app.state.focus_presence
broadcaster = websocket.app.state.broadcaster broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set() 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) manager.register_user(user.id, websocket)
# Only broadcast on a genuine offline->online transition (this user's # Only broadcast on a genuine offline->online transition (this user's
# first open connection), not for every extra tab -- broadcast_member_ # 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. # visible.
if await global_presence.connect(user.id): if await global_presence.connect(user.id):
await broadcast_member_updated(db, broadcaster, 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: try:
while True: while True:
raw = await websocket.receive_json() raw = await websocket.receive_json()
try:
try: try:
envelope = ClientEnvelope.model_validate(raw) envelope = ClientEnvelope.model_validate(raw)
except ValidationError: 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) await presence.leave(envelope.room_id, user.id)
joined_rooms.discard(envelope.room_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": elif envelope.type == "message":
if envelope.room_id is None or ( if envelope.room_id is None or (
not envelope.content 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"} {"type": "error", "detail": "Not a member of this room"}
) )
continue 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 image_id = None
if envelope.image_id is not None: if envelope.image_id is not None:
image = await db.get(MessageImage, envelope.image_id) 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( message = await create_message(
db, envelope.room_id, user.id, envelope.content, image_id, file_id db, envelope.room_id, user.id, envelope.content, image_id, file_id
) )
# Sending implies having seen the room as of now -- without # Sending implies having seen the room as of now --
# this, GET /rooms/mine would show the sender's own room as # without this, GET /rooms/mine would show the sender's
# unread the instant they send into it (last_read_at isn't # own room as unread the instant they send into it
# otherwise bumped until the frontend's own message echo # (last_read_at isn't otherwise bumped until the
# triggers a mark-read call, which is a real but avoidable # frontend's own message echo triggers a mark-read
# race). Deliberately not done in create_message() itself: # call, which is a real but avoidable race).
# the incoming-webhook path also calls it, and a webhook's # 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. # attributed sender may not actually be watching.
await mark_room_read(db, envelope.room_id, user.id) 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": elif envelope.type == "edit":
if envelope.room_id is None or envelope.message_id is None or not envelope.content: 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 continue
await broadcast_message_update(db, broadcaster, envelope.room_id, message) 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": elif envelope.type == "reaction":
if ( if (
envelope.room_id is None envelope.room_id is None
or envelope.message_id is None or envelope.message_id is None
or not envelope.emoji 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( await websocket.send_json(
{"type": "error", "detail": "room_id, message_id, and emoji required"} {"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 continue
target_message = await db.get(Message, envelope.message_id) 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"}) await websocket.send_json({"type": "error", "detail": "Message not found"})
continue continue
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji) 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( await websocket.send_json(
{"type": "error", "detail": f"Unknown message type: {envelope.type}"} {"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: except WebSocketDisconnect:
pass pass
@@ -248,5 +365,8 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager.unregister_user(user.id, websocket) manager.unregister_user(user.id, websocket)
for room_id in joined_rooms: for room_id in joined_rooms:
await presence.leave(room_id, user.id) await presence.leave(room_id, user.id)
if is_blurred:
await focus_presence.mark_focused(user.id)
if await global_presence.disconnect(user.id): if await global_presence.disconnect(user.id):
await broadcast_member_updated(db, broadcaster, 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] [project]
name = "ds-chat" name = "ds-chat"
version = "1.0.0" version = "2026.9.4"
description = "DS Chat backend service" description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" } license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -15,7 +15,14 @@ dependencies = [
"pydantic-settings>=2.6", "pydantic-settings>=2.6",
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"itsdangerous>=2.2", "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", "redis>=5.0",
"httpx>=0.27", "httpx>=0.27",
"gunicorn>=23.0", "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]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "dm_presence_update"}
def _recv(ws) -> dict: def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated """Reads the next frame, transparently discarding presence-change
presence-change broadcasts -- another connection in the same room going broadcasts (member_updated, and #63's dm_presence_update) -- another
online/offline is real, expected noise these tests aren't about.""" connection sharing a room or a DM going online/offline is real,
expected noise these tests aren't about."""
while True: while True:
msg = ws.receive_json() msg = ws.receive_json()
if msg.get("type") != "member_updated": if msg.get("type") not in _NOISE_TYPES:
return msg return msg
def _fake_send_email(monkeypatch): def _fake_send_email(monkeypatch):
calls = [] calls = []
async def fake(db, to, subject, body): async def fake(db, to, subject, paragraphs, **kwargs):
calls.append({"to": to, "subject": subject, "body": body}) calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
monkeypatch.setattr("app.services.room_service.send_email", fake) monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls 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"]} 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): def test_profile_update_notifies_room_members_via_websocket(ws_client_factory, monkeypatch):
# Only reaches clients that have the room's own channel joined -- # Only reaches clients that have the room's own channel joined --
# exactly the case where a stale avatar/display name would actually be # 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" 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): def test_desktop_notification_not_sent_to_non_member(ws_client_factory):
instance1 = ws_client_factory() instance1 = ws_client_factory()
instance2 = 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 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: def _register_ws(ws_client, username: str) -> dict:
from app.schemas.user import UserCreate from app.schemas.user import UserCreate
from app.services.auth_service import register_user from app.services.auth_service import register_user
+67
View File
@@ -1,9 +1,13 @@
import asyncio import asyncio
import uuid import uuid
from datetime import datetime, timedelta, timezone
import pytest_asyncio 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.database import engine as _link_preview_engine
from app.models import LinkPreview
from app.schemas.user import UserCreate from app.schemas.user import UserCreate
from app.services.auth_service import register_user from app.services.auth_service import register_user
from app.services.link_preview_service import extract_first_url 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() history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
assert len(history) == 2 assert len(history) == 2
assert all(m["link_preview"]["title"] == "Example Article" for m in history) 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]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
def _recv(ws) -> dict: def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated """Reads the next frame, transparently discarding presence/offline-
presence-change broadcasts -- another connection in the same room going notify noise -- another connection in the same room going online/
online/offline is real, expected noise these tests aren't about.""" 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: while True:
msg = ws.receive_json() msg = ws.receive_json()
if msg.get("type") != "member_updated": if msg.get("type") not in _NOISE_TYPES:
return msg 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: with ws_client.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]}) 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( bob_ws.send_json(
{ {
"type": "edit", "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: with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]}) 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: with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]}) 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): def _fake_send_email(monkeypatch):
calls = [] calls = []
async def fake(db, to, subject, body): async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
calls.append({"to": to, "subject": subject, "body": body}) 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) monkeypatch.setattr("app.services.password_service.send_email", fake)
return calls 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/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"}) 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}") validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
assert validate.status_code == 204 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 register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout") await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"}) 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 = (await db_session.execute(select(PasswordReset))).scalar_one()
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1) 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 register_and_login(client, db_session, username="alice")
await client.post("/api/auth/logout") await client.post("/api/auth/logout")
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"}) 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( first = await client.post(
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"} "/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 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): async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice")) 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 ws.receive_json()["type"] == "joined"
assert _fetch_subscriptions(ws_client, bob["id"]) == [] 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]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
def _recv(ws) -> dict: def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated """Reads the next frame, transparently discarding presence/offline-
presence-change broadcasts -- another connection in the same room going notify noise -- another connection in the same room going online/
online/offline is real, expected noise these tests aren't about.""" 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: while True:
msg = ws.receive_json() msg = ws.receive_json()
if msg.get("type") != "member_updated": if msg.get("type") not in _NOISE_TYPES:
return msg 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: with ws_client.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]}) 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( ws_client.post(
"/api/auth/login", "/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 import uuid
from PIL import Image
from sqlalchemy import select 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 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): async def test_create_room_requires_auth(client):
resp = await client.post("/api/rooms", json={"name": "general"}) resp = await client.post("/api/rooms", json={"name": "general"})
assert resp.status_code == 401 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 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): async def test_leave_room(client, db_session):
await register_and_login(client, db_session, username="alice") await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] 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): def _fake_send_email(monkeypatch):
calls = [] calls = []
async def fake(db, to, subject, body): async def fake(db, to, subject, paragraphs, **kwargs):
calls.append({"to": to, "subject": subject, "body": body}) calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
monkeypatch.setattr("app.services.room_service.send_email", fake) monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls return calls
@@ -361,6 +484,27 @@ async def test_add_member_directly(client, db_session, monkeypatch):
assert "added" in calls[0]["subject"].lower() 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): async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
_fake_send_email(monkeypatch) _fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice") 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) 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): async def test_create_site_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice") await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"}) 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 invite["status"] == "pending"
assert len(calls) == 1 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}") validate = await client.get(f"/api/signup/validate?token={token}")
assert validate.status_code == 200 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 _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "typo@example.com"}) 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( complete = await client.post(
"/api/signup", "/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"}) resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
invite_id = resp.json()["id"] 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 = await db_session.get(SiteInvite, uuid.UUID(invite_id))
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1) 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 _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "once@example.com"}) 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( first = await client.post(
"/api/signup", "/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"}) resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
invite_id = resp.json()["id"] 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}") revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200 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 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): async def test_list_site_invites(client, db_session, monkeypatch):
_fake_smtp(monkeypatch) _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1") 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 assert resp.status_code == 200
emails = [i["email"] for i in resp.json()] emails = [i["email"] for i in resp.json()]
assert "listed@example.com" in emails 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
+11 -2
View File
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
FRONTEND_DIR="${REPO_DIR}/frontend" FRONTEND_DIR="${REPO_DIR}/frontend"
ENV_FILE="/etc/ds-chat/env" ENV_FILE="/etc/ds-chat/env"
echo "==> Pulling latest code" echo "==> Fetching latest release"
cd "$REPO_DIR" cd "$REPO_DIR"
git pull --ff-only git fetch --tags --force
LATEST_TAG="$(git tag --sort=-creatordate | head -n1)"
if [[ -z "$LATEST_TAG" ]]; then
echo "No tags found -- nothing to deploy" >&2
exit 1
fi
echo "Deploying $LATEST_TAG"
# Detached HEAD, not a branch checkout -- this directory only ever runs a
# tagged release, never whatever the default branch's tip happens to be.
git checkout --quiet --detach "$LATEST_TAG"
echo "==> Installing backend dependencies" echo "==> Installing backend dependencies"
cd "$BACKEND_DIR" cd "$BACKEND_DIR"
+7
View File
@@ -0,0 +1,7 @@
# Shown as the "Source code" link in the app's About screen -- required
# for AGPL-3.0 section 13 compliance once you deploy this (a link so users
# interacting with the app over the network can get the actual source,
# including any modifications you've made). The default below points at
# the upstream project -- fine if you're running it unmodified, but if
# you've forked or patched the code, point this at *your* copy instead.
VITE_SOURCE_URL=https://github.com/ds-ksmith/DS-Chat
+44 -24
View File
@@ -1,13 +1,15 @@
# DS Chat frontend # DS Chat frontend
React 19 + TypeScript + Vite PWA. The full client for DS Chat: auth and 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 invite-based signup, room CRUD with roles/invites, direct messages,
(Markdown, @mentions, reactions, image/file attachments with previews, real-time WebSocket chat (Markdown, @mentions, reactions, built-in and
message editing), unread indicators and presence, per-user theming custom emoji, image/video/file attachments with previews, message editing
(presets plus a custom theme builder), Web Push notifications, offline and deletion), unread indicators and presence, per-user theming (presets
caching and an auto-update banner via a custom service worker, and a plus a custom theme builder), Web Push/email notifications and a desktop-
site-admin portal — wired to the backend's REST API and `/ws/chat` WebSocket notification bridge, an active-sessions view for managing where you're
endpoint. See [`../README.md`](../README.md) and 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. [`../backend/README.md`](../backend/README.md) for full local setup.
## Dev ## Dev
@@ -39,20 +41,24 @@ worker.
``` ```
src/ src/
main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password, main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password,
/rooms, /rooms/:roomId, /admin (AdminRoute-gated); mounts /rooms, /rooms/:roomId, /admin (AdminRoute-gated), /help;
UpdateBanner globally and ChatSocketProvider once authed mounts UpdateBanner globally, ChatSocketProvider and
CustomEmojiProvider once authed
types.ts shared request/response/WS-envelope types, mirroring the types.ts shared request/response/WS-envelope types, mirroring the
backend's Pydantic schemas backend's Pydantic schemas
api/ fetch wrappers, one file per backend resource: client api/ fetch wrappers, one file per backend resource: client
(base fetch/error handling), auth, signup, rooms, users, (base fetch/error handling), auth (incl. active sessions),
bots, webhooks, push, admin, customThemes, uploads signup, rooms (incl. DMs), users, bots, webhooks, push,
admin, customThemes, customEmoji, uploads
ws/useChatSocket.ts the WebSocket hook: connect/reconnect with backoff, 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 presence, triggers an SW update check on reconnect
context/ context/
AuthContext.tsx current-user state, hydrated via GET /api/auth/me AuthContext.tsx current-user state, hydrated via GET /api/auth/me
ChatSocketContext.tsx shares one useChatSocket instance across the app 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/ lib/
avatar.ts deterministic accent-color cycling for avatars avatar.ts deterministic accent-color cycling for avatars
@@ -61,7 +67,9 @@ src/
fileSize.ts human-readable byte formatting fileSize.ts human-readable byte formatting
lastUser.ts cached "who was I last logged in as" for offline shell render lastUser.ts cached "who was I last logged in as" for offline shell render
messageGrouping.ts groups consecutive messages by sender/time, presence lookup 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 swUpdate.ts bridges the SW registration to useChatSocket's reconnect hook
theme.ts applies preset/custom themes as CSS custom properties theme.ts applies preset/custom themes as CSS custom properties
@@ -73,26 +81,38 @@ src/
components/ components/
ProtectedRoute.tsx, AdminRoute.tsx auth/site-admin route guards 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, ChatPane.tsx, MessageList.tsx, Composer.tsx chat view: history+live merge,
message rendering, composer/attach/send message rendering (incl. deleted-message
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering + @mention highlighting/autocomplete tombstones), composer/attach/send
ImageLightbox.tsx, FilePreviewModal.tsx attachment viewers (image/PDF/text/Markdown) MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering (mentions, room
EmojiPicker.tsx reaction/composer emoji picker links, custom emoji `:shortcode:`,
RoomInfoPanel.tsx room details/members/roles panel heading ids, sub/superscript) +
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member picking @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 ProfileModal.tsx, ThemeBuilderModal.tsx, CustomThemePreview.tsx
profile settings + the custom theme editor profile settings (incl. active-sessions
(opened in its own wide dialog) with a live, list) + the custom theme editor (opened
in its own wide dialog) with a live,
hoverable mockup of the real UI hoverable mockup of the real UI
RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot) RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot)
OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt
pages/ pages/
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx 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.) styles/tokens.css design tokens (default theme: colors, spacing, etc.)
sw.ts custom service worker (injectManifest): app-shell sw.ts custom service worker (injectManifest): app-shell
precache + NetworkFirst runtime caching, push/notificationclick precache + NetworkFirst runtime caching, push/notificationclick
handlers, SKIP_WAITING messaging for the update-prompt flow handlers, SKIP_WAITING messaging for the update-prompt flow
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DS Chat</title> <title>DS Chat</title>
</head> </head>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "2026.9.4",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "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 { Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { ChatSocketProvider } from './context/ChatSocketContext' import { ChatSocketProvider } from './context/ChatSocketContext'
import { CustomEmojiProvider } from './context/CustomEmojiContext'
import { AdminRoute } from './components/AdminRoute' import { AdminRoute } from './components/AdminRoute'
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge' import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
@@ -67,8 +68,10 @@ function AppRoutes() {
if (!user) return routes if (!user) return routes
return ( return (
<ChatSocketProvider key={user.id}> <ChatSocketProvider key={user.id}>
<CustomEmojiProvider>
<DesktopNotificationBridge /> <DesktopNotificationBridge />
{routes} {routes}
</CustomEmojiProvider>
</ChatSocketProvider> </ChatSocketProvider>
) )
} }
+7
View File
@@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' }) 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> { export function getSmtpSettings(): Promise<SmtpSettings | null> {
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp') return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
} }
+28 -1
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client' 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 // 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 // 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' }) 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> { export function me(): Promise<User> {
return apiFetch<User>('/api/auth/me') 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> { export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' }) 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( export function updateRoom(
roomId: string, roomId: string,
data: { name?: string; description?: string; is_private?: boolean }, 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' }) 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[]> { export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`) return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
} }
+6 -2
View File
@@ -10,8 +10,10 @@ interface AboutModalProps {
// users remotely through a computer network, you should also make sure // users remotely through a computer network, you should also make sure
// that it provides a way for users to get its source... its interface // that it provides a way for users to get its source... its interface
// could display a 'Source' link" -- this modal is that link, not just a // could display a 'Source' link" -- this modal is that link, not just a
// courtesy credits screen. // courtesy credits screen. Deliberately not a hardcoded URL: whoever
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat' // deploys this needs to point it at *their* copy of the repo (including
// any modifications), not the upstream project -- see frontend/.env.example.
const SOURCE_URL = import.meta.env.VITE_SOURCE_URL as string | undefined
export function AboutModal({ onClose }: AboutModalProps) { export function AboutModal({ onClose }: AboutModalProps) {
return ( return (
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
</a> </a>
. .
</p> </p>
{SOURCE_URL && (
<p className="about-modal-line"> <p className="about-modal-line">
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer"> <a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
Source code Source code
</a> </a>
</p> </p>
)}
<div className="modal-actions" style={{ marginTop: '1rem' }}> <div className="modal-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}> <button type="button" className="btn-secondary" onClick={onClose}>
+33 -3
View File
@@ -171,6 +171,21 @@ export function ChatPane({
setLive((prev) => setLive((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)), 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') { } else if (envelope.type === 'error') {
setWsError(envelope.detail) setWsError(envelope.detail)
} }
@@ -212,6 +227,10 @@ export function ChatPane({
(messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji), (messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji),
[socket, room.id], [socket, room.id],
) )
const sendDelete = useCallback(
(messageId: string) => socket.sendDelete(room.id, messageId),
[socket, room.id],
)
return ( return (
<section className="chat-pane"> <section className="chat-pane">
@@ -224,8 +243,16 @@ export function ChatPane({
</button> </button>
)} )}
<div className="chat-pane-title-block"> <div className="chat-pane-title-block">
<div className="chat-pane-title">#{room.name}</div> <div className="chat-pane-title">
<div className="chat-pane-subtitle">{members.length} member{members.length === 1 ? '' : 's'}</div> {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> </div>
<button <button
type="button" type="button"
@@ -256,13 +283,16 @@ export function ChatPane({
myRooms={myRooms} myRooms={myRooms}
onEdit={sendEdit} onEdit={sendEdit}
onReact={sendReaction} onReact={sendReaction}
onDelete={sendDelete}
/> />
<Composer <Composer
roomId={room.id} 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} members={members}
rooms={rooms} rooms={rooms}
disabled={!connected} disabled={!connected}
archived={room.is_archived}
onSend={send} onSend={send}
/> />
</section> </section>
+149 -9
View File
@@ -12,9 +12,13 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
import { useOnlineStatus } from '../hooks/useOnlineStatus' import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms' import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads' 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 { formatFileSize } from '../lib/fileSize'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker' import { EmojiPicker } from './EmojiPicker'
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
import { MentionAutocomplete } from './MentionAutocomplete' import { MentionAutocomplete } from './MentionAutocomplete'
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete' import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
import './Composer.css' import './Composer.css'
@@ -22,6 +26,7 @@ import './Composer.css'
interface ComposerProps { interface ComposerProps {
roomId: string roomId: string
roomName: string roomName: string
isDm?: boolean
members: RoomMember[] members: RoomMember[]
// #47: rooms this user belongs to, for the #roomname autocomplete -- // #47: rooms this user belongs to, for the #roomname autocomplete --
// deliberately the same list ChatPane already resolves message-display // 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. // while typing and what actually renders as a link later agree.
rooms: MyRoomItem[] rooms: MyRoomItem[]
disabled?: boolean 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 onSend: (content: string, imageId?: string, fileId?: string) => void
} }
@@ -62,6 +73,19 @@ function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery |
return detectTriggerQuery(text, cursor, '#') 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 { interface AttachMenuProps {
onPickPhoto: () => void onPickPhoto: () => void
onPickFile: () => 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 [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null) const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | 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 [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null) const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
const [roomActiveIndex, setRoomActiveIndex] = useState(0) const [roomActiveIndex, setRoomActiveIndex] = useState(0)
const [emojiQuery, setEmojiQuery] = useState<TriggerQuery | null>(null)
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false) const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// #29: a separate input with an image/video accept hint, so mobile // #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) return rooms.filter((r) => r.name.toLowerCase().startsWith(q)).slice(0, 8)
}, [roomQuery, rooms]) }, [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(() => { useEffect(() => {
getUploadLimit() getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes)) .then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
@@ -158,6 +223,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
setValue('') setValue('')
setMentionQuery(null) setMentionQuery(null)
setRoomQuery(null) setRoomQuery(null)
setEmojiQuery(null)
removePendingImage() removePendingImage()
setPendingFile(null) setPendingFile(null)
requestAnimationFrame(autoGrow) 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>) { function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionQuery && mentionMatches.length > 0) { if (mentionQuery && mentionMatches.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
@@ -240,6 +334,28 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
return 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) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
@@ -257,6 +373,8 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
setMentionActiveIndex(0) setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(el.value, cursor)) setRoomQuery(detectRoomReferenceQuery(el.value, cursor))
setRoomActiveIndex(0) setRoomActiveIndex(0)
setEmojiQuery(detectEmojiQuery(el.value, cursor))
setEmojiActiveIndex(0)
} }
async function handleFile(file: File) { async function handleFile(file: File) {
@@ -294,7 +412,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
function handleDragEnter(e: DragEvent<HTMLDivElement>) { function handleDragEnter(e: DragEvent<HTMLDivElement>) {
e.preventDefault() e.preventDefault()
if (disabled) return if (isDisabled) return
dragCounterRef.current++ dragCounterRef.current++
setDragActive(true) setDragActive(true)
} }
@@ -316,7 +434,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
e.preventDefault() e.preventDefault()
dragCounterRef.current = 0 dragCounterRef.current = 0
setDragActive(false) setDragActive(false)
if (disabled) return if (isDisabled) return
// Only the first dropped file, matching the existing single-attachment- // Only the first dropped file, matching the existing single-attachment-
// per-message limit (the button-triggered file input isn't `multiple` // per-message limit (the button-triggered file input isn't `multiple`
// either). // either).
@@ -418,7 +536,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
type="button" type="button"
className="composer-attach" className="composer-attach"
onClick={() => setAttachMenuOpen((v) => !v)} onClick={() => setAttachMenuOpen((v) => !v)}
disabled={disabled || uploading} disabled={isDisabled || uploading}
aria-label="Attach a photo or file" aria-label="Attach a photo or file"
aria-expanded={attachMenuOpen} aria-expanded={attachMenuOpen}
> >
@@ -457,7 +575,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
type="button" type="button"
className="composer-emoji-trigger" className="composer-emoji-trigger"
onClick={() => setEmojiPickerOpen((v) => !v)} onClick={() => setEmojiPickerOpen((v) => !v)}
disabled={disabled} disabled={isDisabled}
aria-label="Insert an emoji" aria-label="Insert an emoji"
> >
🙂 🙂
@@ -476,7 +594,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
ref={textareaRef} ref={textareaRef}
rows={1} rows={1}
value={value} value={value}
disabled={disabled} disabled={isDisabled}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value) setValue(e.target.value)
autoGrow() autoGrow()
@@ -485,10 +603,20 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
setMentionActiveIndex(0) setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor)) setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor))
setRoomActiveIndex(0) setRoomActiveIndex(0)
setEmojiQuery(detectEmojiQuery(e.target.value, cursor))
setEmojiActiveIndex(0)
}} }}
onSelect={handleSelectionChange} onSelect={handleSelectionChange}
onKeyDown={handleKeyDown} 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 spellCheck
/> />
{mentionQuery && mentionMatches.length > 0 && ( {mentionQuery && mentionMatches.length > 0 && (
@@ -507,12 +635,20 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
onHover={setRoomActiveIndex} onHover={setRoomActiveIndex}
/> />
)} )}
{emojiQuery && emojiMatches.length > 0 && (
<EmojiShortcodeAutocomplete
matches={emojiMatches}
activeIndex={emojiActiveIndex}
onPick={selectEmojiShortcode}
onHover={setEmojiActiveIndex}
/>
)}
</div> </div>
<button <button
type="button" type="button"
className="composer-send" className="composer-send"
onClick={handleSend} onClick={handleSend}
disabled={disabled || (!value.trim() && !pendingImage && !pendingFile)} disabled={isDisabled || (!value.trim() && !pendingImage && !pendingFile)}
aria-label="Send message" aria-label="Send message"
> >
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true"> <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> </svg>
</button> </button>
</div> </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 className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
)
)} )}
</div> </div>
) )
@@ -40,6 +40,18 @@
color: var(--ds-accent); 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 { .composer-autocomplete-secondary {
font-size: 0.76rem; font-size: 0.76rem;
color: var(--ds-muted); 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 { useMemo, useState } from 'react'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { useEscapeKey } from '../hooks/useEscapeKey' import { useEscapeKey } from '../hooks/useEscapeKey'
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji' import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
import { EMOJI_NAMES } from '../lib/emojiNames' import { EMOJI_NAMES } from '../lib/emojiNames'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji' import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import { EmojiGlyph } from './MessageContent'
import './EmojiPicker.css' import './EmojiPicker.css'
interface EmojiPickerProps { interface EmojiPickerProps {
@@ -17,7 +19,17 @@ interface EmojiPickerProps {
// available viewport space) need this to know how much room to check for. // available viewport space) need this to know how much room to check for.
export const EMOJI_PICKER_MAX_HEIGHT = 380 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() const q = query.trim().toLowerCase()
if (!q) return [] if (!q) return []
const seen = new Set<string>() const seen = new Set<string>()
@@ -32,13 +44,18 @@ function searchEmoji(query: string): string[] {
results.push(emoji) results.push(emoji)
} }
} }
for (const shortcode of customShortcodes) {
if (shortcode.toLowerCase().includes(q)) results.push(`:${shortcode}:`)
}
return results return results
} }
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) { export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose) useEscapeKey(onClose)
const { list: customEmoji } = useCustomEmoji()
const [query, setQuery] = useState('') 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 const searching = query.trim().length > 0
// A snapshot taken once when the picker opens, not live-updating as picks // A snapshot taken once when the picker opens, not live-updating as picks
// happen within this same session -- picking an emoji always closes the // 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" type="button"
role="menuitem" role="menuitem"
className="emoji-picker-item" className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name} title={titleFor(emoji)}
onClick={() => pick(emoji)} onClick={() => pick(emoji)}
> >
{emoji} <EmojiGlyph value={emoji} />
</button> </button>
))} ))}
</div> </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 && ( {recent.length > 0 && (
<div className="emoji-picker-category"> <div className="emoji-picker-category">
<div className="emoji-picker-category-label">Recently used</div> <div className="emoji-picker-category-label">Recently used</div>
@@ -97,10 +133,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button" type="button"
role="menuitem" role="menuitem"
className="emoji-picker-item" className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name} title={titleFor(emoji)}
onClick={() => pick(emoji)} onClick={() => pick(emoji)}
> >
{emoji} <EmojiGlyph value={emoji} />
</button> </button>
))} ))}
</div> </div>
@@ -116,10 +152,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button" type="button"
role="menuitem" role="menuitem"
className="emoji-picker-item" className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name} title={titleFor(emoji)}
onClick={() => pick(emoji)} onClick={() => pick(emoji)}
> >
{emoji} <EmojiGlyph value={emoji} />
</button> </button>
))} ))}
</div> </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 Markdown from 'markdown-to-jsx'
import { getRoomFileUrl } from '../api/rooms' import { getRoomFileUrl } from '../api/rooms'
import { useEscapeKey } from '../hooks/useEscapeKey' import { useEscapeKey } from '../hooks/useEscapeKey'
import type { MessageFileInfo } from '../types' import type { MessageFileInfo } from '../types'
import { MARKDOWN_OPTIONS } from './MessageContent' import { createMarkdownOptions, preprocessMarkdown } from './MessageContent'
import './FilePreviewModal.css' import './FilePreviewModal.css'
export type PreviewKind = 'markdown' | 'text' | 'pdf' 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 [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const fileUrl = getRoomFileUrl(roomId, file.id) 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(() => { useEffect(() => {
let cancelled = false 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' : ''}`}> <div className={`file-preview-body${kind === 'pdf' ? ' file-preview-body-pdf' : ''}`}>
{error && <p className="file-preview-error">{error}</p>} {error && <p className="file-preview-error">{error}</p>}
{!error && kind !== 'pdf' && content === null && <p className="file-preview-loading">Loading</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"> <div className="message-text file-preview-markdown">
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown> <Markdown options={createMarkdownOptions(markdownPreview.headingIds)}>
{markdownPreview.text}
</Markdown>
</div> </div>
)} )}
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>} {!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 Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
import { Link } from 'react-router-dom' 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 { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './MessageContent.css'
interface MessageContentProps { interface MessageContentProps {
content: string content: string
@@ -49,8 +53,11 @@ interface MarkdownLinkProps {
// span instead of an actual anchor. highlightRoomReferences does the same // span instead of an actual anchor. highlightRoomReferences does the same
// trick for #roomname, but a room reference *is* meant to be navigable, so // 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. // 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 // convertSubSuperscript (#21) reuses the identical trick for `~sub~`/`^sup^`
// existed. // -- 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) { function MarkdownLink({ href, children }: MarkdownLinkProps) {
if (href?.startsWith('mention:')) { if (href?.startsWith('mention:')) {
return <span className="message-mention">{children}</span> return <span className="message-mention">{children}</span>
@@ -62,6 +69,31 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
</Link> </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 ( return (
<a href={href} target="_blank" rel="noopener noreferrer"> <a href={href} target="_blank" rel="noopener noreferrer">
{children} {children}
@@ -101,6 +133,137 @@ function convertShortcodes(text: string): string {
.join('\n') .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 const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
// Turns a validated @username into `[@username](mention:username)` -- // 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 // Shared with FilePreviewModal so both render paths carry the exact same
// XSS mitigation (disableParsingRawHTML) -- duplicating this object would // XSS mitigation (disableParsingRawHTML) and #21's heading-id/sub/superscript
// risk the two drifting out of sync if one gets edited later. // support -- duplicating this would risk the two drifting out of sync if
export const MARKDOWN_OPTIONS = { // 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 // The core XSS mitigation: raw HTML in message content is escaped
// and printed literally instead of being parsed into elements. // and printed literally instead of being parsed into elements.
disableParsingRawHTML: true, disableParsingRawHTML: true,
@@ -204,10 +371,79 @@ export const MARKDOWN_OPTIONS = {
a: { component: MarkdownLink }, a: { component: MarkdownLink },
img: { component: MarkdownImageLink }, 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) { 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 withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions 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 { .message-image {
display: block; display: block;
max-width: min(320px, 100%); /* #71: rem, not px -- scales with the text-size setting (see
max-height: 240px; 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; object-fit: contain;
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid var(--ds-border); border: 1px solid var(--ds-border);
@@ -62,6 +67,44 @@
margin-bottom: 4px; 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 { .message-file-attachment {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -73,7 +116,7 @@
margin-bottom: 4px; margin-bottom: 4px;
color: var(--ds-text); color: var(--ds-text);
text-decoration: none; text-decoration: none;
max-width: min(320px, 100%); max-width: min(20rem, 100%);
} }
.message-file-attachment:hover { .message-file-attachment:hover {
@@ -304,6 +347,25 @@
border-color: var(--ds-accent); 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 { .message-reaction-wrap {
position: relative; position: relative;
} }
+128 -8
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@@ -8,8 +9,9 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal' import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox' import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard' import { LinkPreviewCard } from './LinkPreviewCard'
import { MessageContent } from './MessageContent' import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox'
import './MessageList.css' import './MessageList.css'
export function FileAttachmentIcon() { 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 { interface MessageListProps {
roomId: string roomId: string
messages: (Message | ChatMessageEnvelope)[] messages: (Message | ChatMessageEnvelope)[]
@@ -67,10 +121,23 @@ interface MessageListProps {
myRooms: Map<string, string> myRooms: Map<string, string>
onEdit: (messageId: string, content: string) => void onEdit: (messageId: string, content: string) => void
onReact: (messageId: string, emoji: 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() 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 containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
// Whether the view should be pinned to the latest message -- true right // 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 [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) 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 [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below') const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null) const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
@@ -133,6 +201,14 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
setEditingId(null) 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 ( return (
<div className="message-list" ref={containerRef}> <div className="message-list" ref={containerRef}>
{messages.map((msg, i) => { {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 // Mattermost-style grouping: every message shows who sent it, but
// consecutive messages from the same sender only repeat the // consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run -- // avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages. // applies uniformly, including to your own messages. Also breaks on
const isGroupStart = !prev || prev.user_id !== msg.user_id // 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 editing = editingId === msg.id
const deleted = !!msg.deleted_at
return ( return (
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}> <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> </span>
</div> </div>
)} )}
{editing ? ( {deleted ? (
<div className="message-text message-deleted-text">
<em>This message was deleted</em>
</div>
) : editing ? (
<textarea <textarea
autoFocus autoFocus
rows={Math.min(10, draft.split('\n').length)} 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!))} 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 <FileAttachmentCard
file={msg.file} file={msg.file}
roomId={roomId} roomId={roomId}
@@ -221,7 +319,12 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
title={r.user_ids.map(displayNameForUserId).join(', ')} title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)} 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> <span>{r.count}</span>
</button> </button>
) )
@@ -231,7 +334,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
</> </>
)} )}
</div> </div>
{!editing && ( {!editing && !deleted && (
<div className="message-row-actions"> <div className="message-row-actions">
<div className="message-reaction-wrap"> <div className="message-reaction-wrap">
<button <button
@@ -277,6 +380,16 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
Edit Edit
</button> </button>
)} )}
{mine && (
<button
type="button"
className="message-delete-link"
onClick={() => handleDelete(msg.id)}
aria-label="Delete message"
>
Delete
</button>
)}
</div> </div>
)} )}
</div> </div>
@@ -284,6 +397,13 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
})} })}
<div ref={bottomRef} /> <div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />} {lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{videoLightbox && (
<VideoLightbox
src={videoLightbox.src}
filename={videoLightbox.filename}
onClose={() => setVideoLightbox(null)}
/>
)}
{previewFile && ( {previewFile && (
<FilePreviewModal <FilePreviewModal
roomId={roomId} roomId={roomId}
+91
View File
@@ -230,6 +230,45 @@
color: var(--ds-muted); 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 { .theme-swatch-preview-new {
background: transparent; background: transparent;
border-style: dashed; border-style: dashed;
@@ -389,6 +428,28 @@
border-bottom: none; 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 { .modal-list-row-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -407,6 +468,36 @@
color: var(--ds-muted); 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 { .profile-modal-avatar-row {
display: flex; display: flex;
align-items: center; align-items: center;

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