Commit Graph
56 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 c99a07cae1 Add a download button to the image lightbox (#41)
MessageImage has no stored original filename, so this relies on a bare
`download` attribute (no explicit filename) rather than adding
filename support server-side to match the file-attachment pattern --
the image-serving response's existing Content-Type header is already
enough for the browser to infer a sensible extension on its own.

RoomInfoPanel's Files section reuses the same shared ImageLightbox
component, so its image rows get the same download button for free
with no separate change needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:37:24 -06:00
ksmithandClaude Sonnet 5 2466e76af1 Require password confirmation on account creation (#40)
Both account-creation surfaces now require the password twice:

- Web signup (invite-based self-service): SignupComplete gains a
  password_confirm field with a model_validator backstop server-side,
  plus a client-side match check in SignupPage.tsx for immediate
  feedback -- the client check is the primary UX, the server check is
  defense in depth so the guarantee doesn't rely on the client alone.
- CLI (python -m app.cli create-user): password is now an optional
  positional argument. If omitted, prompts interactively via getpass
  (hidden input) twice, retrying on mismatch -- matching what "entered
  twice and verified" actually means for a human typing blind. Passing
  the password directly as before still works unchanged, for scripted/
  automated provisioning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:48:23 -06:00
ksmithandClaude Sonnet 5 dbf9bfa902 Support dragging files/images onto the composer to attach them (#32)
Reuses the existing upload path unchanged: handleFileSelected's body is
now handleFile(file), called from both the file-input's onChange and a
new onDrop handler on the composer, so drag-and-drop and the "Attach a
file" button share the exact same size-check/branch-on-content-type/
error-surfacing logic rather than duplicating it.

Only the first dropped file, matching the existing single-attachment-
per-message limit. A dashed-border overlay appears while dragging over
the composer for discoverability; a nested dragenter/dragleave counter
keeps it from flickering as the drag crosses child element boundaries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:14:17 -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 bd3e621e9f Support multiple named, saved custom themes per user
Replaces the single custom_theme_colors blob (one palette per user) with
a proper CustomTheme table -- users can now save, name, and switch
between as many custom palettes as they like, not just one.

Data model: users.active_custom_theme_id references whichever saved
CustomTheme (if any) is currently active; theme='custom' + that id
together determine what's rendered. The migration data-migrates any
already-saved single palette into a named CustomTheme row on upgrade,
and best-effort backfills the active one back into the old column shape
on downgrade.

New endpoints under /api/custom-themes: list, create, rename/recolor,
delete (falls back the user to a preset if the deleted theme was
active, so the two theme columns can never disagree), and activate.
UserRead.active_custom_theme is only populated when theme == 'custom'
even though the DB deliberately keeps the id set while a preset is
active, so switching to a preset and back doesn't lose the saved
palette.

ProfileModal now lists saved themes as swatches (click to activate,
pencil to edit -- active or not, trash to delete with a confirm), plus
a "+ New" button that creates, activates, and opens the editor for a
fresh theme immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:53:05 -06:00
ksmithandClaude Sonnet 5 73020fa39f Add PDF preview to the file preview modal (#23)
Extends the existing fetch-and-bypass-Content-Disposition pattern
already used for text/markdown previews: the PDF's bytes are fetched
into a Blob and handed to the browser's native viewer via a blob: URL,
which carries no HTTP headers of its own. That sidesteps
Content-Disposition: attachment the same way a script-initiated fetch()
already does for text, without needing an <iframe>/<embed> to navigate
to the real file URL directly (which would respect that header and
force a download) -- and without the backend allowlist endpoint this
issue's original scoping assumed would be necessary.

MIME type is forced to application/pdf explicitly rather than trusted
from the upload, since getPreviewKind gates on the .pdf extension alone
(matching its existing behavior for .md/.txt), so a mislabeled file
still renders instead of downloading or erroring. Object URLs are
revoked on unmount/file-change to avoid leaking memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:09:10 -06:00
ksmithandClaude Sonnet 5 53d16973fb Add custom theme colors, full per-token control (#30)
Adds a 5th "Custom" option to the theme swatch grid alongside the
existing 4 presets, opening a picker for all ~12 CSS custom properties
(backgrounds, borders, text, three accent tiers, highlight, danger) plus
a light/dark toggle for native control rendering.

Persisted as a new users.custom_theme_colors JSONB column, validated
server-side against exactly what a native <input type="color"> can ever
produce. Colors survive switching to a preset and back, since there's no
reason picking a preset for a moment should force redoing every color
pick. Applied at runtime as inline custom properties on :root (presets
stay static CSS) via a shared applyTheme() helper, which is also
responsible for clearing those inline overrides when switching away --
otherwise they'd silently keep winning over whatever preset's own
stylesheet values should apply next.

Live preview on every color change; persists only on explicit "Save
colors" (not per keystroke, since a color input fires continuously while
dragging), and closing the modal without saving reverts the preview back
to whatever's actually persisted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 06:15:49 -06:00
ksmithandClaude Sonnet 5 68e487e5ec Add unread message indicators for rooms (#38)
Shows a dot on rooms with unread messages in the sidebar, updated live
over WebSocket. Reuses the same offline-member audience computation
already used for push notifications: a member gets the real-time signal
whenever they aren't currently connected to that room's channel, which
correctly covers both "room not open" and "room open but tab
backgrounded" (the client leaves a room's channel while hidden).

Persisted server-side via a new room_memberships.last_read_at column so
state survives reload and stays consistent across devices, advanced by
an explicit mark-read call the frontend makes on room-open and on each
live message received while the room is genuinely visible -- gated on a
live visibility check, not a cached ref, so a backgrounded-but-open room
keeps accumulating unread instead of auto-marking-read the instant a
message arrives somewhere it can't be seen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:44:20 -06:00
ksmithandClaude Sonnet 5 974d92ab4d Unsubscribe from push notifications on logout
logout() cleared the session but never called unsubscribeFromPush(),
so a browser's push subscription (and its server-side row) outlived the
session indefinitely -- the logged-out account kept silently receiving
pushes for as long as that browser stayed open. Runs before the session
cookie is cleared since the unsubscribe call is authenticated, and is
best-effort so a failure there can't block logout itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:04:11 -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 fd0b3863f0 Add a "Recently used" section to the emoji picker (#35)
Shows up to 8 most-recently-picked emoji, pinned above the regular
categories, whenever the picker isn't in search mode -- shared by both
the composer's insert-emoji button and message reactions, since both
go through the same EmojiPicker component. Stored in localStorage
(frontend/src/lib/recentEmoji.ts), per-browser rather than synced
across devices, matching this app's existing local-only preferences
(e.g. the resizable-panel widths).

Verified in-browser: no section when empty, a pick is recorded and
shows up on reopen, order is most-recent-first, re-picking an already-
recent emoji moves it to the front without duplicating, and the list
caps at 8 by dropping the oldest entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 19:25:50 -06:00
ksmithandClaude Sonnet 5 2cd44dc3f7 Fix push notifications permanently suppressed by silent background reloads
Root cause of "still not getting push notifications while backgrounded"
despite #31: joinRoom() was made to send unconditionally on the theory
that opening a room always implies genuine visibility (a real click
can't happen on a truly hidden tab). That's wrong for one real case --
mobile Chrome can silently discard and later reload a long-backgrounded
tab from memory, which re-mounts the room and calls joinRoom() again
with nobody actually looking at the screen. Each such reload re-joined
the room's presence with no matching "leave" (a discard skips normal
unmount cleanup), so a room could accumulate a stuck presence entry
that permanently suppressed push notifications for it -- confirmed
live via a user's server logs (repeated silent WS reconnects, and
their account still showing present in the room's Redis presence hash
while genuinely backgrounded).

joinRoom() and the reconnect replay now check document.visibilityState
live instead of trusting a cached ref or sending unconditionally: a
still-hidden reload correctly stays "left" (the room stays in
desiredRoomsRef, so the next genuine foreground transition still joins
it, just deferred instead of wrongly immediate), while a real
user-driven open still joins immediately as before.

Verified both directions in the browser: mounting a room while
genuinely hidden leaves the room's presence hash empty; a subsequent
real visibility transition to visible correctly triggers the deferred
join.

Note: this prevents new stuck entries but doesn't retroactively clear
any that already exist -- an affected user needs one real close
(not just backgrounding) to send a clean disconnect and reset the
stuck refcount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 19:14:05 -06:00
ksmithandClaude Sonnet 5 85dc83f4e5 Fix presence showing offline on non-chat pages, and a room-join bug it exposed
The WebSocket connection lived entirely inside ChatShellPage, so
navigating to /admin (which never opens its own connection) unmounted
it -- the server correctly marked the user offline since the
connection genuinely closed, even though they were still logged in
and using the app. New ChatSocketContext.tsx hoists the connection to
App.tsx, shared across every authenticated route via a single
provider (keyed by user id, so a logout/login as a different account
gets a clean reconnect rather than an old connection lingering under
a new identity) instead of living inside whichever page happens to be
mounted.

Verifying that fix surfaced a second, independent bug: #31's
visibility handling had gated the *explicit* joinRoom/leaveRoom calls
(fired when a room actually mounts/unmounts in the UI) on the same
isVisibleRef check meant for automatic background/foreground
transitions. That's wrong -- a room can only be opened by a real user
interaction, which can't happen on a genuinely backgrounded tab, so
gating it too meant a stale or momentarily-wrong visibility reading
at mount time could silently skip the join with nothing to ever retry
it. joinRoom/leaveRoom now always send immediately; only the
automatic hide/show transitions and the reconnect replay stay gated
on visibility, which is what #31 actually needed.

Verified both end-to-end in the browser: navigating to /admin via
real in-app navigation (not a reload) keeps the presence dot online,
confirmed via direct Redis inspection and the /api/users/online
endpoint; opening a room and sending a message works immediately
afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:37:40 -06:00
ksmithandClaude Sonnet 5 e9ad5d832b Show a reload banner when a new version has deployed (#28)
The service worker already auto-updates in the background (registerType:
'autoUpdate' + an unconditional self.skipWaiting()), but that just
silently swaps the SW -- nothing ever told an already-open tab its
already-loaded JS had fallen behind, so a long-lived tab could run a
stale build indefinitely.

Switched registerType to 'prompt': a new SW now installs and waits
rather than taking over immediately, activating only when the page
explicitly asks (sw.ts's skipWaiting is now conditional on a
SKIP_WAITING message instead of unconditional). UpdateBanner.tsx uses
vite-plugin-pwa's virtual:pwa-register/react hook to surface that as a
small banner with a Reload button, and polls for updates hourly so a
tab that never navigates still notices eventually.

Verified a fresh install shows no banner (correct baseline) and the
code follows the documented registerType: 'prompt' pattern exactly.
Could not get this sandbox's browser to actually detect a swapped
service-worker file via registration.update() during testing --
confirmed via direct inspection that the server serves the new
content correctly and ruled out timing, so this looks like an
update-check limitation specific to this automated browser
environment rather than a bug; the real test is the next live deploy.

Also adds a "frontend-preview" launch.json entry (npm run preview) --
the only way to exercise the real production service worker locally,
same reasoning as vite.config.ts's existing `preview.proxy` section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:13:05 -06:00
ksmithandClaude Sonnet 5 ccd92787d6 Send push notifications when the app is backgrounded, not just closed (#31)
Root cause (found earlier): the server only pushes to users it
believes are "connected" to a room, but that was a raw WebSocket-
connection check with no concept of whether the tab was actually
foregrounded -- a backgrounded-but-still-connected tab looked exactly
like someone actively watching, so the push got suppressed even
though nothing could surface on a hidden page.

No backend change needed: the server's presence tracking (and the
push-suppression logic built on it) was already correct for "not
joined to this room's channel" -- the gap was purely that the client
never told it about backgrounding. useChatSocket.ts now tracks
document.visibilityState and sends "leave" for every desired room
when hidden (without forgetting the app still wants them joined), and
"join" again when visible -- reusing the exact join/leave path a real
room switch already goes through, no new WS message type or backend
logic required.

Rejoining also triggers a message-history refetch in ChatPane (keyed
off the server's existing "joined" ack), so anything sent while
backgrounded gets backfilled instead of silently missing -- as a side
effect, this also fixes reconnect-after-a-dropped-connection never
backfilling either, which had the same gap.

Verified end-to-end: simulated backgrounding in the browser and
confirmed via direct Redis inspection that the room's presence hash
(what push-suppression actually reads) goes empty on hide and
repopulates with a message resync on show.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:58:39 -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 1c2d2e91c1 Fix stale room/message/member data after leaving and returning (#37)
The service worker cached /api/rooms/mine, /api/rooms, .../messages,
.../members, and /api/invites/mine with StaleWhileRevalidate: serve
the previous cached response immediately, refresh the cache in the
background for next time. That means every repeat visit showed
content one visit behind -- reopening a room after someone messaged
it, or checking a second device, both showed stale data until a
manual reload (which finally picked up the now-revalidated cache).

Only registers in a production build (vite dev never activates it),
which is why this didn't surface during in-browser testing this
session for #26/#27/#34.

Switched all five routes to NetworkFirst: always prefer a live
response, fall back to cache only when the network request itself
fails or times out (genuinely offline), keeping the "readable while
offline" behavior without the staleness.

Verified against a real production build (`vite preview`, the only
way the SW actually registers): sent a message from a second session
against an already-cached room, and the very next fetch showed it
immediately with no staleness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:55:30 -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 1b4d681ad0 Broadcast profile updates so member lists stay live (no reload needed)
Another user's new display name or avatar didn't show up until you
reloaded -- update_profile/upload_avatar/remove_avatar never told
anyone. Same root cause and fix shape as #26 (room_added): the
frontend's already-fetched member list had no way to hear about a
change, since nothing ever pushed one.

Reuses the existing per-room broadcast channel (not the per-user one
#26 added, since this only matters for rooms the affected user shares
with someone currently looking at them) -- publishes member_updated to
every room the user belongs to; ChatShellPage refetches members when
it arrives for the currently open room.

Verified end-to-end in the browser: one user's room-info member list
updated live when another user changed their display name from a
separate session, no reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:08:36 -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 1adb4fcbe0 Add markdown :name: emoji shortcode support in messages (#27)
Typing a complete 😂/😉/etc. shortcode now renders as the emoji,
matching Slack/GitHub/Discord. Render-time only, alongside the existing
preserveLineBreaks preprocessing step -- stored/sent content keeps the
raw :name: text, same as markdown itself is never converted until
display. Fenced code blocks and inline code spans are skipped so
pasted code (a Ruby symbol, a dict key) isn't silently mangled.

frontend/src/lib/emojiShortcodes.ts is generated once from
emojibase-data's GitHub shortcode set (same one-time-generator approach
as #19's emojiNames.ts, never a runtime dependency) -- 928 of the
app's 936 emoji matched, 956 aliases, no collisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:38:09 -06:00
ksmithandClaude Sonnet 5 7dcc7104df Push a live signal when a user is added to a room (#26)
Previously GET /api/rooms/mine was only ever fetched once at app mount,
so a room added mid-session stayed invisible until a full page reload
-- add_member had no way to reach an already-open client at all.

Backend: ConnectionManager and Broadcaster (renamed from RoomBroadcaster)
now support per-user channels alongside the existing per-room ones, so a
signal can reach a user's socket even for a room they haven't joined
(and by definition can't have, until this fires). add_member publishes
a room_added event on the target user's channel.

Frontend: the WebSocket connection is no longer scoped to whichever
room is open -- ChatShellPage now owns one persistent connection for
the whole session (including while no room is open, which is exactly
when this bug showed), and ChatPane joins/leaves rooms on top of it.
A room_added event triggers a room-list refetch with no reload needed.

Verified end-to-end in the browser: a user sitting on the empty room
list saw a newly-added room appear live, then chatted in it normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:29:06 -06:00
ksmithandClaude Sonnet 5 c9d61c3d12 Fix composer send button clipped off-screen on mobile (#24)
The composer's message textarea lacked min-width: 0, so on browsers
that compute a larger default intrinsic width for flex-child form
controls (observed on Firefox for Android), the row could overflow
and push the fixed-width send button past the viewport edge instead
of shrinking the textarea to fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:09:49 -06:00
ksmithandClaude Sonnet 5 b451504d07 Fix mobile display: RoomInfoPanel inaccessible, emoji picker overflow
RoomInfoPanel was hard-gated behind !isMobile in ChatShellPage.tsx, but
its trigger button ("Room details" in ChatPane.tsx) still rendered and
toggled state unconditionally -- tapping it on mobile did nothing
visible, with no way to reach room info/members/settings at all. Fixed
by removing the gate and making the panel itself responsive: it renders
as a full-screen fixed overlay below the mobile breakpoint instead of
the desktop resizable aside (which stays exactly as before -- verified
in-browser at both viewport sizes).

Also found and fixed a second real bug during the mobile audit: the
emoji picker's fixed 320px width overflows a 375px-wide viewport by 5px
depending on trigger position (e.g. the composer's emoji button, near
the left edge). Shrunk it to 280px with a proportionally reduced column
count below 480px, rather than attempting dynamic position-aware sizing
for a 5px overflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:35:19 -06:00
ksmithandClaude Sonnet 5 9c275b1b86 Add emoji search by name
A search box in EmojiPicker filters the flat emoji list by name/keyword
instead of only browsing by category, shared by both the composer's
emoji trigger and the message reaction picker.

Names/keywords didn't exist anywhere in this codebase before (emoji.ts is
just raw unicode characters). Rather than hand-typing entries for all 928
unique emoji -- a lot of manual work, and unlike the codepoints themselves
a wrong name only means a bad search result, not a broken emoji, so the
accuracy argument for hand-typing didn't apply here -- generated
emojiNames.ts once from emojibase-data (MIT licensed), matched against
emoji.ts by codepoint (normalizing variation-selector differences between
the two sources). emojibase-data itself was never added to package.json;
it was only ever a one-time generation tool, so this ships with zero new
runtime dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:04:59 -06:00
ksmithandClaude Sonnet 5 caeff5d6f0 Fix sudoers rule not matching upgrade.sh's actual systemctl status call
The NOPASSWD rule from 3f only covered the bare `systemctl status
ds-chat` with no arguments, but deploy/upgrade.sh actually calls it with
`--no-pager -l`. Sudoers matches commands on the exact argument string
unless a wildcard is present, so the extra flags fell through to a
password prompt on every upgrade run -- one that can never actually be
satisfied, since ds-chat correctly has no password at all (a nologin
system account). Added a wildcarded pattern alongside the exact one so
upgrade.sh's real invocation matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:15:09 -06:00
ksmithandClaude Sonnet 5 691f597597 Fix SMTP sending forcing implicit TLS regardless of port
cfg.use_tls was passed straight through as aiosmtplib's use_tls kwarg,
which means implicit TLS -- encrypted from the first byte, port 465's
convention. Port 587, what most providers (including the one that
surfaced this: DreamHost) document as their primary submission port,
needs STARTTLS instead -- a plaintext connection that upgrades in-band.
Forcing implicit TLS against a STARTTLS-only port breaks the handshake
outright: [SSL: WRONG_VERSION_NUMBER], a client TLS ClientHello sent to a
server still expecting a plaintext SMTP greeting.

The "Use TLS" checkbox still means "encrypt this connection" -- the fix
infers which of the two negotiation modes to use from the port (465 ->
implicit, everything else -> STARTTLS), matching the convention every
mail client uses. start_tls is passed as an explicit requirement rather
than left to aiosmtplib's opportunistic default, so a server that turns
out not to support STARTTLS fails loudly instead of silently sending in
plaintext despite the admin asking for encryption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:09:39 -06:00
ksmithandClaude Sonnet 5 630b07d48f Fix deployment guide issues found during a live walkthrough
Three real, reproducible bugs in DEPLOYMENT.md's app-server setup steps,
each found by actually following the guide on a real box rather than
static review:

- useradd --system --create-home is unreliable on Debian in both
  directions: sometimes it silently skips creating the home directory,
  sometimes it creates it AND populates it from /etc/skel
  (.bashrc/.profile/.bash_logout). Either way it broke a later step --
  the skel files make git clone refuse to clone into a non-empty
  directory. Fixed by dropping --create-home and creating the directory
  ourselves, deferring anything else that goes in it (uploads/) until
  after the clone.
- Documented a personal/deployment-user access token as a first-class
  alternative to the SSH deploy key for cloning, alongside its plaintext-
  in-.git/config tradeoff.
- The create-user command nested a user-chosen password inside two layers
  of shell quoting (an outer bash -c '...' plus inner double quotes) --
  fragile for any password with a space or a literal '. Replaced with
  dropping into an authenticated interactive shell first, so there's only
  one layer of quoting to get right at an actual prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 23:09:27 -06:00
ksmithandClaude Sonnet 5 e20916bca9 Fix stale deployment docs found during a pre-launch review
DEPLOYMENT.md: two spots (§7 backups, §9 known gaps) said "images" when
they should say "images and file attachments" -- generic file attachments
(#13) share the exact same no-backup-coverage and orphaned-upload gaps as
images, but the wording was never updated when that feature shipped.

ARCHITECTURE.md §9.2 and the tech-stack table described a materially
different, outdated architecture: nginx running on the app server,
reverse-proxying to gunicorn over a Unix socket. The actual setup (which
DEPLOYMENT.md already correctly documents) has no nginx on the app server
at all -- gunicorn binds a TCP port directly, and TLS/reverse-proxying is
handled by an external, pre-existing Nginx Proxy Manager instance. Rewrote
both to match reality.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 22:10:39 -06:00
ksmithandClaude Sonnet 5 afdd573182 Add UI themes (Dark, Light, Midnight, Sunset)
Four preset color themes, switchable from Profile settings with instant
preview and server-side persistence (User.theme, applied via a data-theme
attribute the CSS custom-property overrides in themes.css key off). Dark
stays the existing DarkSingularity palette and default. Light is a genuine
new light-mode design; Midnight is a higher-contrast OLED-friendly dark
variant; Sunset swaps in a warm amber/coral accent family.

Custom theme building (pick-your-own-colors) is out of scope for this
pass -- presets only.

Also: fixed the PATCH /api/auth/me handler to only apply fields actually
present in the request body. It previously always overwrote display_name
unconditionally, which happened to be harmless when it was the only
field on ProfileUpdate but would have silently cleared it on any
theme-only update. And switched two hardcoded hex colors
(.btn-primary:hover, .role-badge-admin) to token-derived color-mix()
values so they adapt across themes instead of staying fixed to the
original cyan/violet palette.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 22:02:38 -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 14dc340174 Rename project from KeepItTalking to DS Chat
Renames the app's display name everywhere (page titles, PWA manifest,
TopBar, email subject lines, HMAC signature header) and its internal
technical slug from chatapp to ds-chat/ds_chat: the Python package name
and console script, the systemd unit and its user/group/paths, the deploy
scripts, the Docker container names, and the Postgres database name.

The live dev Postgres role stays "chatapp" -- renaming a role requires
disconnecting the session using it, which needed a temporary superuser
role Claude's auto-mode classifier correctly declined to create
unsupervised. Functionally invisible (it's just a login credential), but
worth knowing about if this ever needs fully cleaning up by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:11:41 -06:00
ksmithandClaude Sonnet 5 62e4760c8a Add admin-configurable upload size limits
The 8MB image/file/avatar cap is now a site setting (UploadSettings,
single-row table like SmtpSettings) editable from the Admin Settings tab,
instead of a hardcoded constant. All three upload endpoints read the live
value and interpolate it into their 413 messages. A new GET
/api/uploads/limit endpoint (open to any authenticated user, unlike the
admin-only settings endpoints) lets the composer reject an oversized file
client-side before it ever hits the network, though the server still
enforces the same cap independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:44:05 -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 e31672719b Expand emoji picker from ~180 to ~940 curated emoji
The old list was a small hand-picked subset that was missing common
ones (party popper, facepalm, shrug, etc.). Reorganized into ten
categories (Smileys, People & Gestures, Hearts, Animals & Nature,
Food & Drink, Travel & Places, Activities & Sports, Objects, Symbols,
Flags) with much broader coverage in each. Widened the picker panel
and bumped the grid to 9 columns to fit the larger set comfortably.
2026-08-14 21:14:54 -06:00
ksmith fc96e85014 Add self-service password change, forgot-password flow, and fix admin UI bugs
Users can change their own password from the profile modal, and a
"forgot password" link sends a 15-minute expiring reset link (same
hashed-token pattern as site invites). The forgot-password response is
always generic so it never reveals which emails are registered.

Also fixes two admin-page display bugs found while testing: table row
divider lines that didn't line up across a row (the actions column had
`display: flex` on the <td> itself, breaking it out of normal table-cell
layout -- moved to a child <div>), and the pending-invites list floating
with no visual grouping (now boxed with a label and per-status badges).
2026-08-14 21:06:17 -06:00
ksmith 8e3b6a16bd Make the room list sidebar resizable
Generalizes useResizableWidth with a left/right anchor option and wires
it into the sidebar the same way the room info panel already uses it,
so both sides of the chat shell can be dragged to a comfortable width.
2026-08-14 20:45:07 -06:00
ksmith 91589ee647 Add resizable room panel, searchable user picker, and direct room membership
Room info panel is now user-resizable (fixing a layout clip at narrow
widths), and every user-selection spot (room membership, admin ownership
transfer) uses a new searchable UserPicker instead of raw text input or
prompt(). Member rows fold role + actions into a single inline dropdown
instead of a row of buttons, so the member list stays usable as rooms grow.

Room invites (the accept/decline flow) are replaced by adding a user to a
room directly -- an admin/owner picks someone and they're a member
immediately, with a "you've been added" notification email instead of an
invite email. Drops the now-unused room_invites table.
2026-08-14 20:40:37 -06:00
ksmith b724f8a33b Add admin-invited signups and email notifications (Gitea issue #15)
Site admins can invite a brand-new person by email from the Admin portal
Users tab -- a signup-link email lets them set their own username/password
and lands them in the app already logged in. Existing users invited to a
room now also get an email. Closes the "invited but never notified" gap
from both directions.

SMTP is configured through the Admin Settings tab at runtime (not the env
file), persisted in a new smtp_settings table with the password encrypted
at rest via a Fernet key derived from SESSION_SECRET -- the first
reversible secret this app stores in the database. A "send test email"
button surfaces real delivery errors; the invite/notification paths
themselves never fail loudly, since an SMTP outage shouldn't block an
action that already succeeded in the database.

New site_invites table mirrors RoomInvite's shape but targets an email
address with no room context; the raw signup token is hashed the same way
API tokens are, and only ever exists in the email link. POST /api/signup
is the first genuinely public, unauthenticated account-creation endpoint
in this app, reusing the existing register_user path for identical
validation.
2026-08-14 17:38:56 -06:00
ksmith ad1beccd3a Fix profile save UX: close modal on success, refresh room members live
Saving a display name previously gave no feedback and left the modal
open, and an open room's message list/member panel kept showing the
pre-edit profile until reload -- both fetch that data from a member list
ChatShellPage only fetched once per room. Now the save closes the modal
(clear confirmation it worked) and ChatShellPage re-fetches room members
whenever the logged-in user's own display_name/avatar_filename changes,
so the update appears immediately everywhere without a reload.
2026-08-14 17:09:22 -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 f43cf61ddd Fix stale-socket race in WS reconnect logic
React StrictMode double-invokes the effect that opens the chat WebSocket in
dev (mount -> cleanup -> mount again, on purpose, to catch exactly this
class of bug). The first socket gets abandoned in cleanup, but its onclose
still fires asynchronously afterward -- and unconditionally ran
`socketRef.current = null`, even after the second (real) socket had already
taken over. That silently orphaned a perfectly live connection: still open
and receiving broadcasts fine, but nothing left holding a reference to send
on, so outgoing messages/edits went nowhere with no visible error.

Found via manual testing: messages sent through the UI weren't appearing,
but a raw WebSocket opened by hand (bypassing React entirely) joined and
sent a message successfully, isolating the bug to the reconnect logic
rather than the backend.

Fix: each socket's onopen/onclose now checks it's still the one referenced
by socketRef before mutating shared state, so a stale/superseded socket's
events are inert instead of clobbering whatever socket is actually current.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 11:46:25 -06:00
ksmithandClaude Sonnet 5 7f579bb508 Phase 8: Production deployment (Debian 13, Nginx Proxy Manager)
Deployment artifacts for the two-server architecture from ARCHITECTURE.md
§9, grounded in verified Debian 13 (trixie) package facts (Python 3.13,
PostgreSQL 17, Node.js 20, redis-server 8.0, certbot 4.0, ufw --
confirmed rather than guessed) rather than a generic "modern Linux" guide:
deploy/systemd/chatapp.service, deploy/chatapp.env.example,
deploy/backup-postgres.sh, deploy/upgrade.sh, and DEPLOYMENT.md as the
actual numbered runbook.

Revised mid-implementation once the user clarified the app sits behind an
existing, separate Nginx Proxy Manager rather than local Nginx+certbot:
dropped the local Nginx config entirely, gunicorn now binds a TCP port
instead of a Unix socket, and app/main.py gained a static-file mount + SPA
fallback route so gunicorn alone serves the built frontend, /api, and /ws
on one port -- what lets NPM's simple one-upstream-per-domain mode work
with zero custom path routing. Path-traversal-guarded (full_path comes
straight from the URL) and cache-header-differentiated (far-future
immutable on Vite's content-hashed assets, no-cache on index.html/sw.js/
manifest so a deploy actually propagates instead of leaving clients on a
stale service worker) -- verified locally against a real gunicorn process
serving a real frontend build, not just eyeballed.

Two real gaps found and fixed alongside the docs, not just noted: gunicorn
wasn't a dependency anywhere despite being the whole app-server design, and
there was no WebSocket reconnect logic on the client -- a reverse proxy's
idle-connection timeout (NPM's or otherwise) would have silently killed a
quiet chat connection with nothing to recover it. Added exponential-backoff
reconnect to useChatSocket.ts, verified by hand (killed and restarted the
local dev backend mid-session, confirmed auto-reconnect and that a message
sends successfully afterward with no page reload).

Every command in DEPLOYMENT.md that could be verified locally, was: the
exact systemd ExecStart line run against local dev Postgres/Redis with
clean SIGTERM shutdown, the static-file serving behavior against a real
build, both shell scripts syntax-checked. What couldn't be verified from
this sandbox (actual Debian 13 hardware, Nginx Proxy Manager itself) is
flagged explicitly in the plan rather than claimed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 11:30:55 -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 4aa8ef89c5 Phase 6: Admin portal
Adds is_site_admin-gated site administration: user management (list,
deactivate/reactivate, reset password, promote/demote), room management
(list all rooms including private ones, archive/unarchive, force-transfer
ownership), and an audit log of every admin action.

Backend: User.is_active (deactivation) and Room.is_archived (archive) are
new columns; AdminAuditLog is a new table matching ARCHITECTURE.md's
admin_audit_log design, written to in the same transaction as every
mutating admin action. require_site_admin (dependencies.py) gates all
/api/admin/* routes. get_current_user now rechecks is_active on every
request, so deactivating a user kills their already-open session
immediately, not just future logins. An admin can't deactivate or demote
their own account (the one self-lockout guard included). Archived rooms
drop out of the open-room browse list but stay readable for existing
members.

Frontend: new /admin route (AdminRoute guard, redirects non-admins to
/rooms) with a tabbed Users / Rooms / Audit log / Settings page, plus an
"Admin" link in the account menu for site admins.

Bot/integration management and system settings -- both listed in the
original issue -- are intentionally not here: bot management has nothing
to manage until Phase 7 builds the actual bot data model, and there's no
settings storage or concrete setting to configure yet. Settings has an
empty placeholder tab; bot management is deferred entirely to Phase 7.
Confirmed this scope cut with the repo owner before implementing.

New tests/test_admin.py (14 tests, full suite now 58/58) covers every
admin endpoint's permission gate, the self-action guards, deactivation's
immediate effect on an already-open session, and that every mutating
action produces exactly one audit log row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 07:37:29 -06:00
ksmithandClaude Sonnet 5 0b995ef75f Phase 5: Redis pub/sub for horizontal scaling
Splits the WebSocket layer into three pieces so one app instance and many
behave identically: ConnectionManager stays a purely local socket registry;
RoomBroadcaster publishes chat messages to a per-room Redis channel and
every instance (including the publisher) forwards received messages to its
own local sockets via a single psubscribe("room:*") listener started in
main.py's lifespan; Presence is a Redis-backed refcounted hash per room
tracking who's connected across all instances.

Presence replaces the old process-local connected_user_ids check that
Phase 4's offline-push logic used -- without it, a user connected on a
different instance would look offline and get a redundant push. Fixing
this was scoped in beyond the issue's literal ask (message fan-out only)
since it's a real correctness gap in a phase specifically about running
more than one instance; a known limitation (no heartbeat/TTL, so a hard
crash leaks a presence increment) is documented in the README instead of
solved here.

New tests/test_broadcast.py spins up two independent app instances sharing
one Postgres + Redis to prove delivery and presence both actually cross
the Redis boundary, not just work in-process. Manually verified the same
thing against two real uvicorn processes on different ports.

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