Private
Public Access
Compare commits
13
Commits
v2026.9.23
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e07c00b99 | ||
|
|
319b7097c2 | ||
|
|
0065b4ced3 | ||
|
|
b52db0ebe0 | ||
|
|
dda85a2b78 | ||
|
|
7ff7f2ec0e | ||
|
|
62b5eec2fb | ||
|
|
eb30dc1753 | ||
|
|
6975ed4dc0 | ||
|
|
c54c1bf97d | ||
|
|
3349cadea2 | ||
|
|
047f47d524 | ||
|
|
850d9ec0fa |
No files matched your search
+145
-4
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -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,
|
||||||
@@ -58,14 +63,30 @@ 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())
|
||||||
|
|
||||||
|
# #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()
|
||||||
|
|
||||||
|
|
||||||
@@ -81,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)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -11,6 +13,14 @@ 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).
|
||||||
@@ -39,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}*")
|
||||||
@@ -67,5 +114,16 @@ class Broadcaster:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
|
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()
|
||||||
+87
-10
@@ -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),
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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}
|
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ds-chat"
|
name = "ds-chat"
|
||||||
version = "2026.9.23"
|
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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
Generated
+953
-11
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2026.9.23",
|
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in new issue
Block a user