15 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5.5 7ff7f2ec0e Bump to v2026.10.9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:25:31 -06:00
ksmithandClaude Sonnet 5.5 62b5eec2fb Show the date on message timestamps that aren't from today
A bare "4:04 PM" gave no way to tell today's message from one sent last
week. Today stays time-only; older messages gain just enough date to
place them: "Yesterday 4:04 PM", a weekday for the past week ("Mon
4:04 PM"), then "Oct 2, 4:04 PM" (with the year if it isn't this one).
Hovering a time shows the full date. All in the viewer's local time.

A new calendar day now always starts a new message group. Previously a
same-sender run that crossed midnight within the 5-minute window (11:58
PM then 12:01 AM) stayed under one header, which would have put the
later message under the wrong day's timestamp. The grouping rule moves
out of MessageList into messageGrouping.ts so it's unit-tested.

useLocalDay refreshes "today" at local midnight and when a hidden tab
becomes visible again, so a tab left open overnight doesn't keep showing
yesterday's messages as a bare time. .message-time no longer wraps, and
.message-author can shrink, so a long display name can't push the longer
label out of the row on a phone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:25:22 -06:00
ksmithandClaude Sonnet 5.5 eb30dc1753 Bump to v2026.10.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:07:07 -06:00
ksmithandClaude Sonnet 5.5 6975ed4dc0 Add a collapsible markdown formatting toolbar to the composer (#75)
Markdown support was already complete and documented, but nothing in the
composer pointed at it -- unlike @, # and : (which advertise themselves
when typed), formatting syntax was only discoverable via Help, buried in
the account menu.

The composer stays a plain markdown textarea; a new "Aa" button opens a
toolbar that inserts the syntax for you: bold, italic, strikethrough,
inline code, code block, link, quote, bulleted/numbered lists, H1-H3,
sub/superscript. Collapsed by default, remembered across reloads and
room switches. Clicking a button again removes the formatting (native
undo doesn't track programmatic edits), and bold/italic/sub/strike
toggles are run-length aware so "***x***" is handled correctly. The
selection math is pure functions in lib/markdownFormat.ts, unit-tested.

The "?" opens /help#formatting in a new tab so the draft isn't lost;
HelpPage now scrolls to the hash once the guide has loaded, since the
browser's own hash scroll fired before the async-fetched headings
existed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:06:56 -06:00
ksmithandClaude Sonnet 5 c54c1bf97d Bump to v2026.9.28
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:22:21 -06:00
ksmithandClaude Sonnet 5 3349cadea2 Fix desktop tray unread indicator: clear only on genuine room entry
The tray indicator must stay visible while a message is unread and
clear only when the user actually opens the room containing it --
never merely because the window was shown, focused, restored, or
opened from the tray.

Adds window.dsDesktop.roomOpened() (called exactly once per genuine
transition to a real room id, via useDesktopRoomBridge) and wires up
window.dsDesktop.setUnreadCount() (declared but never actually called
before this) to fire whenever the unread-room count changes. Both are
no-ops in browser builds, matching this bridge's existing optional-
chaining convention.

Frontend unread state stays authoritative -- clearRoomIndicators is
pulled out of ChatShellPage as a pure function (same behavior, now
directly testable) so opening one room only clears that room's
unread/mention flags, leaving every other room's state untouched.

Also adds a frontend test runner (vitest + React Testing Library) --
none existed in this project before. 10 new tests cover: roomOpened
firing once on entry, not refiring on an unrelated rerender with the
same room id, refiring on a genuine room-to-room transition, never
firing with no room id, setUnreadCount firing independently of
roomOpened, clearing one room preserving every other room's unread
state, and the desktop bridge remaining fully optional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:21:45 -06:00
ksmithandClaude Sonnet 5 047f47d524 Bump to v2026.9.26
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:20:20 -06:00
ksmithandClaude Sonnet 5 850d9ec0fa Supervise the broadcast listener so it recovers from a dropped Redis connection (#76)
The previous fix hardened listen() against a single bad message, but a
connection-level failure (Redis restarting, a network blip) raises from
the async-for iteration itself, outside that guard, and killed the
task permanently and silently -- HTTP/REST and login kept working fine
since neither touches it, but live message delivery was gone for every
room and every user on the affected process until a manual restart.

Confirmed live in production after an OS update restarted Redis:
PUBSUB NUMPAT showed zero pattern subscriptions across all 4 gunicorn
workers, meaning every one of them had lost its listener with nothing
to bring it back.

Broadcaster.run_forever() now supervises listen(), restarting it with
exponential backoff (capped at 30s, reset after a stable 60s+ run) on
any failure, while still letting a genuine shutdown (task
cancellation) propagate immediately instead of being treated as just
another retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:19:59 -06:00
ksmithandClaude Sonnet 5 86584660fb Bump to v2026.9.23
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:59:15 -06:00
ksmithandClaude Sonnet 5 6f67f36578 Add WS heartbeat and harden broadcast against dead sockets (#76)
Messages could silently stop arriving until a room switch happened to
paper over it with a REST refetch. Root cause: a connection that dies
without a clean close (laptop sleep, an idle connection recycled by a
proxy/NAT) looks identical to a healthy-but-quiet one -- readyState
stays OPEN, onclose never fires, so the already-correct reconnect and
rejoin logic never runs.

The backend now pings every 30s and the frontend force-closes the
socket if nothing (any frame, not just a pong) arrives within 65s,
routing straight through the existing reconnect/rejoin path instead of
needing separate recovery logic.

Also found and fixed a more severe adjacent bug while tracing this:
ConnectionManager.broadcast()/send_to_user() had no per-socket error
handling, so one dead or racing socket could raise inside the app's
single, never-restarted Redis pubsub listener task and silently kill
live delivery for every room and every user on that process until
restart. Now caught and logged per-socket instead of propagating.

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:01:33 -06:00
45 changed files with 2547 additions and 103 deletions

No files matched your search

-17
View File
@@ -1,17 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
"port": 5173
},
{
"name": "frontend-preview",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "preview"],
"port": 4173
}
]
}
+1
View File
@@ -17,6 +17,7 @@ dist-ssr/
## Editors / OS ## Editors / OS
.vscode/ .vscode/
.idea/ .idea/
.claude/
.DS_Store .DS_Store
## Test / coverage ## Test / coverage
+51
View File
@@ -0,0 +1,51 @@
# Contributing to DS Chat
Thanks for considering a contribution.
## Reporting bugs and requesting features
Open an issue on this project's issue tracker. Include steps to reproduce
for a bug, or the problem you're trying to solve for a feature request —
that's usually more useful than a proposed solution.
## Development setup
See the root [README.md](README.md)'s Quickstart, plus
[backend/README.md](backend/README.md) and
[frontend/README.md](frontend/README.md) for the full local dev setup
(Postgres, Redis, Python venv, migrations, the Vite dev server).
[ARCHITECTURE.md](ARCHITECTURE.md) covers the overall system design if
you're orienting yourself before a larger change.
## Before opening a pull request
- **Tests**: run `pytest` in `backend/` for any backend change, and add
tests for new behavior rather than just the happy path — see
`backend/README.md`'s "Run tests" section. For frontend changes, run
`npx tsc -b` in `frontend/` and confirm `npm run build` succeeds.
- **Style**: match the conventions already in the file you're editing
rather than introducing a new pattern — this codebase doesn't have a
separate style guide beyond "look at what's already there."
- **Scope**: smaller, focused PRs are easier to review than large ones
that mix unrelated changes.
## Contributor terms
By submitting a contribution (a pull request, patch, or similar), you
agree that:
1. Your contribution is licensed under the project's own license,
AGPL-3.0-or-later ([LICENSE](LICENSE)), and
2. You grant the project's maintainer(s) a perpetual, worldwide,
non-exclusive right to also relicense your contribution under different
terms — for example, as part of a separately-licensed commercial
offering built on this project.
This keeps the option of a future dual-licensed (open-source +
commercial) version of the project available, without requiring a
separate signed agreement for every contribution.
*This is a lightweight starting point, not a substitute for legal advice —
if you're contributing something substantial, or maintaining a fork with
your own commercial plans, it's worth having this reviewed by a lawyer
rather than relying on the paragraph above alone.*
+37 -15
View File
@@ -132,35 +132,45 @@ sudo -u ds-chat ssh-keygen -t ed25519 -f /srv/ds-chat/.ssh/id_ed25519 -N ""
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
``` ```
Add that public key as a **read-only deploy key** on the Gitea repo This project is hosted at
(Settings → Deploy Keys), then: **[github.com/ds-ksmith/DS-Chat](https://github.com/ds-ksmith/DS-Chat)**.
Add that public key there as a **read-only deploy key** (Settings → Deploy
Keys on the repo), then:
```bash ```bash
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts sudo -u ds-chat ssh-keyscan github.com >> /srv/ds-chat/.ssh/known_hosts
sudo -u ds-chat git clone git@git.darksingularity.org:DarkSingularity/ds-chat.git /srv/ds-chat sudo -u ds-chat git clone git@github.com:ds-ksmith/DS-Chat.git /srv/ds-chat
``` ```
(If your Gitea's SSH is on a non-default port, adjust the clone URL and (Deploying from your own fork instead? Substitute its clone URL — the same
`ssh-keyscan -p <port>` accordingly.) deploy-key/access-token steps work the same way on GitHub, GitLab, Gitea,
and most other git hosts.)
**Alternative: a personal/deployment-user access token instead of a deploy **Alternative: a personal/deployment-user access token instead of a deploy
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
and clone over HTTPS with the token embedded in the URL: and clone over HTTPS with the token embedded in the URL:
```bash ```bash
sudo -u ds-chat git clone https://<TOKEN>@git.darksingularity.org/DarkSingularity/ds-chat.git /srv/ds-chat sudo -u ds-chat git clone https://<TOKEN>@github.com/ds-ksmith/DS-Chat.git /srv/ds-chat
``` ```
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
remote -v` shows it) — readable by root and the `ds-chat` user, not by remote -v` shows it) — readable by root and the `ds-chat` user, not by
anyone else under normal file permissions. `deploy/upgrade.sh`'s later anyone else under normal file permissions. `deploy/upgrade.sh`'s later
`git pull`s reuse this same authenticated URL automatically, no extra `git fetch`es reuse this same authenticated URL automatically, no extra
setup needed. Fine as long as the token is scoped to read-only access on setup needed. Fine as long as the token is scoped to read-only access on
just this repo. just this repo.
Either way, now that the repo is cloned: Either way, now that the repo is cloned, check out the latest release tag
rather than deploying whatever the default branch's tip happens to be —
`deploy/upgrade.sh` follows the same rule on every later upgrade (see §6),
so this keeps the very first deploy consistent with all the ones after it:
```bash ```bash
cd /srv/ds-chat
sudo -u ds-chat git fetch --tags
LATEST_TAG="$(sudo -u ds-chat git tag --sort=-creatordate | head -n1)"
sudo -u ds-chat git checkout --detach "$LATEST_TAG"
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
``` ```
@@ -232,7 +242,16 @@ admin sets it up.
`/ws`) whenever that directory exists — that's what lets Nginx Proxy `/ws`) whenever that directory exists — that's what lets Nginx Proxy
Manager forward the whole domain to one port with no custom path routing. Manager forward the whole domain to one port with no custom path routing.
Before building, copy `frontend/.env.example` to `frontend/.env.production`
and set `VITE_SOURCE_URL` to wherever *your* copy of the repo lives — see
that file's own comment for why this matters (AGPL-3.0 source-availability
compliance). Vite bakes this in at build time, so it needs to be in place
before `npm run build` runs, and needs re-running after any future change
to it.
```bash ```bash
sudo -u ds-chat cp /srv/ds-chat/frontend/.env.example /srv/ds-chat/frontend/.env.production
sudo -u ds-chat nano /srv/ds-chat/frontend/.env.production # set VITE_SOURCE_URL
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build' sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
``` ```
@@ -320,12 +339,15 @@ This is config in NPM's own UI/database, not a file this repo ships:
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
``` ```
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`, Fetches tags and checks out whichever one sorts newest (`git tag
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to --sort=-creatordate`) — deliberately not the default branch's tip, so
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops running this between releases is a safe no-op rather than pulling in
before restarting anything if an earlier step — most importantly a failed whatever's mid-flight on `main`. Then reinstalls backend deps, runs
migration — errors out, so a bad deploy doesn't take down the previously `alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
working one. curls `/api/health` to confirm it came back up. Fails loudly
(`set -euo pipefail`) and stops before restarting anything if an earlier
step — most importantly a failed migration — errors out, so a bad deploy
doesn't take down the previously working one.
Active users get disconnected for a few seconds during the restart and Active users get disconnected for a few seconds during the restart and
reconnect automatically (same reconnect logic as §4's NPM-timeout note) — reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
+2 -2
View File
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.> DS Chat, a self-hosted, real-time team chat service.
Copyright (C) <year> <name of author> Copyright (C) 2026 Keith Smith
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
+12 -1
View File
@@ -68,9 +68,20 @@ new message, or if you message them again yourself.
Type in the message box at the bottom of a room and press **Enter** to Type in the message box at the bottom of a room and press **Enter** to
send. Use **Shift+Enter** to add a line break without sending. send. Use **Shift+Enter** to add a line break without sending.
Each message shows when it was sent, in your local time. Messages from today
show just the time; older ones add the day — "Yesterday 4:04 PM", a weekday
like "Mon 4:04 PM" for the past week, then the date ("Oct 2, 4:04 PM", with
the year if it's not this one). Hover a time to see the full date.
## Formatting ## Formatting
Messages support Markdown: Messages support Markdown. Prefer buttons to typing the syntax? Click the
**Aa** button next to the emoji button in the composer to open a formatting
toolbar — it inserts the markdown below into your message for you (select some
text first to wrap it, or click a button again to take the formatting back
off). It stays collapsed until you open it, and remembers your choice.
Or type it yourself:
- `**bold**`, `*italic*`, `~~strikethrough~~` - `**bold**`, `*italic*`, `~~strikethrough~~`
- `` `inline code` `` and fenced code blocks (three backticks) - `` `inline code` `` and fenced code blocks (three backticks)
+6 -2
View File
@@ -58,8 +58,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# ASGITransport, used by the plain REST test fixtures -- only # ASGITransport, used by the plain REST test fixtures -- only
# TestClient's websocket_connect-based tests actually run lifespan). # TestClient's websocket_connect-based tests actually run lifespan).
# The background listener task genuinely needs a running event loop # The background listener task genuinely needs a running event loop
# though, so that part stays here. # though, so that part stays here. run_forever() (not listen() directly)
listener_task = asyncio.create_task(app.state.broadcaster.listen()) # -- #76: a bare listen() here died permanently and silently the moment
# its Redis connection ever dropped (e.g. Redis itself restarting),
# with nothing to notice or reconnect; run_forever() supervises it and
# restarts on failure instead.
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
yield yield
+83 -10
View File
@@ -1,13 +1,26 @@
import asyncio
import contextlib
import json import json
import logging
import uuid import uuid
from redis.asyncio import Redis from redis.asyncio import Redis
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
logger = logging.getLogger(__name__)
ROOM_CHANNEL_PREFIX = "room:" ROOM_CHANNEL_PREFIX = "room:"
USER_CHANNEL_PREFIX = "user:" USER_CHANNEL_PREFIX = "user:"
# #76: a run shorter than this before dying doesn't count as "stable" for
# resetting the backoff in run_forever() below -- long enough that a real,
# working connection has clearly settled in, short enough not to leave a
# just-recovered listener exposed to a full backoff climb if Redis blips
# again shortly after reconnecting.
STABLE_RUN_SECONDS = 60
MAX_BACKOFF_SECONDS = 30
class Broadcaster: class Broadcaster:
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5). """Cross-instance message fan-out (ARCHITECTURE.md phase 5).
@@ -36,6 +49,43 @@ class Broadcaster:
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None: async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload)) await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
async def run_forever(self) -> None:
"""Supervises listen(), restarting it if it ever dies.
#76's real root cause, found after the per-message try/except below
turned out not to be enough: this is the *one* pubsub listener for
the whole process (see main.py's lifespan), created once at startup
with nothing watching over it afterward. A connection-level failure
-- Redis restarting, a network blip between the app and Redis --
raises from the `async for` iteration in listen() itself, outside
that per-message guard, and previously killed this task permanently
and silently: HTTP/REST and login kept working fine (neither
touches this), but live message delivery was gone for every room
and every user on this instance until the whole process was
restarted. Confirmed live in production: Redis got restarted by an
OS update, and `PUBSUB NUMPAT` against it afterward showed zero
pattern subscriptions across all 4 gunicorn workers -- every one of
them had silently lost its listener with nothing to bring it back.
"""
backoff_seconds = 1.0
while True:
started_at = asyncio.get_event_loop().time()
try:
await self.listen()
logger.warning("Broadcaster.listen() exited without error -- restarting")
except asyncio.CancelledError:
raise
except Exception:
logger.error("Broadcaster.listen() failed -- restarting", exc_info=True)
ran_for = asyncio.get_event_loop().time() - started_at
# Sleep at the *current* backoff first, then grow it for next
# time -- computing the next value before sleeping would skip
# straight past the first (shortest) retry delay.
await asyncio.sleep(backoff_seconds)
backoff_seconds = (
1.0 if ran_for > STABLE_RUN_SECONDS else min(backoff_seconds * 2, MAX_BACKOFF_SECONDS)
)
async def listen(self) -> None: async def listen(self) -> None:
pubsub = self._redis.pubsub() pubsub = self._redis.pubsub()
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*") await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
@@ -43,14 +93,37 @@ class Broadcaster:
async for message in pubsub.listen(): async for message in pubsub.listen():
if message["type"] != "pmessage": if message["type"] != "pmessage":
continue continue
channel = message["channel"] # #76: this is the one pubsub listener for the whole process
payload = json.loads(message["data"]) # (see main.py's lifespan) -- ConnectionManager.broadcast/
if channel.startswith(ROOM_CHANNEL_PREFIX): # send_to_user already guard against one dead socket taking
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX)) # the rest of a single fan-out down, but this belt-and-
await self._manager.broadcast(room_id, payload) # suspenders catch is for anything else unexpected (a
elif channel.startswith(USER_CHANNEL_PREFIX): # malformed payload, e.g.) doing the same. An uncaught
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX)) # exception here previously meant *this whole async for*
await self._manager.send_to_user(user_id, payload) # loop died silently -- no more live delivery to any room on
# this instance, for anyone, until the process restarted.
try:
channel = message["channel"]
payload = json.loads(message["data"])
if channel.startswith(ROOM_CHANNEL_PREFIX):
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
await self._manager.broadcast(room_id, payload)
elif channel.startswith(USER_CHANNEL_PREFIX):
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
await self._manager.send_to_user(user_id, payload)
except Exception:
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
finally: finally:
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*") # #76: reached via a connection that's already dead just as
await pubsub.aclose() # often as a clean shutdown (that's the whole failure mode
# run_forever() now recovers from) -- punsubscribe/aclose over
# a broken connection would themselves raise, masking whatever
# actually killed the loop above and, since run_forever() only
# starts backing off *after* listen() returns/raises, delaying
# the reconnect for no benefit (a fresh pubsub object is about
# to be created on retry regardless of whether this cleanup
# succeeded).
with contextlib.suppress(Exception):
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
with contextlib.suppress(Exception):
await pubsub.aclose()
+33 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import uuid import uuid
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
@@ -31,6 +32,18 @@ router = APIRouter(tags=["ws"])
WS_UNAUTHENTICATED = 4401 WS_UNAUTHENTICATED = 4401
# #76: comfortably under the ~60s idle-connection timeout common to reverse
# proxies/load balancers sitting in front of this endpoint, and short enough
# that the frontend's own zombie-detection watchdog (useChatSocket.ts) can
# notice a dead connection and reconnect well within a session -- a socket
# can go silently dead (no close frame at all, e.g. a laptop sleeping or a
# NAT dropping an idle mapping) without this, and no live message reaches it
# again until *something* else finally triggers a close, which used to be
# "switch rooms and back" purely by accident (that remounts the message
# list, which refetches history over plain REST -- unrelated to the socket
# itself actually recovering).
WS_PING_INTERVAL_SECONDS = 30
class ClientEnvelope(BaseModel): class ClientEnvelope(BaseModel):
type: str type: str
@@ -124,7 +137,18 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
try: try:
while True: while True:
raw = await websocket.receive_json() try:
raw = await asyncio.wait_for(
websocket.receive_json(), timeout=WS_PING_INTERVAL_SECONDS
)
except asyncio.TimeoutError:
# Nothing received in a while -- ping rather than assume the
# connection's still good. A genuinely dead socket fails this
# send (raising, caught below same as any other disconnect);
# a merely quiet one just gets proded and the loop continues
# waiting.
await websocket.send_json({"type": "ping"})
continue
try: try:
try: try:
envelope = ClientEnvelope.model_validate(raw) envelope = ClientEnvelope.model_validate(raw)
@@ -146,6 +170,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
joined_rooms.add(envelope.room_id) joined_rooms.add(envelope.room_id)
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)}) await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
elif envelope.type == "pong":
# #76: the client's reply to our "ping" above -- nothing
# to do with it. Its only purpose is resetting the
# frontend's own liveness watchdog; the timeout on the
# receive_json() above already gets reset by receiving
# any frame at all, this one included.
pass
elif envelope.type == "leave": elif envelope.type == "leave":
if envelope.room_id is None: if envelope.room_id is None:
await websocket.send_json({"type": "error", "detail": "room_id required"}) await websocket.send_json({"type": "error", "detail": "room_id required"})
+19 -2
View File
@@ -1,8 +1,11 @@
import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import WebSocket from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager: class ConnectionManager:
"""Local, single-process WebSocket socket registry. """Local, single-process WebSocket socket registry.
@@ -43,8 +46,22 @@ class ConnectionManager:
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None: async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._rooms.get(room_id, ())): for websocket in list(self._rooms.get(room_id, ())):
await websocket.send_json(payload) # #76: one dead/racing socket (e.g. its own disconnect cleanup
# hasn't finished removing it from _rooms yet) must not take the
# rest of this broadcast down with it -- this runs inside
# Broadcaster.listen()'s single, never-restarted per-process
# pubsub loop, so an unhandled exception here previously meant
# one bad socket silently killed *live delivery for every room
# on this instance*, not just the one socket, until the process
# was restarted.
try:
await websocket.send_json(payload)
except Exception:
logger.warning("Dropping dead socket during broadcast to room %s", room_id, exc_info=True)
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None: async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._users.get(user_id, ())): for websocket in list(self._users.get(user_id, ())):
await websocket.send_json(payload) try:
await websocket.send_json(payload)
except Exception:
logger.warning("Dropping dead socket sending to user %s", user_id, exc_info=True)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ds-chat" name = "ds-chat"
version = "2026.9.3" version = "2026.10.9"
description = "DS Chat backend service" description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" } license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -0,0 +1,61 @@
import asyncio
import pytest
from app.ws.broadcaster import Broadcaster
async def test_run_forever_restarts_after_listen_raises(monkeypatch):
# #76: listen() dying (a dropped Redis connection, most commonly -- e.g.
# Redis itself restarting) must not permanently kill live delivery for
# the whole process. run_forever() has to notice and restart it, not
# just let the background task disappear silently while everything else
# (login, REST endpoints) keeps working fine.
broadcaster = Broadcaster(redis=None, manager=None)
calls = 0
async def fake_listen():
nonlocal calls
calls += 1
if calls < 3:
raise ConnectionError("redis went away")
# Stop the loop once the retry behavior below is proven, rather
# than looping forever in a test.
raise asyncio.CancelledError()
monkeypatch.setattr(broadcaster, "listen", fake_listen)
sleep_calls = []
async def fake_sleep(seconds):
sleep_calls.append(seconds)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
with pytest.raises(asyncio.CancelledError):
await broadcaster.run_forever()
assert calls == 3
# Slept once after each of the first two failures (exponential:
# 1s, then 2s) -- not after the third, which is the CancelledError
# that stops the loop before reaching the sleep.
assert sleep_calls == [1.0, 2.0]
async def test_run_forever_does_not_swallow_cancellation(monkeypatch):
# A genuine shutdown (the app's own lifespan cancels this task) must
# propagate immediately, not get treated as just another failure to
# retry after a backoff.
broadcaster = Broadcaster(redis=None, manager=None)
async def fake_listen():
raise asyncio.CancelledError()
monkeypatch.setattr(broadcaster, "listen", fake_listen)
async def fail_if_called(seconds):
raise AssertionError("should not sleep/retry on cancellation")
monkeypatch.setattr(asyncio, "sleep", fail_if_called)
with pytest.raises(asyncio.CancelledError):
await broadcaster.run_forever()
+18
View File
@@ -109,6 +109,24 @@ def test_history_tiebreaks_identical_timestamps_by_id(ws_client):
assert [m["content"] for m in history] == [m["content"] for m in expected_order] assert [m["content"] for m in history] == [m["content"] for m in expected_order]
def test_ws_sends_heartbeat_ping(ws_client, monkeypatch):
# #76: with nothing else sent on the connection, a "ping" must arrive
# proactively once the idle timeout elapses -- shrunk here so the test
# doesn't actually wait the real 30s. Replying "pong" (the frontend's
# own behavior) must be accepted silently and leave the connection
# fully usable afterward, not treated as an unknown message type.
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
username = _unique("alice")
_register(ws_client, username=username)
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
assert ws.receive_json() == {"type": "ping"}
ws.send_json({"type": "pong"})
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json() == {"type": "joined", "room_id": room["id"]}
def test_ws_message_without_join_errors(ws_client): def test_ws_message_without_join_errors(ws_client):
_register(ws_client, username=_unique("alice")) _register(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json() room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+11 -2
View File
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
FRONTEND_DIR="${REPO_DIR}/frontend" FRONTEND_DIR="${REPO_DIR}/frontend"
ENV_FILE="/etc/ds-chat/env" ENV_FILE="/etc/ds-chat/env"
echo "==> Pulling latest code" echo "==> Fetching latest release"
cd "$REPO_DIR" cd "$REPO_DIR"
git pull --ff-only git fetch --tags --force
LATEST_TAG="$(git tag --sort=-creatordate | head -n1)"
if [[ -z "$LATEST_TAG" ]]; then
echo "No tags found -- nothing to deploy" >&2
exit 1
fi
echo "Deploying $LATEST_TAG"
# Detached HEAD, not a branch checkout -- this directory only ever runs a
# tagged release, never whatever the default branch's tip happens to be.
git checkout --quiet --detach "$LATEST_TAG"
echo "==> Installing backend dependencies" echo "==> Installing backend dependencies"
cd "$BACKEND_DIR" cd "$BACKEND_DIR"
+7
View File
@@ -0,0 +1,7 @@
# Shown as the "Source code" link in the app's About screen -- required
# for AGPL-3.0 section 13 compliance once you deploy this (a link so users
# interacting with the app over the network can get the actual source,
# including any modifications you've made). The default below points at
# the upstream project -- fine if you're running it unmodified, but if
# you've forked or patched the code, point this at *your* copy instead.
VITE_SOURCE_URL=https://github.com/ds-ksmith/DS-Chat
+1 -1
View File
@@ -112,7 +112,7 @@ src/
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.) styles/tokens.css design tokens (default theme: colors, spacing, etc.)
sw.ts custom service worker (injectManifest): app-shell sw.ts custom service worker (injectManifest): app-shell
precache + NetworkFirst runtime caching, push/notificationclick precache + NetworkFirst runtime caching, push/notificationclick
handlers, SKIP_WAITING messaging for the update-prompt flow handlers, SKIP_WAITING messaging for the update-prompt flow
+953 -11
View File
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2026.9.3", "version": "2026.10.9",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"markdown-to-jsx": "^9.10.2", "markdown-to-jsx": "^9.10.2",
@@ -18,13 +19,16 @@
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^16.3.3",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"jsdom": "^30.1.1",
"oxlint": "^1.75.0", "oxlint": "^1.75.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.2.0", "vite": "^8.2.0",
"vitest": "^5.0.2",
"workbox-cacheable-response": "^7.4.1", "workbox-cacheable-response": "^7.4.1",
"workbox-expiration": "^7.4.1", "workbox-expiration": "^7.4.1",
"workbox-precaching": "^7.4.1", "workbox-precaching": "^7.4.1",
+11 -7
View File
@@ -10,8 +10,10 @@ interface AboutModalProps {
// users remotely through a computer network, you should also make sure // users remotely through a computer network, you should also make sure
// that it provides a way for users to get its source... its interface // that it provides a way for users to get its source... its interface
// could display a 'Source' link" -- this modal is that link, not just a // could display a 'Source' link" -- this modal is that link, not just a
// courtesy credits screen. // courtesy credits screen. Deliberately not a hardcoded URL: whoever
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat' // deploys this needs to point it at *their* copy of the repo (including
// any modifications), not the upstream project -- see frontend/.env.example.
const SOURCE_URL = import.meta.env.VITE_SOURCE_URL as string | undefined
export function AboutModal({ onClose }: AboutModalProps) { export function AboutModal({ onClose }: AboutModalProps) {
return ( return (
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
</a> </a>
. .
</p> </p>
<p className="about-modal-line"> {SOURCE_URL && (
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer"> <p className="about-modal-line">
Source code <a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
</a> Source code
</p> </a>
</p>
)}
<div className="modal-actions" style={{ marginTop: '1rem' }}> <div className="modal-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}> <button type="button" className="btn-secondary" onClick={onClose}>
+28
View File
@@ -105,6 +105,34 @@
cursor: not-allowed; cursor: not-allowed;
} }
.composer-format-toggle {
width: 36px;
height: 36px;
flex: none;
border-radius: var(--radius);
background: transparent;
color: var(--ds-muted);
border: 1px solid var(--ds-border);
font-size: 0.82rem;
font-weight: 700;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.composer-format-toggle:hover:not(:disabled),
.composer-format-toggle-open {
color: var(--ds-text);
border-color: var(--ds-accent);
}
.composer-format-toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.composer-attach-wrap { .composer-attach-wrap {
position: relative; position: relative;
flex: none; flex: none;
+42
View File
@@ -15,10 +15,13 @@ import { getUploadLimit } from '../api/uploads'
import { useCustomEmoji } from '../context/CustomEmojiContext' import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes' import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
import { formatFileSize } from '../lib/fileSize' import { formatFileSize } from '../lib/fileSize'
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from '../lib/formattingToolbarPref'
import { applyFormat, type FormatAction } from '../lib/markdownFormat'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji' import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker' import { EmojiPicker } from './EmojiPicker'
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete' import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
import { FormattingToolbar } from './FormattingToolbar'
import { MentionAutocomplete } from './MentionAutocomplete' import { MentionAutocomplete } from './MentionAutocomplete'
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete' import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
import './Composer.css' import './Composer.css'
@@ -136,6 +139,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0) const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false) const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const [toolbarOpen, setToolbarOpen] = useState(getFormattingToolbarOpen)
const { byShortcode: customEmojiByShortcode } = useCustomEmoji() const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -449,6 +453,29 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
}) })
} }
function toggleToolbar() {
const next = !toolbarOpen
setToolbarOpen(next)
setFormattingToolbarOpen(next)
}
// #75: the toolbar only ever edits the textarea's plain markdown text --
// nothing about what gets sent changes.
function applyFormatAction(action: FormatAction) {
const el = textareaRef.current
if (!el) return
const result = applyFormat(
{ value, start: el.selectionStart ?? value.length, end: el.selectionEnd ?? value.length },
action,
)
setValue(result.value)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(result.start, result.end)
autoGrow()
})
}
function insertEmoji(emoji: string) { function insertEmoji(emoji: string) {
const el = textareaRef.current const el = textareaRef.current
setEmojiPickerOpen(false) setEmojiPickerOpen(false)
@@ -517,6 +544,9 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
</div> </div>
)} )}
{uploadError && <div className="composer-status composer-error">{uploadError}</div>} {uploadError && <div className="composer-status composer-error">{uploadError}</div>}
{toolbarOpen && (
<FormattingToolbar id="composer-formatting-toolbar" disabled={isDisabled} onAction={applyFormatAction} />
)}
<div className="composer-box"> <div className="composer-box">
<input <input
ref={photoInputRef} ref={photoInputRef}
@@ -589,6 +619,18 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
/> />
)} )}
</div> </div>
<button
type="button"
className={`composer-format-toggle${toolbarOpen ? ' composer-format-toggle-open' : ''}`}
onClick={toggleToolbar}
disabled={isDisabled}
aria-label="Formatting options"
aria-expanded={toolbarOpen}
aria-controls="composer-formatting-toolbar"
title="Formatting options"
>
Aa
</button>
<div className="composer-textarea-wrap"> <div className="composer-textarea-wrap">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -0,0 +1,41 @@
.formatting-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
/* Spacing, not divider lines, between groups -- a border on every group
after the first leaves a dangling line at the start of the second row
whenever the toolbar wraps on a narrow screen. */
gap: 2px 14px;
}
.formatting-toolbar-group {
display: flex;
gap: 2px;
}
.formatting-toolbar-btn {
min-width: 30px;
height: 30px;
padding: 0 6px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--ds-muted);
font-size: 0.82rem;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
.formatting-toolbar-btn:hover:not(:disabled) {
background: var(--ds-surface-2);
color: var(--ds-text);
}
.formatting-toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,55 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FormatAction } from '../lib/markdownFormat'
import { FormattingToolbar } from './FormattingToolbar'
afterEach(cleanup)
const EXPECTED: [string, FormatAction][] = [
['Bold', { kind: 'inline', marker: '**' }],
['Italic', { kind: 'inline', marker: '*' }],
['Strikethrough', { kind: 'inline', marker: '~~' }],
['Inline code', { kind: 'inline', marker: '`' }],
['Code block', { kind: 'codeBlock' }],
['Link', { kind: 'link' }],
['Quote', { kind: 'prefix', prefix: 'quote' }],
['Bulleted list', { kind: 'prefix', prefix: 'bullet' }],
['Numbered list', { kind: 'prefix', prefix: 'ordered' }],
['Heading 1', { kind: 'heading', level: 1 }],
['Heading 2', { kind: 'heading', level: 2 }],
['Heading 3', { kind: 'heading', level: 3 }],
['Subscript', { kind: 'inline', marker: '~' }],
['Superscript', { kind: 'inline', marker: '^' }],
]
describe('FormattingToolbar', () => {
it.each(EXPECTED)('%s button calls onAction with its action', (label, action) => {
const onAction = vi.fn()
render(<FormattingToolbar id="t" onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: label }))
expect(onAction).toHaveBeenCalledTimes(1)
expect(onAction).toHaveBeenCalledWith(action)
})
it('disables every button when disabled, but leaves the help link usable', () => {
render(<FormattingToolbar id="t" disabled onAction={vi.fn()} />)
const buttons = screen.getAllByRole('button') as HTMLButtonElement[]
expect(buttons).toHaveLength(EXPECTED.length)
expect(buttons.every((b) => b.disabled)).toBe(true)
expect(screen.getByRole('link', { name: 'Formatting help' })).toBeTruthy()
})
it('opens help in a new tab so the draft in the composer is not lost', () => {
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
const help = screen.getByRole('link', { name: 'Formatting help' })
expect(help.getAttribute('href')).toBe('/help#formatting')
expect(help.getAttribute('target')).toBe('_blank')
expect(help.getAttribute('rel')).toContain('noopener')
})
it('keeps focus in the textarea by cancelling mousedown on buttons', () => {
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
// fireEvent returns false when the event's default was prevented.
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Bold' }))).toBe(false)
})
})
@@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
import type { FormatAction, InlineMarker } from '../lib/markdownFormat'
import './FormattingToolbar.css'
interface FormattingToolbarProps {
id: string
disabled?: boolean
onAction: (action: FormatAction) => void
}
interface ToolbarButton {
label: string
// Shows the syntax the button inserts -- the toolbar doubles as the
// "quick guide" for people who'd rather learn to type it themselves.
title: string
content: ReactNode
action: FormatAction
}
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
const GROUPS: ToolbarButton[][] = [
[
{ label: 'Bold', title: 'Bold: **text**', content: <b>B</b>, action: inline('**') },
{ label: 'Italic', title: 'Italic: *text*', content: <i>I</i>, action: inline('*') },
{ label: 'Strikethrough', title: 'Strikethrough: ~~text~~', content: <s>S</s>, action: inline('~~') },
],
[
{ label: 'Inline code', title: 'Inline code: `code`', content: '</>', action: inline('`') },
{ label: 'Code block', title: 'Code block: ``` on its own lines', content: '{ }', action: { kind: 'codeBlock' } },
],
[
{
label: 'Link',
title: 'Link: [text](url)',
content: (
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M8.5 11.5a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-.9.9M11.5 8.5a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l.9-.9"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
action: { kind: 'link' },
},
{ label: 'Quote', title: 'Quote: > text', content: '“', action: { kind: 'prefix', prefix: 'quote' } },
],
[
{ label: 'Bulleted list', title: 'Bulleted list: - item', content: '•', action: { kind: 'prefix', prefix: 'bullet' } },
{ label: 'Numbered list', title: 'Numbered list: 1. item', content: '1.', action: { kind: 'prefix', prefix: 'ordered' } },
],
[
{ label: 'Heading 1', title: 'Heading 1: # text', content: 'H1', action: { kind: 'heading', level: 1 } },
{ label: 'Heading 2', title: 'Heading 2: ## text', content: 'H2', action: { kind: 'heading', level: 2 } },
{ label: 'Heading 3', title: 'Heading 3: ### text', content: 'H3', action: { kind: 'heading', level: 3 } },
],
[
{ label: 'Subscript', title: 'Subscript: ~text~ (no spaces)', content: 'x₂', action: inline('~') },
{ label: 'Superscript', title: 'Superscript: ^text^ (no spaces)', content: 'x²', action: inline('^') },
],
]
export function FormattingToolbar({ id, disabled, onAction }: FormattingToolbarProps) {
return (
<div id={id} className="formatting-toolbar" role="toolbar" aria-label="Formatting">
{GROUPS.map((group) => (
<div key={group[0].label} className="formatting-toolbar-group">
{group.map((button) => (
<button
key={button.label}
type="button"
className="formatting-toolbar-btn"
title={button.title}
aria-label={button.label}
disabled={disabled}
// Keeps focus (and the selection) in the textarea -- a click
// on a button would otherwise blur it, and on mobile dismiss
// the keyboard.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction(button.action)}
>
{button.content}
</button>
))}
</div>
))}
{/* A new tab on purpose: the draft lives in Composer's own state, so
navigating this one away would lose it. */}
<a
className="formatting-toolbar-btn formatting-toolbar-help"
href="/help#formatting"
target="_blank"
rel="noopener noreferrer"
title="Formatting help (opens in a new tab)"
aria-label="Formatting help"
>
?
</a>
</div>
)
}
+5
View File
@@ -40,12 +40,17 @@
} }
.message-author { .message-author {
/* Lets a very long display name shrink/wrap instead of pushing the
(now longer, e.g. "Yesterday 4:04 PM") timestamp out of the row. */
min-width: 0;
font-size: 0.86rem; font-size: 0.86rem;
font-weight: 700; font-weight: 700;
color: var(--ds-text); color: var(--ds-text);
} }
.message-time { .message-time {
flex: none;
white-space: nowrap;
font-size: 0.68rem; font-size: 0.68rem;
color: var(--ds-muted); color: var(--ds-muted);
font-family: var(--mono); font-family: var(--mono);
+11 -19
View File
@@ -2,8 +2,10 @@ import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useLocalDay } from '../hooks/useLocalDay'
import { formatFileSize } from '../lib/fileSize' import { formatFileSize } from '../lib/fileSize'
import { avatarUrlFor, displayNameFor, senderColorIndex, statusFor } from '../lib/messageGrouping' import { formatMessageTime, formatMessageTimeTitle } from '../lib/formatMessageTime'
import { avatarUrlFor, displayNameFor, isGroupStart, senderColorIndex, statusFor } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types' import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker' import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal' import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
@@ -69,13 +71,6 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
// trigger a download the moment the browser tries to fetch it). // trigger a download the moment the browser tries to fetch it).
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg']) const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
const GROUP_BREAK_MS = 5 * 60 * 1000
interface VideoAttachmentProps { interface VideoAttachmentProps {
file: MessageFileInfo file: MessageFileInfo
roomId: string roomId: string
@@ -134,6 +129,7 @@ export function MessageList({
onDelete, onDelete,
}: MessageListProps) { }: MessageListProps) {
const { user } = useAuth() const { user } = useAuth()
const now = useLocalDay()
// #71: same viewer preference MessageContent applies to in-text emoji, // #71: same viewer preference MessageContent applies to in-text emoji,
// looked up separately here since a reaction pill isn't a descendant of // looked up separately here since a reaction pill isn't a descendant of
// that component's wrapper div (see EmojiGlyph's own comment). // that component's wrapper div (see EmojiGlyph's own comment).
@@ -218,19 +214,15 @@ export function MessageList({
// consecutive messages from the same sender only repeat the // consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run -- // avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages. Also breaks on // applies uniformly, including to your own messages. Also breaks on
// a long gap (see GROUP_BREAK_MS) so a message sent well after the // a long gap or a new calendar day -- see isGroupStart.
// rest of the run still gets its own visible timestamp. const groupStart = isGroupStart(prev, msg)
const isGroupStart =
!prev ||
prev.user_id !== msg.user_id ||
new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS
const editing = editingId === msg.id const editing = editingId === msg.id
const deleted = !!msg.deleted_at const deleted = !!msg.deleted_at
return ( return (
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}> <div key={msg.id} className={`message-row${groupStart ? ' message-row-start' : ''}`}>
<div className="message-avatar-slot"> <div className="message-avatar-slot">
{isGroupStart && ( {groupStart && (
<UserAvatar <UserAvatar
username={msg.username} username={msg.username}
colorIndex={senderColorIndex(msg.username, members)} colorIndex={senderColorIndex(msg.username, members)}
@@ -240,11 +232,11 @@ export function MessageList({
)} )}
</div> </div>
<div className="message-content"> <div className="message-content">
{isGroupStart && ( {groupStart && (
<div className="message-header"> <div className="message-header">
<span className="message-author">{displayNameFor(msg.username, members)}</span> <span className="message-author">{displayNameFor(msg.username, members)}</span>
<span className="message-time"> <span className="message-time" title={formatMessageTimeTitle(msg.created_at)}>
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} {formatMessageTime(msg.created_at, now)}
</span> </span>
</div> </div>
)} )}
@@ -0,0 +1,68 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as desktopBridge from '../lib/desktopBridge'
import { useDesktopRoomBridge } from './useDesktopRoomBridge'
describe('useDesktopRoomBridge', () => {
beforeEach(() => {
vi.spyOn(desktopBridge, 'notifyRoomOpened')
vi.spyOn(desktopBridge, 'notifyUnreadCount')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('calls roomOpened once when entering a room', () => {
renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
initialProps: { roomId: 'room-1', count: 0 },
})
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
})
it('does not call roomOpened again on a rerender with the same roomId', () => {
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
initialProps: { roomId: 'room-1', count: 0 },
})
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
// Unrelated rerender -- unreadRoomCount changed, roomId did not.
rerender({ roomId: 'room-1', count: 3 })
rerender({ roomId: 'room-1', count: 3 })
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
})
it('calls roomOpened again on a genuine transition to a different room', () => {
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
initialProps: { roomId: 'room-1', count: 0 },
})
rerender({ roomId: 'room-2', count: 0 })
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(2)
})
it('does not call roomOpened when there is no room id', () => {
renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
initialProps: { roomId: undefined as string | undefined, count: 0 },
})
expect(desktopBridge.notifyRoomOpened).not.toHaveBeenCalled()
})
it('sends the unread count whenever it changes, independent of roomOpened', () => {
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
initialProps: { roomId: undefined as string | undefined, count: 0 },
})
expect(desktopBridge.notifyUnreadCount).toHaveBeenCalledWith(0)
rerender({ roomId: undefined, count: 2 })
expect(desktopBridge.notifyUnreadCount).toHaveBeenCalledWith(2)
expect(desktopBridge.notifyRoomOpened).not.toHaveBeenCalled()
})
})
describe('desktop bridge functions with no window.dsDesktop', () => {
it('remain optional -- calling them does not throw when the bridge is absent', () => {
expect(window.dsDesktop).toBeUndefined()
expect(() => desktopBridge.notifyRoomOpened()).not.toThrow()
expect(() => desktopBridge.notifyUnreadCount(5)).not.toThrow()
})
})
@@ -0,0 +1,26 @@
import { useEffect } from 'react'
import { notifyRoomOpened, notifyUnreadCount } from '../lib/desktopBridge'
// Tells the Electron desktop wrapper (a) when the user has genuinely
// entered a room, so it can clear that room's tray indicator, and (b) the
// current unread-room count, so the tray badge stays accurate. Both are
// no-ops in every browser build (see desktopBridge.ts).
//
// roomOpened must fire only on a genuine transition to a real room id --
// never merely because the window was shown, focused, restored, or opened
// from the tray, and never when no room is open at all. Depending on just
// the id (a primitive, not the room object) is what makes this safe to
// call from a component that rerenders often: an unrelated rerender that
// leaves activeRoomId unchanged doesn't rerun the effect, so there's no
// separate de-duplication to get wrong here -- it falls out of React's own
// effect-dependency comparison.
export function useDesktopRoomBridge(activeRoomId: string | undefined, unreadRoomCount: number): void {
useEffect(() => {
if (!activeRoomId) return
notifyRoomOpened()
}, [activeRoomId])
useEffect(() => {
notifyUnreadCount(unreadRoomCount)
}, [unreadRoomCount])
}
+67
View File
@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocalDay } from './useLocalDay'
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
}
describe('useLocalDay', () => {
beforeEach(() => {
vi.useFakeTimers()
setVisibility('visible')
})
afterEach(() => {
vi.useRealTimers()
// Remove the own-property override so jsdom's real getter shows through again.
delete (document as unknown as Record<string, unknown>).visibilityState
})
it('rolls over to the next day when local midnight passes', () => {
vi.setSystemTime(new Date(2026, 9, 9, 23, 59, 30))
const { result } = renderHook(() => useLocalDay())
expect(result.current.getDate()).toBe(9)
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(result.current.getDate()).toBe(10)
})
it('refreshes when the tab becomes visible after the clock moved a day (e.g. a sleeping laptop)', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
// The clock jumps without any timer firing in between.
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.getDate()).toBe(10)
})
it('keeps the same value (no rerender) when visibility changes on the same day', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
const first = result.current
vi.setSystemTime(new Date(2026, 9, 9, 18, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current).toBe(first)
})
it('ignores visibility changes while the tab is hidden', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
setVisibility('hidden')
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.getDate()).toBe(9)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { isSameLocalDay } from '../lib/formatMessageTime'
function msUntilNextLocalMidnight(now: Date): number {
// One second past, so the timer never lands a hair before midnight.
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1)
return next.getTime() - now.getTime()
}
// A `now` that only changes when the local calendar day does -- at midnight,
// and when a tab becomes visible again (a laptop that slept through
// midnight). Without it, a tab left open overnight keeps showing yesterday's
// messages as a bare "4:04 PM" until something unrelated rerenders the list.
export function useLocalDay(): Date {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
function refresh() {
const current = new Date()
// Same state object when the day hasn't changed, so no rerender.
setNow((prev) => (isSameLocalDay(prev, current) ? prev : current))
}
function schedule() {
timer = setTimeout(() => {
refresh()
schedule()
}, msUntilNextLocalMidnight(new Date()))
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') refresh()
}
schedule()
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
clearTimeout(timer)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
return now
}
+16
View File
@@ -15,6 +15,12 @@ declare global {
interface Window { interface Window {
dsDesktop?: { dsDesktop?: {
setUnreadCount?(unreadRoomCount: number): void setUnreadCount?(unreadRoomCount: number): void
// Tells the tray/dock icon the user has genuinely entered a room --
// the desktop wrapper's own signal to clear that room's tray
// indicator. Deliberately not fired just because the window was
// shown/focused/restored, or opened from the tray: none of those mean
// the user actually looked at the room the unread message is in.
roomOpened?(): void
showNotification?(notification: DesktopNotificationRequest): void showNotification?(notification: DesktopNotificationRequest): void
onNotificationClick?(callback: (roomId: string) => void): () => void onNotificationClick?(callback: (roomId: string) => void): () => void
} }
@@ -38,6 +44,16 @@ export function isDesktopClickListenerSupported(): boolean {
return typeof window.dsDesktop?.onNotificationClick === 'function' return typeof window.dsDesktop?.onNotificationClick === 'function'
} }
// Both no-op silently if the bridge or this specific method isn't present,
// same as showDesktopNotification below -- callers never need to guard.
export function notifyRoomOpened(): void {
window.dsDesktop?.roomOpened?.()
}
export function notifyUnreadCount(unreadRoomCount: number): void {
window.dsDesktop?.setUnreadCount?.(unreadRoomCount)
}
// No-ops silently if the bridge or this specific method isn't present -- // No-ops silently if the bridge or this specific method isn't present --
// callers don't need to guard, matching the rest of this module's // callers don't need to guard, matching the rest of this module's
// capability-detect-per-method philosophy (see #49's "feature-test each // capability-detect-per-method philosophy (see #49's "feature-test each
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { formatMessageTime, formatMessageTimeTitle, isSameLocalDay } from './formatMessageTime'
// Local-time constructors throughout, and expected text built with the same
// locale calls the code uses -- so these hold in any timezone and any ICU
// locale instead of hardcoding "3:00 PM".
const NOW = new Date(2026, 9, 9, 15, 0) // Fri Oct 9 2026, 3:00 PM local
const iso = (d: Date) => d.toISOString()
const timeText = (d: Date) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
const weekday = (d: Date) => d.toLocaleDateString([], { weekday: 'short' })
describe('formatMessageTime', () => {
it('shows just the time for a message sent today', () => {
const morning = new Date(2026, 9, 9, 8, 5)
expect(formatMessageTime(iso(morning), NOW)).toBe(timeText(morning))
})
it('prefixes "Yesterday" for yesterday', () => {
const d = new Date(2026, 9, 8, 23, 59)
expect(formatMessageTime(iso(d), NOW)).toBe(`Yesterday ${timeText(d)}`)
})
it('treats 11:59 PM vs 12:03 AM as different days despite being minutes apart', () => {
const justBeforeMidnight = new Date(2026, 9, 8, 23, 59)
const justAfterMidnight = new Date(2026, 9, 9, 0, 3)
expect(formatMessageTime(iso(justBeforeMidnight), justAfterMidnight)).toBe(
`Yesterday ${timeText(justBeforeMidnight)}`,
)
expect(formatMessageTime(iso(justAfterMidnight), justAfterMidnight)).toBe(timeText(justAfterMidnight))
})
it('uses the short weekday for 2 to 6 days ago', () => {
const twoDays = new Date(2026, 9, 7, 12, 0)
const sixDays = new Date(2026, 9, 3, 12, 0)
expect(formatMessageTime(iso(twoDays), NOW)).toBe(`${weekday(twoDays)} ${timeText(twoDays)}`)
expect(formatMessageTime(iso(sixDays), NOW)).toBe(`${weekday(sixDays)} ${timeText(sixDays)}`)
})
it('switches to a month and day at 7 days, without the year when it is this year', () => {
const d = new Date(2026, 9, 2, 12, 0)
const label = formatMessageTime(iso(d), NOW)
expect(label).toBe(`${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${timeText(d)}`)
expect(label).not.toContain('2026')
})
it('adds the year for a message from a different year', () => {
const d = new Date(2025, 11, 25, 9, 30)
const label = formatMessageTime(iso(d), NOW)
expect(label).toBe(
`${d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })}, ${timeText(d)}`,
)
expect(label).toContain('2025')
})
it('counts Dec 31 -> Jan 1 as yesterday', () => {
const d = new Date(2026, 11, 31, 22, 0)
expect(formatMessageTime(iso(d), new Date(2027, 0, 1, 10, 0))).toBe(`Yesterday ${timeText(d)}`)
})
it('treats a slightly-future timestamp (clock skew) as today', () => {
const tomorrow = new Date(2026, 9, 10, 9, 0)
expect(formatMessageTime(iso(tomorrow), NOW)).toBe(timeText(tomorrow))
})
})
describe('formatMessageTimeTitle', () => {
it('includes the full date, with the year', () => {
expect(formatMessageTimeTitle(iso(new Date(2026, 9, 2, 16, 4)))).toContain('2026')
})
})
describe('isSameLocalDay', () => {
it('is false across midnight and true within a day', () => {
expect(isSameLocalDay(new Date(2026, 9, 8, 23, 59), new Date(2026, 9, 9, 0, 1))).toBe(false)
expect(isSameLocalDay(new Date(2026, 9, 9, 0, 1), new Date(2026, 9, 9, 23, 59))).toBe(true)
})
it('accepts ISO strings as well as Dates', () => {
expect(isSameLocalDay(iso(new Date(2026, 9, 9, 8, 0)), new Date(2026, 9, 9, 20, 0))).toBe(true)
})
})
+44
View File
@@ -0,0 +1,44 @@
// "Today" is the viewer's local calendar day, so everything here goes through
// local-time getters, never UTC.
type DateLike = Date | string
function toDate(d: DateLike): Date {
return d instanceof Date ? d : new Date(d)
}
// A calendar-day number, not elapsed milliseconds: built from the local
// y/m/d via Date.UTC so a 23- or 25-hour DST day still counts as one day,
// and 11:58 PM vs 12:01 AM (3 minutes apart) correctly counts as 1 day apart.
function dayNumber(d: Date): number {
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000)
}
export function isSameLocalDay(a: DateLike, b: DateLike): boolean {
return dayNumber(toDate(a)) === dayNumber(toDate(b))
}
// A bare "4:04 PM" gives no way to tell today's message from one sent last
// week. Today stays time-only; anything older gains just enough date to
// place it.
export function formatMessageTime(iso: string, now: Date = new Date()): string {
const sent = new Date(iso)
const time = sent.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
// <= 0 also covers a timestamp slightly in the future (clock skew between
// server and this device) -- better shown as today than as a negative age.
const daysAgo = dayNumber(now) - dayNumber(sent)
if (daysAgo <= 0) return time
if (daysAgo === 1) return `Yesterday ${time}`
// Under 7 so the weekday is never the same as today's (7 days ago would be).
if (daysAgo < 7) return `${sent.toLocaleDateString([], { weekday: 'short' })} ${time}`
const date = sent.toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: sent.getFullYear() === now.getFullYear() ? undefined : 'numeric',
})
return `${date}, ${time}`
}
// Hover text: the one place the full, unambiguous date is always available.
export function formatMessageTimeTitle(iso: string): string {
return new Date(iso).toLocaleString([], { dateStyle: 'full', timeStyle: 'short' })
}
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from './formattingToolbarPref'
afterEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('formatting toolbar preference', () => {
it('is collapsed by default', () => {
expect(getFormattingToolbarOpen()).toBe(false)
})
it('remembers being opened and closed', () => {
setFormattingToolbarOpen(true)
expect(getFormattingToolbarOpen()).toBe(true)
setFormattingToolbarOpen(false)
expect(getFormattingToolbarOpen()).toBe(false)
})
it('falls back to collapsed when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(getFormattingToolbarOpen()).toBe(false)
expect(() => setFormattingToolbarOpen(true)).not.toThrow()
})
})
+21
View File
@@ -0,0 +1,21 @@
const KEY = 'ds-chat-formatting-toolbar-open'
// Collapsed unless the user has opened it before -- and remembered, because
// Composer remounts on every room switch and would otherwise snap shut each
// time.
export function getFormattingToolbarOpen(): boolean {
try {
return localStorage.getItem(KEY) === 'true'
} catch {
return false
}
}
export function setFormattingToolbarOpen(open: boolean): void {
try {
localStorage.setItem(KEY, String(open))
} catch {
// storage unavailable (private browsing, quota) -- the toolbar just
// won't remember its state, not fatal.
}
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import { applyFormat, type FormatAction, type InlineMarker, type TextState } from './markdownFormat'
// ⟦ and ⟧ mark the selection, so cases read as plain strings -- an empty
// pair (⟦⟧) is a bare cursor.
const L = '⟦'
const R = '⟧'
function parse(s: string): TextState {
const start = s.indexOf(L)
const end = s.indexOf(R) - 1
return { value: s.replace(L, '').replace(R, ''), start, end }
}
function render({ value, start, end }: TextState): string {
return value.slice(0, start) + L + value.slice(start, end) + R + value.slice(end)
}
function fmt(s: string, action: FormatAction): string {
return render(applyFormat(parse(s), action))
}
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
describe('inline wrap', () => {
it('wraps the selection and keeps the inner text selected', () => {
expect(fmt('say ⟦hi⟧ now', inline('**'))).toBe('say **⟦hi⟧** now')
})
it('inserts an empty pair with the cursor between when nothing is selected', () => {
expect(fmt('say ⟦⟧now', inline('**'))).toBe('say **⟦⟧**now')
})
it('unwraps when the markers sit just outside the selection', () => {
expect(fmt('say **⟦hi⟧** now', inline('**'))).toBe('say ⟦hi⟧ now')
})
it('unwraps when the selection includes the markers', () => {
expect(fmt('say ⟦**hi**⟧ now', inline('**'))).toBe('say ⟦hi⟧ now')
})
it('keeps markers outside leading/trailing whitespace in the selection', () => {
expect(fmt('a⟦ hi ⟧b', inline('**'))).toBe('a **⟦hi⟧** b')
})
it('italic on a bold selection adds italic rather than stripping bold', () => {
expect(fmt('x **⟦hi⟧** y', inline('*'))).toBe('x ***⟦hi⟧*** y')
})
it('removes just bold from bold+italic', () => {
expect(fmt('x ***⟦hi⟧*** y', inline('**'))).toBe('x *⟦hi⟧* y')
})
it('removes just italic from bold+italic', () => {
expect(fmt('x ***⟦hi⟧*** y', inline('*'))).toBe('x **⟦hi⟧** y')
})
it('strikethrough toggles off without being confused by subscript', () => {
expect(fmt('x ~~⟦hi⟧~~ y', inline('~~'))).toBe('x ⟦hi⟧ y')
expect(fmt('a ⟦hi⟧ b', inline('~'))).toBe('a ~⟦hi⟧~ b')
})
it('handles inline code and superscript', () => {
expect(fmt('run ⟦ls⟧', inline('`'))).toBe('run `⟦ls⟧`')
expect(fmt('x⟦2⟧', inline('^'))).toBe('x^⟦2⟧^')
})
it.each(['**', '*', '~~', '~', '`', '^'] as InlineMarker[])('%s: applying twice restores the original', (marker) => {
const original = parse('a ⟦b⟧ c')
const once = applyFormat(original, inline(marker))
expect(once.value).not.toBe(original.value)
expect(applyFormat(once, inline(marker))).toEqual(original)
})
})
describe('line prefixes', () => {
it('prefixes the current line and moves the cursor with it', () => {
expect(fmt('hello⟦⟧', { kind: 'prefix', prefix: 'quote' })).toBe('> hello⟦⟧')
expect(fmt('one\ntw⟦⟧o', { kind: 'prefix', prefix: 'quote' })).toBe('one\n> tw⟦⟧o')
})
it('starts a list on a blank line', () => {
expect(fmt('⟦⟧', { kind: 'prefix', prefix: 'bullet' })).toBe('- ⟦⟧')
})
it('prefixes every selected line, then toggles them back off', () => {
const bullet: FormatAction = { kind: 'prefix', prefix: 'bullet' }
expect(fmt('⟦a\nb⟧', bullet)).toBe('⟦- a\n- b⟧')
expect(fmt('⟦- a\n- b⟧', bullet)).toBe('⟦a\nb⟧')
})
it('numbers lines sequentially, skipping blank lines, and renumbers existing numbers', () => {
const ordered: FormatAction = { kind: 'prefix', prefix: 'ordered' }
expect(fmt('⟦a\n\nb⟧', ordered)).toBe('⟦1. a\n\n2. b⟧')
expect(fmt('⟦1. a\nb⟧', ordered)).toBe('⟦1. a\n2. b⟧')
expect(fmt('⟦1. a\n2. b⟧', ordered)).toBe('⟦a\nb⟧')
})
it('does not include a line the selection only touches at its very start', () => {
expect(fmt('a\n⟦b\n⟧c', { kind: 'prefix', prefix: 'bullet' })).toBe('a\n⟦- b⟧\nc')
})
})
describe('headings', () => {
it('adds, replaces, and toggles off the heading level', () => {
expect(fmt('title⟦⟧', { kind: 'heading', level: 2 })).toBe('## title⟦⟧')
expect(fmt('⟦# hi⟧', { kind: 'heading', level: 2 })).toBe('⟦## hi⟧')
expect(fmt('⟦## hi⟧', { kind: 'heading', level: 1 })).toBe('⟦# hi⟧')
expect(fmt('⟦## hi⟧', { kind: 'heading', level: 2 })).toBe('⟦hi⟧')
})
})
describe('code block', () => {
it('inserts an empty fenced block with the cursor on the blank line', () => {
expect(fmt('⟦⟧', { kind: 'codeBlock' })).toBe('```\n⟦⟧\n```')
})
it('puts the fences on their own lines when the selection is mid-line', () => {
expect(fmt('see ⟦x = 1⟧ here', { kind: 'codeBlock' })).toBe('see \n```\n⟦x = 1⟧\n```\n here')
})
it('does not double the newline when the selection already ends with one', () => {
expect(fmt('⟦a\n⟧b', { kind: 'codeBlock' })).toBe('```\n⟦a⟧\n```\nb')
})
})
describe('link', () => {
it('wraps the selection and selects the placeholder url', () => {
expect(fmt('see ⟦docs⟧', { kind: 'link' })).toBe('see [docs](⟦url⟧)')
})
it('treats a selected url as the target and leaves the cursor in the text brackets', () => {
expect(fmt('⟦https://example.com⟧', { kind: 'link' })).toBe('[⟦⟧](https://example.com)')
})
it('inserts a placeholder link with its text selected when nothing is selected', () => {
expect(fmt('a ⟦⟧', { kind: 'link' })).toBe('a [⟦link text⟧](url)')
})
})
+204
View File
@@ -0,0 +1,204 @@
// #75: pure text transforms behind the composer's formatting toolbar. Each
// takes the textarea's value plus its selection and returns the same shape,
// so Composer just applies the result -- keeping this free of React/DOM is
// what makes the fiddly selection math directly unit-testable.
export interface TextState {
value: string
start: number
end: number
}
export type InlineMarker = '**' | '*' | '~~' | '`' | '~' | '^'
export type FormatAction =
| { kind: 'inline'; marker: InlineMarker }
| { kind: 'prefix'; prefix: 'quote' | 'bullet' | 'ordered' }
| { kind: 'heading'; level: 1 | 2 | 3 }
| { kind: 'codeBlock' }
| { kind: 'link' }
// Markers whose doubled form is a *different* format (`**` bold vs `*`
// italic, `~~` strikethrough vs `~` subscript), so "is this already
// wrapped?" has to look at how long the run of marker characters is rather
// than just whether one is present.
const DOUBLED_IS_DIFFERENT = new Set(['*', '~'])
// A run of 3 `*` is bold+italic: bold (2) and italic (1) are both "present".
function hasMarker(marker: string, run: number): boolean {
if (marker.length === 2) return run >= 2
return DOUBLED_IS_DIFFERENT.has(marker[0]) ? run % 2 === 1 : run >= 1
}
function applyInline(state: TextState, marker: InlineMarker): TextState {
const { value, start, end } = state
const ch = marker[0]
const m = marker.length
const sel = value.slice(start, end)
// Selection includes the markers themselves (e.g. the user selected the
// whole `**word**`) -- unwrap inside the selection.
if (sel.length > 0) {
let lead = 0
while (lead < sel.length && sel[lead] === ch) lead++
let trail = 0
while (trail < sel.length - lead && sel[sel.length - 1 - trail] === ch) trail++
const run = Math.min(lead, trail)
if (run > 0 && hasMarker(marker, run) && sel.length > 2 * m) {
const inner = sel.slice(m, sel.length - m)
return { value: value.slice(0, start) + inner + value.slice(end), start, end: start + inner.length }
}
}
// Markers sit just outside the selection. Skipped when the selection has
// edge whitespace: markers adjacent to it belong to neighbouring spans
// (`*a* and *b*` with " and " selected), not to this selection.
const edgeWhitespace = sel.length > 0 && (/^\s/.test(sel) || /\s$/.test(sel))
if (!edgeWhitespace) {
let before = 0
while (start - before - 1 >= 0 && value[start - before - 1] === ch) before++
let after = 0
while (end + after < value.length && value[end + after] === ch) after++
const run = Math.min(before, after)
if (run > 0 && hasMarker(marker, run)) {
return {
value: value.slice(0, start - m) + sel + value.slice(end + m),
start: start - m,
end: end - m,
}
}
}
// Nothing selected (or only whitespace): drop in an empty pair, cursor between.
if (sel.trim() === '') {
return {
value: value.slice(0, start) + marker + marker + value.slice(start),
start: start + m,
end: start + m,
}
}
// Markdown doesn't treat `** word**` as bold -- keep the markers hugging
// the text, outside any whitespace the selection happens to include.
const innerStart = start + (sel.length - sel.trimStart().length)
const innerEnd = end - (sel.length - sel.trimEnd().length)
return {
value:
value.slice(0, innerStart) + marker + value.slice(innerStart, innerEnd) + marker + value.slice(innerEnd),
start: innerStart + m,
end: innerEnd + m,
}
}
// The whole lines a selection touches. A selection that ends right after a
// newline (i.e. at the very start of the next line) doesn't count that line.
function lineBounds(value: string, start: number, end: number) {
const lineStart = start === 0 ? 0 : value.lastIndexOf('\n', start - 1) + 1
const effectiveEnd = end > start && value[end - 1] === '\n' ? end - 1 : end
const nextNewline = value.indexOf('\n', effectiveEnd)
return { lineStart, lineEnd: nextNewline === -1 ? value.length : nextNewline }
}
function transformLines(state: TextState, transform: (lines: string[]) => string[]): TextState {
const { value, start, end } = state
const { lineStart, lineEnd } = lineBounds(value, start, end)
const oldBlock = value.slice(lineStart, lineEnd)
const newBlock = transform(oldBlock.split('\n')).join('\n')
const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd)
if (start === end) {
const cursor = Math.max(lineStart, start + (newBlock.length - oldBlock.length))
return { value: next, start: cursor, end: cursor }
}
// Keep the affected lines selected so the same button toggles it back off.
return { value: next, start: lineStart, end: lineStart + newBlock.length }
}
const ORDERED_RE = /^\d+\. /
function applyPrefix(state: TextState, kind: 'quote' | 'bullet' | 'ordered'): TextState {
const marker = kind === 'quote' ? '> ' : '- '
const has = (line: string) => (kind === 'ordered' ? ORDERED_RE.test(line) : line.startsWith(marker))
const strip = (line: string) => (kind === 'ordered' ? line.replace(ORDERED_RE, '') : line.slice(marker.length))
return transformLines(state, (lines) => {
// Blank lines inside a multi-line selection are paragraph breaks, not
// list items -- only a lone blank line (cursor on an empty line) gets one.
const multi = lines.length > 1
const skip = (line: string) => multi && line === ''
const targets = lines.filter((line) => !skip(line))
if (targets.length > 0 && targets.every(has)) {
return lines.map((line) => (has(line) ? strip(line) : line))
}
let n = 0
return lines.map((line) => {
if (skip(line)) return line
n++
if (kind === 'ordered') return `${n}. ${strip(line)}`
return has(line) ? line : marker + line
})
})
}
const HEADING_RE = /^#{1,6} /
function applyHeading(state: TextState, level: 1 | 2 | 3): TextState {
const prefix = '#'.repeat(level) + ' '
return transformLines(state, (lines) => {
const multi = lines.length > 1
const skip = (line: string) => multi && line === ''
const targets = lines.filter((line) => !skip(line))
if (targets.length > 0 && targets.every((line) => line.startsWith(prefix))) {
return lines.map((line) => (line.startsWith(prefix) ? line.slice(prefix.length) : line))
}
return lines.map((line) => (skip(line) ? line : prefix + line.replace(HEADING_RE, '')))
})
}
// A fence only counts at the start of a line, so mid-line cursors get a
// newline inserted first.
function applyCodeBlock(state: TextState): TextState {
const { value, start, end } = state
const before = value.slice(0, start)
const after = value.slice(end)
const sel = value.slice(start, end).replace(/\n$/, '')
const lead = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
const trail = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
const next = `${before}${lead}\`\`\`\n${sel}\n\`\`\`${trail}${after}`
const innerStart = before.length + lead.length + 4
return { value: next, start: innerStart, end: innerStart + sel.length }
}
function applyLink(state: TextState): TextState {
const { value, start, end } = state
const sel = value.slice(start, end)
let text: string
let selStart: number
let selEnd: number
if (/^https?:\/\/\S+$/.test(sel)) {
text = `[](${sel})`
selStart = selEnd = start + 1
} else if (sel) {
text = `[${sel}](url)`
selStart = start + sel.length + 3
selEnd = selStart + 3
} else {
text = '[link text](url)'
selStart = start + 1
selEnd = selStart + 9
}
return { value: value.slice(0, start) + text + value.slice(end), start: selStart, end: selEnd }
}
export function applyFormat(state: TextState, action: FormatAction): TextState {
switch (action.kind) {
case 'inline':
return applyInline(state, action.marker)
case 'prefix':
return applyPrefix(state, action.prefix)
case 'heading':
return applyHeading(state, action.level)
case 'codeBlock':
return applyCodeBlock(state)
case 'link':
return applyLink(state)
}
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { GROUP_BREAK_MS, isGroupStart } from './messageGrouping'
const msg = (user: string, d: Date) => ({ user_id: user, created_at: d.toISOString() })
describe('isGroupStart', () => {
const t = new Date(2026, 9, 9, 10, 0, 0)
const later = (ms: number) => new Date(t.getTime() + ms)
it('starts a group for the first message', () => {
expect(isGroupStart(undefined, msg('a', t))).toBe(true)
})
it('starts a group when the sender changes', () => {
expect(isGroupStart(msg('a', t), msg('b', later(1000)))).toBe(true)
})
it('keeps the same sender grouped within the break window', () => {
expect(isGroupStart(msg('a', t), msg('a', later(60_000)))).toBe(false)
})
it('groups at exactly the break threshold and splits just past it', () => {
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS)))).toBe(false)
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS + 1000)))).toBe(true)
})
it('measures the gap from the previous message, not the first in the run', () => {
const fourMin = 4 * 60_000
expect(isGroupStart(msg('a', later(fourMin)), msg('a', later(2 * fourMin)))).toBe(false)
expect(isGroupStart(msg('a', later(2 * fourMin)), msg('a', later(3 * fourMin)))).toBe(false)
})
it('starts a new group when the same sender crosses midnight, even a few minutes apart', () => {
const before = new Date(2026, 9, 8, 23, 58)
const after = new Date(2026, 9, 9, 0, 1)
expect(after.getTime() - before.getTime()).toBeLessThan(GROUP_BREAK_MS)
expect(isGroupStart(msg('a', before), msg('a', after))).toBe(true)
})
})
+24
View File
@@ -1,7 +1,31 @@
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { hashIndex } from './avatar' import { hashIndex } from './avatar'
import { isSameLocalDay } from './formatMessageTime'
import type { RoomMember } from '../types' import type { RoomMember } from '../types'
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
export const GROUP_BREAK_MS = 5 * 60 * 1000
interface Groupable {
user_id: string
created_at: string
}
// Whether `msg` gets its own avatar/name/timestamp header, vs. continuing
// `prev`'s group. A new calendar day always breaks the group: now that
// timestamps can carry a date, a same-sender run that crosses midnight
// (11:58 PM, then 12:01 AM) would otherwise leave the later message under a
// header showing the wrong day.
export function isGroupStart(prev: Groupable | undefined, msg: Groupable): boolean {
if (!prev || prev.user_id !== msg.user_id) return true
if (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS) return true
return !isSameLocalDay(prev.created_at, msg.created_at)
}
export function senderColorIndex(username: string, members: RoomMember[]): number { export function senderColorIndex(username: string, members: RoomMember[]): number {
const idx = members.findIndex((m) => m.username === username) const idx = members.findIndex((m) => m.username === username)
if (idx >= 0) return idx if (idx >= 0) return idx
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { MyRoomItem } from '../types'
import { clearRoomIndicators } from './roomIndicators'
function makeRoom(overrides: Partial<MyRoomItem> & { id: string }): MyRoomItem {
return {
name: overrides.id,
description: null,
is_private: false,
is_dm: false,
is_archived: false,
owner_id: 'owner',
created_at: '2026-01-01T00:00:00Z',
role: 'member',
has_unread: false,
has_mention: false,
dm_partner: null,
email_notifications: false,
...overrides,
}
}
describe('clearRoomIndicators', () => {
it('clears has_unread and has_mention on the matching room', () => {
const rooms = [makeRoom({ id: 'room-1', has_unread: true, has_mention: true })]
const result = clearRoomIndicators(rooms, 'room-1')
expect(result[0].has_unread).toBe(false)
expect(result[0].has_mention).toBe(false)
})
it('preserves unread state on every other room', () => {
const rooms = [
makeRoom({ id: 'room-1', has_unread: true, has_mention: true }),
makeRoom({ id: 'room-2', has_unread: true, has_mention: false }),
makeRoom({ id: 'room-3', has_unread: false, has_mention: false }),
]
const result = clearRoomIndicators(rooms, 'room-1')
const room2 = result.find((r) => r.id === 'room-2')
const room3 = result.find((r) => r.id === 'room-3')
expect(room2?.has_unread).toBe(true)
expect(room3?.has_unread).toBe(false)
})
it('leaves untouched rooms as the same object reference', () => {
const room2 = makeRoom({ id: 'room-2', has_unread: true })
const rooms = [makeRoom({ id: 'room-1', has_unread: true }), room2]
const result = clearRoomIndicators(rooms, 'room-1')
expect(result[1]).toBe(room2)
})
it('is a no-op if the room id is not found', () => {
const rooms = [makeRoom({ id: 'room-1', has_unread: true })]
const result = clearRoomIndicators(rooms, 'does-not-exist')
expect(result[0].has_unread).toBe(true)
})
})
+9
View File
@@ -0,0 +1,9 @@
import type { MyRoomItem } from '../types'
// Pulled out of ChatShellPage as a pure function purely so it's directly
// unit-testable without rendering the whole page -- behavior is unchanged.
// Only touches the matching room; every other room's object reference (and
// therefore its unread state) is left untouched.
export function clearRoomIndicators(rooms: MyRoomItem[], roomId: string): MyRoomItem[] {
return rooms.map((r) => (r.id === roomId ? { ...r, has_unread: false, has_mention: false } : r))
}
+9 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client' import { NetworkError } from '../api/client'
import { listMyRooms, listRoomMembers } from '../api/rooms' import { listMyRooms, listRoomMembers } from '../api/rooms'
@@ -12,7 +12,9 @@ import { Sidebar } from '../components/Sidebar'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useChatSocketContext } from '../context/ChatSocketContext' import { useChatSocketContext } from '../context/ChatSocketContext'
import { useDesktopRoomBridge } from '../hooks/useDesktopRoomBridge'
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth' import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import { clearRoomIndicators } from '../lib/roomIndicators'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import './ChatShellPage.css' import './ChatShellPage.css'
@@ -60,10 +62,13 @@ export function ChatShellPage() {
const socket = useChatSocketContext() const socket = useChatSocketContext()
const clearRoomIndicators = useCallback((id: string) => { const handleRoomRead = useCallback((id: string) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r))) setRooms((prev) => clearRoomIndicators(prev, id))
}, []) }, [])
const unreadRoomCount = useMemo(() => rooms.filter((r) => r.has_unread).length, [rooms])
useDesktopRoomBridge(activeRoom?.id, unreadRoomCount)
useEffect( useEffect(
() => () =>
socket.subscribe((envelope) => { socket.subscribe((envelope) => {
@@ -134,7 +139,7 @@ export function ChatShellPage() {
onToggleInfo={() => setInfoOpen((v) => !v)} onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen} infoOpen={infoOpen}
socket={socket} socket={socket}
onRoomRead={clearRoomIndicators} onRoomRead={handleRoomRead}
/> />
) : ( ) : (
!isMobile && ( !isMobile && (
+10 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Markdown from 'markdown-to-jsx' import Markdown from 'markdown-to-jsx'
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent' import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
import './HelpPage.css' import './HelpPage.css'
@@ -20,6 +20,15 @@ export function HelpPage() {
[content], [content],
) )
// #75: the composer's "?" links straight to /help#formatting, but the guide
// is fetched after the page loads -- the browser's own scroll-to-hash has
// already run (and found nothing) by the time the headings exist.
const { hash } = useLocation()
useEffect(() => {
if (!markdownPreview || !hash) return
document.getElementById(hash.slice(1))?.scrollIntoView()
}, [markdownPreview, hash])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
fetch(GUIDE_URL) fetch(GUIDE_URL)
+44 -5
View File
@@ -10,6 +10,13 @@ interface UseChatSocketOptions {
const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30000 const RECONNECT_MAX_DELAY_MS = 30000
// #76: the backend pings every 30s (WS_PING_INTERVAL_SECONDS in chat.py) --
// this needs to comfortably tolerate one missed ping plus ordinary network
// jitter before declaring the connection dead, not fire on the very first
// late beat.
const ZOMBIE_TIMEOUT_MS = 65000
const ZOMBIE_CHECK_INTERVAL_MS = 10000
const desktopMode = isDesktopNotificationsSupported() const desktopMode = isDesktopNotificationsSupported()
// Room join/leave (live message delivery) depends only on visibility -- // Room join/leave (live message delivery) depends only on visibility --
@@ -51,6 +58,16 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
// live WebSocket delivery the user can't actually see will do the job. // live WebSocket delivery the user can't actually see will do the job.
const desiredRoomsRef = useRef(new Set<string>()) const desiredRoomsRef = useRef(new Set<string>())
const isVisibleRef = useRef(isVisible()) const isVisibleRef = useRef(isVisible())
// #76: last time *anything* arrived on the socket (a real message, a
// "ping", doesn't matter which) -- the only signal that a connection
// sitting at readyState OPEN is actually still alive rather than a
// zombie. Without this, a connection that dies without ever sending a
// close frame (laptop sleep, a NAT silently dropping an idle mapping)
// looks identical to a healthy-but-quiet one: onclose never fires, so the
// already-correct reconnect+rejoin logic below never runs, and new
// messages just stop arriving until something unrelated (e.g. switching
// rooms, which refetches history over plain REST) papers over it.
const lastActivityRef = useRef(Date.now())
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => { const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
const ws = socketRef.current const ws = socketRef.current
@@ -99,6 +116,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
if (hasConnectedBefore) checkForUpdate() if (hasConnectedBefore) checkForUpdate()
hasConnectedBefore = true hasConnectedBefore = true
reconnectDelay = RECONNECT_BASE_DELAY_MS reconnectDelay = RECONNECT_BASE_DELAY_MS
lastActivityRef.current = Date.now()
setConnected(true) setConnected(true)
// Re-join whatever rooms were joined before a reconnect -- the // Re-join whatever rooms were joined before a reconnect -- the
// server has no memory of a dropped connection's prior state. Only // server has no memory of a dropped connection's prior state. Only
@@ -122,7 +140,17 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (socketRef.current !== ws) return if (socketRef.current !== ws) return
const envelope = JSON.parse(event.data) as ServerEnvelope lastActivityRef.current = Date.now()
const parsed = JSON.parse(event.data)
// #76: the server's heartbeat -- purely transport-level, not a
// domain event, so it's answered and swallowed right here instead
// of being forwarded to subscribers (ServerEnvelope's own type
// doesn't include it for exactly that reason).
if (parsed.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }))
return
}
const envelope = parsed as ServerEnvelope
for (const handler of subscribersRef.current) handler(envelope) for (const handler of subscribersRef.current) handler(envelope)
} }
@@ -143,10 +171,9 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
if (stopped) return if (stopped) return
// Unexpected close -- a deploy restarting the app server, a brief // Unexpected close -- a deploy restarting the app server, a brief
// network blip, or (absent any app-level ping/pong) an idle // network blip, or an idle connection getting recycled by a reverse
// connection getting recycled by a reverse proxy in front of it. // proxy in front of it. Retry with exponential backoff instead of
// Retry with exponential backoff instead of leaving the chat // leaving the chat silently dead until the user manually reloads.
// silently dead until the user manually reloads.
reconnectTimer = setTimeout(connect, reconnectDelay) reconnectTimer = setTimeout(connect, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS) reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
} }
@@ -154,9 +181,21 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
connect() connect()
// #76: catches the case onclose can't -- a connection that dies without
// ever sending a close frame at all (readyState stays OPEN forever) is
// otherwise invisible to this hook. Forcing a close here just routes it
// through the exact same onclose/reconnect/rejoin path as a normal
// disconnect, rather than needing separate recovery logic of its own.
const zombieCheckTimer = setInterval(() => {
if (Date.now() - lastActivityRef.current > ZOMBIE_TIMEOUT_MS) {
socketRef.current?.close()
}
}, ZOMBIE_CHECK_INTERVAL_MS)
return () => { return () => {
stopped = true stopped = true
if (reconnectTimer) clearTimeout(reconnectTimer) if (reconnectTimer) clearTimeout(reconnectTimer)
clearInterval(zombieCheckTimer)
socketRef.current?.close() socketRef.current?.close()
socketRef.current = null socketRef.current = null
} }
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// Deliberately separate from vite.config.ts, not an extension of it -- that
// file's VitePWA plugin does real build-time work (service worker
// injection, manifest generation) that has nothing to do with running unit
// tests and would just slow every test run down for no benefit.
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
},
})