Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 121 additions & 7 deletions backend/src/pmp_backend/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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`."""
Expand All @@ -281,15 +394,16 @@ 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}")
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}")
Expand All @@ -298,15 +412,15 @@ 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}")
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)
Expand Down
3 changes: 3 additions & 0 deletions backend/src/pmp_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
103 changes: 102 additions & 1 deletion backend/src/pmp_backend/services/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading