Private
Public Access
@username tokens in a sent message are parsed against the room's actual members (skipping fenced/inline code, so pasted code isn't misread) and recorded as MessageMention rows, reusing #38's read-tracking and offline-member broadcast infrastructure rather than building a parallel notification path: - Sidebar: a mentioned-and-unread room shows a distinct highlight- colored badge instead of (not alongside) the plain unread dot -- computed the same way as has_unread, just scoped to messages that mention the caller, and cleared by the same last_read_at mark-read flow. - Push notifications: a mentioned offline recipient gets "X mentioned you: ..." instead of the generic "X: ...", still per-recipient since the same message can page some room members and not others. - Message rendering: a validated @username is highlighted inline, implemented by turning it into a `[@username](mention:username)` link before markdown parsing and overriding link rendering to style `mention:`-scheme links as a span instead of an anchor -- reuses markdown-to-jsx's existing parser rather than hand-rolling text-node splitting. - Composer: typing @ opens an autocomplete dropdown of matching room members (arrow keys to navigate, Enter/Tab/click to insert, Escape or moving the cursor away to dismiss). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
8.1 KiB
Python
212 lines
8.1 KiB
Python
import uuid
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def _recv(ws) -> dict:
|
|
"""Reads the next frame, discarding member_updated presence-change
|
|
broadcasts -- another connection going online/offline is real, expected
|
|
noise these tests aren't about."""
|
|
while True:
|
|
msg = ws.receive_json()
|
|
if msg.get("type") != "member_updated":
|
|
return msg
|
|
|
|
|
|
def _register_ws(ws_client, username: str) -> dict:
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
async def _seed():
|
|
async with ws_client.session_factory() as session:
|
|
await register_user(
|
|
session,
|
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
|
)
|
|
|
|
ws_client.portal.call(_seed)
|
|
resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def _has_mention(rooms: list[dict], room_id: str) -> bool:
|
|
return next(r for r in rooms if r["id"] == room_id)["has_mention"]
|
|
|
|
|
|
def _send_and_sync(ws, room_id: str, content: str) -> dict:
|
|
"""See test_unread.py's identical helper -- a sync barrier so the
|
|
message frame's full handling (including the offline-notify step this
|
|
feature hooks into) is guaranteed complete before checking anything."""
|
|
ws.send_json({"type": "message", "room_id": room_id, "content": content})
|
|
message = ws.receive_json()
|
|
ws.send_json({"type": "join", "room_id": room_id})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
return message
|
|
|
|
|
|
def test_mention_notifies_only_the_mentioned_offline_member(ws_client_factory):
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
instance3 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
carol = _register_ws(instance3, _unique("carol"))
|
|
instance3.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
with instance2.websocket_connect("/ws/chat") as bob_ws, instance3.websocket_connect(
|
|
"/ws/chat"
|
|
) as carol_ws:
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
message = _send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}, look at this")
|
|
assert message["type"] == "message"
|
|
|
|
bob_update = _recv(bob_ws)
|
|
assert bob_update == {"type": "unread_update", "room_id": room["id"], "mentioned": True}
|
|
|
|
carol_update = _recv(carol_ws)
|
|
assert carol_update == {"type": "unread_update", "room_id": room["id"], "mentioned": False}
|
|
|
|
bob_rooms = instance2.get("/api/rooms/mine").json()
|
|
assert _has_mention(bob_rooms, room["id"]) is True
|
|
|
|
carol_rooms = instance3.get("/api/rooms/mine").json()
|
|
assert _has_mention(carol_rooms, room["id"]) is False
|
|
|
|
|
|
def test_mention_of_non_member_is_not_a_mention(ws_client_factory):
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
_register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
message = _send_and_sync(alice_ws, room["id"], "hey @nobody-by-this-name, anyone home?")
|
|
assert message["type"] == "message"
|
|
|
|
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is False
|
|
|
|
|
|
def test_mention_inside_code_span_is_not_a_mention(ws_client_factory):
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
message = _send_and_sync(alice_ws, room["id"], f"check this out: `@{bob['username']}`")
|
|
assert message["type"] == "message"
|
|
|
|
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is False
|
|
|
|
|
|
def test_mention_reading_the_room_clears_it(ws_client_factory):
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
message = _send_and_sync(alice_ws, room["id"], f"@{bob['username']} ping")
|
|
assert message["type"] == "message"
|
|
|
|
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is True
|
|
|
|
resp = instance2.post(f"/api/rooms/{room['id']}/read")
|
|
assert resp.status_code == 204
|
|
|
|
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is False
|
|
|
|
|
|
def test_mention_customizes_push_body(ws_client_factory, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
|
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
instance2.post(
|
|
"/api/push/subscribe",
|
|
json={
|
|
"endpoint": f"https://push.example.com/ep-{bob['id']}",
|
|
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
|
|
},
|
|
)
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
message = _send_and_sync(alice_ws, room["id"], f"@{bob['username']} check this out")
|
|
assert message["type"] == "message"
|
|
|
|
assert len(calls) == 1
|
|
assert "mentioned you" in calls[0]["data"]
|
|
assert alice["username"] in calls[0]["data"]
|
|
|
|
|
|
def test_mention_requires_room_membership_to_count(ws_client_factory):
|
|
# A username that exists on the site but isn't a member of *this* room
|
|
# must not be resolvable as a mention here -- membership, not just
|
|
# username existence, is what @username matches against.
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
outsider = _register_ws(instance2, _unique("outsider"))
|
|
# Deliberately not joining `room`.
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
message = _send_and_sync(alice_ws, room["id"], f"@{outsider['username']} are you there?")
|
|
assert message["type"] == "message"
|
|
|
|
async def _mention_count() -> int:
|
|
from sqlalchemy import select
|
|
|
|
from app.models import MessageMention
|
|
|
|
async with instance1.session_factory() as session:
|
|
result = await session.execute(
|
|
select(MessageMention).where(MessageMention.message_id == uuid.UUID(message["id"]))
|
|
)
|
|
return len(result.scalars().all())
|
|
|
|
assert instance1.portal.call(_mention_count) == 0
|