Adding to a message that already had reactions meant going back up to
the hover row's 🙂. A "+🙂" button now sits at the end of the row of
reaction pills, always visible once the message has at least one
reaction, and opens the same emoji picker.
The picker anchors to the left edge of the pill row rather than the
button: the button moves right with every added reaction, and a picker
hanging off it ran past the edge of a phone screen. Both buttons share
the picker's open state, so only one picker is ever open at a time.
MessageList had no tests; this adds a few covering the new button.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A bare "4:04 PM" gave no way to tell today's message from one sent last
week. Today stays time-only; older messages gain just enough date to
place them: "Yesterday 4:04 PM", a weekday for the past week ("Mon
4:04 PM"), then "Oct 2, 4:04 PM" (with the year if it isn't this one).
Hovering a time shows the full date. All in the viewer's local time.
A new calendar day now always starts a new message group. Previously a
same-sender run that crossed midnight within the 5-minute window (11:58
PM then 12:01 AM) stayed under one header, which would have put the
later message under the wrong day's timestamp. The grouping rule moves
out of MessageList into messageGrouping.ts so it's unit-tested.
useLocalDay refreshes "today" at local midnight and when a hidden tab
becomes visible again, so a tab left open overnight doesn't keep showing
yesterday's messages as a bare time. .message-time no longer wraps, and
.message-author can shrink, so a long display name can't push the longer
label out of the row on a phone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
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>
Mirrors the existing @-mention system's shape: a regex finds #roomname
tokens, extract_referenced_room_ids validates them against rooms the
*sender* actually belongs to (mirrors mentions' "must be a real member"
rule -- referencing a private room the sender isn't in would otherwise
leak its existence), and a MessageRoomReference join row is stored per
match in create_message. No notification/unread layer, unlike mentions --
referencing a room has no "you were referenced" semantics.
Rendering is the same markdown-link rewrite trick MessageContent.tsx
already uses for mentions (#username -> [#username](mention:username)),
but resolved against the *viewer's* own room list (threaded down from
ChatShellPage's room state through ChatPane/MessageList) rather than the
stored server-side reference -- a reference to a room the current viewer
isn't in quietly renders as plain text instead of a link, same as an
@mention of someone outside the room does. The href scheme renders a
real react-router Link instead of mentions' inert span, since a room
reference is meant to be navigable.
mention_service.strip_code_spans (was _strip_code_spans) is now shared
between both extraction paths rather than private to one module.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@username tokens in a sent message are parsed against the room's actual
members (skipping fenced/inline code, so pasted code isn't misread) and
recorded as MessageMention rows, reusing #38's read-tracking and
offline-member broadcast infrastructure rather than building a parallel
notification path:
- Sidebar: a mentioned-and-unread room shows a distinct highlight-
colored badge instead of (not alongside) the plain unread dot --
computed the same way as has_unread, just scoped to messages that
mention the caller, and cleared by the same last_read_at mark-read
flow.
- Push notifications: a mentioned offline recipient gets "X mentioned
you: ..." instead of the generic "X: ...", still per-recipient since
the same message can page some room members and not others.
- Message rendering: a validated @username is highlighted inline,
implemented by turning it into a `[@username](mention:username)` link
before markdown parsing and overriding link rendering to style
`mention:`-scheme links as a span instead of an anchor -- reuses
markdown-to-jsx's existing parser rather than hand-rolling text-node
splitting.
- Composer: typing @ opens an autocomplete dropdown of matching room
members (arrow keys to navigate, Enter/Tab/click to insert, Escape or
moving the cursor away to dismiss).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Messages can now carry an arbitrary file (MessageFile), parallel to the
existing MessageImage feature rather than a refactor of it. Files serve
with Content-Disposition: attachment to force a download and prevent an
uploaded HTML/SVG from executing same-origin. No content-type allowlist,
same 8MB cap as images for now (a separate size-limit redesign is tracked
as its own issue).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renders message content with markdown-to-jsx: bold/italic/strikethrough,
inline code, fenced code blocks, blockquotes, lists (incl. nested and
task lists), tables, footnotes, headings, and highlight (==text==).
Raw HTML in message content is parsed to escaped literal text rather
than rendered (disableParsingRawHTML), which is the XSS mitigation for
this being user-generated content -- verified against both <img
onerror> and <script> probes. Markdown image embeds degrade to a link
instead of an <img>, since the app already has a first-class image
upload and a second silent remote-image-embed path would duplicate it
and leak the viewer's IP to arbitrary URLs.
The inline message-edit control is upgraded from a single-line <input>
to a <textarea> so multi-line markdown can actually be edited without
losing newlines, mirroring the Composer's Enter-sends/Shift+Enter-
newlines convention.
Since CommonMark treats a single newline as a soft break (collapses to
a space) rather than a visible line break, added a small code-fence-
aware preprocessor that converts single newlines to hard breaks --
without it, existing multi-line messages sent via the Composer's
Shift+Enter would silently collapse onto one line.
Also fixes heading levels rendering at an identical capped size
(should still step down by level, just capped lower than default), and
adds CSS for markdown constructs the library already parsed but hadn't
been styled for the dark theme: table borders, highlight/mark color,
task-list checkbox accent, and footnote divider.
Composer gains an emoji picker (static curated unicode list, insert at
cursor position) and messages gain Slack/Mattermost-style reactions:
react with any emoji, toggle off by reacting again, see who reacted via
a tooltip on each pill.
Backend: MessageReaction model (unique on message_id+user_id+emoji backs
toggle semantics), WS "reaction" envelope broadcasts the full recomputed
reaction list per message (same approach as message edits), REST message
list embeds reactions so a reload doesn't lose state that only arrived
over WS.
Frontend: shared EmojiPicker component (anchored popover, Escape/outside-
click dismiss via new useEscapeKey hook) used by both the composer and a
new hover-revealed reaction trigger on each message row.
Images live on the app server's local disk (uploads/), served through an
authenticated, room-membership-gated endpoint since rooms can be private.
Uploads are streamed with a byte-count cap, validated as genuine decodable
images with Pillow (not just a spoofed Content-Type), and downscaled to
2000px on the longer side (except GIF, to preserve animation).
Backend: MessageImage model + nullable Message.content/image_id with a
content-or-image CheckConstraint, upload/serve endpoints in rooms.py, WS
message envelope gains image_id, push notification body says "sent an
image" for image-only messages.
Frontend: Composer gets an attach button with upload progress and a
thumbnail chip; MessageList renders images inline with a click-to-zoom
ImageLightbox.
Replaced the Slack/iMessage-style layout (your own messages right-aligned
in an accent-colored bubble, no name shown since you obviously know you
sent it) with a flat, uniformly left-aligned chat log matching Mattermost:
every message shows its sender's avatar and name, dropped only for
consecutive messages from the same sender in a row (grouping logic already
existed for other people's messages; now applies uniformly regardless of
who sent it). No more per-message "mine" background color, since real
Mattermost doesn't have one either -- messages are distinguished only by
the username label, with a hover-revealed row highlight and the existing
edit affordance (still author-only) taking the place of the old bubble.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages,
write:messages, manage:rooms) authenticated via Authorization: Bearer on both
REST and the WS handshake, live bot WebSocket access on the same /ws/chat
endpoint humans use, message editing (WS "edit" envelope -> message_update
broadcast, fans out cross-instance for free via the existing broadcaster),
incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing
webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery,
creation-time SSRF validation against private/loopback/link-local targets).
Token auth is additive, not a parallel system: a bearer-token-authenticated
bot goes through the exact same room-membership/role checks a session-
authenticated human does everywhere; only read:messages/write:messages are
separately scope-gated (the two message endpoints). manage:rooms scope
enforcement, full per-delivery SSRF re-validation, and bot API rate limiting
were explicitly scoped out (confirmed with the repo owner) as disproportionate
to this phase -- documented as known gaps in backend/README.md rather than
silently skipped.
Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens,
cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/
subscription management, mirroring how invites already work there. The chat
UI also gets a minimal "edit your own message" affordance -- not asked for
by the issue, but the only practical way to exercise the edit pipeline by
hand instead of only via a scripted bot client.
Along the way: fixed a real bug caught while writing the incoming-webhook
test -- offline-push notification relied on the sender being "connected" to
exclude themselves, true for WS-originated messages but not for the new
webhook path, which has no WS connection for the attributed sender at all.
Now explicitly excluded. Also discovered the REST-only test fixture never
triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for
it; moved their construction out of the lifespan into create_app() itself
(Redis client construction is synchronous/lazy) so both the WS and
REST-only paths always have them.
New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite
now 78/78, stable across repeated runs) plus a scripted end-to-end smoke
test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing
delivery) and a full browser walkthrough of the new admin/room UI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the Phase 1 placeholder UI with a single persistent app shell (top
bar + sidebar + chat pane, 860px responsive breakpoint) matching the
"PWA chat system UI" design handoff: message bubbles with consecutive-run
avatar/name grouping, auto-growing composer, room search, and the real
DarkSingularity logo (also used to regenerate the PWA icons).
The handoff didn't cover Phase 2 (private rooms, roles, invites) or
browsing/joining open rooms, so those are added using the same visual
language: a room info panel with role badges, invite-by-username with a
pending-invites list, member management (remove/promote/demote/transfer
ownership), room settings (rename/describe/delete), and separate
browse-rooms/invites-inbox modals. Unread badges, last-message preview, and
the typing indicator are deliberately deferred -- both need new backend
features (read-tracking, a WS typing event) that weren't in scope this pass.
Two small backend additions round out data the new UI needs but the API
didn't expose: MessageRead.username (historic messages had no sender name)
and InviteRead.target_username / MyInviteRead.room_name+invited_by_username
(a recipient's invite list can't otherwise resolve a room they're not in).
Both are additive; 35 backend tests still pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>