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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:37:58 -06:00
28 changed files with 2103 additions and 40 deletions

No files matched your search

+7 -1
View File
@@ -70,7 +70,13 @@ send. Use **Shift+Enter** to add a line break without sending.
## Formatting ## Formatting
Messages support Markdown: Messages support Markdown. Prefer buttons to typing the syntax? Click the
**Aa** button next to the emoji button in the composer to open a formatting
toolbar — it inserts the markdown below into your message for you (select some
text first to wrap it, or click a button again to take the formatting back
off). It stays collapsed until you open it, and remembers your choice.
Or type it yourself:
- `**bold**`, `*italic*`, `~~strikethrough~~` - `**bold**`, `*italic*`, `~~strikethrough~~`
- `` `inline code` `` and fenced code blocks (three backticks) - `` `inline code` `` and fenced code blocks (three backticks)
+6 -2
View File
@@ -58,8 +58,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# ASGITransport, used by the plain REST test fixtures -- only # ASGITransport, used by the plain REST test fixtures -- only
# TestClient's websocket_connect-based tests actually run lifespan). # TestClient's websocket_connect-based tests actually run lifespan).
# The background listener task genuinely needs a running event loop # The background listener task genuinely needs a running event loop
# though, so that part stays here. # though, so that part stays here. run_forever() (not listen() directly)
listener_task = asyncio.create_task(app.state.broadcaster.listen()) # -- #76: a bare listen() here died permanently and silently the moment
# its Redis connection ever dropped (e.g. Redis itself restarting),
# with nothing to notice or reconnect; run_forever() supervises it and
# restarts on failure instead.
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
yield yield
+83 -10
View File
@@ -1,13 +1,26 @@
import asyncio
import contextlib
import json import json
import logging
import uuid import uuid
from redis.asyncio import Redis from redis.asyncio import Redis
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
logger = logging.getLogger(__name__)
ROOM_CHANNEL_PREFIX = "room:" ROOM_CHANNEL_PREFIX = "room:"
USER_CHANNEL_PREFIX = "user:" USER_CHANNEL_PREFIX = "user:"
# #76: a run shorter than this before dying doesn't count as "stable" for
# resetting the backoff in run_forever() below -- long enough that a real,
# working connection has clearly settled in, short enough not to leave a
# just-recovered listener exposed to a full backoff climb if Redis blips
# again shortly after reconnecting.
STABLE_RUN_SECONDS = 60
MAX_BACKOFF_SECONDS = 30
class Broadcaster: class Broadcaster:
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5). """Cross-instance message fan-out (ARCHITECTURE.md phase 5).
@@ -36,6 +49,43 @@ class Broadcaster:
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None: async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload)) await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
async def run_forever(self) -> None:
"""Supervises listen(), restarting it if it ever dies.
#76's real root cause, found after the per-message try/except below
turned out not to be enough: this is the *one* pubsub listener for
the whole process (see main.py's lifespan), created once at startup
with nothing watching over it afterward. A connection-level failure
-- Redis restarting, a network blip between the app and Redis --
raises from the `async for` iteration in listen() itself, outside
that per-message guard, and previously killed this task permanently
and silently: HTTP/REST and login kept working fine (neither
touches this), but live message delivery was gone for every room
and every user on this instance until the whole process was
restarted. Confirmed live in production: Redis got restarted by an
OS update, and `PUBSUB NUMPAT` against it afterward showed zero
pattern subscriptions across all 4 gunicorn workers -- every one of
them had silently lost its listener with nothing to bring it back.
"""
backoff_seconds = 1.0
while True:
started_at = asyncio.get_event_loop().time()
try:
await self.listen()
logger.warning("Broadcaster.listen() exited without error -- restarting")
except asyncio.CancelledError:
raise
except Exception:
logger.error("Broadcaster.listen() failed -- restarting", exc_info=True)
ran_for = asyncio.get_event_loop().time() - started_at
# Sleep at the *current* backoff first, then grow it for next
# time -- computing the next value before sleeping would skip
# straight past the first (shortest) retry delay.
await asyncio.sleep(backoff_seconds)
backoff_seconds = (
1.0 if ran_for > STABLE_RUN_SECONDS else min(backoff_seconds * 2, MAX_BACKOFF_SECONDS)
)
async def listen(self) -> None: async def listen(self) -> None:
pubsub = self._redis.pubsub() pubsub = self._redis.pubsub()
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*") await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
@@ -43,14 +93,37 @@ class Broadcaster:
async for message in pubsub.listen(): async for message in pubsub.listen():
if message["type"] != "pmessage": if message["type"] != "pmessage":
continue continue
channel = message["channel"] # #76: this is the one pubsub listener for the whole process
payload = json.loads(message["data"]) # (see main.py's lifespan) -- ConnectionManager.broadcast/
if channel.startswith(ROOM_CHANNEL_PREFIX): # send_to_user already guard against one dead socket taking
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX)) # the rest of a single fan-out down, but this belt-and-
await self._manager.broadcast(room_id, payload) # suspenders catch is for anything else unexpected (a
elif channel.startswith(USER_CHANNEL_PREFIX): # malformed payload, e.g.) doing the same. An uncaught
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX)) # exception here previously meant *this whole async for*
await self._manager.send_to_user(user_id, payload) # loop died silently -- no more live delivery to any room on
# this instance, for anyone, until the process restarted.
try:
channel = message["channel"]
payload = json.loads(message["data"])
if channel.startswith(ROOM_CHANNEL_PREFIX):
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
await self._manager.broadcast(room_id, payload)
elif channel.startswith(USER_CHANNEL_PREFIX):
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
await self._manager.send_to_user(user_id, payload)
except Exception:
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
finally: finally:
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*") # #76: reached via a connection that's already dead just as
await pubsub.aclose() # often as a clean shutdown (that's the whole failure mode
# run_forever() now recovers from) -- punsubscribe/aclose over
# a broken connection would themselves raise, masking whatever
# actually killed the loop above and, since run_forever() only
# starts backing off *after* listen() returns/raises, delaying
# the reconnect for no benefit (a fresh pubsub object is about
# to be created on retry regardless of whether this cleanup
# succeeded).
with contextlib.suppress(Exception):
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
with contextlib.suppress(Exception):
await pubsub.aclose()
+33 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import uuid import uuid
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
@@ -31,6 +32,18 @@ router = APIRouter(tags=["ws"])
WS_UNAUTHENTICATED = 4401 WS_UNAUTHENTICATED = 4401
# #76: comfortably under the ~60s idle-connection timeout common to reverse
# proxies/load balancers sitting in front of this endpoint, and short enough
# that the frontend's own zombie-detection watchdog (useChatSocket.ts) can
# notice a dead connection and reconnect well within a session -- a socket
# can go silently dead (no close frame at all, e.g. a laptop sleeping or a
# NAT dropping an idle mapping) without this, and no live message reaches it
# again until *something* else finally triggers a close, which used to be
# "switch rooms and back" purely by accident (that remounts the message
# list, which refetches history over plain REST -- unrelated to the socket
# itself actually recovering).
WS_PING_INTERVAL_SECONDS = 30
class ClientEnvelope(BaseModel): class ClientEnvelope(BaseModel):
type: str type: str
@@ -124,7 +137,18 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
try: try:
while True: while True:
raw = await websocket.receive_json() try:
raw = await asyncio.wait_for(
websocket.receive_json(), timeout=WS_PING_INTERVAL_SECONDS
)
except asyncio.TimeoutError:
# Nothing received in a while -- ping rather than assume the
# connection's still good. A genuinely dead socket fails this
# send (raising, caught below same as any other disconnect);
# a merely quiet one just gets proded and the loop continues
# waiting.
await websocket.send_json({"type": "ping"})
continue
try: try:
try: try:
envelope = ClientEnvelope.model_validate(raw) envelope = ClientEnvelope.model_validate(raw)
@@ -146,6 +170,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
joined_rooms.add(envelope.room_id) joined_rooms.add(envelope.room_id)
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)}) await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
elif envelope.type == "pong":
# #76: the client's reply to our "ping" above -- nothing
# to do with it. Its only purpose is resetting the
# frontend's own liveness watchdog; the timeout on the
# receive_json() above already gets reset by receiving
# any frame at all, this one included.
pass
elif envelope.type == "leave": elif envelope.type == "leave":
if envelope.room_id is None: if envelope.room_id is None:
await websocket.send_json({"type": "error", "detail": "room_id required"}) await websocket.send_json({"type": "error", "detail": "room_id required"})
+19 -2
View File
@@ -1,8 +1,11 @@
import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import WebSocket from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager: class ConnectionManager:
"""Local, single-process WebSocket socket registry. """Local, single-process WebSocket socket registry.
@@ -43,8 +46,22 @@ class ConnectionManager:
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None: async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._rooms.get(room_id, ())): for websocket in list(self._rooms.get(room_id, ())):
await websocket.send_json(payload) # #76: one dead/racing socket (e.g. its own disconnect cleanup
# hasn't finished removing it from _rooms yet) must not take the
# rest of this broadcast down with it -- this runs inside
# Broadcaster.listen()'s single, never-restarted per-process
# pubsub loop, so an unhandled exception here previously meant
# one bad socket silently killed *live delivery for every room
# on this instance*, not just the one socket, until the process
# was restarted.
try:
await websocket.send_json(payload)
except Exception:
logger.warning("Dropping dead socket during broadcast to room %s", room_id, exc_info=True)
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None: async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._users.get(user_id, ())): for websocket in list(self._users.get(user_id, ())):
await websocket.send_json(payload) try:
await websocket.send_json(payload)
except Exception:
logger.warning("Dropping dead socket sending to user %s", user_id, exc_info=True)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ds-chat" name = "ds-chat"
version = "2026.9.4" version = "2026.10.2"
description = "DS Chat backend service" description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" } license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -0,0 +1,61 @@
import asyncio
import pytest
from app.ws.broadcaster import Broadcaster
async def test_run_forever_restarts_after_listen_raises(monkeypatch):
# #76: listen() dying (a dropped Redis connection, most commonly -- e.g.
# Redis itself restarting) must not permanently kill live delivery for
# the whole process. run_forever() has to notice and restart it, not
# just let the background task disappear silently while everything else
# (login, REST endpoints) keeps working fine.
broadcaster = Broadcaster(redis=None, manager=None)
calls = 0
async def fake_listen():
nonlocal calls
calls += 1
if calls < 3:
raise ConnectionError("redis went away")
# Stop the loop once the retry behavior below is proven, rather
# than looping forever in a test.
raise asyncio.CancelledError()
monkeypatch.setattr(broadcaster, "listen", fake_listen)
sleep_calls = []
async def fake_sleep(seconds):
sleep_calls.append(seconds)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
with pytest.raises(asyncio.CancelledError):
await broadcaster.run_forever()
assert calls == 3
# Slept once after each of the first two failures (exponential:
# 1s, then 2s) -- not after the third, which is the CancelledError
# that stops the loop before reaching the sleep.
assert sleep_calls == [1.0, 2.0]
async def test_run_forever_does_not_swallow_cancellation(monkeypatch):
# A genuine shutdown (the app's own lifespan cancels this task) must
# propagate immediately, not get treated as just another failure to
# retry after a backoff.
broadcaster = Broadcaster(redis=None, manager=None)
async def fake_listen():
raise asyncio.CancelledError()
monkeypatch.setattr(broadcaster, "listen", fake_listen)
async def fail_if_called(seconds):
raise AssertionError("should not sleep/retry on cancellation")
monkeypatch.setattr(asyncio, "sleep", fail_if_called)
with pytest.raises(asyncio.CancelledError):
await broadcaster.run_forever()
+18
View File
@@ -109,6 +109,24 @@ def test_history_tiebreaks_identical_timestamps_by_id(ws_client):
assert [m["content"] for m in history] == [m["content"] for m in expected_order] assert [m["content"] for m in history] == [m["content"] for m in expected_order]
def test_ws_sends_heartbeat_ping(ws_client, monkeypatch):
# #76: with nothing else sent on the connection, a "ping" must arrive
# proactively once the idle timeout elapses -- shrunk here so the test
# doesn't actually wait the real 30s. Replying "pong" (the frontend's
# own behavior) must be accepted silently and leave the connection
# fully usable afterward, not treated as an unknown message type.
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
username = _unique("alice")
_register(ws_client, username=username)
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
assert ws.receive_json() == {"type": "ping"}
ws.send_json({"type": "pong"})
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json() == {"type": "joined", "room_id": room["id"]}
def test_ws_message_without_join_errors(ws_client): def test_ws_message_without_join_errors(ws_client):
_register(ws_client, username=_unique("alice")) _register(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json() room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+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.4", "version": "2026.10.2",
"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>
)
}
@@ -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])
}
+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,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)
}
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { MyRoomItem } from '../types'
import { clearRoomIndicators } from './roomIndicators'
function makeRoom(overrides: Partial<MyRoomItem> & { id: string }): MyRoomItem {
return {
name: overrides.id,
description: null,
is_private: false,
is_dm: false,
is_archived: false,
owner_id: 'owner',
created_at: '2026-01-01T00:00:00Z',
role: 'member',
has_unread: false,
has_mention: false,
dm_partner: null,
email_notifications: false,
...overrides,
}
}
describe('clearRoomIndicators', () => {
it('clears has_unread and has_mention on the matching room', () => {
const rooms = [makeRoom({ id: 'room-1', has_unread: true, has_mention: true })]
const result = clearRoomIndicators(rooms, 'room-1')
expect(result[0].has_unread).toBe(false)
expect(result[0].has_mention).toBe(false)
})
it('preserves unread state on every other room', () => {
const rooms = [
makeRoom({ id: 'room-1', has_unread: true, has_mention: true }),
makeRoom({ id: 'room-2', has_unread: true, has_mention: false }),
makeRoom({ id: 'room-3', has_unread: false, has_mention: false }),
]
const result = clearRoomIndicators(rooms, 'room-1')
const room2 = result.find((r) => r.id === 'room-2')
const room3 = result.find((r) => r.id === 'room-3')
expect(room2?.has_unread).toBe(true)
expect(room3?.has_unread).toBe(false)
})
it('leaves untouched rooms as the same object reference', () => {
const room2 = makeRoom({ id: 'room-2', has_unread: true })
const rooms = [makeRoom({ id: 'room-1', has_unread: true }), room2]
const result = clearRoomIndicators(rooms, 'room-1')
expect(result[1]).toBe(room2)
})
it('is a no-op if the room id is not found', () => {
const rooms = [makeRoom({ id: 'room-1', has_unread: true })]
const result = clearRoomIndicators(rooms, 'does-not-exist')
expect(result[0].has_unread).toBe(true)
})
})
+9
View File
@@ -0,0 +1,9 @@
import type { MyRoomItem } from '../types'
// Pulled out of ChatShellPage as a pure function purely so it's directly
// unit-testable without rendering the whole page -- behavior is unchanged.
// Only touches the matching room; every other room's object reference (and
// therefore its unread state) is left untouched.
export function clearRoomIndicators(rooms: MyRoomItem[], roomId: string): MyRoomItem[] {
return rooms.map((r) => (r.id === roomId ? { ...r, has_unread: false, has_mention: false } : r))
}
+9 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client' import { NetworkError } from '../api/client'
import { listMyRooms, listRoomMembers } from '../api/rooms' import { listMyRooms, listRoomMembers } from '../api/rooms'
@@ -12,7 +12,9 @@ import { Sidebar } from '../components/Sidebar'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useChatSocketContext } from '../context/ChatSocketContext' import { useChatSocketContext } from '../context/ChatSocketContext'
import { useDesktopRoomBridge } from '../hooks/useDesktopRoomBridge'
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth' import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import { clearRoomIndicators } from '../lib/roomIndicators'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import './ChatShellPage.css' import './ChatShellPage.css'
@@ -60,10 +62,13 @@ export function ChatShellPage() {
const socket = useChatSocketContext() const socket = useChatSocketContext()
const clearRoomIndicators = useCallback((id: string) => { const handleRoomRead = useCallback((id: string) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r))) setRooms((prev) => clearRoomIndicators(prev, id))
}, []) }, [])
const unreadRoomCount = useMemo(() => rooms.filter((r) => r.has_unread).length, [rooms])
useDesktopRoomBridge(activeRoom?.id, unreadRoomCount)
useEffect( useEffect(
() => () =>
socket.subscribe((envelope) => { socket.subscribe((envelope) => {
@@ -134,7 +139,7 @@ export function ChatShellPage() {
onToggleInfo={() => setInfoOpen((v) => !v)} onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen} infoOpen={infoOpen}
socket={socket} socket={socket}
onRoomRead={clearRoomIndicators} onRoomRead={handleRoomRead}
/> />
) : ( ) : (
!isMobile && ( !isMobile && (
+10 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Markdown from 'markdown-to-jsx' import Markdown from 'markdown-to-jsx'
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent' import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
import './HelpPage.css' import './HelpPage.css'
@@ -20,6 +20,15 @@ export function HelpPage() {
[content], [content],
) )
// #75: the composer's "?" links straight to /help#formatting, but the guide
// is fetched after the page loads -- the browser's own scroll-to-hash has
// already run (and found nothing) by the time the headings exist.
const { hash } = useLocation()
useEffect(() => {
if (!markdownPreview || !hash) return
document.getElementById(hash.slice(1))?.scrollIntoView()
}, [markdownPreview, hash])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
fetch(GUIDE_URL) fetch(GUIDE_URL)
+44 -5
View File
@@ -10,6 +10,13 @@ interface UseChatSocketOptions {
const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30000 const RECONNECT_MAX_DELAY_MS = 30000
// #76: the backend pings every 30s (WS_PING_INTERVAL_SECONDS in chat.py) --
// this needs to comfortably tolerate one missed ping plus ordinary network
// jitter before declaring the connection dead, not fire on the very first
// late beat.
const ZOMBIE_TIMEOUT_MS = 65000
const ZOMBIE_CHECK_INTERVAL_MS = 10000
const desktopMode = isDesktopNotificationsSupported() const desktopMode = isDesktopNotificationsSupported()
// Room join/leave (live message delivery) depends only on visibility -- // Room join/leave (live message delivery) depends only on visibility --
@@ -51,6 +58,16 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
// live WebSocket delivery the user can't actually see will do the job. // live WebSocket delivery the user can't actually see will do the job.
const desiredRoomsRef = useRef(new Set<string>()) const desiredRoomsRef = useRef(new Set<string>())
const isVisibleRef = useRef(isVisible()) const isVisibleRef = useRef(isVisible())
// #76: last time *anything* arrived on the socket (a real message, a
// "ping", doesn't matter which) -- the only signal that a connection
// sitting at readyState OPEN is actually still alive rather than a
// zombie. Without this, a connection that dies without ever sending a
// close frame (laptop sleep, a NAT silently dropping an idle mapping)
// looks identical to a healthy-but-quiet one: onclose never fires, so the
// already-correct reconnect+rejoin logic below never runs, and new
// messages just stop arriving until something unrelated (e.g. switching
// rooms, which refetches history over plain REST) papers over it.
const lastActivityRef = useRef(Date.now())
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => { const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
const ws = socketRef.current const ws = socketRef.current
@@ -99,6 +116,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
if (hasConnectedBefore) checkForUpdate() if (hasConnectedBefore) checkForUpdate()
hasConnectedBefore = true hasConnectedBefore = true
reconnectDelay = RECONNECT_BASE_DELAY_MS reconnectDelay = RECONNECT_BASE_DELAY_MS
lastActivityRef.current = Date.now()
setConnected(true) setConnected(true)
// Re-join whatever rooms were joined before a reconnect -- the // Re-join whatever rooms were joined before a reconnect -- the
// server has no memory of a dropped connection's prior state. Only // server has no memory of a dropped connection's prior state. Only
@@ -122,7 +140,17 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (socketRef.current !== ws) return if (socketRef.current !== ws) return
const envelope = JSON.parse(event.data) as ServerEnvelope lastActivityRef.current = Date.now()
const parsed = JSON.parse(event.data)
// #76: the server's heartbeat -- purely transport-level, not a
// domain event, so it's answered and swallowed right here instead
// of being forwarded to subscribers (ServerEnvelope's own type
// doesn't include it for exactly that reason).
if (parsed.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }))
return
}
const envelope = parsed as ServerEnvelope
for (const handler of subscribersRef.current) handler(envelope) for (const handler of subscribersRef.current) handler(envelope)
} }
@@ -143,10 +171,9 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
if (stopped) return if (stopped) return
// Unexpected close -- a deploy restarting the app server, a brief // Unexpected close -- a deploy restarting the app server, a brief
// network blip, or (absent any app-level ping/pong) an idle // network blip, or an idle connection getting recycled by a reverse
// connection getting recycled by a reverse proxy in front of it. // proxy in front of it. Retry with exponential backoff instead of
// Retry with exponential backoff instead of leaving the chat // leaving the chat silently dead until the user manually reloads.
// silently dead until the user manually reloads.
reconnectTimer = setTimeout(connect, reconnectDelay) reconnectTimer = setTimeout(connect, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS) reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
} }
@@ -154,9 +181,21 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
connect() connect()
// #76: catches the case onclose can't -- a connection that dies without
// ever sending a close frame at all (readyState stays OPEN forever) is
// otherwise invisible to this hook. Forcing a close here just routes it
// through the exact same onclose/reconnect/rejoin path as a normal
// disconnect, rather than needing separate recovery logic of its own.
const zombieCheckTimer = setInterval(() => {
if (Date.now() - lastActivityRef.current > ZOMBIE_TIMEOUT_MS) {
socketRef.current?.close()
}
}, ZOMBIE_CHECK_INTERVAL_MS)
return () => { return () => {
stopped = true stopped = true
if (reconnectTimer) clearTimeout(reconnectTimer) if (reconnectTimer) clearTimeout(reconnectTimer)
clearInterval(zombieCheckTimer)
socketRef.current?.close() socketRef.current?.close()
socketRef.current = null socketRef.current = null
} }
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// Deliberately separate from vite.config.ts, not an extension of it -- that
// file's VitePWA plugin does real build-time work (service worker
// injection, manifest generation) that has nothing to do with running unit
// tests and would just slow every test run down for no benefit.
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
},
})