11 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5.5 5e07c00b99 Document integrations in the user guide
Adds an Integrations section covering incoming webhooks, outgoing event
subscriptions (including verifying the signature and the best-effort
delivery limits), and bots with API tokens. Nothing in the guide
mentioned any of these; the only write-up was the developer README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:28:50 -06:00
ksmithandClaude Sonnet 5.5 319b7097c2 Bump to v2026.10.22
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:49:44 -06:00
ksmithandClaude Sonnet 5.5 0065b4ced3 Add an add-reaction button to the reaction row (#78)
Adding to a message that already had reactions meant going back up to
the hover row's 🙂. A "+🙂" button now sits at the end of the row of
reaction pills, always visible once the message has at least one
reaction, and opens the same emoji picker.

The picker anchors to the left edge of the pill row rather than the
button: the button moves right with every added reaction, and a picker
hanging off it ran past the edge of a phone screen. Both buttons share
the picker's open state, so only one picker is ever open at a time.

MessageList had no tests; this adds a few covering the new button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:49:44 -06:00
ksmithandClaude Sonnet 5.5 b52db0ebe0 Bump to v2026.10.13
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:21:39 -06:00
ksmithandClaude Sonnet 5.5 dda85a2b78 Expire presence so users can't get stuck online (#77)
Presence was a per-user refcount in Redis, decremented only when a
connection's cleanup ran. Anything that ended a connection without
cleanup (a crash, an unclean restart, a laptop asleep with the page
frozen) leaked its count forever, so those users never showed offline.
Production had 19 counts for 2 live connections.

Presence is now one expiring entry per connection (90s TTL) for global,
per-room and focus presence, in new presence:v2:* keys. Clients renew
theirs by sending any frame, which the existing 30s ping/pong already
does. The server closes a human connection that sends nothing for 90s
(code 4408), which catches a frozen page whose browser still answers
transport pings. Bot connections can't be expected to answer pings, so
the server renews theirs itself while the handler is alive.

An expiry has no disconnect event to announce it, so a sweeper on each
worker (one per 15s overall, via a Redis lock) prunes expired entries
and broadcasts the offline transition. The old presence:* hashes are
deleted at startup.

Tests cover the expiry store, the sweeper, and the websocket cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:21:39 -06:00
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
41 changed files with 3330 additions and 155 deletions

No files matched your search

+145 -4
View File
@@ -17,6 +17,7 @@ attachments, notifications, and personalizing your account.
- [Presence and notifications](#presence-and-notifications) - [Presence and notifications](#presence-and-notifications)
- [Your profile](#your-profile) - [Your profile](#your-profile)
- [Room details](#room-details) - [Room details](#room-details)
- [Integrations](#integrations)
- [Active sessions](#active-sessions) - [Active sessions](#active-sessions)
- [Staying up to date](#staying-up-to-date) - [Staying up to date](#staying-up-to-date)
@@ -68,9 +69,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)
@@ -122,7 +134,9 @@ you'll see an error before it uploads.
Hover over a message and click the 🙂 icon in its action row to react Hover over a message and click the 🙂 icon in its action row to react
with an emoji. Reactions from everyone appear as small pills under the with an emoji. Reactions from everyone appear as small pills under the
message with a count; click an existing pill to add or remove your own message with a count; click an existing pill to add or remove your own
reaction to it. Hovering a pill shows who reacted. reaction to it. Hovering a pill shows who reacted. Once a message has
reactions, a small **+🙂** button at the end of the row of pills lets you
add a different emoji without going back to the action row.
Anyone can add a custom emoji: open the emoji picker (the 🙂 button, either Anyone can add a custom emoji: open the emoji picker (the 🙂 button, either
in the composer or on a message) and click **+ Add** in the **Custom** in the composer or on a message) and click **+ Add** in the **Custom**
@@ -196,8 +210,135 @@ you can:
- Leave the room — unless you're the owner, in which case ownership has - Leave the room — unless you're the owner, in which case ownership has
to be transferred to someone else first to be transferred to someone else first
Room admins and owners see additional management options here that Room admins and owners see additional management options here, including
aren't covered in this guide. **Integrations** (see [Integrations](#integrations)).
## Integrations
DS Chat can exchange messages with other tools, three ways. The first two
are set up by a room's admins or owners; the third by a site admin.
| You want to… | Use |
| --- | --- |
| Post into a room from a script, CI job, or another service | [Incoming webhook](#incoming-webhooks) |
| Get notified elsewhere when a room's messages change | [Outgoing event subscription](#outgoing-event-subscriptions) |
| Have a program read and write messages as its own account | [Bot](#bots) |
Links in messages already get a preview card on their own, and Web Push
and email notifications work without any setup — see
[Presence and notifications](#presence-and-notifications).
### Incoming webhooks
An incoming webhook is a private URL that posts into one room.
1. Open the room's details panel (the info icon in the header) and expand
**Integrations**. You need to be an admin or the owner of the room.
2. Under **Incoming webhooks**, add an optional description (such as
"Deploy notices") and create it. The full URL is listed there whenever
you need to copy it again.
3. Send a `POST` with a JSON body to that URL:
```bash
curl -X POST "https://chat.example.com/api/webhooks/incoming/<token>" \
-H "Content-Type: application/json" \
-d '{"content": "Deploy finished: **v2026.10.22** is live"}'
```
The message appears in the room right away, formatted like any other
(markdown works), attributed to whoever created the webhook, and it
notifies members the same way a typed message would. A successful post
returns `204`; an unknown or revoked URL returns `404`, and an archived
room returns `403`.
**Treat the URL like a password.** There's no other authentication: anyone
who has it can post into the room. If it leaks, click **Revoke** next to
it in the same panel and create a new one.
### Outgoing event subscriptions
An outgoing subscription sends DS Chat's events to a URL you control.
1. In the same **Integrations** panel, under **Outgoing event
subscriptions**, enter the URL and tick the events you want:
`message.created` (a new message) and/or `message.updated` (an edit).
2. Click **Add**. A **signing secret** appears once — copy it now, it
won't be shown again.
Each event arrives as a `POST` with a JSON body:
```json
{
"event": "message.created",
"data": { "id": "…", "room_id": "…", "username": "alice", "content": "hello", "…": "…" }
}
```
To let you confirm a request really came from DS Chat, each one carries an
`X-DS-Chat-Signature: sha256=<hex>` header: the HMAC-SHA256 of the raw
request body, keyed with your signing secret. For example, in Python:
```python
import hashlib, hmac
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
valid = hmac.compare_digest(expected, request_signature_header)
```
Things to know:
- **Delivery is best-effort.** Each event is tried once with a short
timeout; if your endpoint is down or slow, that event is dropped and
not retried. Don't rely on it as your only record of a conversation.
- **Only message creation and edits** are sent. Deletions, reactions,
and joins don't generate events.
- **The URL must be publicly reachable.** Addresses that point to
private or internal networks (localhost, `192.168.x.x`, and so on) are
rejected when you add the subscription.
- **Revoke** removes a subscription immediately.
### Bots
A bot is an account for a program rather than a person. It signs in with
an API token instead of a password and uses the same interface as the
app itself, so it can read and write messages in real time.
Only **site admins** create bots: open the admin page, choose the **Bots**
tab, and click **Create bot**, then **Issue token** and pick what the
token may do:
- `read:messages` — read a room's message history
- `write:messages` — send, edit, and delete messages
- `manage:rooms` — recognized, but doesn't grant anything beyond what the
bot's room role already allows
The token is shown once — copy it then. You can revoke it from the same
tab at any time.
A bot is held to the same rules as any member: it can only see and post
in rooms it belongs to, and its room role limits what it can do there.
The token can narrow that further, never widen it.
The bot connects to the WebSocket at `/ws/chat`, sending
`Authorization: Bearer <token>` with the connection, and then speaks
simple JSON messages:
```json
{"type": "join", "room_id": "<room id>"}
{"type": "message", "room_id": "<room id>", "content": "Hello from a bot"}
{"type": "edit", "room_id": "<room id>", "message_id": "<id>", "content": "Fixed"}
```
Messages from the room arrive on the same connection as they happen. The
same bearer token also works for the regular `/api/` endpoints, such as
fetching a room's history.
The server sends a `{"type": "ping"}` every 30 seconds to check a
connection is alive; a bot may ignore it, since bot connections are never
dropped for staying silent.
There is currently **no rate limiting** on bots (or anyone else), so keep
automation polite.
## Staying up to date ## Staying up to date
+19 -10
View File
@@ -289,17 +289,26 @@ instance and running many behave identically:
to `room:*`; each message it receives is handed to its own local to `room:*`; each message it receives is handed to its own local
`ConnectionManager.broadcast()`. One instance just talks to itself `ConnectionManager.broadcast()`. One instance just talks to itself
through Redis, so there's no separate single-instance code path. through Redis, so there's no separate single-instance code path.
- `app/ws/presence.py` (`Presence`) — a Redis hash per room - `app/ws/presence.py` (`Presence`) — a Redis sorted set per room
(`presence:{room_id}`, field = user ID, value = connection refcount) is (`presence:v2:room:{id}`, one member per *connection*, `<user_id>:<conn_id>`,
the cross-instance answer to "is this member connected *anywhere* right score = expiry time) is the cross-instance answer to "is this member
now," which is what the Phase 4 offline-push check uses instead of the connected *anywhere* right now," which is what the Phase 4 offline-push
local `ConnectionManager`. Refcounted so a user connected from two tabs check uses instead of the local `ConnectionManager`. A user is connected
(or two instances) isn't marked offline until every connection closes. while any of their entries is unexpired, so two tabs (or two instances)
don't mark them offline until every connection is gone. `GlobalPresence`
(`presence:v2:global`) and `FocusPresence` (`presence:v2:unfocused`) use the
same store, `app/ws/expiring_presence.py` (`ExpiringPresenceSet`).
Known limitation: `Presence` has no heartbeat/TTL, so a hard process crash Presence entries expire (#77) so nothing can be stuck online. A connection
(not a clean disconnect) leaks that connection's increment forever — same renews its entries (90s TTL) whenever the client sends a frame — the frontend
category of simplification as the "no server-side session revocation" note answers the server's 30s `ping` with a `pong`, which is exactly that — and
below. the server closes a human client's socket (code 4408) after 90s of silence.
Bot (API-token) connections don't speak the heartbeat, so the server renews
their entries itself while their handler is alive. An expiry has no
disconnect event to announce it, so `app/ws/presence_sweeper.py` runs on every
worker (a Redis lock makes it one sweep per 15s overall), prunes expired
entries, and broadcasts the offline transition. The pre-#77 `presence:*`
refcount hashes are deleted at startup.
## Push notifications (Phase 4) ## Push notifications (Phase 4)
+21 -3
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import contextlib import contextlib
import logging
import pathlib import pathlib
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -11,6 +12,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.config import settings from app.config import settings
from app.database import async_session_factory
from app.routers import ( from app.routers import (
admin, admin,
auth, auth,
@@ -31,6 +33,9 @@ from app.ws.connection_manager import ConnectionManager
from app.ws.focus_presence import FocusPresence from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence from app.ws.presence import Presence
from app.ws.presence_sweeper import purge_legacy_presence, run_presence_sweeper
logger = logging.getLogger(__name__)
# backend/app/main.py -> backend/ -> repo root -- matches both the local # backend/app/main.py -> backend/ -> repo root -- matches both the local
# monorepo layout and the production layout (/srv/ds-chat/backend, # monorepo layout and the production layout (/srv/ds-chat/backend,
@@ -65,11 +70,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# restarts on failure instead. # restarts on failure instead.
listener_task = asyncio.create_task(app.state.broadcaster.run_forever()) listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
# #77: presence entries now expire, and an expiry has no disconnect event
# to announce it -- this loop is what notices. The purge clears the old
# refcount hashes (and the counts that had leaked into them); it's
# best-effort, since Redis being down at startup shouldn't stop the app.
try:
await purge_legacy_presence(app.state.redis)
except Exception:
logger.warning("Could not purge legacy presence keys", exc_info=True)
sweeper_task = asyncio.create_task(run_presence_sweeper(app))
yield yield
listener_task.cancel() for task in (sweeper_task, listener_task):
with contextlib.suppress(asyncio.CancelledError): task.cancel()
await listener_task for task in (sweeper_task, listener_task):
with contextlib.suppress(asyncio.CancelledError):
await task
await app.state.redis.aclose() await app.state.redis.aclose()
@@ -85,6 +102,7 @@ def create_app() -> FastAPI:
) )
app.state.connection_manager = ConnectionManager() app.state.connection_manager = ConnectionManager()
app.state.session_factory = async_session_factory
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True) app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
app.state.presence = Presence(app.state.redis) app.state.presence = Presence(app.state.redis)
app.state.global_presence = GlobalPresence(app.state.redis) app.state.global_presence = GlobalPresence(app.state.redis)
+87 -10
View File
@@ -1,4 +1,7 @@
import asyncio import asyncio
import contextlib
import logging
import time
import uuid import uuid
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
@@ -28,6 +31,8 @@ from app.services.message_service import (
from app.services.room_service import mark_room_read from app.services.room_service import mark_room_read
from app.services.session_service import resolve_session from app.services.session_service import resolve_session
logger = logging.getLogger(__name__)
router = APIRouter(tags=["ws"]) router = APIRouter(tags=["ws"])
WS_UNAUTHENTICATED = 4401 WS_UNAUTHENTICATED = 4401
@@ -44,6 +49,22 @@ WS_UNAUTHENTICATED = 4401
# itself actually recovering). # itself actually recovering).
WS_PING_INTERVAL_SECONDS = 30 WS_PING_INTERVAL_SECONDS = 30
# #77: a human client that has sent nothing -- not even the "pong" to the ping
# above -- for this long is treated as gone and disconnected, so its presence
# stops being renewed and cleanup runs. Needed on top of the transport's own
# keepalive: a browser answers *protocol-level* pings from its network layer
# even while the page's JavaScript is frozen (a suspended or discarded tab,
# a sleeping laptop that keeps its network up), so the connection looks
# healthy forever while nobody is actually there. Only the page itself can
# send the app-level "pong". The frontend reconnects on any non-4401 close.
WS_DEAD_AFTER_SECONDS = 90
WS_HEARTBEAT_TIMEOUT = 4408
# Presence entries (see ExpiringPresenceSet) are renewed on incoming frames,
# at most this often -- a chatty client shouldn't turn every message into
# several extra Redis writes.
PRESENCE_REFRESH_MIN_INTERVAL_SECONDS = 10
class ClientEnvelope(BaseModel): class ClientEnvelope(BaseModel):
type: str type: str
@@ -74,6 +95,21 @@ def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
return api_token is not None and scope not in api_token.scopes return api_token is not None and scope not in api_token.scopes
async def _broadcast_offline(db: AsyncSession, broadcaster, user_id: uuid.UUID) -> None:
await broadcast_member_updated(db, broadcaster, user_id)
await broadcast_dm_presence_update(db, broadcaster, user_id, online=False)
async def _best_effort(label: str, awaitable):
# Disconnect cleanup is several independent Redis/DB steps; one failing
# must not skip the ones after it.
try:
return await awaitable
except Exception:
logger.warning("Presence cleanup step failed: %s", label, exc_info=True)
return None
@router.websocket("/ws/chat") @router.websocket("/ws/chat")
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None: async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
api_token: ApiToken | None = None api_token: ApiToken | None = None
@@ -114,13 +150,19 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
# refcount if the socket closes while still blurred -- mirroring how # refcount if the socket closes while still blurred -- mirroring how
# joined_rooms tracks per-connection room membership for its own cleanup. # joined_rooms tracks per-connection room membership for its own cleanup.
is_blurred = False is_blurred = False
# Identifies this connection's own presence entries (#77) -- see
# ExpiringPresenceSet -- so one connection ending can't affect another
# belonging to the same user, and a dead one can simply stop renewing.
conn_id = uuid.uuid4().hex
last_frame_at = time.monotonic()
last_presence_refresh = last_frame_at
manager.register_user(user.id, websocket) manager.register_user(user.id, websocket)
# Only broadcast on a genuine offline->online transition (this user's # Only broadcast on a genuine offline->online transition (this user's
# first open connection), not for every extra tab -- broadcast_member_ # first open connection), not for every extra tab -- broadcast_member_
# updated tells every room this user's in to refresh, which would be # updated tells every room this user's in to refresh, which would be
# wasted churn on a transition that didn't actually change anything # wasted churn on a transition that didn't actually change anything
# visible. # visible.
if await global_presence.connect(user.id): if await global_presence.connect(user.id, conn_id):
await broadcast_member_updated(db, broadcaster, user.id) await broadcast_member_updated(db, broadcaster, user.id)
await broadcast_dm_presence_update(db, broadcaster, user.id, online=True) await broadcast_dm_presence_update(db, broadcaster, user.id, online=True)
# This session is shared for the connection's entire lifetime (which can # This session is shared for the connection's entire lifetime (which can
@@ -135,6 +177,18 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
# waiting for the next one. # waiting for the next one.
await db.commit() await db.commit()
async def refresh_presence() -> None:
# Renews every presence entry this connection holds. If the global
# one had already lapsed (a long stall) this is also the moment the
# user comes back online, and has to be announced like a fresh connect.
if await global_presence.refresh(user.id, conn_id):
await broadcast_member_updated(db, broadcaster, user.id)
await broadcast_dm_presence_update(db, broadcaster, user.id, online=True)
for room_id in joined_rooms:
await presence.refresh(room_id, user.id, conn_id)
if is_blurred:
await focus_presence.refresh(user.id, conn_id)
try: try:
while True: while True:
try: try:
@@ -147,9 +201,30 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
# send (raising, caught below same as any other disconnect); # send (raising, caught below same as any other disconnect);
# a merely quiet one just gets proded and the loop continues # a merely quiet one just gets proded and the loop continues
# waiting. # waiting.
if api_token is None and time.monotonic() - last_frame_at >= WS_DEAD_AFTER_SECONDS:
# Gone (see WS_DEAD_AFTER_SECONDS). The socket may be too
# dead to even take a close frame; either way, leaving the
# loop runs the normal cleanup below.
with contextlib.suppress(Exception):
await websocket.close(code=WS_HEARTBEAT_TIMEOUT)
break
if api_token is not None:
# A bot isn't expected to speak our app-level heartbeat
# (it may just ignore the ping), so it can't be dropped
# for not answering and its presence can't depend on
# answers. While the handler is alive the transport's own
# keepalive is what vouches for it -- a dead bot socket
# ends this handler, and cleanup does the rest.
await refresh_presence()
last_presence_refresh = time.monotonic()
await db.commit()
await websocket.send_json({"type": "ping"}) await websocket.send_json({"type": "ping"})
continue continue
last_frame_at = time.monotonic()
try: try:
if last_frame_at - last_presence_refresh >= PRESENCE_REFRESH_MIN_INTERVAL_SECONDS:
await refresh_presence()
last_presence_refresh = last_frame_at
try: try:
envelope = ClientEnvelope.model_validate(raw) envelope = ClientEnvelope.model_validate(raw)
except ValidationError: except ValidationError:
@@ -166,7 +241,7 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
) )
continue continue
manager.join(envelope.room_id, websocket) manager.join(envelope.room_id, websocket)
await presence.join(envelope.room_id, user.id) await presence.join(envelope.room_id, user.id, conn_id)
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)})
@@ -183,7 +258,7 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await websocket.send_json({"type": "error", "detail": "room_id required"}) await websocket.send_json({"type": "error", "detail": "room_id required"})
continue continue
manager.leave(envelope.room_id, websocket) manager.leave(envelope.room_id, websocket)
await presence.leave(envelope.room_id, user.id) await presence.leave(envelope.room_id, user.id, conn_id)
joined_rooms.discard(envelope.room_id) joined_rooms.discard(envelope.room_id)
elif envelope.type == "focus": elif envelope.type == "focus":
@@ -198,11 +273,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
continue continue
if envelope.focused: if envelope.focused:
if is_blurred: if is_blurred:
await focus_presence.mark_focused(user.id) await focus_presence.mark_focused(user.id, conn_id)
is_blurred = False is_blurred = False
else: else:
if not is_blurred: if not is_blurred:
await focus_presence.mark_blurred(user.id) await focus_presence.mark_blurred(user.id, conn_id)
is_blurred = True is_blurred = True
elif envelope.type == "message": elif envelope.type == "message":
@@ -396,9 +471,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager.leave_all(websocket) manager.leave_all(websocket)
manager.unregister_user(user.id, websocket) manager.unregister_user(user.id, websocket)
for room_id in joined_rooms: for room_id in joined_rooms:
await presence.leave(room_id, user.id) await _best_effort("room presence", presence.leave(room_id, user.id, conn_id))
if is_blurred: if is_blurred:
await focus_presence.mark_focused(user.id) await _best_effort("focus presence", focus_presence.mark_focused(user.id, conn_id))
if await global_presence.disconnect(user.id): if await _best_effort("global presence", global_presence.disconnect(user.id, conn_id)):
await broadcast_member_updated(db, broadcaster, user.id) await _best_effort(
await broadcast_dm_presence_update(db, broadcaster, user.id, online=False) "offline broadcast",
_broadcast_offline(db, broadcaster, user.id),
)
+103
View File
@@ -0,0 +1,103 @@
import time
import uuid
from collections.abc import Callable
from redis.asyncio import Redis
# #77: an entry that isn't renewed for this long stops counting. Chosen as 3x
# the app-level ping interval (chat.py's WS_PING_INTERVAL_SECONDS): a healthy
# connection renews roughly every ping, so two missed beats are tolerated
# before someone flips offline.
PRESENCE_TTL_SECONDS = 90
def _text(value: str | bytes) -> str:
return value.decode() if isinstance(value, bytes) else value
def _user_part(member: str) -> str:
return member.split(":", 1)[0]
class ExpiringPresenceSet:
""""Which users have at least one live connection" for one Redis key,
where every connection is its own entry with its own expiry.
This replaced per-user refcounts (#77): a refcount only goes down when a
connection's cleanup code actually runs, so any connection that ended
without it -- a server crash or reboot, a process killed mid-restart, a
client that vanished without the server ever noticing -- left a +1 that
nothing could ever subtract, showing that person online forever (and in
the per-room case, suppressing their push notifications). Here a dead
connection simply stops being renewed and ages out on its own, so there
is no state that can leak.
One sorted set per key: member `<user_id>:<connection_id>`, score = the
moment it expires. "Live" means score > now. Scans are O(connections) --
fine for a self-hosted team chat; only the global key grows with the
whole server rather than one room.
"""
def __init__(
self,
redis: Redis,
ttl_seconds: float = PRESENCE_TTL_SECONDS,
clock: Callable[[], float] = time.time,
) -> None:
self._redis = redis
self._ttl = ttl_seconds
self._clock = clock
@staticmethod
def _member(user_id: uuid.UUID, conn_id: str) -> str:
return f"{user_id}:{conn_id}"
async def _live_members(self, key: str) -> list[str]:
members = await self._redis.zrangebyscore(key, f"({self._clock()}", "+inf")
return [_text(m) for m in members]
async def add(self, key: str, user_id: uuid.UUID, conn_id: str) -> bool:
"""Creates or renews this connection's entry. Returns True iff the
user had no live entry beforehand -- a genuine offline->online
transition worth telling anyone about. Renewing an entry that's still
live (the common case, every heartbeat) is a single cheap write."""
member = self._member(user_id, conn_id)
now = self._clock()
score = await self._redis.zscore(key, member)
came_online = False
if score is None or score <= now:
came_online = not await self.is_live(key, user_id)
await self._redis.zadd(key, {member: now + self._ttl})
# Lets a key nobody renews any more (an idle room) disappear on its
# own instead of sitting there holding only expired entries.
await self._redis.expire(key, int(self._ttl * 2))
return came_online
async def remove(self, key: str, user_id: uuid.UUID, conn_id: str) -> bool:
"""Drops this connection's entry. Returns True iff that left the user
with no live entry -- a genuine online->offline transition. False if
the entry was already gone: whoever removed it (see prune) already
reported the transition, and reporting it twice would be noise."""
removed = await self._redis.zrem(key, self._member(user_id, conn_id))
if not removed:
return False
return not await self.is_live(key, user_id)
async def is_live(self, key: str, user_id: uuid.UUID) -> bool:
prefix = f"{user_id}:"
return any(m.startswith(prefix) for m in await self._live_members(key))
async def live_user_ids(self, key: str) -> set[uuid.UUID]:
return {uuid.UUID(_user_part(m)) for m in await self._live_members(key)}
async def prune(self, key: str) -> set[uuid.UUID]:
"""Deletes expired entries. Returns the users whose last live entry
was among them -- the offline transitions nobody was told about,
since an expiry has no disconnect event to hang a broadcast on."""
now = self._clock()
expired = [_text(m) for m in await self._redis.zrangebyscore(key, "-inf", now)]
if not expired:
return set()
await self._redis.zremrangebyscore(key, "-inf", now)
gone = {_user_part(m) for m in expired} - {_user_part(m) for m in await self._live_members(key)}
return {uuid.UUID(u) for u in gone}
+24 -23
View File
@@ -2,44 +2,45 @@ import uuid
from redis.asyncio import Redis from redis.asyncio import Redis
from app.ws.expiring_presence import ExpiringPresenceSet
class FocusPresence: class FocusPresence:
"""Cross-instance "is this desktop-mode user's window currently """Cross-instance "is this desktop-mode user's window currently
unfocused," used only to widen desktop_notification/push eligibility unfocused," used only to widen desktop_notification/push eligibility
beyond plain room-connection state (#59). A Redis hash (field = beyond plain room-connection state (#59). One expiring entry per
user_id, value = refcount of that user's currently-blurred desktop currently-blurred desktop connection (see ExpiringPresenceSet),
connections), parallel to Presence/GlobalPresence. parallel to Presence/GlobalPresence.
Absence from this hash is the default and means "focused." That default Absence from the set is the default and means "focused." That default
is also exactly right for every browser-tab connection: only the is also exactly right for every browser-tab connection: only the
desktop client ever sends focus/blur frames at all (see chat.py's desktop client ever sends focus/blur frames at all (see chat.py's
"focus" envelope handling), so a browser user never appears here -- "focus" envelope handling), so a browser user never appears here --
their attention is already fully captured by Presence's room-connection their attention is already fully captured by Presence's room-connection
state, which stays visibility-gated with no separate focus signal. state, which stays visibility-gated with no separate focus signal.
Refcounted for the same multi-connection reason as Presence/ Same known simplification as before: two desktop windows for one user,
GlobalPresence, with the same known simplification: two desktop windows one focused and one blurred, count as "unfocused" (any live blurred
for one user, one focused and one blurred, count as "unfocused" here entry) even though the user does have attention somewhere. That errs
(refcount > 0) even though the user does have attention somewhere. That toward notifying rather than silently missing one, which is the safer
errs toward notifying rather than silently missing one, which is the failure mode for a notification. Entries expire (#77) -- a desktop
safer failure mode for a notification. window that died while blurred used to leave its user "unfocused"
forever.
""" """
def __init__(self, redis: Redis) -> None: KEY = "presence:v2:unfocused"
self._redis = redis
def _key(self) -> str: def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
return "presence:unfocused" self._store = store or ExpiringPresenceSet(redis)
async def mark_blurred(self, user_id: uuid.UUID) -> None: async def mark_blurred(self, user_id: uuid.UUID, conn_id: str) -> None:
await self._redis.hincrby(self._key(), str(user_id), 1) await self._store.add(self.KEY, user_id, conn_id)
async def mark_focused(self, user_id: uuid.UUID) -> None: async def mark_focused(self, user_id: uuid.UUID, conn_id: str) -> None:
key = self._key() await self._store.remove(self.KEY, user_id, conn_id)
field = str(user_id)
remaining = await self._redis.hincrby(key, field, -1) async def refresh(self, user_id: uuid.UUID, conn_id: str) -> None:
if remaining <= 0: await self._store.add(self.KEY, user_id, conn_id)
await self._redis.hdel(key, field)
async def is_unfocused(self, user_id: uuid.UUID) -> bool: async def is_unfocused(self, user_id: uuid.UUID) -> bool:
return await self._redis.hexists(self._key(), str(user_id)) return await self._store.is_live(self.KEY, user_id)
+32 -27
View File
@@ -2,50 +2,55 @@ import uuid
from redis.asyncio import Redis from redis.asyncio import Redis
from app.ws.expiring_presence import ExpiringPresenceSet
class GlobalPresence: class GlobalPresence:
"""Cross-instance "does this user have the app open at all right now," """Cross-instance "does this user have the app open at all right now,"
independent of which (if any) room they currently have open -- backing independent of which (if any) room they currently have open -- backing
the online/offline presence dot shown wherever a user's avatar renders. the online/offline presence dot shown wherever a user's avatar renders.
A single Redis hash (field = user_id, value = connection refcount),
parallel to but separate from Presence's per-room hashes.
Refcounted for the same reason as Presence: multiple tabs/instances for Each connection has its own expiring entry (see ExpiringPresenceSet), so
one user shouldn't flip them offline until the last connection closes. a user with several tabs/devices stays online until the last one goes,
and a connection that dies without cleaning up ages out instead of
leaving them online forever (#77). The key is versioned (`v2`) because
the old refcount hash lives at `presence:global` and its leaked counts
must not be read as live entries.
""" """
def __init__(self, redis: Redis) -> None: KEY = "presence:v2:global"
self._redis = redis
def _key(self) -> str: def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
return "presence:global" self._store = store or ExpiringPresenceSet(redis)
async def connect(self, user_id: uuid.UUID) -> bool: async def connect(self, user_id: uuid.UUID, conn_id: str) -> bool:
"""Returns True iff this was the user's first open connection -- """Returns True iff this made the user newly online -- a genuine
a genuine offline->online transition worth telling anyone about.""" offline->online transition worth telling anyone about."""
count = await self._redis.hincrby(self._key(), str(user_id), 1) return await self._store.add(self.KEY, user_id, conn_id)
return count == 1
async def disconnect(self, user_id: uuid.UUID) -> bool: async def refresh(self, user_id: uuid.UUID, conn_id: str) -> bool:
"""Returns True iff this was the user's last open connection -- a """Heartbeat. Same as connect() on purpose: a connection whose entry
had already expired (a long stall) comes back online here, and the
caller needs to announce that exactly like a fresh connect."""
return await self._store.add(self.KEY, user_id, conn_id)
async def disconnect(self, user_id: uuid.UUID, conn_id: str) -> bool:
"""Returns True iff that was the user's last live connection -- a
genuine online->offline transition.""" genuine online->offline transition."""
key = self._key() return await self._store.remove(self.KEY, user_id, conn_id)
field = str(user_id)
remaining = await self._redis.hincrby(key, field, -1) async def sweep(self) -> set[uuid.UUID]:
if remaining <= 0: """Drops expired entries and returns the users that went offline as a
await self._redis.hdel(key, field) result -- the transitions no disconnect ever reported."""
return True return await self._store.prune(self.KEY)
return False
async def is_online(self, user_id: uuid.UUID) -> bool: async def is_online(self, user_id: uuid.UUID) -> bool:
return await self._redis.hexists(self._key(), str(user_id)) return await self._store.is_live(self.KEY, user_id)
async def online_user_ids(self, user_ids: list[uuid.UUID]) -> set[uuid.UUID]: async def online_user_ids(self, user_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not user_ids: if not user_ids:
return set() return set()
values = await self._redis.hmget(self._key(), [str(u) for u in user_ids]) return set(user_ids) & await self._store.live_user_ids(self.KEY)
return {uid for uid, v in zip(user_ids, values) if v is not None}
async def all_online_user_ids(self) -> set[uuid.UUID]: async def all_online_user_ids(self) -> set[uuid.UUID]:
fields = await self._redis.hkeys(self._key()) return await self._store.live_user_ids(self.KEY)
return {uuid.UUID(f) for f in fields}
+26 -24
View File
@@ -2,38 +2,40 @@ import uuid
from redis.asyncio import Redis from redis.asyncio import Redis
from app.ws.expiring_presence import ExpiringPresenceSet
class Presence: class Presence:
"""Cross-instance "who's connected to this room," backed by a Redis hash """Cross-instance "who's connected to this room," one expiring set per
per room (field = user_id, value = connection refcount). room (see ExpiringPresenceSet).
Refcounted rather than a plain set so a user with two connections to the This is what the offline-push check reads, so a leaked entry used to mean
same room -- two tabs, or one per app instance -- doesn't get marked a user who'd gone away was still treated as "watching the room" and never
offline when only one of those connections closes. got a push notification (#77). Entries now expire unless the connection
keeps renewing them, so that can't outlive a dead connection by more than
Known limitation: a hard crash (not a clean disconnect) leaks that the TTL. Keys are versioned (`v2`) so the old refcount hashes
connection's increment forever, since there's no heartbeat/TTL here to (`presence:{room_id}`), with whatever they leaked, are never read.
reclaim it -- out of scope for this phase, same category of
simplification as the "no server-side session revocation" note in the
README.
""" """
def __init__(self, redis: Redis) -> None: def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
self._redis = redis self._store = store or ExpiringPresenceSet(redis)
def _key(self, room_id: uuid.UUID) -> str: def _key(self, room_id: uuid.UUID) -> str:
return f"presence:{room_id}" return f"presence:v2:room:{room_id}"
async def join(self, room_id: uuid.UUID, user_id: uuid.UUID) -> None: async def join(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
await self._redis.hincrby(self._key(room_id), str(user_id), 1)
async def leave(self, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
key = self._key(room_id) key = self._key(room_id)
field = str(user_id) # Per-room offline transitions aren't broadcast anywhere, so unlike
remaining = await self._redis.hincrby(key, field, -1) # the global key there's nothing to lose by pruning here -- it just
if remaining <= 0: # keeps a busy room's set from accumulating expired entries.
await self._redis.hdel(key, field) await self._store.prune(key)
await self._store.add(key, user_id, conn_id)
async def refresh(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
await self._store.add(self._key(room_id), user_id, conn_id)
async def leave(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
await self._store.remove(self._key(room_id), user_id, conn_id)
async def connected_user_ids(self, room_id: uuid.UUID) -> set[uuid.UUID]: async def connected_user_ids(self, room_id: uuid.UUID) -> set[uuid.UUID]:
fields = await self._redis.hkeys(self._key(room_id)) return await self._store.live_user_ids(self._key(room_id))
return {uuid.UUID(f) for f in fields}
+83
View File
@@ -0,0 +1,83 @@
import asyncio
import logging
import uuid
from collections.abc import Callable
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.message_events import broadcast_dm_presence_update, broadcast_member_updated
from app.ws.broadcaster import Broadcaster
from app.ws.global_presence import GlobalPresence
logger = logging.getLogger(__name__)
PRESENCE_SWEEP_INTERVAL_SECONDS = 15
SWEEP_LOCK_KEY = "presence:v2:sweeper-lock"
async def sweep_once(
redis: Redis,
global_presence: GlobalPresence,
broadcaster: Broadcaster,
session_factory: Callable[[], AsyncSession],
lock_seconds: int | None = max(1, PRESENCE_SWEEP_INTERVAL_SECONDS - 1),
) -> set[uuid.UUID]:
"""Expires dead connections' presence entries and tells everyone about
the users that went offline as a result.
A clean disconnect broadcasts its own offline transition (chat.py), but a
connection that just stops being renewed has no such event -- this is the
only thing that notices. Every app worker runs this loop; the Redis lock
makes it one sweep per interval across the whole deployment rather than
N workers each doing (and broadcasting) the same thing. `lock_seconds=None`
skips the lock (tests).
"""
if lock_seconds is not None and not await redis.set(SWEEP_LOCK_KEY, "1", nx=True, ex=lock_seconds):
return set()
went_offline = await global_presence.sweep()
for user_id in went_offline:
# One user's failed broadcast must not cost everyone after them
# theirs -- their entry is already gone, so this is the only chance.
try:
async with session_factory() as db:
await broadcast_member_updated(db, broadcaster, user_id)
await broadcast_dm_presence_update(db, broadcaster, user_id, online=False)
except Exception:
logger.error("Failed to broadcast offline for %s", user_id, exc_info=True)
return went_offline
async def run_presence_sweeper(app, interval: float = PRESENCE_SWEEP_INTERVAL_SECONDS) -> None:
# Same lesson as Broadcaster.run_forever (#76): a background task that
# dies on its first error does so silently, and here that would quietly
# bring the stuck-online bug back.
while True:
await asyncio.sleep(interval)
try:
await sweep_once(
app.state.redis,
app.state.global_presence,
app.state.broadcaster,
app.state.session_factory,
lock_seconds=max(1, int(interval) - 1),
)
except asyncio.CancelledError:
raise
except Exception:
logger.error("Presence sweep failed", exc_info=True)
async def purge_legacy_presence(redis: Redis) -> int:
"""Deletes the old refcount hashes (`presence:global`, `presence:unfocused`,
`presence:{room_id}`). Nothing reads them any more -- the live data is
under `presence:v2:` -- and they hold whatever counts leaked before #77,
so there's nothing worth keeping. Safe to run on every startup."""
deleted = 0
async for key in redis.scan_iter("presence:*"):
name = key.decode() if isinstance(key, bytes) else key
if name.startswith("presence:v2:"):
continue
await redis.delete(name)
deleted += 1
return deleted
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ds-chat" name = "ds-chat"
version = "2026.9.26" version = "2026.10.22"
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"
+155
View File
@@ -0,0 +1,155 @@
import os
import time
import uuid
import pytest_asyncio
from redis.asyncio import Redis
from app.ws.expiring_presence import ExpiringPresenceSet
class Clock:
def __init__(self) -> None:
self.now = time.time()
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
@pytest_asyncio.fixture
async def redis_client():
client = Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
yield client
await client.aclose()
@pytest_asyncio.fixture
async def setup(redis_client):
clock = Clock()
store = ExpiringPresenceSet(redis_client, ttl_seconds=90, clock=clock)
key = f"presence:v2:test:{uuid.uuid4()}"
yield store, clock, key
await redis_client.delete(key)
async def test_first_connection_is_a_transition_and_a_second_is_not(setup):
store, _, key = setup
user = uuid.uuid4()
assert await store.add(key, user, "tab1") is True
assert await store.add(key, user, "tab2") is False
assert await store.live_user_ids(key) == {user}
async def test_renewing_a_live_entry_is_not_a_transition(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(30)
assert await store.add(key, user, "tab1") is False
async def test_entry_stops_counting_after_the_ttl(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(89)
assert await store.is_live(key, user) is True
clock.advance(2)
assert await store.is_live(key, user) is False
assert await store.live_user_ids(key) == set()
async def test_renewal_keeps_a_connection_alive_past_the_original_expiry(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
for _ in range(5):
clock.advance(60)
await store.add(key, user, "tab1")
assert await store.is_live(key, user) is True
async def test_coming_back_after_expiry_is_a_transition_again(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(200)
assert await store.add(key, user, "tab1") is True
async def test_one_live_connection_keeps_the_user_online_when_another_expires(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "old-tab")
clock.advance(60)
await store.add(key, user, "new-tab")
clock.advance(60) # old-tab is now 120s stale, new-tab 60s
assert await store.is_live(key, user) is True
async def test_remove_reports_only_the_last_connection_leaving(setup):
store, _, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
await store.add(key, user, "tab2")
assert await store.remove(key, user, "tab1") is False
assert await store.remove(key, user, "tab2") is True
async def test_remove_of_an_entry_that_is_already_gone_reports_nothing(setup):
# Whoever removed it first (e.g. prune) already reported the transition.
store, _, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
await store.remove(key, user, "tab1")
assert await store.remove(key, user, "tab1") is False
async def test_remove_of_an_expired_but_unpruned_entry_still_reports_offline(setup):
# Nobody has been told this user went offline yet (no prune has run), so
# their eventual clean close is the one place left to report it.
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(200)
assert await store.remove(key, user, "tab1") is True
async def test_users_are_independent(setup):
store, _, key = setup
alice, bob = uuid.uuid4(), uuid.uuid4()
await store.add(key, alice, "a")
await store.add(key, bob, "b")
await store.remove(key, alice, "a")
assert await store.live_user_ids(key) == {bob}
async def test_prune_reports_users_who_lost_their_last_live_entry(setup):
store, clock, key = setup
gone, still_here = uuid.uuid4(), uuid.uuid4()
await store.add(key, gone, "tab1")
await store.add(key, still_here, "old")
clock.advance(60)
await store.add(key, still_here, "new")
clock.advance(40) # gone/tab1 and still_here/old are expired; still_here/new is not
assert await store.prune(key) == {gone}
async def test_prune_is_idempotent_and_removes_what_it_reports(setup):
store, clock, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
clock.advance(200)
assert await store.prune(key) == {user}
assert await store.prune(key) == set()
async def test_prune_with_nothing_expired_does_nothing(setup):
store, _, key = setup
user = uuid.uuid4()
await store.add(key, user, "tab1")
assert await store.prune(key) == set()
assert await store.is_live(key, user) is True
+105
View File
@@ -130,3 +130,108 @@ def test_online_users_endpoint_respects_appear_offline(ws_client):
online = ws_client.get("/api/users/online").json() online = ws_client.get("/api/users/online").json()
assert alice["id"] not in online assert alice["id"] not in online
# --- #77: liveness -----------------------------------------------------------
# A connection that stops answering the app-level heartbeat is dropped by the
# server, and a connection that does answer keeps its presence entry renewed.
# Timings are shrunk so nothing here waits on the real 30s/90s values.
def _fast_heartbeat(monkeypatch):
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
monkeypatch.setattr("app.ws.chat.WS_DEAD_AFTER_SECONDS", 0.2)
monkeypatch.setattr("app.ws.chat.PRESENCE_REFRESH_MIN_INTERVAL_SECONDS", 0)
def test_unresponsive_client_is_disconnected_by_the_server(ws_client, monkeypatch):
# The frozen-page / sleeping-laptop case: the transport stays open (the
# browser answers protocol pings itself) but the page never replies to
# the app-level ping.
from starlette.websockets import WebSocketDisconnect
_fast_heartbeat(monkeypatch)
_register_ws(ws_client, _unique("alice"))
with ws_client.websocket_connect("/ws/chat") as ws:
pings = 0
try:
while True:
assert ws.receive_json() == {"type": "ping"}
pings += 1
assert pings < 100, "server never gave up on a silent client"
except WebSocketDisconnect as exc:
assert exc.code == 4408
assert pings >= 2
def test_client_that_answers_pings_is_not_disconnected(ws_client, monkeypatch):
_fast_heartbeat(monkeypatch)
_register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
# Far longer than WS_DEAD_AFTER_SECONDS in total.
for _ in range(12):
assert ws.receive_json() == {"type": "ping"}
ws.send_json({"type": "pong"})
ws.send_json({"type": "join", "room_id": room["id"]})
while (msg := ws.receive_json())["type"] == "ping":
pass
assert msg == {"type": "joined", "room_id": room["id"]}
def test_bot_connection_is_not_dropped_for_ignoring_pings(ws_client_factory, monkeypatch):
# API-token clients aren't expected to speak the app heartbeat.
_fast_heartbeat(monkeypatch)
ws_client = ws_client_factory()
admin = _register_ws(ws_client, _unique("admin"))
async def _promote():
from app.models import User
async with ws_client.session_factory() as session:
user = await session.get(User, uuid.UUID(admin["id"]))
user.is_site_admin = True
await session.commit()
ws_client.portal.call(_promote)
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
token = ws_client.post(
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages", "write:messages"]}
).json()["token"]
ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
with ws_client.websocket_connect("/ws/chat", headers={"Authorization": f"Bearer {token}"}) as ws:
for _ in range(8): # ~0.4s of silence, twice WS_DEAD_AFTER_SECONDS
assert ws.receive_json() == {"type": "ping"}
ws.send_json({"type": "join", "room_id": room["id"]})
while (msg := ws.receive_json())["type"] == "ping":
pass
assert msg["type"] == "joined"
def test_presence_entry_is_renewed_while_the_client_answers(ws_client, monkeypatch):
import time
_fast_heartbeat(monkeypatch)
alice = _register_ws(ws_client, _unique("alice"))
async def _scores():
redis = ws_client.app.state.redis
return await redis.zrange("presence:v2:global", 0, -1, withscores=True)
with ws_client.websocket_connect("/ws/chat") as ws:
# Wait for the first ping so the connect-time entry exists.
assert ws.receive_json() == {"type": "ping"}
first = [s for m, s in ws_client.portal.call(_scores) if m.startswith(alice["id"])]
assert len(first) == 1
time.sleep(0.1)
ws.send_json({"type": "pong"})
# Any later frame arriving back proves the pong was processed.
while ws.receive_json()["type"] != "ping":
pass
second = [s for m, s in ws_client.portal.call(_scores) if m.startswith(alice["id"])]
assert second and second[0] > first[0]
+188
View File
@@ -0,0 +1,188 @@
import os
import time
import uuid
import pytest_asyncio
from redis.asyncio import Redis
from app.schemas.room import RoomCreate
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.services.room_service import create_room, find_or_create_dm
from app.ws.expiring_presence import ExpiringPresenceSet
from app.ws.global_presence import GlobalPresence
from app.ws.presence_sweeper import SWEEP_LOCK_KEY, purge_legacy_presence, sweep_once
class Clock:
def __init__(self) -> None:
self.now = time.time()
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
class FakeBroadcaster:
def __init__(self) -> None:
self.room_events: list[tuple[uuid.UUID, dict]] = []
self.user_events: list[tuple[uuid.UUID, dict]] = []
async def publish(self, room_id, payload):
self.room_events.append((room_id, payload))
async def publish_to_user(self, user_id, payload):
self.user_events.append((user_id, payload))
@pytest_asyncio.fixture
async def redis_client():
client = Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
await client.delete(GlobalPresence.KEY, SWEEP_LOCK_KEY)
yield client
await client.delete(GlobalPresence.KEY, SWEEP_LOCK_KEY)
await client.aclose()
@pytest_asyncio.fixture
async def world(redis_client, db_session):
clock = Clock()
presence = GlobalPresence(redis_client, ExpiringPresenceSet(redis_client, ttl_seconds=90, clock=clock))
broadcaster = FakeBroadcaster()
class _Factory:
def __call__(self):
return self
async def __aenter__(self):
return db_session
async def __aexit__(self, *exc):
return False
async def make_user(prefix: str):
name = f"{prefix}-{uuid.uuid4().hex[:8]}"
return await register_user(
db_session, UserCreate(username=name, email=f"{name}@example.com", password="password123")
)
async def sweep(**kwargs):
return await sweep_once(redis_client, presence, broadcaster, _Factory(), **kwargs)
return clock, presence, broadcaster, make_user, sweep
async def test_sweep_announces_a_user_whose_connection_expired(world, db_session):
clock, presence, broadcaster, make_user, sweep = world
alice, bob = await make_user("alice"), await make_user("bob")
room = await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
dm = await find_or_create_dm(db_session, alice.id, bob.id)
await presence.connect(alice.id, "tab1")
clock.advance(200)
assert await sweep(lock_seconds=None) == {alice.id}
room_ids = {rid for rid, p in broadcaster.room_events if p["type"] == "member_updated"}
assert {room.id, dm.id} <= room_ids
assert (bob.id, {"type": "dm_presence_update", "user_id": str(alice.id), "status": "offline"}) in (
broadcaster.user_events
)
assert await presence.is_online(alice.id) is False
async def test_sweep_announces_each_expiry_only_once(world, db_session):
clock, presence, broadcaster, make_user, sweep = world
alice = await make_user("alice")
await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
await presence.connect(alice.id, "tab1")
clock.advance(200)
await sweep(lock_seconds=None)
sent = len(broadcaster.room_events)
assert sent > 0
assert await sweep(lock_seconds=None) == set()
assert len(broadcaster.room_events) == sent
async def test_sweep_stays_quiet_while_the_user_has_another_live_connection(world, db_session):
clock, presence, broadcaster, make_user, sweep = world
alice = await make_user("alice")
await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
await presence.connect(alice.id, "old-tab")
clock.advance(60)
await presence.connect(alice.id, "new-tab")
clock.advance(60)
assert await sweep(lock_seconds=None) == set()
assert broadcaster.room_events == []
assert await presence.is_online(alice.id) is True
async def test_sweep_leaves_healthy_connections_alone(world, db_session):
clock, presence, broadcaster, make_user, sweep = world
alice = await make_user("alice")
await presence.connect(alice.id, "tab1")
clock.advance(30)
assert await sweep(lock_seconds=None) == set()
assert broadcaster.room_events == []
async def test_only_one_sweeper_runs_per_interval(world, db_session, redis_client):
clock, presence, broadcaster, make_user, sweep = world
alice = await make_user("alice")
await presence.connect(alice.id, "tab1")
clock.advance(200)
# Another worker holds the lock: this one must not sweep (or broadcast).
await redis_client.set(SWEEP_LOCK_KEY, "1", ex=14)
assert await sweep(lock_seconds=14) == set()
assert await redis_client.zcard(GlobalPresence.KEY) == 1
await redis_client.delete(SWEEP_LOCK_KEY)
assert await sweep(lock_seconds=14) == {alice.id}
# And the lock is now held, so an immediate second attempt does nothing.
assert await sweep(lock_seconds=14) == set()
async def test_one_failed_broadcast_does_not_stop_the_rest(world, db_session):
clock, presence, broadcaster, make_user, sweep = world
alice, bob = await make_user("alice"), await make_user("bob")
room = await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
other = await create_room(db_session, bob.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
await presence.connect(alice.id, "a")
await presence.connect(bob.id, "b")
clock.advance(200)
real_publish = broadcaster.publish
failed = []
async def flaky_publish(room_id, payload):
if room_id == room.id:
failed.append(room_id)
raise RuntimeError("redis hiccup")
await real_publish(room_id, payload)
broadcaster.publish = flaky_publish
assert await sweep(lock_seconds=None) == {alice.id, bob.id}
assert failed == [room.id]
assert other.id in {rid for rid, _ in broadcaster.room_events}
async def test_purge_removes_legacy_keys_but_keeps_v2(redis_client):
legacy = ["presence:global", "presence:unfocused", f"presence:{uuid.uuid4()}"]
current = f"presence:v2:test:{uuid.uuid4()}"
for key in legacy:
await redis_client.hset(key, "someone", 3)
await redis_client.zadd(current, {"x:y": time.time() + 90})
try:
assert await purge_legacy_presence(redis_client) >= len(legacy)
for key in legacy:
assert await redis_client.exists(key) == 0
assert await redis_client.exists(current) == 1
finally:
await redis_client.delete(current, *legacy)
+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.26", "version": "2026.10.22",
"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",
+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>
)
}
+27
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);
@@ -386,6 +391,7 @@
} }
.message-reaction-pills { .message-reaction-pills {
position: relative;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
@@ -425,3 +431,24 @@
width: 100%; width: 100%;
resize: vertical; resize: vertical;
} }
/* The picker anchors to the row of pills, not to this button: the button's
position moves right with every added reaction, and a picker hanging
off it ran past the edge of a phone screen. (No `position` here on
purpose -- .message-reaction-wrap's relative would re-anchor it.) */
.message-reaction-add-wrap {
display: contents;
}
/* #78: always visible once a message has reactions, so adding to the pile
doesn't need the hover-row button. Same shape as the pills it sits
beside, kept quieter so it doesn't read as another reaction. */
.message-reaction-add {
gap: 2px;
opacity: 0.8;
}
.message-reaction-add:hover,
.message-reaction-add:focus-visible {
opacity: 1;
}
@@ -0,0 +1,122 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { Message } from '../types'
import { MessageList } from './MessageList'
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({ user: { id: 'u1', username: 'alice', emoji_scale: 'normal' } }),
}))
vi.mock('../context/CustomEmojiContext', () => ({
useCustomEmoji: () => ({ byShortcode: new Map(), list: [], refresh: () => {} }),
}))
beforeAll(() => {
// jsdom doesn't implement it; MessageList scrolls to the bottom on mount.
Element.prototype.scrollIntoView = () => {}
})
afterEach(cleanup)
function message(overrides: Partial<Message>): Message {
return {
id: 'm1',
room_id: 'r1',
user_id: 'u2',
username: 'bob',
content: 'hello',
image_id: null,
file: null,
link_preview: null,
reactions: [],
created_at: '2026-10-20T16:46:00Z',
edited_at: null,
deleted_at: null,
...overrides,
}
}
function renderList(messages: Message[], onReact = vi.fn()) {
render(
<MessageList
roomId="r1"
messages={messages}
members={[]}
myRooms={new Map()}
onEdit={() => {}}
onReact={onReact}
onDelete={() => {}}
/>,
)
return onReact
}
const reacted = [{ emoji: '🎉', count: 1, user_ids: ['u2'] }]
describe('MessageList add-reaction button (#78)', () => {
it('shows one in the reaction row only when the message already has reactions', () => {
renderList([
message({ id: 'm1', content: 'with', reactions: reacted }),
message({ id: 'm2', content: 'without', reactions: [] }),
])
const pills = document.querySelectorAll('.message-reaction-pills')
expect(pills).toHaveLength(1)
expect(within(pills[0] as HTMLElement).getByRole('button', { name: 'Add reaction' })).toBeTruthy()
})
it('keeps the hover-row button on every message', () => {
renderList([
message({ id: 'm1', reactions: reacted }),
message({ id: 'm2', content: 'plain', reactions: [] }),
])
// Two on the reacted message (row + pills), one on the plain one.
expect(screen.getAllByRole('button', { name: 'Add reaction' })).toHaveLength(3)
})
it('opens the picker next to the pills and reacts with the pick', () => {
const onReact = renderList([message({ reactions: reacted })])
const pillsAdd = within(document.querySelector('.message-reaction-pills') as HTMLElement).getByRole(
'button',
{ name: 'Add reaction' },
)
fireEvent.click(pillsAdd)
const pickers = document.querySelectorAll('.emoji-picker')
expect(pickers).toHaveLength(1)
expect(document.querySelector('.message-reaction-pills .emoji-picker')).not.toBeNull()
fireEvent.click(within(pickers[0] as HTMLElement).getAllByRole('menuitem')[0])
expect(onReact).toHaveBeenCalledTimes(1)
expect(onReact.mock.calls[0][0]).toBe('m1')
expect(document.querySelector('.emoji-picker')).toBeNull()
})
it('opens only one picker at a time, in whichever place was used last', () => {
renderList([message({ reactions: reacted })])
const [rowButton, pillsButton] = [
document.querySelector('.message-row-actions .message-reaction-trigger') as HTMLElement,
document.querySelector('.message-reaction-pills .message-reaction-add') as HTMLElement,
]
fireEvent.click(pillsButton)
expect(document.querySelectorAll('.emoji-picker')).toHaveLength(1)
fireEvent.click(rowButton)
expect(document.querySelectorAll('.emoji-picker')).toHaveLength(1)
expect(document.querySelector('.message-row-actions .emoji-picker')).not.toBeNull()
expect(document.querySelector('.message-reaction-pills .emoji-picker')).toBeNull()
})
it('clicking the same button again closes its picker', () => {
renderList([message({ reactions: reacted })])
const pillsButton = document.querySelector('.message-reaction-pills .message-reaction-add') as HTMLElement
fireEvent.click(pillsButton)
expect(document.querySelector('.emoji-picker')).not.toBeNull()
fireEvent.click(pillsButton)
expect(document.querySelector('.emoji-picker')).toBeNull()
})
it('is not offered on a deleted message', () => {
renderList([message({ content: null, deleted_at: '2026-10-21T10:00:00Z', reactions: [] })])
expect(screen.queryByRole('button', { name: 'Add reaction' })).toBeNull()
})
})
+59 -35
View File
@@ -1,9 +1,11 @@
import type { CSSProperties } from 'react' import type { CSSProperties, MouseEvent as ReactMouseEvent } 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).
@@ -150,6 +146,10 @@ export function MessageList({
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null) const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null) const [reactingId, setReactingId] = useState<string | null>(null)
// #78: a message has two buttons that open the same picker (the hover-row
// one and the one at the end of its reaction pills); this says which one
// the picker belongs to, so it renders next to the button that was used.
const [reactingFrom, setReactingFrom] = useState<'row' | 'pills'>('row')
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below') const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null) const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]) const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members])
@@ -159,6 +159,25 @@ export function MessageList({
return member?.display_name || member?.username || 'someone' return member?.display_name || member?.username || 'someone'
} }
function toggleReactionPicker(
e: ReactMouseEvent<HTMLButtonElement>,
messageId: string,
from: 'row' | 'pills',
) {
if (reactingId === messageId && reactingFrom === from) {
setReactingId(null)
return
}
// Flip upward when the picker wouldn't fit below the trigger -- a
// message near the bottom of the scrolled list otherwise opens a
// picker that runs off-screen and can't be used.
const rect = e.currentTarget.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
setReactionPlacement(spaceBelow < EMOJI_PICKER_MAX_HEIGHT ? 'above' : 'below')
setReactingFrom(from)
setReactingId(messageId)
}
useEffect(() => { useEffect(() => {
pinnedToBottomRef.current = true pinnedToBottomRef.current = true
bottomRef.current?.scrollIntoView({ block: 'end' }) bottomRef.current?.scrollIntoView({ block: 'end' })
@@ -218,19 +237,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 +255,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>
)} )}
@@ -329,6 +344,28 @@ export function MessageList({
</button> </button>
) )
})} })}
<div className="message-reaction-add-wrap">
<button
type="button"
className="message-reaction-pill message-reaction-add"
onClick={(e) => toggleReactionPicker(e, msg.id, 'pills')}
aria-label="Add reaction"
>
<span aria-hidden="true">+</span>
<span aria-hidden="true">🙂</span>
</button>
{reactingId === msg.id && reactingFrom === 'pills' && (
<EmojiPicker
onPick={(emoji) => {
onReact(msg.id, emoji)
setReactingId(null)
}}
onClose={() => setReactingId(null)}
placement={reactionPlacement}
align="left"
/>
)}
</div>
</div> </div>
)} )}
</> </>
@@ -340,25 +377,12 @@ export function MessageList({
<button <button
type="button" type="button"
className="message-reaction-trigger" className="message-reaction-trigger"
onClick={(e) => { onClick={(e) => toggleReactionPicker(e, msg.id, 'row')}
if (reactingId === msg.id) {
setReactingId(null)
return
}
// Flip upward when the picker wouldn't fit below the
// trigger -- a message near the bottom of the
// scrolled list otherwise opens a picker that runs
// off-screen and can't be used.
const rect = e.currentTarget.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
setReactionPlacement(spaceBelow < EMOJI_PICKER_MAX_HEIGHT ? 'above' : 'below')
setReactingId(msg.id)
}}
aria-label="Add reaction" aria-label="Add reaction"
> >
🙂 🙂
</button> </button>
{reactingId === msg.id && ( {reactingId === msg.id && reactingFrom === 'row' && (
<EmojiPicker <EmojiPicker
onPick={(emoji) => { onPick={(emoji) => {
onReact(msg.id, emoji) onReact(msg.id, emoji)
@@ -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)
+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',
},
})