Commit Graph
19 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 77809758a2 Fix chat view not landing on the latest message after switching rooms (#50)
MessageList auto-scrolled to the bottom in a useEffect keyed on
messages.length, but .message-image has no reserved width/height (only
max-width/max-height caps) -- unlike UserAvatar and LinkPreviewCard's
thumbnail, which both reserve fixed pixel dimensions. If a message near
the bottom of a room's history has an image attachment, that scroll ran
before the image loaded; the image then grew the container a moment
later, leaving the view scrolled short of the true bottom until the
user scrolled down manually.

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

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

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

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

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

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

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

Verified end-to-end against the reported URL.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also fixes heading levels rendering at an identical capped size
(should still step down by level, just capped lower than default), and
adds CSS for markdown constructs the library already parsed but hadn't
been styled for the dark theme: table borders, highlight/mark color,
task-list checkbox accent, and footnote divider.
2026-08-14 21:42:57 -06:00
ksmith 8ca3e2e23d Add user profile management: display name + avatar upload (Gitea issue #12)
Users can set a display name (shown instead of username in the message
list, room member list, TopBar, and admin Users tab) and upload a real
avatar, replacing the generated color-initial avatars everywhere a user
appears. Avatars are square-cropped and downscaled to 512px, reusing
app/storage.py's upload primitives from image uploads with a new square
option.

Two deliberate divergences from message-image handling, documented in
backend/README.md: the previous avatar file is deleted on replace/remove
(safe since it's strictly one file per user), and avatar serving is not
room-gated and uses a short cache (identity-addressed and mutable, unlike
a message image's permanent content-addressed URL).

Frontend: new ProfileModal reachable from the TopBar account menu;
AuthContext gains updateUser() so a profile change reflects instantly
everywhere without a refetch.
2026-08-14 16:59:56 -06:00
ksmith c6f90d49fc Add emoji picker and message reactions (Gitea issue #11)
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.
2026-08-14 15:37:59 -06:00
ksmith f2a59f798b Add image uploads in chat messages (Gitea issue #10)
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.
2026-08-14 12:21:42 -06:00
ksmithandClaude Sonnet 5 559adf9b7e Redesign message list to Mattermost-style: left-aligned, always named
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>
2026-08-14 11:48:59 -06:00
ksmithandClaude Sonnet 5 0ab23c44a7 Phase 7: Bot/extension system
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>
2026-08-14 08:12:41 -06:00
ksmithandClaude Sonnet 5 e9fcb9fea2 Rebuild frontend from the Claude Design handoff, DarkSingularity brand
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>
2026-08-13 21:10:58 -06:00
ksmithandClaude Sonnet 5 99aa029c0d Phase 1: auth, room CRUD, WebSocket chat, PWA frontend
Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie
auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat)
and a React + Vite PWA frontend (login, room list, chat view). Backend tests
pass against a local Postgres DB. See README.md and backend/README.md for
setup, and ARCHITECTURE.md for the full phased design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 20:01:17 -06:00