From 8e45b287c236a916f39b09844b171f52ddad8c50 Mon Sep 17 00:00:00 2001 From: Ocean Dekeyser Date: Thu, 29 Oct 2026 11:15:04 +0100 Subject: [PATCH] =?UTF-8?q?Chat:=20"is=20typing=E2=80=A6",=20"Seen=20by"?= =?UTF-8?q?=20and=20search=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing kept in the backend's memory for 5 s; "Seen by N" on your own messages counting only chat people, live; search a chat's text and file names with From and Files only. Open chats poll one updates endpoint. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 4 + backend/src/pmp_backend/api/chat.py | 128 +++++++++++++++- backend/src/pmp_backend/app.py | 3 + backend/src/pmp_backend/services/chat.py | 103 ++++++++++++- backend/tests/test_chat_typing_seen_search.py | 120 +++++++++++++++ docs/CHANGELOG.md | 1 + docs/adr/0008-chat-updates-by-polling.md | 4 +- frontend/src/pmp_frontend/api.py | 31 ++++ frontend/src/pmp_frontend/chat_page.py | 144 +++++++++++++++++- frontend/tests/test_chat.py | 13 +- .../tests/test_chat_typing_seen_search.py | 66 ++++++++ 11 files changed, 602 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_chat_typing_seen_search.py create mode 100644 frontend/tests/test_chat_typing_seen_search.py diff --git a/CONTEXT.md b/CONTEXT.md index 3c77421..c34bd3f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -132,6 +132,10 @@ _Avoid_: Tag, ping, notification One of six emoji (👍 ❤️ 😂 😮 😢 🎉) a User adds to a Chat message, shown with a count and, on hover, the names; several per person, and clicking your own takes it back. _Avoid_: Like, emoji vote +**Seen by**: +On a User's own Chat message, how many of the Chat people have read up to it, with their names on click. Teachers and the Superuser who only read along don't count. +_Avoid_: Read receipts, viewed by + **Deleted message**: A Chat message removed by its author, or by a Teacher or the Superuser (moderation): its text and files are gone, but it stays in the Chat as "This message was deleted.", and so do quotes of it. Authors can also edit their own messages, shown "(edited)". _Avoid_: Removed message, hidden message diff --git a/backend/src/pmp_backend/api/chat.py b/backend/src/pmp_backend/api/chat.py index 3520129..a776f1b 100644 --- a/backend/src/pmp_backend/api/chat.py +++ b/backend/src/pmp_backend/api/chat.py @@ -2,8 +2,19 @@ from typing import Annotated from urllib.parse import quote -from fastapi import APIRouter, File, Form, Response, UploadFile, status +from fastapi import ( + APIRouter, + Depends, + File, + Form, + Query, + Request, + Response, + UploadFile, + status, +) from pydantic import BaseModel +from sqlalchemy.orm import Session from pmp_backend.api.deps import CurrentUserDep, SessionDep, SettingsDep from pmp_backend.api.schemas import PersonOut @@ -128,15 +139,29 @@ class MessageOut(BaseModel): reactions: list[ReactionOut] # It mentions the User (@name or @team): shown highlighted. mentions_me: bool + # On the User's own messages: the Chat people who have read it (staff who + # only read along don't count). None on others' and deleted messages. + seen_by: list[str] | None # What the User may do with it (Archived Chats aside: they are read-only). can_edit: bool can_delete: bool @classmethod - def of(cls, message: ChatMessage, user: User) -> "MessageOut": + def of( + cls, + message: ChatMessage, + user: User, + marks: list[tuple[User, int]] | None = None, + ) -> "MessageOut": + """`marks`: chat.read_marks of the message's Chat, for `seen_by`.""" author = message.author mine = message.author_id == user.id deleted = message.deleted_at is not None + seen_by = None + if mine and not deleted: + seen_by = [ + person.full_name for person, last in marks or [] if last >= message.id + ] return cls( id=message.id, author=PersonOut.of(author) if author else None, @@ -150,6 +175,7 @@ def of(cls, message: ChatMessage, user: User) -> "MessageOut": reply_to=QuoteOut.of(message.reply_to) if message.reply_to else None, reactions=_reactions(message, user), mentions_me=any(m.user_id == user.id for m in message.mentions), + seen_by=seen_by, can_edit=mine and not deleted, can_delete=(mine or chat.is_staff(user)) and not deleted, ) @@ -159,6 +185,43 @@ class EditIn(BaseModel): body: str +class ReadMarkOut(BaseModel): + """How far one of the Chat people has read: the last message id they saw.""" + + name: str + last_read_id: int + + +class UpdatesOut(BaseModel): + """What an open Chat asks for every ~2 seconds (ADR 0008).""" + + # Sent, edited or deleted since the revision asked for. + messages: list["MessageOut"] + # First names of the others typing now: "Anna is typing…". + typing: list[str] + # The other Chat people's read marks, to keep "Seen by" up to date. + reads: list[ReadMarkOut] + + +def _out( + session: Session, user: User, project_id: int, messages: list[ChatMessage] +) -> list[MessageOut]: + """The messages of one Chat, with "Seen by" on the User's own.""" + marks = chat.read_marks(session, user, project_id) + return [MessageOut.of(message, user, marks) for message in messages] + + +def _one(session: Session, user: User, message: ChatMessage) -> MessageOut: + return _out(session, user, message.project_id, [message])[0] + + +def get_typing(request: Request) -> chat.TypingBoard: + return request.app.state.chat_typing + + +TypingDep = Annotated[chat.TypingBoard, Depends(get_typing)] + + class ReadIn(BaseModel): message_id: int @@ -244,7 +307,55 @@ def list_messages( """The newest messages, oldest first; with `since` (a revision): every message sent, edited or deleted after it, in the order they changed.""" found = chat.messages(session, user, project_id, since) - return [MessageOut.of(message, user) for message in found] + return _out(session, user, project_id, found) + + +@router.get("/projects/{project_id}/chat/updates") +def updates( + project_id: int, + since: int, + session: SessionDep, + user: CurrentUserDep, + board: TypingDep, +) -> UpdatesOut: + """Messages changed since revision `since`, who is typing and how far the + others have read.""" + found = chat.messages(session, user, project_id, since) + marks = chat.read_marks(session, user, project_id) + return UpdatesOut( + messages=[MessageOut.of(message, user, marks) for message in found], + typing=board.names(project_id, user), + reads=[ + ReadMarkOut(name=person.full_name, last_read_id=last) + for person, last in marks + ], + ) + + +@router.post( + "/projects/{project_id}/chat/typing", status_code=status.HTTP_204_NO_CONTENT +) +def typing( + project_id: int, session: SessionDep, user: CurrentUserDep, board: TypingDep +) -> Response: + """Send while typing (every few seconds); shown to the others for 5 s.""" + chat.typing(session, board, user, project_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/projects/{project_id}/chat/search") +def search( + project_id: int, + session: SessionDep, + user: CurrentUserDep, + q: str = "", + from_: Annotated[int | None, Query(alias="from")] = None, + files_only: bool = False, +) -> list[MessageOut]: + """Text and file names containing `q`, by person `from` (a user id), only + messages with files; newest first, at most 50.""" + found = chat.search(session, user, project_id, q, from_, files_only) + return _out(session, user, project_id, found) @router.post( @@ -258,6 +369,8 @@ def send_message( body: Annotated[str, Form()] = "", files: Annotated[list[UploadFile], File()] = [], # noqa: B006 reply_to_id: Annotated[int | None, Form()] = None, + *, + board: TypingDep, ) -> MessageOut: """A form with the text in `body`, up to 5 photos or files in `files`, and optionally the message it replies to in `reply_to_id`.""" @@ -281,7 +394,8 @@ def send_message( settings.chat_files_dir, reply_to_id=reply_to_id, ) - return MessageOut.of(message, user) + board.stopped(project_id, user.id) + return _one(session, user, message) @router.put("/chat/messages/{message_id}") @@ -289,7 +403,7 @@ def edit_message( message_id: int, body: EditIn, session: SessionDep, user: CurrentUserDep ) -> MessageOut: """Your own messages only.""" - return MessageOut.of(chat.edit(session, user, message_id, body.body), user) + return _one(session, user, chat.edit(session, user, message_id, body.body)) @router.put("/chat/messages/{message_id}/reactions/{emoji}") @@ -298,7 +412,7 @@ def add_reaction( ) -> MessageOut: """One of 👍 ❤️ 😂 😮 😢 🎉; adding it again changes nothing.""" message = chat.react(session, user, message_id, emoji, on=True) - return MessageOut.of(message, user) + return _one(session, user, message) @router.delete("/chat/messages/{message_id}/reactions/{emoji}") @@ -306,7 +420,7 @@ def remove_reaction( message_id: int, emoji: str, session: SessionDep, user: CurrentUserDep ) -> MessageOut: message = chat.react(session, user, message_id, emoji, on=False) - return MessageOut.of(message, user) + return _one(session, user, message) @router.delete("/chat/messages/{message_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/pmp_backend/app.py b/backend/src/pmp_backend/app.py index 621df19..6d3d8cd 100644 --- a/backend/src/pmp_backend/app.py +++ b/backend/src/pmp_backend/app.py @@ -23,6 +23,7 @@ from pmp_backend.errors import Conflict, Forbidden, NotFound, Refused, Unavailable from pmp_backend.services import daily_email, mail from pmp_backend.services.auth import LoginGuard +from pmp_backend.services.chat import TypingBoard from pmp_backend.services.github import HttpGitHub from pmp_backend.services.seeding import seed_starting_accounts from pmp_backend.services.trello import HttpTrello @@ -55,6 +56,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.settings = settings app.state.sessionmaker = make_session app.state.login_guard = LoginGuard() + # "Is typing…" in the Chats (in memory, like the LoginGuard). + app.state.chat_typing = TypingBoard() # Tests replace it with testing.FakeGitHub. app.state.github = HttpGitHub(settings) # Tests replace it with testing.FakeTrello. diff --git a/backend/src/pmp_backend/services/chat.py b/backend/src/pmp_backend/services/chat.py index 4e67e93..bb073bb 100644 --- a/backend/src/pmp_backend/services/chat.py +++ b/backend/src/pmp_backend/services/chat.py @@ -7,7 +7,8 @@ import re import shutil -from collections.abc import Sequence +import time +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path, PurePath @@ -79,6 +80,10 @@ ".webp": ("image/webp", b"RIFF"), } OTHER_FILE = "application/octet-stream" +# "Is typing…" lasts this long after the last keystroke. +TYPING_SECONDS = 5.0 +# The most search results shown, newest first. +SEARCH_LIMIT = 50 @dataclass(frozen=True) @@ -168,6 +173,102 @@ def messages( return list(reversed(list(newest))) +class TypingBoard: + """Who is typing in which Chat. Kept in memory (one backend process, like + the LoginGuard); an entry is forgotten TYPING_SECONDS after the last + keystroke, or when the person sends their message.""" + + def __init__(self, clock: Callable[[], float] = time.monotonic) -> None: + self._clock = clock + # Per Project: user id -> (first name, when they last typed). + self._typing: dict[int, dict[int, tuple[str, float]]] = {} + + def typed(self, project_id: int, user: User) -> None: + self._typing.setdefault(project_id, {})[user.id] = ( + user.first_name, + self._clock(), + ) + + def stopped(self, project_id: int, user_id: int) -> None: + self._typing.get(project_id, {}).pop(user_id, None) + + def names(self, project_id: int, viewer: User) -> list[str]: + """The first names of those typing now, except the viewer, by name.""" + since = self._clock() - TYPING_SECONDS + current = { + user_id: entry + for user_id, entry in self._typing.get(project_id, {}).items() + if entry[1] > since + } + if current: + self._typing[project_id] = current + else: + self._typing.pop(project_id, None) + return sorted( + name for user_id, (name, _) in current.items() if user_id != viewer.id + ) + + +def typing(session: Session, board: TypingBoard, user: User, project_id: int) -> None: + """The User is typing; only the Chat people show as typing.""" + project = writable_project(session, user, project_id) + if is_chat_person(session, user, project): + board.typed(project.id, user) + + +def read_marks(session: Session, user: User, project_id: int) -> list[tuple[User, int]]: + """How far each of the Chat people (not the User, not staff who only read + along) has read the Chat: drives "Seen by".""" + project = readable_project(session, user, project_id) + people = {person.user.id: person.user for person in _chat_people(session, project)} + people.pop(user.id, None) + marks = dict( + session.execute( + select(ChatRead.user_id, ChatRead.last_read_id).where( + ChatRead.project_id == project.id, ChatRead.user_id.in_(people) + ) + ).all() + ) + return [(person, marks.get(person.id, 0)) for person in people.values()] + + +def search( + session: Session, + user: User, + project_id: int, + text: str = "", + author_id: int | None = None, + files_only: bool = False, +) -> list[ChatMessage]: + """Messages whose text or file names contain `text` (any case), optionally + by one person and only those with files; newest first, at most + SEARCH_LIMIT. Deleted messages are never found.""" + readable_project(session, user, project_id) + text = text.strip() + if not text and author_id is None and not files_only: + raise ChatError("Type something to search for.") + query = select(ChatMessage).where( + ChatMessage.project_id == project_id, ChatMessage.deleted_at.is_(None) + ) + if text: + pattern = "%" + re.sub(r"([\\%_])", r"\\\1", text) + "%" + in_files = ( + select(ChatFile.id) + .where(ChatFile.message_id == ChatMessage.id, ChatFile.name.ilike(pattern)) + .exists() + ) + query = query.where(or_(ChatMessage.body.ilike(pattern), in_files)) + if author_id is not None: + query = query.where(ChatMessage.author_id == author_id) + if files_only: + query = query.where( + select(ChatFile.id).where(ChatFile.message_id == ChatMessage.id).exists() + ) + return list( + session.scalars(query.order_by(ChatMessage.id.desc()).limit(SEARCH_LIMIT)) + ) + + def _message_in(session: Session, user: User, message_id: int) -> ChatMessage: """A message in a Chat the User can write in, not yet deleted.""" message = session.get(ChatMessage, message_id) diff --git a/backend/tests/test_chat_typing_seen_search.py b/backend/tests/test_chat_typing_seen_search.py new file mode 100644 index 0000000..884ff1f --- /dev/null +++ b/backend/tests/test_chat_typing_seen_search.py @@ -0,0 +1,120 @@ +from fastapi.testclient import TestClient + +from pmp_backend.services.chat import TypingBoard +from tests.conftest import ready_to_work +from tests.test_chat import logged_in_student, send +from tests.test_chat_edit_delete import delete, team_chat +from tests.test_chat_files import send as send_files +from tests.test_login_lockout import FakeClock + + +def updates(client: TestClient, headers, project_id: int, since: int = 0) -> dict: + response = client.get( + f"/api/projects/{project_id}/chat/updates", + params={"since": since}, + headers=headers, + ) + assert response.status_code == 200, response.text + return response.json() + + +def typing(client: TestClient, headers, project_id: int): + return client.post(f"/api/projects/{project_id}/chat/typing", headers=headers) + + +def read(client: TestClient, headers, project_id: int, message_id: int) -> None: + client.post( + f"/api/projects/{project_id}/chat/read", + json={"message_id": message_id}, + headers=headers, + ) + + +def search(client: TestClient, headers, project_id: int, **params): + return client.get( + f"/api/projects/{project_id}/chat/search", params=params, headers=headers + ) + + +def test_others_see_who_is_typing_for_five_seconds(client: TestClient): + clock = FakeClock() + client.app.state.chat_typing = TypingBoard(clock=clock) + teacher, student, drone = team_chat(client) + + assert typing(client, student, drone).status_code == 204 + + assert updates(client, teacher, drone)["typing"] == ["Student"] + # Never yourself. + assert updates(client, student, drone)["typing"] == [] + clock.now += 5.1 + assert updates(client, teacher, drone)["typing"] == [] + + +def test_sending_ends_typing_and_staff_reading_along_never_type(client: TestClient): + client.app.state.chat_typing = TypingBoard(clock=FakeClock()) + teacher, student, drone = team_chat(client) + superuser = ready_to_work(client, "superuser") + + typing(client, student, drone) + typing(client, superuser, drone) + send(client, student, drone, "Done") + + assert updates(client, teacher, drone)["typing"] == [] + + +def test_seen_by_counts_the_chat_people_who_read_it(client: TestClient): + teacher, student, drone = team_chat(client) + superuser = ready_to_work(client, "superuser") + first = send(client, student, drone, "First").json() + second = send(client, student, drone, "Second").json() + assert second["seen_by"] == [] + + read(client, teacher, drone, first["id"]) + # The Superuser only reads along: never in "Seen by". + read(client, superuser, drone, second["id"]) + + shown = updates(client, student, drone) + assert [m["seen_by"] for m in shown["messages"]] == [["Teacher Account"], []] + assert shown["reads"] == [{"name": "Teacher Account", "last_read_id": first["id"]}] + # Only on your own messages. + assert [m["seen_by"] for m in updates(client, teacher, drone)["messages"]] == [ + None, + None, + ] + + +def test_search_text_and_file_names_with_filters(client: TestClient): + teacher, student, drone = team_chat(client) + send(client, teacher, drone, "The 50% budget_plan is ready") + send(client, student, drone, "Where is the BUDGET?") + send_files(client, student, drone, [("budget.xlsx", b"x", "text/plain")]) + gone = send(client, student, drone, "budget secret").json() + delete(client, student, gone["id"]) + student_user = client.get("/api/auth/me", headers=student).json() + + def bodies(**params) -> list[str]: + response = search(client, teacher, drone, **params) + assert response.status_code == 200, response.text + return [m["body"] or m["files"][0]["name"] for m in response.json()] + + assert bodies(q="budget") == [ + "budget.xlsx", + "Where is the BUDGET?", + "The 50% budget_plan is ready", + ] + assert bodies(q="50%") == ["The 50% budget_plan is ready"] + # % and _ are searched for as they are, not as wildcards. + assert bodies(q="t%p") == [] + assert bodies(q="budget", files_only="true") == ["budget.xlsx"] + assert len(bodies(**{"from": str(student_user["id"])})) == 2 + + +def test_search_needs_something_and_access(client: TestClient): + teacher, student, drone = team_chat(client) + + empty = search(client, teacher, drone, q=" ") + + assert empty.status_code == 422 + assert empty.json()["detail"] == "Type something to search for." + outsider = logged_in_student(client, ready_to_work(client, "superuser"), "Lotte") + assert search(client, outsider, drone, q="x").status_code == 403 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 71e8b36..a482d66 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -243,6 +243,7 @@ Newest first. "Live" means deployed to the VIVES VM by the Deploy workflow. "Mig | New | Weekly scrum notes of the team in `Scrum/scrum.md` (newest week on top) | – | #103 | | New | **Chat edit, delete and reply:** edit your own messages ("(edited)"), delete them ("This message was deleted.", files go too); Teachers and the Superuser delete any message; reply with a quote that scrolls to the original; edits and deletions appear live (open chats poll `since` a revision); migration `0017` | #95 | #104 | | New | **Chat reactions and @mentions:** 👍 ❤️ 😂 😮 😢 🎉 on any message (count, names on hover, click to take yours back); typing @ suggests the chat people and @team; a mentioned person sees the message highlighted and a red @ marker on the Chat item and the Chats screen until read; staff who only read along can't be mentioned; live in open chats; links in messages are now underlined; migration `0018` | #96 | #105 | +| New | **Chat: "is typing…", "Seen by" and search:** "Anna is typing…" for 5 s after the last keystroke (kept in the backend's memory); "Seen by N" on your own messages with the names on click (chat people only), updating live; search a chat's text and file names with From and Files only, a result scrolls to its message; open chats now poll `GET .../chat/updates` | #97 | #106 | ### 2026-10-22: Project chat (spec #91) diff --git a/docs/adr/0008-chat-updates-by-polling.md b/docs/adr/0008-chat-updates-by-polling.md index cfd3d2d..06ed1ee 100644 --- a/docs/adr/0008-chat-updates-by-polling.md +++ b/docs/adr/0008-chat-updates-by-polling.md @@ -1,6 +1,6 @@ # Chat updates by polling -An open Project Chat (spec #91) asks the backend every 2 seconds for the messages sent, edited or deleted since the last change it saw (every change gets a new number from the `chat_revision` sequence; #95), and the navigation asks every 10 seconds for the Unread count. The backend doesn't push anything: the frontend keeps calling its REST API, as for every other screen (ADR 0003). +An open Project Chat (spec #91) asks the backend every 2 seconds for what changed (`GET /api/projects/{id}/chat/updates?since=`, #97): the messages sent, edited, deleted or reacted to since the last change it saw (every change gets a new number from the `chat_revision` sequence; #95), who is typing, and how far the others have read (for "Seen by"), and the navigation asks every 10 seconds for the Unread count. The backend doesn't push anything: the frontend keeps calling its REST API, as for every other screen (ADR 0003). The browser already has a live WebSocket to the NiceGUI frontend, so whatever the frontend learns appears at once. Only the step from the frontend to the backend is polled. With a few dozen people in open chats that is a few dozen small, indexed queries per second, which the VM handles easily. @@ -12,5 +12,5 @@ The browser already has a live WebSocket to the NiceGUI frontend, so whatever th ## Consequences - A message from someone else appears within about 2 seconds; the sender sees their own at once. -- "Is typing…" (#97) uses the same polling, and the backend keeps it in memory, which works because the backend runs as one process. If it ever runs as several workers, typing has to move to the database. +- "Is typing…" (#97) uses the same polling: the page tells the backend at most every 2.5 s while someone types, and the backend keeps it in memory for 5 s (`chat.TypingBoard`), which works because the backend runs as one process. If it ever runs as several workers, typing has to move to the database. - Polling stops when the page closes, so idle tabs cost nothing more than the 10-second badge check. diff --git a/frontend/src/pmp_frontend/api.py b/frontend/src/pmp_frontend/api.py index 22939c8..44d8147 100644 --- a/frontend/src/pmp_frontend/api.py +++ b/frontend/src/pmp_frontend/api.py @@ -704,6 +704,37 @@ async def send_chat_message( ) +async def chat_updates(token: str, project_id: int, since: int) -> dict[str, Any]: + """What changed since revision `since`: messages, typing (first names) and + reads (name, last_read_id of the other Chat people).""" + return await _request( + "GET", f"/api/projects/{project_id}/chat/updates?since={since}", token=token + ) + + +async def chat_typing(token: str, project_id: int) -> None: + await _request("POST", f"/api/projects/{project_id}/chat/typing", token=token) + + +async def search_chat( + token: str, + project_id: int, + text: str, + *, + author_id: int | None = None, + files_only: bool = False, +) -> list[dict[str, Any]]: + """Messages whose text or file names contain `text`, newest first.""" + params: dict[str, Any] = {"q": text, "files_only": str(files_only).lower()} + if author_id is not None: + params["from"] = author_id + return await _request( + "GET", + f"/api/projects/{project_id}/chat/search?{urlencode(params)}", + token=token, + ) + + async def edit_chat_message(token: str, message_id: int, body: str) -> dict[str, Any]: return await _request( "PUT", f"/api/chat/messages/{message_id}", token=token, json={"body": body} diff --git a/frontend/src/pmp_frontend/chat_page.py b/frontend/src/pmp_frontend/chat_page.py index 79ccd25..5f4fcbd 100644 --- a/frontend/src/pmp_frontend/chat_page.py +++ b/frontend/src/pmp_frontend/chat_page.py @@ -3,6 +3,7 @@ import html import re +import time from collections.abc import Awaitable, Callable from contextlib import suppress from dataclasses import dataclass @@ -32,6 +33,8 @@ # The longest first line a quote shows (as the backend's). QUOTE_LENGTH = 100 DELETED = "This message was deleted." +# How often the backend hears "is typing" while someone keeps typing. +TYPING_EVERY = 2.5 # A web link, without trailing punctuation such as the full stop ending a sentence. URL = re.compile(r"https?://[^\s<>\"']*[^\s<>\"'.,;:!?)\]]") @@ -291,6 +294,34 @@ def _message(message: dict[str, Any], actions: _Actions) -> None: ).mark("message-body") _files(message["files"]) _reactions(message, actions) + if message["seen_by"] is not None: + _seen_by(message) + + +def _seen_by(message: dict[str, Any]) -> None: + """On your own message: how many of the Chat people read it; a click shows + who.""" + names = message["seen_by"] + with ui.row().classes("w-full justify-end"): + label = f"Seen by {len(names)}" if names else "Not seen yet" + with ( + ui.button(label, icon="done_all" if names else "done") + .props("flat dense no-caps size=sm color=dark") + .mark(f"seen-by-{message['id']}") + ): + if names: + with ui.menu(): + for name in names: + ui.menu_item(name).mark(f"seen-name-{message['id']}") + + +def typing_line(names: list[str]) -> str: + """ "Anna is typing…", "Anna and Bram are typing…", "Anna, Bram and Cas…".""" + if not names: + return "" + if len(names) == 1: + return f"{names[0]} is typing…" + return f"{', '.join(names[:-1])} and {names[-1]} are typing…" async def chat_view(project_id: int) -> None: @@ -302,9 +333,10 @@ async def chat_view(project_id: int) -> None: except api.ApiError as error: ui.label(error.message) return - # Each shown message's element and the revision it shows. + # Each shown message's element, the revision it shows, and its data. rows: dict[int, ui.element] = {} revisions: dict[int, int] = {} + messages_shown: dict[int, dict[str, Any]] = {} # last: the newest message id (read mark); since: the newest revision # fetched; oldest: the first message shown (older ones are not loaded). state: dict[str, Any] = { @@ -315,6 +347,8 @@ async def chat_view(project_id: int) -> None: "reply_to": None, # The text being typed, for the @ suggestions. "draft": "", + # When the backend last heard that the User is typing. + "typed_at": 0.0, } def show(messages: list[dict[str, Any]]) -> bool: @@ -335,6 +369,7 @@ def show(messages: list[dict[str, Any]]) -> bool: elif revisions[number] >= message["revision"]: continue revisions[number] = message["revision"] + messages_shown[number] = message rows[number].clear() with rows[number]: _message(message, actions) @@ -350,16 +385,33 @@ async def read() -> None: async def poll() -> None: try: - changed = await api.chat_messages(token(), project_id, since=state["since"]) + found = await api.chat_updates(token(), project_id, state["since"]) except api.ApiError: # Shown again on the next try; a Deploy takes a minute. return + changed = found["messages"] # Only what was fetched moves `since`: a message you just sent may have # a newer revision than others' messages not fetched yet. state["since"] = max([state["since"]] + [m["revision"] for m in changed]) - if show(changed): + typing_label.text = typing_line(found["typing"]) + added = show(changed) + update_seen(found["reads"]) + if added: await read() + def update_seen(reads: list[dict[str, Any]]) -> None: + """Redraw your own messages whose "Seen by" changed.""" + for number, message in list(messages_shown.items()): + if message["seen_by"] is None: + continue + names = [r["name"] for r in reads if r["last_read_id"] >= number] + if names != message["seen_by"]: + message = message | {"seen_by": names} + messages_shown[number] = message + rows[number].clear() + with rows[number]: + _message(message, actions) + def go_to(message_id: int) -> None: if message_id not in rows: ui.notify("That message is older than the ones shown here.") @@ -452,9 +504,15 @@ def suggestions() -> None: "flat dense no-caps color=dark" ).mark("mention-option") - def typed() -> None: + async def typed() -> None: state["draft"] = box.value or "" suggestions.refresh() + # Tell the others, at most every few seconds ("is typing…" lasts 5 s). + now = time.monotonic() + if state["draft"].strip() and now - state["typed_at"] > TYPING_EVERY: + state["typed_at"] = now + with suppress(api.ApiError): + await api.chat_typing(token(), project_id) def mention(name: str) -> None: """Put "@name " in place of the "@…" being typed.""" @@ -541,6 +599,7 @@ def attachments() -> None: "Leave chat", icon="logout", on_click=lambda: _leave(project_id) ).props("outline color=dark").mark("leave-chat") await _people(project_id, chat) + _search(project_id, people, first, go_to) with ui.card().classes("w-full p-0"): scroll = ui.scroll_area().classes("w-full h-[60vh]").mark("chat-messages") with scroll: @@ -549,6 +608,12 @@ def attachments() -> None: with column: state["empty"] = ui.label("No messages yet.").classes("text-caption") show(first) + typing_label = ( + ui.label() + .classes("text-caption italic") + .style("color: #666666; min-height: 1.25rem") + .mark("chat-typing") + ) await read() if chat["read_only"]: ui.label("This Project is archived: its chat is read-only.") @@ -595,6 +660,77 @@ def attachments() -> None: ui.timer(POLL_SECONDS, poll) +def _search( + project_id: int, + people: list[dict[str, Any]], + first: list[dict[str, Any]], + go_to: Callable[[int], None], +) -> None: + """Search this Chat's text and file names, by person and Files only; a + result scrolls to its message.""" + # Everyone who can be picked under From: the Chat people and the authors + # of the messages shown (staff who wrote along). + authors = {person["id"]: person["name"] for person in people} + for message in first: + if message["author"]: + authors.setdefault(message["author"]["id"], message["author"]["name"]) + + async def run() -> None: + try: + found = await api.search_chat( + token(), + project_id, + text.value or "", + author_id=author.value, + files_only=files_only.value, + ) + except api.ApiError as error: + problem.show(error.message) + return + problem.hide() + results.clear() + with results: + if not found: + ui.label("Nothing found.").classes("text-caption") + for message in found: + with ( + ui.row() + .classes("w-full items-center no-wrap cursor-pointer gap-2") + .on("click", lambda m=message: go_to(m["id"])) + .mark(f"search-result-{message['id']}") + ): + ui.label( + f"{author_name(message)} · {shown(message['created_at'])}" + ).classes("text-caption shrink-0").style("color: #666666") + ui.label(first_line(message)).classes("ellipsis") + + with ( + ui.expansion("Search", icon="search") + .classes("w-full bg-white rounded-lg") + .mark("chat-search") + ): + with ui.row().classes("w-full items-center"): + text = ( + ui.input(placeholder="Text or file name") + .props("dense outlined clearable") + .classes("grow") + .mark("search-text") + ) + author = ( + ui.select({None: "Anyone"} | authors, value=None, label="From") + .props("dense outlined") + .classes("w-48") + .mark("search-from") + ) + files_only = ui.checkbox("Files only").mark("search-files-only") + ui.button("Search", icon="search", on_click=run).props( + "outline color=dark" + ).mark("search-run") + text.on("keydown.enter", run) + problem = ErrorMessage() + results = ui.column().classes("w-full gap-1").mark("search-results") + + async def _leave(project_id: int) -> None: question = "Leave this chat? You can only come back when someone adds you again." if not await confirm(question, "Leave"): diff --git a/frontend/tests/test_chat.py b/frontend/tests/test_chat.py index 4dd1b87..2fc1e4c 100644 --- a/frontend/tests/test_chat.py +++ b/frontend/tests/test_chat.py @@ -49,8 +49,19 @@ def navigation_targets(user: User) -> set[str]: return {link.props.get("href") for link in found(user, "navigation")} +def message_id(element) -> int: + """The id of the message an element is part of (from its "message-N" marker); + a redrawn message keeps its place but gets new elements.""" + while element is not None: + for marker in element._markers: + if marker.startswith("message-") and marker[8:].isdigit(): + return int(marker[8:]) + element = element.parent_slot.parent if element.parent_slot else None + return 0 + + def bodies(user: User) -> list[str]: - shown = sorted(found(user, "message-body"), key=lambda element: element.id) + shown = sorted(found(user, "message-body"), key=message_id) return [element.content for element in shown] diff --git a/frontend/tests/test_chat_typing_seen_search.py b/frontend/tests/test_chat_typing_seen_search.py new file mode 100644 index 0000000..97c7dde --- /dev/null +++ b/frontend/tests/test_chat_typing_seen_search.py @@ -0,0 +1,66 @@ +from nicegui import ui +from nicegui.testing import User + +from pmp_frontend import api +from pmp_frontend.chat_page import typing_line +from tests.conftest import eventually +from tests.test_chat import found +from tests.test_chat_edit_delete import open_student_chat + + +def label_text(user: User, marker: str) -> str | None: + shown = found(user, marker) + return shown[0].text if shown else None + + +def test_the_typing_line_names_everyone(): + assert typing_line([]) == "" + assert typing_line(["Anna"]) == "Anna is typing…" + assert typing_line(["Anna", "Bram"]) == "Anna and Bram are typing…" + assert typing_line(["Anna", "Bram", "Cas"]) == "Anna, Bram and Cas are typing…" + + +async def test_a_student_sees_the_teacher_typing(user: User): + drone_message, teacher = await open_student_chat(user) + chat = (await api.chats(teacher))[0] + + await api.chat_typing(teacher, chat["project_id"]) + + await eventually( + lambda: label_text(user, "chat-typing") == "Teacher is typing…", retries=100 + ) + + +async def test_seen_by_follows_reading(user: User): + _, teacher = await open_student_chat(user) + drone = (await api.chats(teacher))[0]["project_id"] + user.find(marker="chat-input").type("Can you check my code?") + user.find(marker="chat-send").click() + await user.should_see(marker="seen-by-2", retries=30) + assert user.find(marker="seen-by-2").elements.pop().text == "Not seen yet" + + await api.mark_chat_read(teacher, drone, 2) + + await eventually(lambda: label_text(user, "seen-by-2") == "Seen by 1", retries=100) + await user.should_see("Teacher Account") + + +async def test_search_finds_a_message_and_jumps_to_it(user: User): + _, teacher = await open_student_chat(user) + drone = (await api.chats(teacher))[0]["project_id"] + await api.send_chat_message(teacher, drone, "The budget is 300 euro") + await user.should_see(marker="message-2", retries=100) + + user.find(marker="chat-search").click() + user.find(marker="search-text").type("BUDGET") + user.find(marker="search-run").click() + + await user.should_see(marker="search-result-2", retries=30) + assert not found(user, "search-result-1") + user.find(marker="search-result-2").click() + + files_only = user.find(marker="search-files-only").elements.pop() + assert isinstance(files_only, ui.checkbox) + files_only.value = True + user.find(marker="search-run").click() + await user.should_see("Nothing found.", retries=30)