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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Shows the app version, links the AGPL-3.0-or-later license text, and
links the source repo -- AGPL's own suggested-usage text recommends
exactly this ("if your software can interact with users remotely...
its interface could display a 'Source' link"), not just a courtesy
credits screen.
Version comes from package.json at build time via a Vite `define`
(__APP_VERSION__), so it can't drift from what's actually released.
License text is served at /LICENSE via a frontend/public/ symlink to
the repo-root LICENSE, the same pattern already used for the user
guide. Also bumps both package manifests to 1.0.0 ahead of tagging
the first release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the verbatim license text as LICENSE, sets license metadata in
both package manifests, and links it from both README.md files.
Chosen specifically for the network-copyleft clause (AGPL §13): a
modified version run as a hosted service must offer its source to
that service's users, which plain GPL's distribution-only trigger
doesn't cover.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On mobile, a file input with no accept hint (needed to allow arbitrary
file attachments) makes some Android browsers fall back to a generic
chooser -- Camera, Camera Video, Files -- with no direct Photos/Gallery
shortcut, confirmed via a screenshot showing exactly that. Android
can't reliably offer both "any file type" and a gallery shortcut from
a single input, so the attach button now opens a small menu: "Photo or
video" uses a new input with accept="image/*,video/*" (should surface
the OS media picker's gallery shortcut), "File" keeps today's
unrestricted picker. Upload routing (handleFile) is unchanged either
way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A "People" button next to "Browse rooms" opens a modal listing every
site user with an online/offline status dot, online users sorted
first. No backend changes needed -- GET /api/users (the user
directory) and GET /api/users/online (a snapshot of who's connected
anywhere in the app, backing every avatar's status dot already) both
already existed from other features, just never had a UI surface of
their own for regular members.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
USER_GUIDE.md at the repo root is the single source of truth --
frontend/public/USER_GUIDE.md symlinks to it so the same file is both
readable directly in the repo and served by the app, rendered on a new
/help page reusing the existing markdown renderer. Scoped to regular
member features (messaging, rooms, attachments, notifications,
profile); room admin/site admin features are intentionally left out.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Presence (which gates push, desktop notifications, and the unread dot)
only tracked document.visibilityState, which in Electron only flips on
minimize/hide -- not on losing OS focus, e.g. alt-tabbing away with the
window still open. That left the offline-audience computation treating
an unfocused-but-visible desktop window as "present," so notifications
never fired unless the app was actually minimized to tray.
Desktop mode now also requires document.hasFocus() before considering
a room joined; regular browser-tab behavior (visibility alone) is
unchanged. Verified in-browser: losing focus sends a leave frame,
regaining it sends join + gets acked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Offline members now also get a desktop_notification WS envelope
alongside the existing Web Push send, since Electron has no push
delivery service configured. The client only acts on it when
window.dsDesktop is present and the user's local preference allows it,
so the server needs no awareness of which clients are Electron.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mirrors the @mention autocomplete exactly -- same trigger-detection
logic (factored into a shared detectTriggerQuery helper, parameterized
on '@' vs '#'), same arrow-key/Enter/Tab/Escape keyboard handling, same
dropdown. Suggests rooms the user belongs to, filtered by name prefix,
showing the room's description as a subtitle when it has one.
MentionAutocomplete.css is renamed to ComposerAutocomplete.css with
generic class names (composer-autocomplete-primary/-secondary instead of
-username/-display-name), now shared by both the mention and room-
reference dropdowns instead of being mention-specific.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>