Private
Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c9a85ab3 | ||
|
|
62a25c6278 | ||
|
|
7a84d2f09f | ||
|
|
d74527a29c | ||
|
|
f0e4c76ffd | ||
|
|
3be8d9d731 | ||
|
|
30e63ffa83 | ||
|
|
019e10ac5c | ||
|
|
6e889b8ea4 | ||
|
|
b4a104f8c6 | ||
|
|
03cc16f236 | ||
|
|
520b971247 | ||
|
|
cd6296d079 | ||
|
|
f6c71753b5 | ||
|
|
3e5f842df3 | ||
|
|
ad745c734e | ||
|
|
092f1de585 | ||
|
|
2e84ca42b7 | ||
|
|
b26643527d | ||
|
|
278f8bb995 | ||
|
|
250bb862f6 | ||
|
|
3dabf0022c | ||
|
|
08a36248a0 | ||
|
|
5ec79e652f | ||
|
|
89d609f584 | ||
|
|
51d0092bd3 | ||
|
|
66e9c80422 | ||
|
|
aeaaef96ec | ||
|
|
1a05cf5515 | ||
|
|
7b44ce325c | ||
|
|
ef615e1ef4 | ||
|
|
157f1e30ac | ||
|
|
072405eb2d | ||
|
|
2fd055f7d6 | ||
|
|
4cc3823adf | ||
|
|
d42bf114dd | ||
|
|
ed88eb0205 | ||
|
|
1d9fe25410 | ||
|
|
766883c992 | ||
|
|
1d322d9516 | ||
|
|
f3f59ad822 | ||
|
|
8a461ebb13 | ||
|
|
222ca49355 |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "frontend-preview",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "frontend", "run", "preview"],
|
||||
"port": 4173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ dist-ssr/
|
||||
## Editors / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.DS_Store
|
||||
|
||||
## Test / coverage
|
||||
|
||||
+32
-5
@@ -28,7 +28,7 @@ database and Redis; the other runs the application and serves the frontend.
|
||||
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
|
||||
| Reverse proxy / TLS | External Nginx Proxy Manager (pre-existing infra, not deployed by this project) | Terminates TLS, forwards REST + WebSocket traffic to the app server's one port. The app itself serves the built static frontend directly — no separate static-asset server needed |
|
||||
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
|
||||
| Auth | Session cookies (httpOnly, secure) | Simplest to carry through a WebSocket handshake automatically |
|
||||
| Auth | Session cookies (httpOnly, secure), backed by a server-side session table | Cookie carries only an opaque session id — simplest way to carry auth through a WebSocket handshake automatically, while still allowing server-side revocation (see §4/§10) |
|
||||
|
||||
## 3. System architecture
|
||||
|
||||
@@ -85,17 +85,31 @@ users
|
||||
id, username, email, password_hash, is_bot, is_site_admin, created_at
|
||||
|
||||
rooms
|
||||
id, name, description, is_private, owner_id, created_at
|
||||
id, name, description, is_private, is_dm, is_archived, owner_id, created_at
|
||||
|
||||
room_memberships
|
||||
room_id, user_id, role (owner | admin | member), joined_at
|
||||
room_id, user_id, role (owner | admin | member), joined_at, last_read_at,
|
||||
hidden_at (DM-only: hides it from one participant's sidebar),
|
||||
email_notifications (opt-in, non-DM rooms only)
|
||||
|
||||
room_invites
|
||||
id, room_id, invited_by, token, target_user_id or target_email,
|
||||
expires_at, status (pending | accepted | revoked)
|
||||
|
||||
messages
|
||||
id, room_id, user_id, content, created_at, edited_at, deleted_at
|
||||
id, room_id, user_id, content, image_id, file_id, created_at, edited_at,
|
||||
deleted_at
|
||||
|
||||
message_reactions
|
||||
id, message_id, user_id, emoji (unicode glyph or a custom emoji's
|
||||
`:shortcode:`)
|
||||
|
||||
custom_emoji
|
||||
id, shortcode (unique, site-wide), storage_filename, content_type,
|
||||
uploaded_by, created_at
|
||||
|
||||
sessions
|
||||
id, user_id, ip_address, user_agent, created_at, last_seen_at, revoked_at
|
||||
|
||||
push_subscriptions
|
||||
id, user_id, endpoint, p256dh_key, auth_key, created_at
|
||||
@@ -114,6 +128,13 @@ admin_audit_log
|
||||
id, actor_id, action, target_type, target_id, metadata, created_at
|
||||
```
|
||||
|
||||
Not shown above (present in the implementation, omitted here for brevity):
|
||||
`message_images`, `message_files`, `message_mentions`,
|
||||
`message_room_references`, `link_previews`, `custom_themes`,
|
||||
`upload_settings`, `smtp_settings`, `site_invites`, `password_resets` — see
|
||||
`backend/README.md` for the full, current model list and per-feature
|
||||
detail on all of these.
|
||||
|
||||
## 5. Permission model
|
||||
|
||||
- **Room visibility**: `open` (any authenticated user can find and join) or
|
||||
@@ -133,7 +154,13 @@ admin_audit_log
|
||||
4. Every app instance subscribed to that channel forwards it to its own connected
|
||||
members over WebSocket.
|
||||
5. For members with no active connection, the server looks up
|
||||
`push_subscriptions` and sends a Web Push notification via `pywebpush`.
|
||||
`push_subscriptions` and sends a Web Push notification via `pywebpush`,
|
||||
and a native desktop notification over the socket if the client is DS
|
||||
Chat Desktop.
|
||||
6. Separately, a genuinely-offline DM recipient always gets an email; a
|
||||
regular room's member gets one too if they've opted in for that room
|
||||
(first unread message, or any `@mention` regardless of debounce) — see
|
||||
`backend/README.md`'s "Email notifications for missed messages".
|
||||
|
||||
## 7. Extension system: bots and AI agents
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contributing to DS Chat
|
||||
|
||||
Thanks for considering a contribution.
|
||||
|
||||
## Reporting bugs and requesting features
|
||||
|
||||
Open an issue on this project's issue tracker. Include steps to reproduce
|
||||
for a bug, or the problem you're trying to solve for a feature request —
|
||||
that's usually more useful than a proposed solution.
|
||||
|
||||
## Development setup
|
||||
|
||||
See the root [README.md](README.md)'s Quickstart, plus
|
||||
[backend/README.md](backend/README.md) and
|
||||
[frontend/README.md](frontend/README.md) for the full local dev setup
|
||||
(Postgres, Redis, Python venv, migrations, the Vite dev server).
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md) covers the overall system design if
|
||||
you're orienting yourself before a larger change.
|
||||
|
||||
## Before opening a pull request
|
||||
|
||||
- **Tests**: run `pytest` in `backend/` for any backend change, and add
|
||||
tests for new behavior rather than just the happy path — see
|
||||
`backend/README.md`'s "Run tests" section. For frontend changes, run
|
||||
`npx tsc -b` in `frontend/` and confirm `npm run build` succeeds.
|
||||
- **Style**: match the conventions already in the file you're editing
|
||||
rather than introducing a new pattern — this codebase doesn't have a
|
||||
separate style guide beyond "look at what's already there."
|
||||
- **Scope**: smaller, focused PRs are easier to review than large ones
|
||||
that mix unrelated changes.
|
||||
|
||||
## Contributor terms
|
||||
|
||||
By submitting a contribution (a pull request, patch, or similar), you
|
||||
agree that:
|
||||
|
||||
1. Your contribution is licensed under the project's own license,
|
||||
AGPL-3.0-or-later ([LICENSE](LICENSE)), and
|
||||
2. You grant the project's maintainer(s) a perpetual, worldwide,
|
||||
non-exclusive right to also relicense your contribution under different
|
||||
terms — for example, as part of a separately-licensed commercial
|
||||
offering built on this project.
|
||||
|
||||
This keeps the option of a future dual-licensed (open-source +
|
||||
commercial) version of the project available, without requiring a
|
||||
separate signed agreement for every contribution.
|
||||
|
||||
*This is a lightweight starting point, not a substitute for legal advice —
|
||||
if you're contributing something substantial, or maintaining a fork with
|
||||
your own commercial plans, it's worth having this reviewed by a lawyer
|
||||
rather than relying on the paragraph above alone.*
|
||||
+46
-23
@@ -132,35 +132,45 @@ sudo -u ds-chat ssh-keygen -t ed25519 -f /srv/ds-chat/.ssh/id_ed25519 -N ""
|
||||
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
Add that public key as a **read-only deploy key** on the Gitea repo
|
||||
(Settings → Deploy Keys), then:
|
||||
This project is hosted at
|
||||
**[github.com/ds-ksmith/DS-Chat](https://github.com/ds-ksmith/DS-Chat)**.
|
||||
Add that public key there as a **read-only deploy key** (Settings → Deploy
|
||||
Keys on the repo), then:
|
||||
|
||||
```bash
|
||||
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts
|
||||
sudo -u ds-chat git clone git@git.darksingularity.org:DarkSingularity/ds-chat.git /srv/ds-chat
|
||||
sudo -u ds-chat ssh-keyscan github.com >> /srv/ds-chat/.ssh/known_hosts
|
||||
sudo -u ds-chat git clone git@github.com:ds-ksmith/DS-Chat.git /srv/ds-chat
|
||||
```
|
||||
|
||||
(If your Gitea's SSH is on a non-default port, adjust the clone URL and
|
||||
`ssh-keyscan -p <port>` accordingly.)
|
||||
(Deploying from your own fork instead? Substitute its clone URL — the same
|
||||
deploy-key/access-token steps work the same way on GitHub, GitLab, Gitea,
|
||||
and most other git hosts.)
|
||||
|
||||
**Alternative: a personal/deployment-user access token instead of a deploy
|
||||
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
|
||||
and clone over HTTPS with the token embedded in the URL:
|
||||
|
||||
```bash
|
||||
sudo -u ds-chat git clone https://<TOKEN>@git.darksingularity.org/DarkSingularity/ds-chat.git /srv/ds-chat
|
||||
sudo -u ds-chat git clone https://<TOKEN>@github.com/ds-ksmith/DS-Chat.git /srv/ds-chat
|
||||
```
|
||||
|
||||
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
|
||||
remote -v` shows it) — readable by root and the `ds-chat` user, not by
|
||||
anyone else under normal file permissions. `deploy/upgrade.sh`'s later
|
||||
`git pull`s reuse this same authenticated URL automatically, no extra
|
||||
`git fetch`es reuse this same authenticated URL automatically, no extra
|
||||
setup needed. Fine as long as the token is scoped to read-only access on
|
||||
just this repo.
|
||||
|
||||
Either way, now that the repo is cloned:
|
||||
Either way, now that the repo is cloned, check out the latest release tag
|
||||
rather than deploying whatever the default branch's tip happens to be —
|
||||
`deploy/upgrade.sh` follows the same rule on every later upgrade (see §6),
|
||||
so this keeps the very first deploy consistent with all the ones after it:
|
||||
|
||||
```bash
|
||||
cd /srv/ds-chat
|
||||
sudo -u ds-chat git fetch --tags
|
||||
LATEST_TAG="$(sudo -u ds-chat git tag --sort=-creatordate | head -n1)"
|
||||
sudo -u ds-chat git checkout --detach "$LATEST_TAG"
|
||||
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
|
||||
```
|
||||
|
||||
@@ -232,7 +242,16 @@ admin sets it up.
|
||||
`/ws`) whenever that directory exists — that's what lets Nginx Proxy
|
||||
Manager forward the whole domain to one port with no custom path routing.
|
||||
|
||||
Before building, copy `frontend/.env.example` to `frontend/.env.production`
|
||||
and set `VITE_SOURCE_URL` to wherever *your* copy of the repo lives — see
|
||||
that file's own comment for why this matters (AGPL-3.0 source-availability
|
||||
compliance). Vite bakes this in at build time, so it needs to be in place
|
||||
before `npm run build` runs, and needs re-running after any future change
|
||||
to it.
|
||||
|
||||
```bash
|
||||
sudo -u ds-chat cp /srv/ds-chat/frontend/.env.example /srv/ds-chat/frontend/.env.production
|
||||
sudo -u ds-chat nano /srv/ds-chat/frontend/.env.production # set VITE_SOURCE_URL
|
||||
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
|
||||
```
|
||||
|
||||
@@ -320,12 +339,15 @@ This is config in NPM's own UI/database, not a file this repo ships:
|
||||
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
|
||||
```
|
||||
|
||||
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`,
|
||||
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to
|
||||
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops
|
||||
before restarting anything if an earlier step — most importantly a failed
|
||||
migration — errors out, so a bad deploy doesn't take down the previously
|
||||
working one.
|
||||
Fetches tags and checks out whichever one sorts newest (`git tag
|
||||
--sort=-creatordate`) — deliberately not the default branch's tip, so
|
||||
running this between releases is a safe no-op rather than pulling in
|
||||
whatever's mid-flight on `main`. Then reinstalls backend deps, runs
|
||||
`alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
|
||||
curls `/api/health` to confirm it came back up. Fails loudly
|
||||
(`set -euo pipefail`) and stops before restarting anything if an earlier
|
||||
step — most importantly a failed migration — errors out, so a bad deploy
|
||||
doesn't take down the previously working one.
|
||||
|
||||
Active users get disconnected for a few seconds during the restart and
|
||||
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
|
||||
@@ -345,11 +367,12 @@ producing a gzipped `pg_dump` in `/var/backups/ds-chat/` with 14-day local
|
||||
rotation. Off-box shipping is a placeholder in that script (commented-out
|
||||
rsync/S3 examples) — decide where those need to go and fill it in.
|
||||
|
||||
That script covers Postgres only. Uploaded chat images and file attachments
|
||||
both live on the **app** server's disk (`/srv/ds-chat/uploads`, created in
|
||||
§3a) — a separate machine from the data server this script runs on — and
|
||||
currently have no backup mechanism at all. Whatever off-box destination you
|
||||
pick above, include `/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
|
||||
That script covers Postgres only. Uploaded chat images, file/video
|
||||
attachments, avatars, and custom emoji all live on the **app** server's
|
||||
disk (`/srv/ds-chat/uploads`, created in §3a) — a separate machine from
|
||||
the data server this script runs on — and currently have no backup
|
||||
mechanism at all. Whatever off-box destination you pick above, include
|
||||
`/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
|
||||
line run from the app server).
|
||||
|
||||
**Test a restore** (against a scratch database, never directly onto
|
||||
@@ -389,13 +412,13 @@ Carried forward from earlier phases (see `backend/README.md`'s own "Notes /
|
||||
scope decisions" for the full detail on each):
|
||||
- No rate limiting on human or bot API traffic.
|
||||
- No CSRF token (relies on `SameSite=Lax` cookies).
|
||||
- No server-side session revocation (signed cookies only).
|
||||
- SSRF protection on outgoing webhooks is creation-time only, not
|
||||
re-validated per delivery (DNS-rebinding gap).
|
||||
- Backup off-box shipping is a placeholder — decide a destination and fill
|
||||
in `deploy/backup-postgres.sh`.
|
||||
- Uploaded chat images and file attachments (`/srv/ds-chat/uploads` on the
|
||||
app server) have no backup coverage at all yet, on-box or off — see §7.
|
||||
- Uploaded chat images, file/video attachments, and custom emoji
|
||||
(`/srv/ds-chat/uploads` on the app server) have no backup coverage at
|
||||
all yet, on-box or off — see §7.
|
||||
- Uploaded-but-never-sent images or files (a user attaches one, then never
|
||||
hits Send) leak an orphaned file on disk — no cleanup job for this yet.
|
||||
Not a security issue (still gated by room membership to view), just an
|
||||
|
||||
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
DS Chat, a self-hosted, real-time team chat service.
|
||||
Copyright (C) 2026 Keith Smith
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
|
||||
@@ -18,21 +18,26 @@ build plan.
|
||||
|
||||
## Features
|
||||
|
||||
- **Auth & accounts** — session-based auth, invite-only signup (admin-issued
|
||||
site invites or room invites, both delivered by email), password reset,
|
||||
per-user light/dark/midnight/sunset presets plus a live theme builder for
|
||||
fully custom, named, savable color themes.
|
||||
- **Rooms** — open and private rooms, owner/admin/member roles, invites,
|
||||
room browsing/search, file/image galleries per room.
|
||||
- **Auth & accounts** — server-side, revocable sessions (see every device
|
||||
you're logged in from and sign one out remotely), invite-only signup
|
||||
(admin-issued site invites or room invites, both delivered by email),
|
||||
password reset, per-user light/dark/midnight/sunset presets plus a live
|
||||
theme builder for fully custom, named, savable color themes.
|
||||
- **Rooms & direct messages** — open and private rooms, owner/admin/member
|
||||
roles, invites (with resend), room browsing/search, file/image galleries
|
||||
per room, plus 1:1 direct messages with a collapsible sidebar section.
|
||||
- **Real-time chat** — WebSocket-based messaging with automatic reconnect
|
||||
and backoff, Markdown rendering, @mentions with autocomplete and inline
|
||||
highlighting, emoji reactions, message editing, image and file
|
||||
attachments (drag-and-drop, paste, or picker) with inline previews for
|
||||
images/PDFs/text/Markdown, unread indicators, and presence (online/away/
|
||||
and backoff, Markdown rendering (headings with custom anchors, sub/
|
||||
superscript, tables, and more), @mentions and #room-reference links with
|
||||
autocomplete and inline highlighting, emoji reactions and shortcodes plus
|
||||
site-wide custom/uploaded emoji, message editing and deletion, image/
|
||||
video/file attachments (drag-and-drop, paste, or picker) with inline
|
||||
previews and playback, unread indicators, and presence (online/away/
|
||||
offline, with a manual "appear offline" override).
|
||||
- **Notifications** — Web Push for offline/backgrounded members, with
|
||||
per-type opt-in/out (mentions vs. all messages), plus in-app unread
|
||||
badges.
|
||||
- **Notifications** — Web Push and native desktop notifications for
|
||||
offline/backgrounded members, plus email: always-on for direct messages,
|
||||
opt-in per room (first unread message and every mention), all alongside
|
||||
in-app unread badges.
|
||||
- **PWA** — installable, offline-capable (cached room/message data, a
|
||||
dedicated offline banner), with automatic update detection that prompts
|
||||
a reload as soon as a new deploy goes live.
|
||||
|
||||
+59
-9
@@ -7,15 +7,17 @@ attachments, notifications, and personalizing your account.
|
||||
|
||||
- [Getting started](#getting-started)
|
||||
- [Rooms](#rooms)
|
||||
- [Direct messages](#direct-messages)
|
||||
- [Sending messages](#sending-messages)
|
||||
- [Formatting](#formatting)
|
||||
- [Mentions and room links](#mentions-and-room-links)
|
||||
- [Attachments](#attachments)
|
||||
- [Reactions](#reactions)
|
||||
- [Editing a message](#editing-a-message)
|
||||
- [Reactions and custom emoji](#reactions-and-custom-emoji)
|
||||
- [Editing and deleting a message](#editing-and-deleting-a-message)
|
||||
- [Presence and notifications](#presence-and-notifications)
|
||||
- [Your profile](#your-profile)
|
||||
- [Room details](#room-details)
|
||||
- [Active sessions](#active-sessions)
|
||||
- [Staying up to date](#staying-up-to-date)
|
||||
|
||||
## Getting started
|
||||
@@ -45,6 +47,21 @@ search box at the top to filter by name.
|
||||
- **Unread indicators**: a room with new activity shows a dot next to its
|
||||
name — a plain dot for unread messages, a highlighted dot if you were
|
||||
specifically @mentioned.
|
||||
- **Collapsible sections**: the **Direct Messages** and **Rooms** headers in
|
||||
the sidebar can be collapsed to hide their contents — click the header to
|
||||
toggle. Your choice is remembered.
|
||||
|
||||
## Direct messages
|
||||
|
||||
Click **People** to see everyone on the site and start a 1:1 conversation
|
||||
with someone — it opens (or reopens, if you've messaged them before)
|
||||
under **Direct Messages** in the sidebar. There's only ever one
|
||||
conversation per pair of people, however many times you start it.
|
||||
|
||||
You can hide a conversation you're done with (its own menu, or from its
|
||||
**Room details** panel) without deleting anything — it drops out of your
|
||||
sidebar but reappears automatically the moment the other person sends a
|
||||
new message, or if you message them again yourself.
|
||||
|
||||
## Sending messages
|
||||
|
||||
@@ -61,7 +78,9 @@ Messages support Markdown:
|
||||
- `-` or `1.` for bulleted/numbered lists
|
||||
- `[link text](https://example.com)` — or just paste a bare URL and it
|
||||
becomes clickable automatically
|
||||
- `#`, `##`, `###` for headings
|
||||
- `#`, `##`, `###` for headings — add `{#custom-id}` at the end of a
|
||||
heading line to control its link anchor instead of the auto-generated one
|
||||
- `~sub~` and `^sup^` for subscript and superscript
|
||||
|
||||
Pasting a link on its own often also generates a preview card underneath
|
||||
your message, pulled from that page's title/description/image, when the
|
||||
@@ -69,7 +88,9 @@ page provides one.
|
||||
|
||||
Emoji: click the 🙂 button in the composer to open the emoji picker, or
|
||||
type a shortcode like `:tada:` and it's converted automatically once you
|
||||
send. The picker remembers your recently-used emoji and has a search box.
|
||||
send. The picker remembers your recently-used emoji and has a search box —
|
||||
see [Reactions and custom emoji](#reactions-and-custom-emoji) for uploading
|
||||
your own.
|
||||
|
||||
## Mentions and room links
|
||||
|
||||
@@ -87,26 +108,39 @@ can see the message (and is a member of that room) straight to it.
|
||||
|
||||
Click the paperclip icon to attach a file, or just drag a file onto the
|
||||
message box and drop it. Images show as an inline thumbnail — click one
|
||||
to view it full-size. Other files show as a small card with the
|
||||
filename and size; `.txt`, `.md`, and `.pdf` files open in a preview
|
||||
without leaving the room, everything else downloads when clicked.
|
||||
to view it full-size. Common video formats (MP4, WebM, Ogg) play inline
|
||||
too, with a button to expand to a larger view; other files show as a
|
||||
small card with the filename and size — `.txt`, `.md`, and `.pdf` files
|
||||
open in a preview without leaving the room, everything else downloads
|
||||
when clicked.
|
||||
|
||||
There's a server-configured maximum file size — if a file is too large,
|
||||
you'll see an error before it uploads.
|
||||
|
||||
## Reactions
|
||||
## Reactions and custom emoji
|
||||
|
||||
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
|
||||
message with a count; click an existing pill to add or remove your own
|
||||
reaction to it. Hovering a pill shows who reacted.
|
||||
|
||||
## Editing a message
|
||||
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**
|
||||
section. Give it a short name and an image — it's then usable by everyone,
|
||||
both as a reaction and inline in message text via `:your-name:`, right
|
||||
alongside the built-in picker. You can remove a custom emoji you uploaded
|
||||
(or any of them, if you're a site admin) from the same picker.
|
||||
|
||||
## Editing and deleting a message
|
||||
|
||||
You can edit any message you sent: hover it and click **Edit**, make your
|
||||
changes, then press **Enter** to save or **Escape** to cancel. Clicking
|
||||
away also saves. Edited messages are marked *(edited)*.
|
||||
|
||||
To delete a message you sent, hover it and click **Delete**. It's replaced
|
||||
with a "message deleted" placeholder rather than disappearing outright, so
|
||||
the conversation doesn't visibly shift for anyone else reading it.
|
||||
|
||||
## Presence and notifications
|
||||
|
||||
Everyone's avatar shows a small status dot — green for online, grey for
|
||||
@@ -124,6 +158,12 @@ The same menu has a notifications toggle:
|
||||
get a notification whenever the app is minimized *or* simply not the
|
||||
focused window, even if it's still open somewhere on screen.
|
||||
|
||||
You'll also get an email if someone messages you in a direct conversation
|
||||
while you're genuinely offline — no setup needed. For regular rooms,
|
||||
email is opt-in per room: open a room's **Room details** panel and turn
|
||||
on **Email notifications** to get emailed on that room's first unread
|
||||
message and on every `@mention`, while you're offline.
|
||||
|
||||
## Your profile
|
||||
|
||||
Open your account menu and choose **Profile settings** to:
|
||||
@@ -135,12 +175,22 @@ Open your account menu and choose **Profile settings** to:
|
||||
live)
|
||||
- Change your password
|
||||
|
||||
## Active sessions
|
||||
|
||||
Profile settings also lists **Active sessions** — every device/browser
|
||||
currently logged into your account, with its approximate location (IP
|
||||
address) and when it was last active. If you see one you don't
|
||||
recognize, click **Revoke** to sign it out immediately. Revoking your own
|
||||
current device signs you out too.
|
||||
|
||||
## Room details
|
||||
|
||||
Click the info icon in a room's header to open its details panel, where
|
||||
you can:
|
||||
|
||||
- See who else is in the room and their role (member/admin/owner)
|
||||
- Turn on **Email notifications** for that room (see
|
||||
[Presence and notifications](#presence-and-notifications))
|
||||
- Browse and re-download every file and image ever shared in the room,
|
||||
without scrolling back through history
|
||||
- Leave the room — unless you're the owner, in which case ownership has
|
||||
|
||||
+245
-54
@@ -1,18 +1,22 @@
|
||||
# DS Chat backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset)
|
||||
# DS Chat backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset, direct messages, active sessions)
|
||||
|
||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
||||
CRUD (open and private), room roles (owner/admin/member) and direct
|
||||
membership management, a WebSocket chat endpoint that fans out across
|
||||
multiple app-server instances via Redis pub/sub, Web Push notifications for
|
||||
offline room members, a site-admin portal (user/room/bot management + an
|
||||
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
|
||||
access, incoming and outgoing webhooks, message editing), image uploads and
|
||||
generic file attachments in chat messages, emoji reactions on messages,
|
||||
self-service user profiles
|
||||
(display name, avatar), self-service password change and a token-based
|
||||
forgot-password flow, and admin-issued email invites for new accounts
|
||||
plus email notifications when a user is added to a room. See
|
||||
`../ARCHITECTURE.md` for the full system design and the phased build plan.
|
||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth
|
||||
(server-side, revocable sessions — see Active sessions below), room CRUD
|
||||
(open and private), room roles (owner/admin/member) and direct membership
|
||||
management, direct messages, a WebSocket chat endpoint that fans out across
|
||||
multiple app-server instances via Redis pub/sub, Web Push and email
|
||||
notifications for offline room members (plus a native desktop-notification
|
||||
bridge for DS Chat Desktop), a site-admin portal (user/room/bot management +
|
||||
an audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
|
||||
access, incoming and outgoing webhooks, message editing), image uploads,
|
||||
inline-playable video attachments, and generic file attachments in chat
|
||||
messages, message deletion, emoji reactions (built-in Unicode plus
|
||||
site-wide custom/uploaded emoji, both usable in reactions and inline in
|
||||
message text), self-service user profiles (display name, avatar),
|
||||
self-service password change and a token-based forgot-password flow, and
|
||||
admin-issued email invites for new accounts plus email notifications when a
|
||||
user is added to a room. See `../ARCHITECTURE.md` for the full system
|
||||
design and the phased build plan.
|
||||
|
||||
This is an **invite-only site**: there is no public registration endpoint.
|
||||
Accounts are created by an operator on the app server — see step 4 below.
|
||||
@@ -129,10 +133,14 @@ app/
|
||||
on-disk save/read -- see Image uploads below
|
||||
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||
messages, message_images, message_reactions,
|
||||
messages, message_images, message_files,
|
||||
message_reactions, message_mentions,
|
||||
message_room_references, link_previews,
|
||||
custom_emoji, custom_themes, sessions,
|
||||
site_invites, password_resets, smtp_settings,
|
||||
push_subscriptions, admin_audit_log, api_tokens,
|
||||
webhooks_incoming, event_subscriptions)
|
||||
upload_settings, push_subscriptions,
|
||||
admin_audit_log, api_tokens, webhooks_incoming,
|
||||
event_subscriptions)
|
||||
schemas/ Pydantic request/response models
|
||||
routers/ auth, rooms, users, signup, push, admin,
|
||||
bots, webhooks, health
|
||||
@@ -345,6 +353,36 @@ No `User`/`PushSubscription` schema change was needed for this feature —
|
||||
the only backend change is the new `desktop_notification` envelope type,
|
||||
covered by `backend/tests/test_desktop_notifications.py`.
|
||||
|
||||
## Email notifications for missed messages
|
||||
|
||||
Two related, separately-scoped email triggers layered on top of Web Push/
|
||||
desktop notifications, both in `app/services/message_events.py`:
|
||||
|
||||
**DMs** (`_maybe_email_dm_notification`) — always on, no opt-in toggle.
|
||||
Emails a DM's other participant when they're *genuinely* offline
|
||||
(`GlobalPresence.is_online`, not just "not connected to this room's own
|
||||
channel" the way the Web Push/desktop-notification audience is computed —
|
||||
someone actively using the app in a different room shouldn't get emailed
|
||||
for a DM) or have `appear_offline` set. Debounced to the first unread
|
||||
message in the conversation, not one email per message in a burst, by
|
||||
checking whether any other unread message already exists in the room
|
||||
since the recipient's `last_read_at`.
|
||||
|
||||
**Regular rooms** (`_maybe_email_room_notifications`) — opt-in per
|
||||
member, per room (`RoomMembership.email_notifications`, toggled via `PATCH
|
||||
/api/rooms/{id}/notifications`; rejected for a DM — `CannotModifyDmError`
|
||||
→ 400 — since DMs already get the always-on behavior above). Two triggers,
|
||||
not one: the room's first unread message debounces the same way DMs do,
|
||||
*but* a message that `@mentions` the subscriber always emails regardless
|
||||
of that debounce — a mention is a stronger, individually-addressed signal
|
||||
that shouldn't get silently absorbed by an earlier plain message in the
|
||||
same burst already having used up the "first unread" email.
|
||||
|
||||
Both paths go through the same `email_service.send_email` used by
|
||||
invites/room-membership notifications above, so they're silently skipped
|
||||
if SMTP isn't configured and never block message delivery on an SMTP
|
||||
outage.
|
||||
|
||||
## Room roles and membership (Phase 2)
|
||||
|
||||
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
|
||||
@@ -365,6 +403,47 @@ since nothing meaningful was gained by making the target confirm first.
|
||||
`GET /api/rooms/mine` lists every room (open + private) the current user
|
||||
belongs to, alongside their role.
|
||||
|
||||
## Direct messages
|
||||
|
||||
A DM is a `Room` with `is_dm=True` and a deterministic, never-shown
|
||||
internal `name` (`dm_room_name(user_a, user_b)` — the two user ids sorted
|
||||
and joined, so it's the same string regardless of who initiates), not a
|
||||
separate model — `POST /api/rooms/dm` (`find_or_create_dm`) looks up an
|
||||
existing DM by that name and creates one (`is_private=True`, both
|
||||
participants as plain `member`) only if none exists yet, so starting a DM
|
||||
with the same person twice always resolves to the one conversation. The
|
||||
frontend never renders `Room.name` for a DM; `MyRoomItem.dm_partner`
|
||||
(`DmPartnerInfo`: the *other* participant's id/username/display
|
||||
name/avatar/status) is precomputed server-side instead, batched per
|
||||
request rather than N+1.
|
||||
|
||||
**Hiding a DM**: `RoomMembership.hidden_at` lets one participant remove a
|
||||
DM from their own sidebar without touching the other participant's copy or
|
||||
deleting anything — a DM has no sensible "leave" (it would violate
|
||||
`find_or_create_dm`'s exactly-two-members assumption). `POST
|
||||
/api/rooms/{id}/hide` sets it; it's cleared automatically (un-hiding the
|
||||
DM) whenever a new message arrives in it or `find_or_create_dm` resolves
|
||||
back to an already-hidden one — both count as the conversation being
|
||||
active again, matching how a re-opened DM in Slack/Discord reappears on
|
||||
its own rather than needing an explicit "unhide."
|
||||
|
||||
## Message deletion
|
||||
|
||||
`DELETE`-shaped over WS (`{"type": "delete", "room_id", "message_id"}`,
|
||||
`message_service.delete_message`) — author-only (`NotMessageAuthorError` →
|
||||
error frame otherwise, no admin/moderator override yet). A real delete of
|
||||
content, not a UI-only hide: `content`, `image_id`, `file_id`, and
|
||||
`preview_url` are all cleared and any attached `MessageImage`/`MessageFile`
|
||||
row (plus its on-disk file) is actually removed, only `deleted_at` (and
|
||||
`id`/`room_id`/`user_id`/`created_at`, so the tombstone still occupies its
|
||||
place in history) survives. The attachment's storage filename is read and
|
||||
the DB row/file only unlinked *after* a successful commit — same ordering
|
||||
`delete_room` already uses, so a rolled-back transaction never leaves an
|
||||
already-destroyed file with no way back. Broadcasts
|
||||
`{"type": "message_deleted", "id", "room_id"}`; the frontend renders a
|
||||
"message deleted" placeholder rather than removing the row, so the
|
||||
conversation doesn't visibly shift when someone deletes something above.
|
||||
|
||||
## Image uploads
|
||||
|
||||
A message can carry an image (`Message.image_id`, nullable), a caption
|
||||
@@ -432,6 +511,18 @@ share them; `ImageTooLargeError` was likewise renamed to
|
||||
|
||||
Same orphaned-upload disk-space caveat as images applies here too.
|
||||
|
||||
**Inline video playback**: a browser-natively-playable video attachment
|
||||
(`INLINE_SAFE_VIDEO_CONTENT_TYPES` in `app/storage.py` — a strict allowlist,
|
||||
`video/mp4`/`video/webm`/`video/ogg`, deliberately not "every `video/*`
|
||||
type") is served *without* the `filename=` param above, so it plays inline
|
||||
in a `<video>` tag instead of forcing a download — the same reasoning
|
||||
`MessageImage`'s own always-inline endpoint already relies on: these are
|
||||
content types a browser only ever interprets as media, never as something
|
||||
that could execute script, so the `Content-Disposition: attachment`
|
||||
mitigation doesn't need to apply to them. Anything outside that allowlist
|
||||
(e.g. `.mov`/`video/quicktime`) still forces a download like any other
|
||||
file.
|
||||
|
||||
## Upload size limits
|
||||
|
||||
The 8 MB image/file/avatar cap is no longer hardcoded — it's an
|
||||
@@ -462,14 +553,17 @@ returning `None`.
|
||||
|
||||
## Emoji & reactions
|
||||
|
||||
An emoji picker in the frontend composer is purely client-side (a static
|
||||
curated unicode list, no backend involvement). Message **reactions** are
|
||||
full-stack: `message_reactions` (`app/models/message_reaction.py`) has
|
||||
The built-in emoji picker in the frontend composer is purely client-side (a
|
||||
static curated unicode list, no backend involvement). Message **reactions**
|
||||
are full-stack: `message_reactions` (`app/models/message_reaction.py`) has
|
||||
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
|
||||
backing toggle semantics — the same user reacting with the same emoji on
|
||||
the same message twice removes it (Slack/Mattermost convention).
|
||||
`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
|
||||
no upsert needed.
|
||||
no upsert needed. `emoji` is `String(32)`, sized to hold either a raw
|
||||
unicode glyph or a custom emoji's `:shortcode:` reference (see Custom emoji
|
||||
below) — the WS reaction envelope's own length check matches this exactly,
|
||||
not an arbitrary smaller cap.
|
||||
|
||||
WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
|
||||
reaction; the server broadcasts the message's **full recomputed** reaction
|
||||
@@ -482,8 +576,42 @@ reload doesn't lose reaction state that only ever arrived over WS.
|
||||
|
||||
Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
|
||||
in `webhook_service.py` is unchanged — same restraint as image uploads), no
|
||||
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
|
||||
only, curated client-side list in `frontend/src/lib/emoji.ts`).
|
||||
reaction-count limit or rate limiting.
|
||||
|
||||
## Custom emoji
|
||||
|
||||
Site-wide (not room-scoped), uploadable by any authenticated user —
|
||||
distinct from the built-in Unicode picker above. `CustomEmoji`
|
||||
(`app/models/custom_emoji.py`): `shortcode` (unique, 30 chars max — sized
|
||||
so a `:shortcode:` reference fits `MessageReaction.emoji`'s column
|
||||
alongside its own colons with zero width change), `storage_filename`,
|
||||
`content_type`, `uploaded_by`.
|
||||
|
||||
- `POST /api/custom-emoji` (multipart: `shortcode` form field + `file`) —
|
||||
reuses `app/storage.py`'s upload primitives (`read_capped`,
|
||||
`process_image(..., square=True, max_dimension=128)`, `save_file`), same
|
||||
pattern as avatars. Shortcode format (`^[a-z0-9_-]{2,30}$`) and
|
||||
uniqueness are checked *before* processing/saving the image, so a
|
||||
rejected upload never orphans a file on disk.
|
||||
- `GET /api/custom-emoji` — full list, any authenticated user.
|
||||
- `DELETE /api/custom-emoji/{id}` — the uploader or a site admin only
|
||||
(`NotEmojiOwnerError` → 403 otherwise).
|
||||
- `GET /api/custom-emoji/{shortcode}/image` — serves the file,
|
||||
`Cache-Control: private, no-cache` (not `immutable`, and deliberately
|
||||
*not* a long `max-age` either — a shortcode can be deleted and
|
||||
re-uploaded with different image data under the same URL, and a timed
|
||||
cache let a browser keep serving the old image for its full duration
|
||||
after that happened; `no-cache` forces revalidation on every use, still
|
||||
cheap since `FileResponse`'s own `ETag`/`Last-Modified` make an
|
||||
unchanged file a 304, not a full re-transfer).
|
||||
|
||||
A `:shortcode:` reference is stored/sent as literal text everywhere (message
|
||||
content, reaction values) and resolved to an image only at render time on
|
||||
the frontend — the same convention the built-in Unicode shortcode
|
||||
autocomplete already used for glyphs, extended to a case with no unicode
|
||||
codepoint to substitute. No server-side collision check against the ~950
|
||||
built-in shortcode names (that list only exists in the frontend); the
|
||||
upload UI warns about a colliding name but doesn't hard-block it.
|
||||
|
||||
## User profiles
|
||||
|
||||
@@ -530,15 +658,21 @@ the Admin portal; being added directly to a room (see Room roles and
|
||||
membership above) sends a "you've been added" email too.
|
||||
|
||||
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
|
||||
`send_email(db, to, subject, body)` is the fire-and-forget path used by
|
||||
invite flows — if `SmtpSettings` isn't configured yet it logs at debug and
|
||||
returns (same "silently skip if unconfigured" UX push notifications already
|
||||
use for a missing VAPID key), and it never raises on delivery failure (an
|
||||
SMTP outage must not block an invite/membership action that already
|
||||
succeeded in the database). `send_test_email(db, to)` is the one exception —
|
||||
used only by the admin "send test email" button, it raises so the UI can
|
||||
show *why* it failed instead of a silent no-op. Plain-text bodies only, no
|
||||
HTML templates, matching this codebase's existing minimalism.
|
||||
`send_email(db, to, subject, paragraphs, *, cta_label=None, cta_url=None,
|
||||
theme_user=None)` is the fire-and-forget path used by invite/notification
|
||||
flows — if `SmtpSettings` isn't configured yet it logs (at `.warning`, not
|
||||
`.debug` — this app has no logging config lowering the root level below
|
||||
Python's own `WARNING` default, so anything below that is silently
|
||||
invisible in production) and returns, and it never raises on delivery
|
||||
failure (an SMTP outage must not block an invite/membership/notification
|
||||
action that already succeeded in the database). `send_test_email(db, to)`
|
||||
is the one exception — used only by the admin "send test email" button, it
|
||||
raises so the UI can show *why* it failed instead of a silent no-op.
|
||||
`paragraphs` (a `list[str]`, not a flat `body: str`) renders both an HTML
|
||||
email — styled with `theme_user`'s own selected theme palette when given,
|
||||
falling back to the default palette — and a plain-text fallback part from
|
||||
the same source, rather than a single pre-formatted string that can't
|
||||
cleanly become HTML without re-parsing it.
|
||||
|
||||
**SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s
|
||||
`/settings/smtp` endpoints) lives in the database, not the env file — the
|
||||
@@ -558,19 +692,25 @@ tokens use — it's a bearer secret looked up by itself). `POST /api/signup`
|
||||
(`app/routers/signup.py`) is the first genuinely public,
|
||||
unauthenticated endpoint in this app that creates a `User` row — it calls
|
||||
the existing `auth_service.register_user` directly for identical
|
||||
hashing/uniqueness handling, and logs the new user in immediately (same
|
||||
session-cookie line `auth.py`'s `login()` uses) so they land in the app
|
||||
already signed in. No new rate limiting on it — the unguessable, single-use,
|
||||
expiring token is the actual protection, inheriting the same "no rate
|
||||
limiting on human/bot traffic" gap already documented below, not a new one.
|
||||
hashing/uniqueness handling, then `session_service.start_session` (see
|
||||
Active sessions below) so they land in the app already signed in. No new
|
||||
rate limiting on it — the unguessable, single-use, expiring token is the
|
||||
actual protection, inheriting the same "no rate limiting on human/bot
|
||||
traffic" gap already documented below, not a new one.
|
||||
|
||||
`POST /api/admin/invites/{id}/resend` (site-admin only,
|
||||
`resend_site_invite`) issues a fresh token and resets the 7-day expiry
|
||||
rather than re-sending the original link — the old link stops working the
|
||||
moment this runs, and it means resending something close to expiring buys
|
||||
the full week again, not just whatever was left. Only valid for a still-
|
||||
`pending` invite (`SiteInviteNotPendingError` otherwise).
|
||||
|
||||
**Room-membership email**: `room_service.add_member` sends one email to
|
||||
the target user after creating the `RoomMembership`, using the live
|
||||
request's `base_url` for the link — no new "public URL" config needed.
|
||||
|
||||
Scope cuts: no outgoing-webhook event type for these (matching image
|
||||
uploads/reactions), no resend for a site invite (revoke + re-invite covers
|
||||
it), no HTML email templates.
|
||||
uploads/reactions).
|
||||
|
||||
## Self-service password change and reset
|
||||
|
||||
@@ -581,10 +721,11 @@ password, and a "forgot password" flow for someone locked out.
|
||||
`current_password` + `new_password`; verifies the current one with
|
||||
`security.verify_password` before setting `password_hash =
|
||||
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
|
||||
(profile update): mutate `current_user`, commit, done. No session
|
||||
invalidation elsewhere (there's no server-side session table to invalidate
|
||||
against — see Notes below), so other logged-in sessions for that account
|
||||
stay valid until they expire naturally.
|
||||
(profile update): mutate `current_user`, commit, done. Doesn't proactively
|
||||
revoke any other logged-in session for that account — a session table now
|
||||
exists (see Active sessions below), but changing your password doesn't
|
||||
walk it and revoke everything else; if you suspect a specific device, use
|
||||
Active sessions to revoke it directly instead.
|
||||
|
||||
**Forgot password** (`app/models/password_reset.py`,
|
||||
`app/services/password_service.py`) — same hashed-token-with-expiry shape as
|
||||
@@ -597,8 +738,8 @@ registered, so a miss is a silent no-op (no row created, no email sent) after
|
||||
a single `SELECT`. `GET /api/auth/reset-password/validate` lets the frontend
|
||||
show a "this link is invalid" state before rendering the password form.
|
||||
`POST /api/auth/reset-password` completes it and — like signup — logs the
|
||||
user in immediately (`request.session["user_id"]`), since they've just proven
|
||||
they control the account's email.
|
||||
user in immediately (`session_service.start_session`, see Active sessions
|
||||
below), since they've just proven they control the account's email.
|
||||
|
||||
Scope cuts: no rate limiting on `/forgot-password` (inherits the same
|
||||
documented gap as every other endpoint below, not a new one — the
|
||||
@@ -606,6 +747,50 @@ unguessable expiring token is the actual protection once a request is made),
|
||||
no cleanup job for expired/used `password_resets` rows (same as
|
||||
`site_invites`, which has never had one either).
|
||||
|
||||
## Active sessions
|
||||
|
||||
Replaces the previously-stateless signed cookie (a bare `user_id`) with a
|
||||
real server-side `Session` table (`app/models/session.py`) — the cookie
|
||||
now only ever carries an opaque session id, resolved against this table
|
||||
via `session_service.resolve_session` on *every* request (`get_current_user`
|
||||
in `app/dependencies.py`, and the WS handshake in `app/ws/chat.py`), which
|
||||
is the single choke point that makes revocation actually take effect on a
|
||||
session's very next request rather than only once its cookie happens to
|
||||
expire.
|
||||
|
||||
Each row records `ip_address` (`X-Forwarded-For`'s first entry, since
|
||||
production sits behind Nginx Proxy Manager — falls back to the direct peer
|
||||
address with nothing in front locally), `user_agent`, `created_at`, and a
|
||||
throttled `last_seen_at` (only bumped if stale by more than 5 minutes —
|
||||
`get_current_user` resolves a session on every authenticated request, so
|
||||
writing on every single one would turn a read into a write storm for no
|
||||
real benefit). `session_service.start_session` is the one place every
|
||||
"log this browser in" call site (login, signup completion, password-reset
|
||||
completion) creates the row and stashes its id in the cookie.
|
||||
|
||||
- `GET /api/auth/sessions` — every non-revoked session for the current
|
||||
user, newest-last-seen first, with a parsed "Browser on OS" label
|
||||
(`app/services/user_agent_service.py` — plain substring checks against
|
||||
the User-Agent header, no new dependency; special-cases an `Electron/`
|
||||
token as "DS Chat Desktop" rather than the underlying Chromium version)
|
||||
and `is_current` (compares against `request.state.session_id`, set by
|
||||
`get_current_user`) so the frontend can label "this device" and treat
|
||||
revoking it as a self-logout.
|
||||
- `DELETE /api/auth/sessions/{id}` — any of the current user's own
|
||||
sessions, including their own current one (a remote sign-out of the
|
||||
same device is a legitimate thing to do); 404 if it belongs to someone
|
||||
else or is already revoked.
|
||||
- `POST /api/auth/logout` also revokes the session row, not just clears
|
||||
the cookie (`revoke_session_unchecked` — no ownership check needed,
|
||||
since a session can only ever log itself out, and never fails even if
|
||||
the row is already gone).
|
||||
|
||||
Scope cuts: changing your password doesn't proactively revoke other
|
||||
sessions (see Self-service password change and reset above) — this is a
|
||||
deliberate scope boundary, not an oversight, since it's a meaningfully
|
||||
different feature (auto-revoke-everywhere-on-password-change) from
|
||||
"let a user see and manually revoke what's logged in."
|
||||
|
||||
## Link previews
|
||||
|
||||
Slack/Discord-style unfurling: the first `http(s)://` URL found in a
|
||||
@@ -637,10 +822,13 @@ preview card fetched from that page's Open Graph tags (`og:title`,
|
||||
`Content-Type` is `text/html`.
|
||||
- **Cached by URL, not by message** (`link_previews` table, unique on
|
||||
`url`) — a URL posted by five different people in five different rooms
|
||||
fetches once. A row also gets written on a *failed* fetch
|
||||
(`fetch_failed=True`) so a URL that genuinely doesn't unfurl (SSRF
|
||||
rejection, timeout, no usable title) isn't re-attempted on every message
|
||||
that references it; both kinds expire after 7 days (`_CACHE_TTL`).
|
||||
within the same short window fetches once. A row also gets written on a
|
||||
*failed* fetch (`fetch_failed=True`) so a URL that genuinely doesn't
|
||||
unfurl (SSRF rejection, timeout, no usable title) isn't re-attempted on
|
||||
every message that references it; both kinds expire after 5 minutes
|
||||
(`_CACHE_TTL` — #70: was 7 days, confirmed live as far too long, a
|
||||
re-posted URL whose title/content had genuinely changed kept showing
|
||||
the stale first-fetch preview for up to a week).
|
||||
- Parsed with stdlib `html.parser.HTMLParser`, not a new dependency — only
|
||||
meta-tag scraping is needed, not general HTML parsing.
|
||||
- Editing a message re-extracts the URL; if it changed or was removed, the
|
||||
@@ -656,13 +844,16 @@ preview card fetched from that page's Open Graph tags (`og:title`,
|
||||
|
||||
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
||||
provisioned with `python -m app.cli create-user` (see step 4 above), or via
|
||||
a site invite (see Site invites & email below). This is separate from
|
||||
a site invite (see Site invites & email above). This is separate from
|
||||
adding an existing user to a private room — site accounts vs. room
|
||||
membership.
|
||||
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
|
||||
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
|
||||
through a WebSocket handshake). This means there's currently no way to force-
|
||||
revoke a session server-side; that needs a real session table later.
|
||||
- Sessions are backed by a real server-side table (`app/models/session.py`,
|
||||
see Active sessions above) — the signed cookie (Starlette
|
||||
`SessionMiddleware`) now only ever carries an opaque session id, resolved
|
||||
against that table on every request, which is what makes revocation
|
||||
possible. Carrying auth through the WebSocket handshake automatically is
|
||||
still why it's cookie-based at all, per `ARCHITECTURE.md`'s original
|
||||
rationale.
|
||||
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
|
||||
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
|
||||
- Deleting a room explicitly deletes its messages/memberships first
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""room email notifications opt-in
|
||||
|
||||
Revision ID: 05b28b0a2261
|
||||
Revises: e2fc4d65f93e
|
||||
Create Date: 2026-08-28 19:34:23.507200
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '05b28b0a2261'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e2fc4d65f93e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('room_memberships', sa.Column('email_notifications', sa.Boolean(), server_default='false', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('room_memberships', 'email_notifications')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add sessions table for active-sessions feature
|
||||
|
||||
Revision ID: 319c30e24cd9
|
||||
Revises: 05b28b0a2261
|
||||
Create Date: 2026-08-28 19:59:51.475384
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '319c30e24cd9'
|
||||
down_revision: Union[str, Sequence[str], None] = '05b28b0a2261'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('sessions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('last_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add user text_scale preference
|
||||
|
||||
Revision ID: 339b78011a4f
|
||||
Revises: a318850726ee
|
||||
Create Date: 2026-08-30 18:03:16.159924
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '339b78011a4f'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a318850726ee'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('text_scale', sa.String(length=20), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'text_scale')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,35 @@
|
||||
"""room is_dm flag for direct messages
|
||||
|
||||
Revision ID: 9ca717f837c2
|
||||
Revises: 9484fbd1cb3a
|
||||
Create Date: 2026-08-19 15:50:25.781735
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9ca717f837c2'
|
||||
down_revision: Union[str, Sequence[str], None] = '9484fbd1cb3a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# server_default backfills existing rows; dropped right after since the
|
||||
# model itself only sets a Python-side default (see is_archived above).
|
||||
op.add_column(
|
||||
'rooms', sa.Column('is_dm', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
op.alter_column('rooms', 'is_dm', server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('rooms', 'is_dm')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add custom emoji
|
||||
|
||||
Revision ID: a318850726ee
|
||||
Revises: 319c30e24cd9
|
||||
Create Date: 2026-08-28 20:34:27.293658
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a318850726ee'
|
||||
down_revision: Union[str, Sequence[str], None] = '319c30e24cd9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('custom_emoji',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('shortcode', sa.String(length=30), nullable=False),
|
||||
sa.Column('storage_filename', sa.String(length=64), nullable=False),
|
||||
sa.Column('content_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('uploaded_by', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_custom_emoji_shortcode'), 'custom_emoji', ['shortcode'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_custom_emoji_shortcode'), table_name='custom_emoji')
|
||||
op.drop_table('custom_emoji')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,39 @@
|
||||
"""allow deleted messages to clear content and attachments
|
||||
|
||||
Revision ID: e2fc4d65f93e
|
||||
Revises: f3f255da9c96
|
||||
Create Date: 2026-08-28 16:20:02.114630
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e2fc4d65f93e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f3f255da9c96'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.drop_constraint('messages_content_or_attachment_required', 'messages', type_='check')
|
||||
op.create_check_constraint(
|
||||
'messages_content_or_attachment_required',
|
||||
'messages',
|
||||
'content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL '
|
||||
'OR deleted_at IS NOT NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('messages_content_or_attachment_required', 'messages', type_='check')
|
||||
op.create_check_constraint(
|
||||
'messages_content_or_attachment_required',
|
||||
'messages',
|
||||
'content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL',
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add user emoji_scale preference
|
||||
|
||||
Revision ID: e81c9bcc82b9
|
||||
Revises: 339b78011a4f
|
||||
Create Date: 2026-08-30 18:14:26.045186
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e81c9bcc82b9'
|
||||
down_revision: Union[str, Sequence[str], None] = '339b78011a4f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('emoji_scale', sa.String(length=20), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'emoji_scale')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""hide DM conversations per-participant
|
||||
|
||||
Revision ID: f3f255da9c96
|
||||
Revises: 9ca717f837c2
|
||||
Create Date: 2026-08-19 16:36:28.332581
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f3f255da9c96'
|
||||
down_revision: Union[str, Sequence[str], None] = '9ca717f837c2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('room_memberships', sa.Column('hidden_at', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('room_memberships', 'hidden_at')
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, RoomRole, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.session_service import resolve_session
|
||||
|
||||
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
|
||||
@@ -29,16 +30,26 @@ async def get_current_user(
|
||||
request.state.api_token = token
|
||||
return user
|
||||
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
session_id = request.session.get("session_id")
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# #69: a session row, not a bare user_id -- resolve_session is also
|
||||
# where a revoked session (this endpoint's own DELETE, or another
|
||||
# device's "sign out") actually takes effect, since there's no other
|
||||
# per-request check of that state.
|
||||
session = await resolve_session(db, uuid.UUID(session_id))
|
||||
if session is None:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
request.state.session_id = session.id
|
||||
|
||||
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
|
||||
# be read without a MissingGreenlet -- selectinload skips the second
|
||||
# query entirely when active_custom_theme_id is null (the common case),
|
||||
# so this costs nothing for users who've never set a custom theme.
|
||||
user = await db.get(
|
||||
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
|
||||
User, session.user_id, options=[selectinload(User.active_custom_theme)]
|
||||
)
|
||||
if user is None or not user.is_active:
|
||||
request.session.clear()
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.routers import (
|
||||
admin,
|
||||
auth,
|
||||
bots,
|
||||
custom_emoji,
|
||||
custom_themes,
|
||||
health,
|
||||
push,
|
||||
@@ -27,6 +28,7 @@ from app.routers import (
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
from app.ws.focus_presence import FocusPresence
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
from app.ws.presence import Presence
|
||||
|
||||
@@ -82,6 +84,7 @@ def create_app() -> FastAPI:
|
||||
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(app.state.redis)
|
||||
app.state.global_presence = GlobalPresence(app.state.redis)
|
||||
app.state.focus_presence = FocusPresence(app.state.redis)
|
||||
app.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager)
|
||||
|
||||
app.include_router(health.router)
|
||||
@@ -91,6 +94,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(users.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(custom_themes.router)
|
||||
app.include_router(custom_emoji.router)
|
||||
app.include_router(uploads.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(bots.router)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.models.admin_audit_log import AdminAuditLog
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.base import Base
|
||||
from app.models.custom_emoji import CustomEmoji
|
||||
from app.models.custom_theme import CustomTheme
|
||||
from app.models.event_subscription import EventSubscription
|
||||
from app.models.invite import InviteStatus
|
||||
@@ -15,6 +16,7 @@ from app.models.message_room_reference import MessageRoomReference
|
||||
from app.models.password_reset import PasswordReset
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.session import Session
|
||||
from app.models.site_invite import SiteInvite
|
||||
from app.models.smtp_settings import SmtpSettings
|
||||
from app.models.upload_settings import UploadSettings
|
||||
@@ -35,6 +37,7 @@ __all__ = [
|
||||
"MessageRoomReference",
|
||||
"InviteStatus",
|
||||
"PasswordReset",
|
||||
"Session",
|
||||
"SiteInvite",
|
||||
"SmtpSettings",
|
||||
"UploadSettings",
|
||||
@@ -44,5 +47,6 @@ __all__ = [
|
||||
"WebhookIncoming",
|
||||
"EventSubscription",
|
||||
"CustomTheme",
|
||||
"CustomEmoji",
|
||||
"LinkPreview",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class CustomEmoji(Base):
|
||||
__tablename__ = "custom_emoji"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
# #18: site-wide, not room-scoped -- kept globally unique so a bare
|
||||
# `:shortcode:` in any message/reaction is unambiguous without also
|
||||
# knowing which room it was posted in. 30 chars, not 32 -- the stored
|
||||
# *reference* in MessageReaction.emoji (String(32)) is the shortcode
|
||||
# wrapped in colons, so this is sized to leave room for both without
|
||||
# widening that column.
|
||||
shortcode: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False)
|
||||
storage_filename: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
content_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
uploader = relationship("User")
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
@@ -33,6 +33,23 @@ class RoomMembership(Base):
|
||||
last_read_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# #52 follow-up: lets a DM be hidden from one participant's own sidebar
|
||||
# without touching the other participant's copy or deleting anything --
|
||||
# a DM has no sensible "leave" (it would corrupt find_or_create_dm's
|
||||
# exactly-two-members assumption), so this is deliberately a per-viewer
|
||||
# display flag on their own membership row, not a membership deletion.
|
||||
# Cleared automatically (see message_events.py) whenever a new message
|
||||
# arrives in the room, or when find_or_create_dm resolves back to it --
|
||||
# both count as the conversation being active again.
|
||||
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# #67: opt-in, per-member -- email while offline on the room's first
|
||||
# unread message, plus every mention regardless of that debounce (see
|
||||
# message_events.py's _maybe_email_room_notifications). Deliberately
|
||||
# separate from #66's DM emails (always-on, no toggle) rather than a
|
||||
# shared flag, since DMs are explicitly out of scope for this setting.
|
||||
email_notifications: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room", back_populates="memberships")
|
||||
user = relationship("User")
|
||||
|
||||
@@ -10,8 +10,12 @@ from app.models.base import Base
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
__table_args__ = (
|
||||
# #53: a deleted message clears content/image_id/file_id entirely
|
||||
# (see message_service.delete_message) -- the "must have something"
|
||||
# rule only applies while the message is actually live.
|
||||
CheckConstraint(
|
||||
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL",
|
||||
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL "
|
||||
"OR deleted_at IS NOT NULL",
|
||||
name="messages_content_or_attachment_required",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,10 @@ class Room(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# #52: a DM is a Room whose `name` is an internal, never-displayed
|
||||
# deterministic token (see room_service.dm_room_name) rather than a
|
||||
# user-chosen name -- see that function's docstring for the scheme.
|
||||
is_dm: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# #69: the row's own id doubles as the opaque value stored in the
|
||||
# signed session cookie (see app/dependencies.py) -- no separate
|
||||
# generate_token()/hash_token() pair like ApiToken needs. A bearer API
|
||||
# token has to be looked up *by itself* from a plaintext string a bot
|
||||
# pastes into an Authorization header (real leak risk, hence hashing
|
||||
# it at rest); this id only ever travels inside itsdangerous's signed,
|
||||
# tamper-proof cookie payload, so a plain UUID primary key carries the
|
||||
# same security properties the stateless cookie already had before
|
||||
# this table existed.
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
# 45 chars fits the longest possible IPv6 text representation.
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# Bumped (throttled, not on every request -- see session_service.py) so
|
||||
# "active sessions" can be sorted/labeled by actual recent use, not just
|
||||
# login time -- a session opened once a week ago and used constantly
|
||||
# since should not look identical to one opened once and abandoned.
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# Null while active. Set on explicit logout or a deliberate "sign out
|
||||
# this device" from another session -- never deleted outright, so a
|
||||
# revoked row still means something if anyone ever needs to ask "was
|
||||
# this session valid at time X."
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user = relationship("User")
|
||||
@@ -19,6 +19,17 @@ class User(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(50))
|
||||
theme: Mapped[str | None] = mapped_column(String(20))
|
||||
# #71: null means "normal" (the pre-existing default before this
|
||||
# setting existed) -- a preset name, not a raw scale factor, so it's
|
||||
# validated/enumerable the same way `theme` already is rather than
|
||||
# accepting an arbitrary float.
|
||||
text_scale: Mapped[str | None] = mapped_column(String(20))
|
||||
# #71: independent of text_scale above -- scales emoji rendered in
|
||||
# message text specifically, not the whole UI (see
|
||||
# frontend/src/components/MessageContent.tsx's --emoji-scale, scoped
|
||||
# to message content only so it can't also inflate the emoji picker's
|
||||
# grid or reaction pills).
|
||||
emoji_scale: Mapped[str | None] = mapped_column(String(20))
|
||||
# Only meaningful when theme == "custom" -- which of this user's saved
|
||||
# CustomTheme rows (app/models/custom_theme.py) is currently active.
|
||||
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.services.site_invite_service import (
|
||||
SiteInviteNotPendingError,
|
||||
create_site_invite,
|
||||
list_site_invites,
|
||||
resend_site_invite,
|
||||
revoke_site_invite,
|
||||
)
|
||||
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings
|
||||
@@ -322,6 +323,22 @@ async def revoke_site_invite_endpoint(
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/resend", response_model=SiteInviteRead)
|
||||
async def resend_site_invite_endpoint(
|
||||
invite_id: uuid.UUID,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
return await resend_site_invite(db, current_user, str(request.base_url), invite_id)
|
||||
except SiteInviteNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
except SiteInviteNotPendingError:
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
|
||||
async def get_smtp_settings_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -376,7 +393,7 @@ async def test_smtp_settings_endpoint(
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
await send_test_email(db, current_user.email)
|
||||
await send_test_email(db, current_user.email, theme_user=current_user)
|
||||
except SmtpNotConfiguredError:
|
||||
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
|
||||
from app.schemas.session import SessionRead
|
||||
from app.schemas.user import ProfileUpdate, UserRead
|
||||
from app.services.auth_service import (
|
||||
AccountDeactivatedError,
|
||||
@@ -22,7 +25,15 @@ from app.services.password_service import (
|
||||
request_password_reset,
|
||||
validate_reset_token,
|
||||
)
|
||||
from app.services.session_service import (
|
||||
SessionNotFoundError,
|
||||
list_sessions,
|
||||
revoke_session,
|
||||
revoke_session_unchecked,
|
||||
start_session,
|
||||
)
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.services.user_agent_service import describe_user_agent
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
InvalidImageError,
|
||||
@@ -55,12 +66,15 @@ async def login(
|
||||
except AccountDeactivatedError:
|
||||
raise HTTPException(status_code=401, detail="Account is deactivated")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(request: Request) -> Response:
|
||||
async def logout(request: Request, db: AsyncSession = Depends(get_db)) -> Response:
|
||||
session_id = request.session.get("session_id")
|
||||
if session_id:
|
||||
await revoke_session_unchecked(db, uuid.UUID(session_id))
|
||||
request.session.clear()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -86,6 +100,10 @@ async def update_profile(
|
||||
current_user.display_name = display_name or None
|
||||
if "theme" in updates:
|
||||
current_user.theme = updates["theme"]
|
||||
if "text_scale" in updates:
|
||||
current_user.text_scale = updates["text_scale"]
|
||||
if "emoji_scale" in updates:
|
||||
current_user.emoji_scale = updates["emoji_scale"]
|
||||
if "appear_offline" in updates:
|
||||
current_user.appear_offline = updates["appear_offline"]
|
||||
await db.commit()
|
||||
@@ -218,5 +236,37 @@ async def complete_reset_password_endpoint(
|
||||
except PasswordResetInvalidError:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionRead])
|
||||
async def list_sessions_endpoint(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sessions = await list_sessions(db, current_user.id)
|
||||
return [
|
||||
SessionRead(
|
||||
id=s.id,
|
||||
ip_address=s.ip_address,
|
||||
device_label=describe_user_agent(s.user_agent),
|
||||
created_at=s.created_at,
|
||||
last_seen_at=s.last_seen_at,
|
||||
is_current=s.id == request.state.session_id,
|
||||
)
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
async def revoke_session_endpoint(
|
||||
session_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await revoke_session(db, current_user.id, session_id)
|
||||
except SessionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.custom_emoji import CustomEmojiRead
|
||||
from app.services.custom_emoji_service import (
|
||||
SHORTCODE_PATTERN,
|
||||
CustomEmojiNotFoundError,
|
||||
DuplicateShortcodeError,
|
||||
InvalidShortcodeError,
|
||||
NotEmojiOwnerError,
|
||||
create_custom_emoji,
|
||||
delete_custom_emoji,
|
||||
get_custom_emoji_by_shortcode,
|
||||
list_custom_emoji,
|
||||
)
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
UPLOADS_DIR,
|
||||
InvalidImageError,
|
||||
UploadTooLargeError,
|
||||
process_image,
|
||||
read_capped,
|
||||
save_file,
|
||||
)
|
||||
|
||||
# Small and square -- these render inline in message text/reaction pills at
|
||||
# roughly text size, nowhere near message-image or avatar dimensions.
|
||||
CUSTOM_EMOJI_MAX_DIMENSION = 128
|
||||
|
||||
router = APIRouter(prefix="/api/custom-emoji", tags=["custom-emoji"])
|
||||
|
||||
|
||||
@router.post("", response_model=CustomEmojiRead, status_code=201)
|
||||
async def upload_custom_emoji_endpoint(
|
||||
shortcode: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
shortcode = shortcode.strip().lower()
|
||||
# Checked here, before any file processing/saving, so the common
|
||||
# rejection cases (bad format, name taken) never leave an orphaned
|
||||
# file on disk -- create_custom_emoji below still re-checks both
|
||||
# (the actual source of truth, and the only thing that closes the
|
||||
# TOCTOU race on the uniqueness check).
|
||||
if not SHORTCODE_PATTERN.match(shortcode):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Shortcode must be 2-30 characters: lowercase letters, numbers, hyphens, underscores",
|
||||
)
|
||||
if await get_custom_emoji_by_shortcode(db, shortcode) is not None:
|
||||
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
|
||||
|
||||
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Unsupported image type")
|
||||
|
||||
upload_settings = await get_upload_settings(db)
|
||||
try:
|
||||
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
|
||||
except UploadTooLargeError:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
|
||||
)
|
||||
|
||||
try:
|
||||
data, ext = process_image(
|
||||
data, file.content_type, square=True, max_dimension=CUSTOM_EMOJI_MAX_DIMENSION
|
||||
)
|
||||
except InvalidImageError:
|
||||
raise HTTPException(status_code=400, detail="File is not a valid image")
|
||||
|
||||
storage_filename = save_file(data, ext)
|
||||
try:
|
||||
return await create_custom_emoji(
|
||||
db, current_user.id, shortcode, storage_filename, file.content_type
|
||||
)
|
||||
except (InvalidShortcodeError, DuplicateShortcodeError):
|
||||
# Already checked above -- only reachable via the uniqueness
|
||||
# check's TOCTOU race (two uploads of the same new shortcode at
|
||||
# once), not the common case.
|
||||
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomEmojiRead])
|
||||
async def list_custom_emoji_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_custom_emoji(db)
|
||||
|
||||
|
||||
@router.delete("/{emoji_id}", status_code=204)
|
||||
async def delete_custom_emoji_endpoint(
|
||||
emoji_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await delete_custom_emoji(db, emoji_id, current_user)
|
||||
except CustomEmojiNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom emoji not found")
|
||||
except NotEmojiOwnerError:
|
||||
raise HTTPException(status_code=403, detail="Only the uploader or a site admin can remove this")
|
||||
|
||||
|
||||
@router.get("/{shortcode}/image")
|
||||
async def get_custom_emoji_image_endpoint(
|
||||
shortcode: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
emoji = await get_custom_emoji_by_shortcode(db, shortcode)
|
||||
if emoji is None:
|
||||
raise HTTPException(status_code=404, detail="Custom emoji not found")
|
||||
return FileResponse(
|
||||
UPLOADS_DIR / emoji.storage_filename,
|
||||
media_type=emoji.content_type,
|
||||
# #18 follow-up: `max-age=300` (the avatar endpoint's own
|
||||
# convention) meant a browser that had already fetched this
|
||||
# shortcode's image kept serving it from cache for up to 5 minutes
|
||||
# after a delete-and-reupload under the same name swapped in a
|
||||
# genuinely different file underneath the same URL -- confirmed
|
||||
# live, re-adding an emoji with a just-deleted shortcode showed the
|
||||
# old image. `no-cache` (despite the name, still cacheable) forces
|
||||
# a revalidation round trip on every use instead of trusting a
|
||||
# timed cache -- FileResponse already sets ETag/Last-Modified from
|
||||
# the file's own mtime+size (see Starlette's set_stat_headers), so
|
||||
# an unchanged file still gets served as a cheap 304 and only an
|
||||
# actually-different one (any re-upload) returns fresh bytes.
|
||||
headers={"Cache-Control": "private, no-cache"},
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from app.schemas.message import LinkPreviewInfo, MessageFileInfo, MessageRead
|
||||
from app.schemas.message_file import MessageFileCreated
|
||||
from app.schemas.message_image import MessageImageCreated
|
||||
from app.schemas.room import (
|
||||
DmPartnerInfo,
|
||||
MyRoomItem,
|
||||
RoomAttachmentRead,
|
||||
RoomCreate,
|
||||
@@ -24,8 +25,10 @@ from app.schemas.room import (
|
||||
RoomMemberAdd,
|
||||
RoomMemberRead,
|
||||
RoomMemberRoleUpdate,
|
||||
RoomNotificationSettingsUpdate,
|
||||
RoomRead,
|
||||
RoomUpdate,
|
||||
StartDmRequest,
|
||||
TransferOwnershipRequest,
|
||||
)
|
||||
from app.schemas.webhook import (
|
||||
@@ -36,19 +39,24 @@ from app.schemas.webhook import (
|
||||
WebhookIncomingRead,
|
||||
)
|
||||
from app.services.link_preview_service import get_link_previews_for_urls
|
||||
from app.services.message_events import broadcast_room_added
|
||||
from app.services.message_events import broadcast_new_message, broadcast_room_added
|
||||
from app.services.message_service import (
|
||||
create_message,
|
||||
get_reactions_for_messages,
|
||||
list_recent_messages,
|
||||
list_room_attachments,
|
||||
)
|
||||
from app.services.system_user_service import get_or_create_system_user
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.services.room_service import (
|
||||
AlreadyMemberError,
|
||||
CannotDmSelfError,
|
||||
CannotModifyDmError,
|
||||
CannotRemoveOwnerError,
|
||||
DuplicateRoomError,
|
||||
InsufficientRoleError,
|
||||
MembershipNotFoundError,
|
||||
NotADmError,
|
||||
OwnerMustTransferError,
|
||||
RoomIsPrivateError,
|
||||
RoomNotFoundError,
|
||||
@@ -57,7 +65,9 @@ from app.services.room_service import (
|
||||
change_member_role,
|
||||
create_room,
|
||||
delete_room,
|
||||
find_or_create_dm,
|
||||
get_room,
|
||||
hide_dm,
|
||||
join_room,
|
||||
leave_room,
|
||||
list_member_rooms,
|
||||
@@ -65,6 +75,7 @@ from app.services.room_service import (
|
||||
list_room_members,
|
||||
mark_room_read,
|
||||
remove_member,
|
||||
set_room_email_notifications,
|
||||
transfer_ownership,
|
||||
update_room,
|
||||
)
|
||||
@@ -82,6 +93,7 @@ from app.services.webhook_service import (
|
||||
from app.services.ssrf import UnsafeUrlError
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
INLINE_SAFE_VIDEO_CONTENT_TYPES,
|
||||
UPLOADS_DIR,
|
||||
InvalidImageError,
|
||||
UploadTooLargeError,
|
||||
@@ -105,6 +117,28 @@ async def create_room_endpoint(
|
||||
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
||||
|
||||
|
||||
@router.post("/dm", response_model=RoomRead, status_code=201)
|
||||
async def start_dm_endpoint(
|
||||
data: StartDmRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
room = await find_or_create_dm(db, current_user.id, data.other_user_id)
|
||||
except CannotDmSelfError:
|
||||
raise HTTPException(status_code=400, detail="Cannot start a DM with yourself")
|
||||
except TargetUserNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="No user with that ID")
|
||||
# Same signal add_member sends -- without it, the other participant's
|
||||
# already-open client has no way to know this DM exists until they
|
||||
# reload: GET /rooms/mine is only fetched once at app mount. Sent
|
||||
# unconditionally (not just on genuine creation) since re-finding an
|
||||
# existing DM and refreshing their room list again is harmless.
|
||||
await broadcast_room_added(request.app.state.broadcaster, data.other_user_id, room)
|
||||
return room
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoomListItem])
|
||||
async def list_rooms_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -117,6 +151,8 @@ async def list_rooms_endpoint(
|
||||
name=room.name,
|
||||
description=room.description,
|
||||
is_private=room.is_private,
|
||||
is_dm=room.is_dm,
|
||||
is_archived=room.is_archived,
|
||||
owner_id=room.owner_id,
|
||||
created_at=room.created_at,
|
||||
is_member=is_member,
|
||||
@@ -127,23 +163,40 @@ async def list_rooms_endpoint(
|
||||
|
||||
@router.get("/mine", response_model=list[MyRoomItem])
|
||||
async def list_my_rooms_endpoint(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
rooms = await list_member_rooms(db, current_user.id)
|
||||
partner_ids = [partner.id for *_, partner in rooms if partner is not None]
|
||||
online_ids = await request.app.state.global_presence.online_user_ids(partner_ids)
|
||||
return [
|
||||
MyRoomItem(
|
||||
id=room.id,
|
||||
name=room.name,
|
||||
description=room.description,
|
||||
is_private=room.is_private,
|
||||
is_dm=room.is_dm,
|
||||
is_archived=room.is_archived,
|
||||
owner_id=room.owner_id,
|
||||
created_at=room.created_at,
|
||||
role=role,
|
||||
has_unread=has_unread,
|
||||
has_mention=has_mention,
|
||||
email_notifications=email_notifications,
|
||||
dm_partner=(
|
||||
DmPartnerInfo(
|
||||
user_id=partner.id,
|
||||
username=partner.username,
|
||||
display_name=partner.display_name,
|
||||
avatar_filename=partner.avatar_filename,
|
||||
status=_member_status(partner, online_ids),
|
||||
)
|
||||
for room, role, has_unread, has_mention in rooms
|
||||
if partner is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
for room, role, has_unread, has_mention, email_notifications, partner in rooms
|
||||
]
|
||||
|
||||
|
||||
@@ -167,6 +220,8 @@ async def update_room_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
except DuplicateRoomError:
|
||||
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
||||
except CannotModifyDmError:
|
||||
raise HTTPException(status_code=400, detail="DMs can't be edited")
|
||||
|
||||
|
||||
@router.delete("/{room_id}", status_code=204)
|
||||
@@ -215,6 +270,24 @@ async def leave_room_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Not a member of this room")
|
||||
|
||||
|
||||
@router.post("/{room_id}/hide", status_code=204)
|
||||
async def hide_dm_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
try:
|
||||
room = await get_room(db, room_id)
|
||||
await hide_dm(db, room, current_user.id)
|
||||
except RoomNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
except NotADmError:
|
||||
raise HTTPException(status_code=400, detail="Only DMs can be hidden")
|
||||
except MembershipNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Not a member of this room")
|
||||
|
||||
|
||||
@router.post("/{room_id}/read", status_code=204)
|
||||
async def mark_room_read_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
@@ -225,6 +298,20 @@ async def mark_room_read_endpoint(
|
||||
await mark_room_read(db, room_id, current_user.id)
|
||||
|
||||
|
||||
@router.patch("/{room_id}/notifications", status_code=204)
|
||||
async def update_room_notifications_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: RoomNotificationSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
try:
|
||||
await set_room_email_notifications(db, room_id, current_user.id, data.email_notifications)
|
||||
except CannotModifyDmError:
|
||||
raise HTTPException(status_code=400, detail="Email notifications aren't available for DMs")
|
||||
|
||||
|
||||
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
||||
# appear_offline always wins, regardless of actual connection -- that's
|
||||
# the whole point of the override (lurking in a room undetected).
|
||||
@@ -366,6 +453,7 @@ async def get_room_messages_endpoint(
|
||||
reactions=reactions_by_message.get(m.id, []),
|
||||
created_at=m.created_at,
|
||||
edited_at=m.edited_at,
|
||||
deleted_at=m.deleted_at,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
@@ -480,12 +568,25 @@ async def get_room_file_endpoint(
|
||||
message_file = await db.get(MessageFile, file_id)
|
||||
if message_file is None or message_file.room_id != room_id:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
# #65: a browser-playable video is served inline (no filename=) so a
|
||||
# <video> tag can actually play it instead of triggering a download --
|
||||
# gated to a strict allowlist (INLINE_SAFE_VIDEO_CONTENT_TYPES), the
|
||||
# same reasoning MessageImage's own endpoint already relies on: these
|
||||
# are content types a browser only ever interprets as media, never as
|
||||
# something that could execute script, so the attachment-disposition
|
||||
# mitigation below doesn't need to apply to them.
|
||||
if message_file.content_type in INLINE_SAFE_VIDEO_CONTENT_TYPES:
|
||||
return FileResponse(
|
||||
UPLOADS_DIR / message_file.storage_filename,
|
||||
media_type=message_file.content_type,
|
||||
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||
)
|
||||
# `filename=` makes Starlette set Content-Disposition: attachment,
|
||||
# forcing a download instead of an inline render regardless of
|
||||
# content-type -- the mitigation for a same-origin-served, user-
|
||||
# uploaded file (e.g. .html/.svg) executing script in this app's own
|
||||
# origin if opened directly. No content-type allowlist needed on top
|
||||
# of this; see backend/README.md.
|
||||
# of this beyond the video carve-out above; see backend/README.md.
|
||||
return FileResponse(
|
||||
UPLOADS_DIR / message_file.storage_filename,
|
||||
media_type=message_file.content_type,
|
||||
@@ -556,7 +657,37 @@ async def add_member_endpoint(
|
||||
raise HTTPException(status_code=404, detail="No user with that ID")
|
||||
except AlreadyMemberError:
|
||||
raise HTTPException(status_code=409, detail="That user is already a member")
|
||||
|
||||
# room_added first -- the new member's client needs to know this room
|
||||
# exists before it can make sense of an unread_update for it, which the
|
||||
# welcome message below would otherwise trigger out of order.
|
||||
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
|
||||
|
||||
# #74: posted as the auto-provisioned System account, not the admin who
|
||||
# did the adding -- "Welcome, bob!" reads as coming from the room/app
|
||||
# itself, not as something the admin personally typed.
|
||||
system_user = await get_or_create_system_user(db)
|
||||
welcome_name = membership.user.display_name or membership.user.username
|
||||
welcome_message = await create_message(
|
||||
db, room.id, system_user.id, f"Welcome to #{room.name}, {welcome_name}!"
|
||||
)
|
||||
# Same "sending implies having seen the room" reasoning as ws/chat.py's
|
||||
# own live-message path -- the admin is the one who caused this message,
|
||||
# and is presumably already looking at this room's member management, so
|
||||
# without this their own client would show it as unread regardless.
|
||||
await mark_room_read(db, room.id, current_user.id)
|
||||
await broadcast_new_message(
|
||||
db,
|
||||
request.app.state.broadcaster,
|
||||
request.app.state.presence,
|
||||
request.app.state.focus_presence,
|
||||
request.app.state.global_presence,
|
||||
str(request.base_url),
|
||||
room.id,
|
||||
welcome_message,
|
||||
system_user,
|
||||
)
|
||||
|
||||
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
||||
return RoomMemberRead(
|
||||
user_id=membership.user_id,
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.database import get_db
|
||||
from app.schemas.site_invite import SignupComplete, SignupValidateRead
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.auth_service import DuplicateUserError
|
||||
from app.services.session_service import start_session
|
||||
from app.services.site_invite_service import (
|
||||
SiteInviteInvalidError,
|
||||
complete_signup,
|
||||
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
|
||||
except DuplicateUserError:
|
||||
raise HTTPException(status_code=409, detail="That username or email is already taken")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.schemas.webhook import IncomingWebhookPost
|
||||
from app.services.message_events import broadcast_new_message
|
||||
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
|
||||
from app.services.webhook_service import RoomArchivedError, WebhookNotFoundError, post_via_webhook
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
@@ -22,7 +22,21 @@ async def incoming_webhook_endpoint(
|
||||
message, room, sender = await post_via_webhook(db, token, data.content)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Unknown webhook")
|
||||
except RoomArchivedError:
|
||||
raise HTTPException(status_code=403, detail="This room has been archived and is read-only")
|
||||
|
||||
broadcaster = request.app.state.broadcaster
|
||||
presence = request.app.state.presence
|
||||
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
|
||||
focus_presence = request.app.state.focus_presence
|
||||
global_presence = request.app.state.global_presence
|
||||
await broadcast_new_message(
|
||||
db,
|
||||
broadcaster,
|
||||
presence,
|
||||
focus_presence,
|
||||
global_presence,
|
||||
str(request.base_url),
|
||||
room.id,
|
||||
message,
|
||||
sender,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CustomEmojiRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
shortcode: str
|
||||
uploaded_by: uuid.UUID
|
||||
created_at: datetime
|
||||
@@ -44,3 +44,9 @@ class MessageRead(BaseModel):
|
||||
reactions: list[ReactionSummary]
|
||||
created_at: datetime
|
||||
edited_at: datetime | None
|
||||
# #53: null for a live message; set once deleted, at which point
|
||||
# content/image_id/file/link_preview are all already cleared
|
||||
# server-side (see message_service.delete_message). `reactions` isn't
|
||||
# cleared server-side -- the frontend just doesn't render them once
|
||||
# deleted_at is set, same as it doesn't render the rest of a tombstone.
|
||||
deleted_at: datetime | None
|
||||
|
||||
@@ -26,6 +26,12 @@ class RoomRead(BaseModel):
|
||||
name: str
|
||||
description: str | None
|
||||
is_private: bool
|
||||
is_dm: bool
|
||||
# #57: previously only exposed on the admin-only AdminRoom schema, so a
|
||||
# member of an archived room had no way to even know it was archived --
|
||||
# the flag was set server-side but had no effect on their own view of
|
||||
# the room at all.
|
||||
is_archived: bool
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
@@ -34,6 +40,14 @@ class RoomListItem(RoomRead):
|
||||
is_member: bool
|
||||
|
||||
|
||||
class DmPartnerInfo(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
username: str
|
||||
display_name: str | None
|
||||
avatar_filename: str | None
|
||||
status: Literal["online", "offline"]
|
||||
|
||||
|
||||
class MyRoomItem(RoomRead):
|
||||
role: RoomRole
|
||||
# Whether this room has a message newer than the caller's last_read_at --
|
||||
@@ -44,6 +58,25 @@ class MyRoomItem(RoomRead):
|
||||
# over has_unread in the sidebar (see RoomRow.tsx), not shown alongside
|
||||
# it.
|
||||
has_mention: bool
|
||||
# #52: populated only when is_dm is true -- the *other* participant,
|
||||
# precomputed here so the sidebar can render a DM row (their name +
|
||||
# avatar, not this room's internal `name`) without a second fetch per
|
||||
# row. None for a regular room.
|
||||
dm_partner: DmPartnerInfo | None = None
|
||||
# #67: this viewer's own opt-in for email while offline -- the room's
|
||||
# first unread message plus every mention (see message_events.py's
|
||||
# _maybe_email_room_notifications for the exact debounce shape). Always
|
||||
# false for a DM, deliberately out of scope -- DMs already get #66's
|
||||
# automatic offline email.
|
||||
email_notifications: bool = False
|
||||
|
||||
|
||||
class StartDmRequest(BaseModel):
|
||||
other_user_id: uuid.UUID
|
||||
|
||||
|
||||
class RoomNotificationSettingsUpdate(BaseModel):
|
||||
email_notifications: bool
|
||||
|
||||
|
||||
class RoomMemberRead(BaseModel):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
ip_address: str | None
|
||||
# Parsed from the stored user_agent by the router (see
|
||||
# user_agent_service.describe_user_agent) -- not a stored column, so a
|
||||
# future improvement to the parser applies retroactively to old rows
|
||||
# too.
|
||||
device_label: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
# Whether this is the session the request making this call is itself
|
||||
# authenticated with -- lets the UI mark "this device" and treat
|
||||
# revoking it as a self-logout rather than just another row.
|
||||
is_current: bool
|
||||
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
|
||||
is_site_admin: bool
|
||||
display_name: str | None
|
||||
theme: str | None
|
||||
text_scale: str | None
|
||||
emoji_scale: str | None
|
||||
# Resolved, not just an id -- the frontend needs the actual palette to
|
||||
# paint on load without a second round trip (see lib/theme.ts).
|
||||
active_custom_theme: CustomThemeRead | None
|
||||
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
|
||||
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
|
||||
# bare theme name with nothing to point it at.
|
||||
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
|
||||
# #71: kept in sync with frontend/src/lib/theme.ts's TEXT_SCALE_PERCENT map.
|
||||
text_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
|
||||
# #71: kept in sync with MessageContent.tsx's EMOJI_SCALE_MULTIPLIER map.
|
||||
emoji_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
|
||||
appear_offline: bool | None = Field(default=None)
|
||||
|
||||
|
||||
|
||||
@@ -93,9 +93,14 @@ async def set_user_site_admin(
|
||||
|
||||
|
||||
async def list_rooms_admin(db: AsyncSession) -> list[tuple[Room, int]]:
|
||||
# #52: DMs are fully private, not just unlisted -- excluded here rather
|
||||
# than merely omitted from the response, so there's no admin-portal
|
||||
# surface (this list, or the audit log via anything that touches this
|
||||
# query) that reveals a DM even exists between two users.
|
||||
result = await db.execute(
|
||||
select(Room, func.count(RoomMembership.user_id))
|
||||
.outerjoin(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(Room.is_dm.is_(False))
|
||||
.group_by(Room.id)
|
||||
.order_by(Room.created_at)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import CustomEmoji, User
|
||||
from app.storage import delete_file
|
||||
|
||||
# Deliberately stricter than the built-in Unicode shortcode set's charset
|
||||
# (see frontend/src/lib/emojiShortcodes.ts, which also allows '+') -- this
|
||||
# is validating a *new name being chosen*, not matching against an
|
||||
# existing fixed list, so there's no reason to allow anything a person
|
||||
# wouldn't naturally type. Max 30 chars matches CustomEmoji.shortcode's
|
||||
# column width exactly (see that model's comment for why).
|
||||
SHORTCODE_PATTERN = re.compile(r"^[a-z0-9_-]{2,30}$")
|
||||
|
||||
|
||||
class InvalidShortcodeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DuplicateShortcodeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CustomEmojiNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotEmojiOwnerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_custom_emoji(
|
||||
db: AsyncSession,
|
||||
uploaded_by: uuid.UUID,
|
||||
shortcode: str,
|
||||
storage_filename: str,
|
||||
content_type: str,
|
||||
) -> CustomEmoji:
|
||||
if not SHORTCODE_PATTERN.match(shortcode):
|
||||
raise InvalidShortcodeError()
|
||||
|
||||
emoji = CustomEmoji(
|
||||
shortcode=shortcode,
|
||||
storage_filename=storage_filename,
|
||||
content_type=content_type,
|
||||
uploaded_by=uploaded_by,
|
||||
)
|
||||
db.add(emoji)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise DuplicateShortcodeError() from exc
|
||||
await db.refresh(emoji)
|
||||
return emoji
|
||||
|
||||
|
||||
async def list_custom_emoji(db: AsyncSession) -> list[CustomEmoji]:
|
||||
result = await db.execute(select(CustomEmoji).order_by(CustomEmoji.shortcode))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_custom_emoji_by_shortcode(db: AsyncSession, shortcode: str) -> CustomEmoji | None:
|
||||
result = await db.execute(select(CustomEmoji).where(CustomEmoji.shortcode == shortcode))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_custom_emoji(db: AsyncSession, emoji_id: uuid.UUID, current_user: User) -> None:
|
||||
emoji = await db.get(CustomEmoji, emoji_id)
|
||||
if emoji is None:
|
||||
raise CustomEmojiNotFoundError()
|
||||
if emoji.uploaded_by != current_user.id and not current_user.is_site_admin:
|
||||
raise NotEmojiOwnerError()
|
||||
delete_file(emoji.storage_filename)
|
||||
await db.delete(emoji)
|
||||
await db.commit()
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
@@ -5,7 +6,7 @@ import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crypto import decrypt
|
||||
from app.models import SmtpSettings
|
||||
from app.models import CustomTheme, SmtpSettings, User
|
||||
from app.services.smtp_settings_service import get_smtp_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
|
||||
# #68: the 6 tokens actually used by the email template below, out of the
|
||||
# full ~12-token palette themes.css defines per preset -- an email has no
|
||||
# equivalent of --ds-surface-2/--ds-void-2/--ds-highlight/--ds-danger, it's
|
||||
# one card on one background with one accent. Kept in sync by hand with
|
||||
# frontend/src/styles/tokens.css (the default) and themes.css (the other
|
||||
# three presets) -- there's no way to share the source of truth across the
|
||||
# Python/CSS boundary, so if either changes the other needs updating too.
|
||||
DEFAULT_PALETTE = {
|
||||
"void": "#07080f",
|
||||
"surface": "#101030",
|
||||
"border": "#242478",
|
||||
"text": "#fce4fc",
|
||||
"muted": "#c0ccd8",
|
||||
"accent": "#60d8fc",
|
||||
}
|
||||
_PRESET_PALETTES: dict[str, dict[str, str]] = {
|
||||
"dark": DEFAULT_PALETTE,
|
||||
"light": {
|
||||
"void": "#f5f3fb",
|
||||
"surface": "#ffffff",
|
||||
"border": "#d8d2ee",
|
||||
"text": "#1a1030",
|
||||
"muted": "#675f80",
|
||||
"accent": "#0891b2",
|
||||
},
|
||||
"midnight": {
|
||||
"void": "#000000",
|
||||
"surface": "#0a0a14",
|
||||
"border": "#262640",
|
||||
"text": "#ffffff",
|
||||
"muted": "#a8b0c0",
|
||||
"accent": "#00f0ff",
|
||||
},
|
||||
"sunset": {
|
||||
"void": "#120a07",
|
||||
"surface": "#241408",
|
||||
"border": "#4a2c14",
|
||||
"text": "#fce8d8",
|
||||
"muted": "#c8b0a0",
|
||||
"accent": "#fca050",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_palette(db: AsyncSession, theme_user: User | None) -> dict[str, str]:
|
||||
"""#68: an email addressed to an existing user is styled with *their*
|
||||
selected theme (mirroring the app itself), not a fixed look -- but
|
||||
there's no such thing as "their theme" for someone who doesn't have an
|
||||
account yet (site invites), so theme_user is None there and this falls
|
||||
back to the default DarkSingularity palette, same as a logged-out page.
|
||||
"""
|
||||
if theme_user is None or theme_user.theme is None:
|
||||
return DEFAULT_PALETTE
|
||||
if theme_user.theme == "custom":
|
||||
if theme_user.active_custom_theme_id is not None:
|
||||
# A fresh PK fetch, not `theme_user.active_custom_theme` --
|
||||
# that relationship is essentially never eager-loaded by
|
||||
# whatever query got this User row in the first place, and
|
||||
# touching it lazily here would raise MissingGreenlet in
|
||||
# async SQLAlchemy.
|
||||
custom = await db.get(CustomTheme, theme_user.active_custom_theme_id)
|
||||
if custom is not None:
|
||||
colors = custom.colors
|
||||
return {key: colors[key] for key in DEFAULT_PALETTE}
|
||||
return DEFAULT_PALETTE
|
||||
return _PRESET_PALETTES.get(theme_user.theme, DEFAULT_PALETTE)
|
||||
|
||||
|
||||
def _render_text(paragraphs: list[str], cta_label: str | None, cta_url: str | None) -> str:
|
||||
body = "\n\n".join(paragraphs)
|
||||
if cta_label and cta_url:
|
||||
body += f"\n\n{cta_label}: {cta_url}"
|
||||
return body
|
||||
|
||||
|
||||
def _render_html(
|
||||
palette: dict[str, str],
|
||||
subject: str,
|
||||
paragraphs: list[str],
|
||||
cta_label: str | None,
|
||||
cta_url: str | None,
|
||||
) -> str:
|
||||
# Table-based layout with everything inlined -- not the app's own CSS
|
||||
# custom properties (email clients strip <style> blocks and don't
|
||||
# support :root variables), just their resolved hex values baked in
|
||||
# per send. Deliberately plain: one card, one accent color, no imagery
|
||||
# that could get blocked by a client's "show images" gate and leave
|
||||
# the email looking broken instead of just plain.
|
||||
paragraphs_html = "".join(
|
||||
f'<p style="margin:0 0 16px;color:{palette["muted"]};font-size:15px;'
|
||||
f'line-height:1.6;">{html.escape(p)}</p>'
|
||||
for p in paragraphs
|
||||
)
|
||||
cta_html = ""
|
||||
if cta_label and cta_url:
|
||||
cta_html = f"""
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">
|
||||
<tr>
|
||||
<td style="border-radius:8px;background:{palette["accent"]};">
|
||||
<a href="{html.escape(cta_url)}" style="display:inline-block;padding:12px 22px;
|
||||
font-size:15px;font-weight:700;color:{palette["void"]};text-decoration:none;
|
||||
border-radius:8px;">{html.escape(cta_label)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:{palette["void"]};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background:{palette["void"]};padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0"
|
||||
style="max-width:480px;width:100%;background:{palette["surface"]};
|
||||
border:1px solid {palette["border"]};border-radius:12px;padding:32px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size:13px;font-weight:800;letter-spacing:0.06em;
|
||||
text-transform:uppercase;color:{palette["accent"]};margin:0 0 20px;">DS Chat</div>
|
||||
<h1 style="margin:0 0 16px;font-size:20px;font-weight:800;
|
||||
color:{palette["text"]};">{html.escape(subject)}</h1>
|
||||
{paragraphs_html}
|
||||
{cta_html}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
async def _deliver(
|
||||
cfg: SmtpSettings, to_address: str, subject: str, html_body: str, text_body: str
|
||||
) -> None:
|
||||
"""Raises on failure -- internal helper only. Callers decide whether to
|
||||
swallow (send_email) or surface (send_test_email) the error."""
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg.from_address
|
||||
message["To"] = to_address
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
# Plain-text part first, HTML as the alternative -- standard
|
||||
# multipart/alternative ordering (least to most preferred), so a
|
||||
# client with no HTML support (or a spam filter) still gets a normal
|
||||
# readable email instead of raw markup.
|
||||
message.set_content(text_body)
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
|
||||
|
||||
@@ -55,29 +198,56 @@ async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str)
|
||||
)
|
||||
|
||||
|
||||
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
|
||||
async def send_email(
|
||||
db: AsyncSession,
|
||||
to_address: str,
|
||||
subject: str,
|
||||
paragraphs: list[str],
|
||||
*,
|
||||
cta_label: str | None = None,
|
||||
cta_url: str | None = None,
|
||||
theme_user: User | None = None,
|
||||
) -> None:
|
||||
"""Best-effort -- used by invite/notification flows. Never raises: an
|
||||
SMTP outage or missing configuration must never block an action (an
|
||||
invite, a room membership) that already succeeded in the database."""
|
||||
invite, a room membership) that already succeeded in the database.
|
||||
|
||||
`paragraphs` replaces the old flat `body: str` (#68) -- each entry
|
||||
renders as its own paragraph in both the HTML and plain-text parts,
|
||||
which a single pre-formatted string can't cleanly become HTML from
|
||||
without re-parsing it. `theme_user`, when given, styles the email with
|
||||
that user's own selected theme (default palette if they haven't picked
|
||||
one, or don't have an account at all -- see _resolve_palette).
|
||||
"""
|
||||
cfg = await get_smtp_settings(db)
|
||||
if cfg is None:
|
||||
logger.debug("SMTP not configured; skipping email to %s", to_address)
|
||||
# WARNING, not .debug -- this app has no logging config lowering
|
||||
# the root level below Python's own WARNING default, so anything
|
||||
# below that is silently invisible in production (confirmed live:
|
||||
# a real "no emails arriving" report produced nothing in the logs
|
||||
# at all, this line included, even though it was relevant).
|
||||
logger.warning("SMTP not configured; skipping email to %s", to_address)
|
||||
return
|
||||
palette = await _resolve_palette(db, theme_user)
|
||||
html_body = _render_html(palette, subject, paragraphs, cta_label, cta_url)
|
||||
text_body = _render_text(paragraphs, cta_label, cta_url)
|
||||
try:
|
||||
await _deliver(cfg, to_address, subject, body)
|
||||
await _deliver(cfg, to_address, subject, html_body, text_body)
|
||||
except Exception:
|
||||
logger.warning("Failed to send email to %s", to_address, exc_info=True)
|
||||
|
||||
|
||||
async def send_test_email(db: AsyncSession, to_address: str) -> None:
|
||||
async def send_test_email(db: AsyncSession, to_address: str, theme_user: User | None = None) -> None:
|
||||
"""Used only by the admin 'send test email' button -- raises so the
|
||||
admin UI can show why it failed instead of a silent no-op."""
|
||||
admin UI can show why it failed instead of a silent no-op. theme_user
|
||||
is the admin themselves (see routers/admin.py) -- the preview shows
|
||||
them their own emails' real look, not a generic default."""
|
||||
cfg = await get_smtp_settings(db)
|
||||
if cfg is None:
|
||||
raise SmtpNotConfiguredError()
|
||||
await _deliver(
|
||||
cfg,
|
||||
to_address,
|
||||
"DS Chat test email",
|
||||
"This is a test email from DS Chat to confirm your SMTP settings are working.",
|
||||
)
|
||||
subject = "DS Chat test email"
|
||||
paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
|
||||
palette = await _resolve_palette(db, theme_user)
|
||||
html_body = _render_html(palette, subject, paragraphs, None, None)
|
||||
text_body = _render_text(paragraphs, None, None)
|
||||
await _deliver(cfg, to_address, subject, html_body, text_body)
|
||||
|
||||
@@ -27,8 +27,18 @@ _TRAILING_PUNCTUATION = ".,;:!?)'\">"
|
||||
_FETCH_TIMEOUT_SECONDS = 5.0
|
||||
_MAX_BYTES = 512 * 1024
|
||||
_MAX_REDIRECTS = 3
|
||||
# #70: was 7 days -- confirmed live as too long for how this app actually
|
||||
# gets used: re-posting a URL whose title/content had genuinely changed
|
||||
# kept showing the stale first-fetch preview for up to a week. Short
|
||||
# enough that it's effectively "always fresh" for any realistic human
|
||||
# posting cadence, while still doing the one thing a cache here is
|
||||
# actually for -- collapsing a burst of near-simultaneous fetches of the
|
||||
# same URL (several people pasting the same link within moments of each
|
||||
# other, or the same person's message history being loaded repeatedly)
|
||||
# into one, and not hammering a URL that just failed on every message
|
||||
# that references it.
|
||||
_USER_AGENT = "ds-chat-link-preview/1.0"
|
||||
_CACHE_TTL = timedelta(days=7)
|
||||
_CACHE_TTL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def extract_first_url(content: str | None) -> str | None:
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.email_service import send_email
|
||||
from app.services.link_preview_service import fetch_and_broadcast_link_preview
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.room_service import list_dm_partner_ids
|
||||
from app.services.webhook_service import dispatch_event
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
from app.ws.focus_presence import FocusPresence
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
from app.ws.presence import Presence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession,
|
||||
broadcaster: Broadcaster,
|
||||
presence: Presence,
|
||||
focus_presence: FocusPresence,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
@@ -25,12 +33,28 @@ async def _notify_offline_members(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
connected_ids = await presence.connected_user_ids(room_id)
|
||||
# Subtract the sender explicitly rather than relying on them being
|
||||
# "connected" (true for the WS path, since they just sent this over an
|
||||
# active connection -- not true for the incoming-webhook REST path,
|
||||
# which has no WS connection for the attributed sender at all).
|
||||
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
|
||||
if not offline_ids:
|
||||
offline_ids = member_ids - connected_ids - {sender.id}
|
||||
|
||||
# #59: a desktop-mode member can be *connected* to this room's channel
|
||||
# (it's open on screen, live messages are rendering) while their window
|
||||
# sits unfocused behind something else -- still exactly the situation a
|
||||
# desktop notification should fire for, same as #49's original intent.
|
||||
# This used to be handled by the client faking "offline" (leaving the
|
||||
# room's channel on blur), which also silently stopped live delivery to
|
||||
# that room; FocusPresence is a separate signal so notification
|
||||
# eligibility no longer has to ride on room-connection state at all.
|
||||
connected_but_unfocused_ids = {
|
||||
user_id
|
||||
for user_id in connected_ids - {sender.id}
|
||||
if await focus_presence.is_unfocused(user_id)
|
||||
}
|
||||
notify_ids = offline_ids | connected_but_unfocused_ids
|
||||
if not notify_ids:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
@@ -38,12 +62,10 @@ async def _notify_offline_members(
|
||||
)
|
||||
mentioned_ids = {row[0] for row in result.all()}
|
||||
|
||||
# This is also exactly the right audience for "give this room an unread
|
||||
# dot": presence.connected_user_ids(room_id) means "has this room's
|
||||
# channel joined right now" -- which the client only does while the tab
|
||||
# is genuinely foregrounded (see useChatSocket.ts's visibility-gated
|
||||
# join/leave), so a backgrounded-but-open room correctly lands here too,
|
||||
# not just rooms that aren't open at all.
|
||||
# Unread-dot audience stays exactly offline_ids, not notify_ids: a
|
||||
# connected-but-unfocused member still has the room open and rendering
|
||||
# on screen right now, so it isn't actually "unread" for them the way a
|
||||
# room they haven't got open at all is.
|
||||
for user_id in offline_ids:
|
||||
await broadcaster.publish_to_user(
|
||||
user_id,
|
||||
@@ -56,7 +78,7 @@ async def _notify_offline_members(
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
title = f"#{room.name}" if room else "New message"
|
||||
for user_id in offline_ids:
|
||||
for user_id in notify_ids:
|
||||
mentioned = user_id in mentioned_ids
|
||||
if message.content:
|
||||
prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: "
|
||||
@@ -117,6 +139,10 @@ async def _message_payload(db: AsyncSession, message: Message, username: str) ->
|
||||
"reactions": [],
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
# Always null here -- a message just being created can't already be
|
||||
# deleted -- but included for wire-format parity with MessageRead
|
||||
# and message_deleted (#53).
|
||||
"deleted_at": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +153,199 @@ def _maybe_fetch_link_preview(broadcaster: Broadcaster, room_id: uuid.UUID, mess
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_email_dm_notification(
|
||||
db: AsyncSession,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
) -> None:
|
||||
"""#66: DMs only, deliberately narrower than _notify_offline_members'
|
||||
own "offline" -- that one means "not connected to this room's channel
|
||||
right now," which fires on every message and is fine for a lightweight
|
||||
channel (push/desktop). Email is heavier-weight and a DM's other
|
||||
participant could easily be actively using the app in a different room,
|
||||
so this uses GlobalPresence (genuinely no open connection anywhere)
|
||||
instead -- the same "is this user actually offline" logic as
|
||||
rooms.py's private _member_status (not importable from here), just
|
||||
re-derived.
|
||||
"""
|
||||
# #68 follow-up: this whole function used to have zero logging on any
|
||||
# of its early-return paths, which made "why didn't an email go out"
|
||||
# completely undiagnosable from the outside -- confirmed live, a real
|
||||
# report of "no emails" produced nothing in the logs at all, not even
|
||||
# at the level that turned out to be the actual cause. logger.warning
|
||||
# (not .info/.debug) is deliberate: this app has no logging config
|
||||
# setting the root level below Python's own WARNING default, so
|
||||
# anything logged lower than that is silently invisible in production
|
||||
# regardless of what it's actually about -- these aren't really
|
||||
# warnings, they're the only level guaranteed to reach journalctl
|
||||
# today.
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None or not room.is_dm:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id != sender.id
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if membership is None:
|
||||
logger.warning("DM email skipped for room %s: no other participant found", room_id)
|
||||
return
|
||||
recipient = await db.get(User, membership.user_id)
|
||||
if recipient is None:
|
||||
logger.warning(
|
||||
"DM email skipped for room %s: recipient user %s not found", room_id, membership.user_id
|
||||
)
|
||||
return
|
||||
# appear_offline is a manual "always look offline" override -- treated
|
||||
# the same as genuinely offline here, same as everywhere else it's
|
||||
# checked in this codebase.
|
||||
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
|
||||
logger.warning(
|
||||
"DM email skipped for room %s: recipient %s is online", room_id, recipient.id
|
||||
)
|
||||
return
|
||||
|
||||
# Debounced to the first unread message in this conversation, not
|
||||
# every single one -- a burst of DMs while someone's asleep should be
|
||||
# one email, not one per message.
|
||||
already_unread = await db.execute(
|
||||
select(Message.id)
|
||||
.where(
|
||||
Message.room_id == room_id,
|
||||
Message.id != message.id,
|
||||
Message.created_at > membership.last_read_at,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if already_unread.scalar_one_or_none() is not None:
|
||||
logger.warning(
|
||||
"DM email skipped for room %s: recipient %s already has unread messages",
|
||||
room_id,
|
||||
recipient.id,
|
||||
)
|
||||
return
|
||||
|
||||
if message.content:
|
||||
body_line = f"{sender.username}: {message.content[:200]}"
|
||||
elif message.file_id:
|
||||
body_line = f"{sender.username} sent a file"
|
||||
else:
|
||||
body_line = f"{sender.username} sent an image"
|
||||
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
|
||||
logger.warning("Sending DM email to %s for room %s", recipient.email, room_id)
|
||||
await send_email(
|
||||
db,
|
||||
recipient.email,
|
||||
f"New message from {sender.username}",
|
||||
[body_line],
|
||||
cta_label="Open conversation",
|
||||
cta_url=link,
|
||||
theme_user=recipient,
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_email_room_notifications(
|
||||
db: AsyncSession,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
) -> None:
|
||||
"""#67: opt-in, per-room email -- deliberately scoped to regular rooms
|
||||
only (see set_room_email_notifications: DMs already get #66's
|
||||
always-on offline email, no separate toggle).
|
||||
|
||||
Two triggers, not one: the room's first unread message debounces the
|
||||
same way #66 does (one email per unread burst, not one per message),
|
||||
but a mention always emails regardless of that debounce -- a mention
|
||||
is a stronger, individually-addressed signal that shouldn't get
|
||||
silently swallowed just because an earlier plain message in the same
|
||||
burst already used up the "first unread" email.
|
||||
"""
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None or room.is_dm:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
|
||||
)
|
||||
mentioned_ids = {row[0] for row in result.all()}
|
||||
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id,
|
||||
RoomMembership.user_id != sender.id,
|
||||
RoomMembership.email_notifications.is_(True),
|
||||
)
|
||||
)
|
||||
subscribed_memberships = result.scalars().all()
|
||||
if not subscribed_memberships:
|
||||
return
|
||||
|
||||
for membership in subscribed_memberships:
|
||||
user_id = membership.user_id
|
||||
recipient = await db.get(User, user_id)
|
||||
if recipient is None:
|
||||
continue
|
||||
# appear_offline always wins here too, same as _maybe_email_dm_notification.
|
||||
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
|
||||
continue
|
||||
|
||||
mentioned = user_id in mentioned_ids
|
||||
if not mentioned:
|
||||
already_unread = await db.execute(
|
||||
select(Message.id)
|
||||
.where(
|
||||
Message.room_id == room_id,
|
||||
Message.id != message.id,
|
||||
Message.created_at > membership.last_read_at,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if already_unread.scalar_one_or_none() is not None:
|
||||
continue
|
||||
|
||||
if mentioned:
|
||||
subject = f"New mention in #{room.name}"
|
||||
body_line = (
|
||||
f"{sender.username} mentioned you: {message.content[:200]}"
|
||||
if message.content
|
||||
else f"{sender.username} mentioned you"
|
||||
)
|
||||
else:
|
||||
subject = f"New message in #{room.name}"
|
||||
if message.content:
|
||||
body_line = f"{sender.username}: {message.content[:200]}"
|
||||
elif message.file_id:
|
||||
body_line = f"{sender.username} sent a file"
|
||||
else:
|
||||
body_line = f"{sender.username} sent an image"
|
||||
|
||||
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
|
||||
await send_email(
|
||||
db,
|
||||
recipient.email,
|
||||
subject,
|
||||
[body_line],
|
||||
cta_label="Open room",
|
||||
cta_url=link,
|
||||
theme_user=recipient,
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_new_message(
|
||||
db: AsyncSession,
|
||||
broadcaster: Broadcaster,
|
||||
presence: Presence,
|
||||
focus_presence: FocusPresence,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
message: Message,
|
||||
sender: User,
|
||||
@@ -140,7 +355,30 @@ async def broadcast_new_message(
|
||||
trigger identical fan-out/push/event behavior."""
|
||||
payload = await _message_payload(db, message, sender.username)
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
|
||||
# A no-op for a regular room (hidden_at is only ever set on a DM's
|
||||
# membership row -- see RoomMembership.hidden_at) -- new activity
|
||||
# un-hiding a DM someone closed matches find_or_create_dm's own
|
||||
# un-hide-on-reopen behavior. `.returning` so we know exactly who was
|
||||
# un-hidden -- their client needs the same room_added signal a brand
|
||||
# new DM does (see broadcast_room_added's docstring): the room wasn't
|
||||
# in their already-loaded room list at all, so unread_update's plain
|
||||
# setRooms(prev => prev.map(...)) can't make it reappear -- there's
|
||||
# nothing in `prev` for it to match.
|
||||
unhidden_result = await db.execute(
|
||||
update(RoomMembership)
|
||||
.where(RoomMembership.room_id == room_id, RoomMembership.hidden_at.is_not(None))
|
||||
.values(hidden_at=None)
|
||||
.returning(RoomMembership.user_id)
|
||||
)
|
||||
unhidden_user_ids = list(unhidden_result.scalars().all())
|
||||
await db.commit()
|
||||
for unhidden_user_id in unhidden_user_ids:
|
||||
await broadcaster.publish_to_user(
|
||||
unhidden_user_id, {"type": "room_added", "room_id": str(room_id)}
|
||||
)
|
||||
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
|
||||
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
|
||||
await _maybe_email_room_notifications(db, global_presence, base_url, room_id, sender, message)
|
||||
await dispatch_event(db, "message.created", room_id, payload)
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
@@ -165,6 +403,16 @@ async def broadcast_message_update(
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
|
||||
async def broadcast_message_delete(broadcaster: Broadcaster, room_id: uuid.UUID, message_id: uuid.UUID) -> None:
|
||||
# #53: no dispatch_event() call, deliberately -- same scope cut as
|
||||
# broadcast_reaction_update's, and for the same reason (see
|
||||
# backend/README.md): message.deleted isn't an outgoing-webhook event
|
||||
# type here.
|
||||
await broadcaster.publish(
|
||||
room_id, {"type": "message_deleted", "id": str(message_id), "room_id": str(room_id)}
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_reaction_update(
|
||||
broadcaster: Broadcaster,
|
||||
room_id: uuid.UUID,
|
||||
@@ -199,6 +447,29 @@ async def broadcast_member_updated(db: AsyncSession, broadcaster: Broadcaster, u
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_dm_presence_update(
|
||||
db: AsyncSession, broadcaster: Broadcaster, user_id: uuid.UUID, online: bool
|
||||
) -> None:
|
||||
"""Tells every one of user_id's DM partners that their online/offline
|
||||
status just changed (#63) -- on each partner's own per-user channel,
|
||||
not the DM room's channel. The room channel alone doesn't reach the
|
||||
sidebar: Presence gates room-channel delivery on actually having that
|
||||
specific room's channel joined right now, which is only ever the one
|
||||
room currently open in the UI -- so a DM sitting unopened in the
|
||||
sidebar (which is the normal case; the sidebar shows every DM's status
|
||||
at once) never saw its partner's status change until something else
|
||||
forced a full room-list refetch."""
|
||||
for partner_id in await list_dm_partner_ids(db, user_id):
|
||||
await broadcaster.publish_to_user(
|
||||
partner_id,
|
||||
{
|
||||
"type": "dm_presence_update",
|
||||
"user_id": str(user_id),
|
||||
"status": "online" if online else "offline",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_room_added(broadcaster: Broadcaster, user_id: uuid.UUID, room: Room) -> None:
|
||||
"""The only signal a user's open client gets that they were just added
|
||||
to a room -- without it, GET /rooms/mine is only ever fetched once at
|
||||
|
||||
@@ -6,11 +6,19 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, MessageMention, MessageReaction, MessageRoomReference
|
||||
from app.models import (
|
||||
Message,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageMention,
|
||||
MessageReaction,
|
||||
MessageRoomReference,
|
||||
)
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.link_preview_service import extract_first_url
|
||||
from app.services.mention_service import extract_mentioned_user_ids
|
||||
from app.services.room_reference_service import extract_referenced_room_ids
|
||||
from app.storage import delete_file
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
@@ -56,7 +64,9 @@ async def edit_message(
|
||||
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None:
|
||||
# A deleted message might as well not exist for editing purposes --
|
||||
# same MessageNotFoundError a genuinely missing id would raise.
|
||||
if message is None or message.deleted_at is not None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != editor_id:
|
||||
raise NotMessageAuthorError()
|
||||
@@ -69,6 +79,50 @@ async def edit_message(
|
||||
return message
|
||||
|
||||
|
||||
async def delete_message(db: AsyncSession, message_id: uuid.UUID, deleter_id: uuid.UUID) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None or message.deleted_at is not None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != deleter_id:
|
||||
raise NotMessageAuthorError()
|
||||
|
||||
# Fetch the attachment's storage filename (if any) before clearing the
|
||||
# message's own FK to it -- the file is only unlinked from disk after a
|
||||
# successful commit below, mirroring delete_room's identical ordering:
|
||||
# a rolled-back transaction should never leave us having destroyed
|
||||
# something we couldn't get back.
|
||||
image_filename: str | None = None
|
||||
file_filename: str | None = None
|
||||
if message.image_id is not None:
|
||||
image = await db.get(MessageImage, message.image_id)
|
||||
if image is not None:
|
||||
image_filename = image.storage_filename
|
||||
await db.delete(image)
|
||||
if message.file_id is not None:
|
||||
message_file = await db.get(MessageFile, message.file_id)
|
||||
if message_file is not None:
|
||||
file_filename = message_file.storage_filename
|
||||
await db.delete(message_file)
|
||||
|
||||
# #53: a real delete, not just a UI hide -- content and any attachment
|
||||
# are actually gone, not merely unlinked-but-still-fetchable. Only
|
||||
# deleted_at (plus id/room_id/user_id/created_at, kept so the tombstone
|
||||
# still occupies its place in history) survives.
|
||||
message.content = None
|
||||
message.image_id = None
|
||||
message.file_id = None
|
||||
message.preview_url = None
|
||||
message.deleted_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
for filename in (image_filename, file_filename):
|
||||
if filename is not None:
|
||||
delete_file(filename)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||
) -> list[Message]:
|
||||
|
||||
@@ -46,10 +46,13 @@ async def request_password_reset(db: AsyncSession, email: str, base_url: str) ->
|
||||
db,
|
||||
email,
|
||||
"Reset your DS Chat password",
|
||||
f"Someone requested a password reset for this account.\n\n"
|
||||
f"Reset it here:\n{reset_link}\n\n"
|
||||
f"This link expires in 15 minutes. If you didn't request this, "
|
||||
f"you can ignore this email.",
|
||||
[
|
||||
"Someone requested a password reset for this account.",
|
||||
"This link expires in 15 minutes. If you didn't request this, you can ignore this email.",
|
||||
],
|
||||
cta_label="Reset password",
|
||||
cta_url=reset_link,
|
||||
theme_user=user,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlalchemy import delete, select
|
||||
@@ -53,7 +54,23 @@ async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> No
|
||||
await db.commit()
|
||||
|
||||
|
||||
# #56 correction: bumping to pywebpush's latest release (2.4.0) turned out
|
||||
# not to actually fix WNS -- checked the installed package's own source
|
||||
# directly and it has no WNS-specific code anywhere; the upstream
|
||||
# discussion (web-push-libs/pywebpush#162) apparently never shipped.
|
||||
# Worked around here instead, using the `headers` param webpush() already
|
||||
# exposes for exactly this: WNS (Windows/Edge push,
|
||||
# *.notify.windows.com) has required this header since April 2024, or it
|
||||
# 400s with no useful body -- "cache" for a non-zero TTL, "no-cache" for
|
||||
# zero (this app never sets a TTL, so always the latter).
|
||||
def _is_wns_endpoint(endpoint: str) -> bool:
|
||||
return urlparse(endpoint).hostname is not None and urlparse(endpoint).hostname.endswith(
|
||||
"notify.windows.com"
|
||||
)
|
||||
|
||||
|
||||
def _send_one(subscription: PushSubscription, payload: dict) -> None:
|
||||
extra_headers = {"X-WNS-Cache-Policy": "no-cache"} if _is_wns_endpoint(subscription.endpoint) else None
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": subscription.endpoint,
|
||||
@@ -62,6 +79,7 @@ def _send_one(subscription: PushSubscription, payload: dict) -> None:
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=settings.vapid_private_key,
|
||||
vapid_claims={"sub": settings.vapid_subject},
|
||||
headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
@@ -95,4 +113,17 @@ async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict)
|
||||
)
|
||||
await db.commit()
|
||||
else:
|
||||
logger.warning("Push delivery failed for %s: %s", subscription.id, exc)
|
||||
# #56: WNS's own 400s carry the actual reason in a response
|
||||
# *header* ("Ttl value conflicts with X-WNS-Cache-Policy"),
|
||||
# not the body -- pywebpush's own exception message only
|
||||
# ever surfaces the body, so that specific bug still would
|
||||
# have needed a full journalctl+DB-dump investigation to
|
||||
# diagnose even with a body-only log line. Logging headers
|
||||
# too is the difference between "something is broken" and
|
||||
# this log line alone being enough next time, for any push
|
||||
# provider's failure, not just WNS's.
|
||||
response = exc.response
|
||||
detail = ""
|
||||
if response is not None:
|
||||
detail = f" | response: {response.text!r} | headers: {dict(response.headers)!r}"
|
||||
logger.warning("Push delivery failed for %s: %s%s", subscription.id, exc, detail)
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, MessageMention, Room, RoomMembership, RoomRole, User
|
||||
from app.models import (
|
||||
EventSubscription,
|
||||
Message,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageMention,
|
||||
MessageReaction,
|
||||
MessageRoomReference,
|
||||
Room,
|
||||
RoomMembership,
|
||||
RoomRole,
|
||||
User,
|
||||
WebhookIncoming,
|
||||
)
|
||||
from app.schemas.room import RoomCreate, RoomUpdate
|
||||
from app.services.email_service import send_email
|
||||
from app.storage import delete_file
|
||||
|
||||
|
||||
class DuplicateRoomError(Exception):
|
||||
@@ -46,6 +60,50 @@ class AlreadyMemberError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotDmSelfError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotModifyDmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotADmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str:
|
||||
"""Deterministic, internal-only name for the DM room between these two
|
||||
users -- same canonical string regardless of argument order, so
|
||||
find_or_create_dm can look up an existing DM with a single indexed
|
||||
query (Room.name is already unique+indexed) instead of a membership-set
|
||||
join. Never shown to a user -- the frontend renders a DM's dm_partner
|
||||
info instead of its `name` (see MyRoomItem)."""
|
||||
ids = sorted((str(user_a_id), str(user_b_id)))
|
||||
return f"dm:{ids[0]}:{ids[1]}"
|
||||
|
||||
|
||||
async def _unhide(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
membership = (
|
||||
await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if membership is not None and membership.hidden_at is not None:
|
||||
membership.hidden_at = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def hide_dm(db: AsyncSession, room: Room, user_id: uuid.UUID) -> None:
|
||||
if not room.is_dm:
|
||||
raise NotADmError()
|
||||
membership = await _get_membership(db, room.id, user_id)
|
||||
membership.hidden_at = func.now()
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||
room = Room(
|
||||
name=data.name,
|
||||
@@ -66,10 +124,53 @@ async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -
|
||||
return room
|
||||
|
||||
|
||||
async def find_or_create_dm(db: AsyncSession, user_id: uuid.UUID, other_user_id: uuid.UUID) -> Room:
|
||||
if user_id == other_user_id:
|
||||
raise CannotDmSelfError()
|
||||
other = await db.get(User, other_user_id)
|
||||
if other is None:
|
||||
raise TargetUserNotFoundError()
|
||||
|
||||
name = dm_room_name(user_id, other_user_id)
|
||||
result = await db.execute(select(Room).where(Room.name == name))
|
||||
room = result.scalar_one_or_none()
|
||||
if room is not None:
|
||||
await _unhide(db, room.id, user_id)
|
||||
return room
|
||||
|
||||
# is_private=True is belt-and-suspenders here -- list_open_rooms also
|
||||
# excludes is_dm directly -- but it's also just semantically correct: a
|
||||
# DM genuinely is a private room. Both participants get the plain
|
||||
# `member` role (there's no meaningful owner/admin distinction for a
|
||||
# 1:1 DM); `owner_id` still has to be someone to satisfy the column,
|
||||
# but nothing reads it as meaningful for a DM.
|
||||
room = Room(name=name, is_private=True, is_dm=True, owner_id=user_id)
|
||||
db.add(room)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Lost a race with a concurrent find_or_create_dm for the same pair
|
||||
# (e.g. both people click "message" on each other at once) -- the
|
||||
# unique constraint on `name` is exactly what caught it, same
|
||||
# pattern as create_room's DuplicateRoomError. The row that won the
|
||||
# race is the room we actually want.
|
||||
await db.rollback()
|
||||
result = await db.execute(select(Room).where(Room.name == name))
|
||||
room = result.scalar_one()
|
||||
await _unhide(db, room.id, user_id)
|
||||
return room
|
||||
|
||||
db.add(RoomMembership(room_id=room.id, user_id=user_id, role=RoomRole.member))
|
||||
db.add(RoomMembership(room_id=room.id, user_id=other_user_id, role=RoomRole.member))
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
|
||||
|
||||
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
|
||||
result = await db.execute(
|
||||
select(Room)
|
||||
.where(Room.is_private.is_(False), Room.is_archived.is_(False))
|
||||
.where(Room.is_private.is_(False), Room.is_archived.is_(False), Room.is_dm.is_(False))
|
||||
.options(selectinload(Room.memberships))
|
||||
.order_by(Room.created_at, Room.id)
|
||||
)
|
||||
@@ -81,7 +182,7 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
|
||||
|
||||
async def list_member_rooms(
|
||||
db: AsyncSession, user_id: uuid.UUID
|
||||
) -> list[tuple[Room, RoomRole, bool, bool]]:
|
||||
) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
|
||||
last_message_at = (
|
||||
select(func.max(Message.created_at))
|
||||
.where(Message.room_id == Room.id)
|
||||
@@ -104,10 +205,15 @@ async def list_member_rooms(
|
||||
)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
|
||||
Room,
|
||||
RoomMembership.role,
|
||||
RoomMembership.last_read_at,
|
||||
last_message_at,
|
||||
has_unread_mention,
|
||||
RoomMembership.email_notifications,
|
||||
)
|
||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(RoomMembership.user_id == user_id)
|
||||
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
|
||||
# A secondary key on the primary key -- without it, Postgres has no
|
||||
# obligation to return two same-instant rooms (a plausible tie:
|
||||
# bulk-created/migrated rooms, or just two created in quick
|
||||
@@ -116,12 +222,55 @@ async def list_member_rooms(
|
||||
# device's fetch and another's.
|
||||
.order_by(Room.created_at, Room.id)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# #52: one batched follow-up query for every DM room's *other*
|
||||
# participant, rather than a fetch per row -- a DM only ever has
|
||||
# exactly two members, so "the other one" is unambiguous.
|
||||
dm_room_ids = [room.id for room, *_ in rows if room.is_dm]
|
||||
partners_by_room: dict[uuid.UUID, User] = {}
|
||||
if dm_room_ids:
|
||||
partner_result = await db.execute(
|
||||
select(RoomMembership.room_id, User)
|
||||
.join(User, User.id == RoomMembership.user_id)
|
||||
.where(RoomMembership.room_id.in_(dm_room_ids), RoomMembership.user_id != user_id)
|
||||
)
|
||||
partners_by_room = {room_id: user for room_id, user in partner_result.all()}
|
||||
|
||||
return [
|
||||
(room, role, last_message_at is not None and last_message_at > last_read_at, has_mention)
|
||||
for room, role, last_read_at, last_message_at, has_mention in result.all()
|
||||
(
|
||||
room,
|
||||
role,
|
||||
last_message_at is not None and last_message_at > last_read_at,
|
||||
has_mention,
|
||||
email_notifications,
|
||||
partners_by_room.get(room.id),
|
||||
)
|
||||
for room, role, last_read_at, last_message_at, has_mention, email_notifications in rows
|
||||
]
|
||||
|
||||
|
||||
async def list_dm_partner_ids(db: AsyncSession, user_id: uuid.UUID) -> list[uuid.UUID]:
|
||||
"""Every user this user_id shares a DM with (#63) -- used to know who
|
||||
needs telling about a global online/offline transition, since Presence
|
||||
gates room-channel delivery on actually having that specific room
|
||||
joined right now (only ever the one room currently open in the UI), so
|
||||
a DM sitting unopened in the sidebar would otherwise never hear about
|
||||
its partner's status changing at all."""
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id)
|
||||
.join(Room, Room.id == RoomMembership.room_id)
|
||||
.where(
|
||||
Room.is_dm.is_(True),
|
||||
RoomMembership.user_id != user_id,
|
||||
RoomMembership.room_id.in_(
|
||||
select(RoomMembership.room_id).where(RoomMembership.user_id == user_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None:
|
||||
@@ -173,8 +322,10 @@ async def add_member(
|
||||
db,
|
||||
target.email,
|
||||
f"You've been added to #{room.name}",
|
||||
f"You've been added to the #{room.name} room on DS Chat.\n\n"
|
||||
f"Open the app: {base_url.rstrip('/')}",
|
||||
[f"You've been added to the #{room.name} room on DS Chat."],
|
||||
cta_label="Open DS Chat",
|
||||
cta_url=base_url.rstrip("/"),
|
||||
theme_user=target,
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
@@ -186,6 +337,14 @@ async def add_member(
|
||||
|
||||
|
||||
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
||||
# A DM's `name` is an internal token find_or_create_dm's lookup depends
|
||||
# on being stable -- renaming it (even via the #48 site-admin bypass in
|
||||
# the router) would silently orphan that invariant, not just leak a
|
||||
# detail that's supposed to stay private. Blocked here, not just in the
|
||||
# UI, since it's a correctness issue for every caller, not a permission
|
||||
# one.
|
||||
if room.is_dm:
|
||||
raise CannotModifyDmError()
|
||||
if data.name is not None:
|
||||
room.name = data.name
|
||||
if data.description is not None:
|
||||
@@ -203,12 +362,60 @@ async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
||||
|
||||
async def delete_room(db: AsyncSession, room: Room) -> None:
|
||||
# Explicit deletes rather than relying on ORM cascade + eager-loading —
|
||||
# simpler and more predictable in async code.
|
||||
# simpler and more predictable in async code. None of these FKs are
|
||||
# declared ON DELETE CASCADE at the DB level (confirmed across every
|
||||
# migration that added one), so every table referencing this room --
|
||||
# directly, or indirectly via one of its messages -- has to be cleared
|
||||
# explicitly, in dependency order, or the final room delete 500s on
|
||||
# whichever one it happens to hit first (originally surfaced as a
|
||||
# message_room_references FK violation, but every table below has the
|
||||
# exact same gap).
|
||||
room_message_ids = select(Message.id).where(Message.room_id == room.id).scalar_subquery()
|
||||
|
||||
# Message-child tables first -- these reference message_id, so they'd
|
||||
# block deleting this room's own messages otherwise.
|
||||
await db.execute(delete(MessageMention).where(MessageMention.message_id.in_(room_message_ids)))
|
||||
await db.execute(delete(MessageReaction).where(MessageReaction.message_id.in_(room_message_ids)))
|
||||
# Both directions: a reference *from* one of this room's own messages,
|
||||
# and a reference *to* this room from a message in a completely
|
||||
# different room (the case that originally surfaced this bug).
|
||||
await db.execute(
|
||||
delete(MessageRoomReference).where(
|
||||
or_(
|
||||
MessageRoomReference.message_id.in_(room_message_ids),
|
||||
MessageRoomReference.room_id == room.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch attachment storage filenames before deleting their rows -- the
|
||||
# actual files are only unlinked after a successful commit below, so a
|
||||
# rolled-back transaction never leaves us having destroyed something we
|
||||
# couldn't get back.
|
||||
image_filenames = (
|
||||
await db.execute(select(MessageImage.storage_filename).where(MessageImage.room_id == room.id))
|
||||
).scalars().all()
|
||||
file_filenames = (
|
||||
await db.execute(select(MessageFile.storage_filename).where(MessageFile.room_id == room.id))
|
||||
).scalars().all()
|
||||
|
||||
# Messages themselves, now that nothing still references them.
|
||||
await db.execute(delete(Message).where(Message.room_id == room.id))
|
||||
|
||||
# Room-scoped attachments/integrations -- messages.image_id/file_id
|
||||
# reference these, so they must come after the message delete above.
|
||||
await db.execute(delete(MessageImage).where(MessageImage.room_id == room.id))
|
||||
await db.execute(delete(MessageFile).where(MessageFile.room_id == room.id))
|
||||
await db.execute(delete(WebhookIncoming).where(WebhookIncoming.room_id == room.id))
|
||||
await db.execute(delete(EventSubscription).where(EventSubscription.room_id == room.id))
|
||||
|
||||
await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
|
||||
await db.delete(room)
|
||||
await db.commit()
|
||||
|
||||
for filename in (*image_filenames, *file_filenames):
|
||||
delete_file(filename)
|
||||
|
||||
|
||||
async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
|
||||
result = await db.execute(
|
||||
@@ -285,6 +492,22 @@ async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUI
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_room_email_notifications(
|
||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, enabled: bool
|
||||
) -> None:
|
||||
"""#67: DMs are deliberately excluded -- they already get #66's
|
||||
automatic offline email with no opt-in needed, and this setting only
|
||||
makes sense for a regular room's mention-based notifications."""
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None:
|
||||
raise RoomNotFoundError()
|
||||
if room.is_dm:
|
||||
raise CannotModifyDmError()
|
||||
membership = await _get_membership(db, room_id, user_id)
|
||||
membership.email_notifications = enabled
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
membership = await _get_membership(db, room_id, user_id)
|
||||
if membership.role == RoomRole.owner:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Session
|
||||
|
||||
# #69: how stale last_seen_at has to be before a request bothers updating
|
||||
# it. get_current_user resolves a session on *every* authenticated
|
||||
# request (dozens per minute per active browser tab, between message
|
||||
# polling, presence, etc.) -- writing+committing on every single one would
|
||||
# turn a read into a write storm for no real benefit, since "active
|
||||
# sessions" only needs last-seen accurate to within a few minutes, not to
|
||||
# the second.
|
||||
LAST_SEEN_THROTTLE = timedelta(minutes=5)
|
||||
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_client_ip(request_or_websocket) -> str | None:
|
||||
# X-Forwarded-For's first entry is the original client -- everything
|
||||
# after it was appended by intermediate proxies. Production runs
|
||||
# behind Nginx Proxy Manager (see backend/README.md's "Admin portal"
|
||||
# section preamble), which sets this; local dev has nothing in front
|
||||
# of the app, so this falls back to the direct peer address.
|
||||
forwarded = request_or_websocket.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
client = request_or_websocket.client
|
||||
return client.host if client else None
|
||||
|
||||
|
||||
async def create_session(
|
||||
db: AsyncSession, user_id: uuid.UUID, ip_address: str | None, user_agent: str | None
|
||||
) -> Session:
|
||||
session = Session(user_id=user_id, ip_address=ip_address, user_agent=user_agent)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def start_session(request: Request, db: AsyncSession, user_id: uuid.UUID) -> Session:
|
||||
"""Every "log this browser in" call site (login, reset-password
|
||||
completion, signup completion) needs the exact same three steps --
|
||||
read the request's IP/UA, create the row, stash its id in the signed
|
||||
cookie -- so this is the one place that combination lives."""
|
||||
session = await create_session(db, user_id, get_client_ip(request), request.headers.get("user-agent"))
|
||||
request.session["session_id"] = str(session.id)
|
||||
return session
|
||||
|
||||
|
||||
async def resolve_session(db: AsyncSession, session_id: uuid.UUID) -> Session | None:
|
||||
"""Returns the session iff it exists and hasn't been revoked -- the
|
||||
single choke point get_current_user and the WS handshake both go
|
||||
through, so revoking a session (this endpoint or another device's
|
||||
"sign out") takes effect on that session's very next request rather
|
||||
than only once its signed cookie happens to expire."""
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.revoked_at is not None:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if now - session.last_seen_at > LAST_SEEN_THROTTLE:
|
||||
session.last_seen_at = now
|
||||
await db.commit()
|
||||
return session
|
||||
|
||||
|
||||
async def list_sessions(db: AsyncSession, user_id: uuid.UUID) -> list[Session]:
|
||||
result = await db.execute(
|
||||
select(Session)
|
||||
.where(Session.user_id == user_id, Session.revoked_at.is_(None))
|
||||
.order_by(Session.last_seen_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_session(db: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.user_id != user_id or session.revoked_at is not None:
|
||||
raise SessionNotFoundError()
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def revoke_session_unchecked(db: AsyncSession, session_id: uuid.UUID) -> None:
|
||||
"""Logout's own path -- no ownership check needed (a session can only
|
||||
ever log itself out) and silently does nothing for a session that's
|
||||
missing or already revoked, since "sign this browser out" should
|
||||
never itself fail."""
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.revoked_at is not None:
|
||||
return
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import InviteStatus, SiteInvite, User
|
||||
from app.models.site_invite import DEFAULT_SITE_INVITE_LIFETIME
|
||||
from app.schemas.user import UserCreate
|
||||
from app.security import hash_token
|
||||
from app.services.audit import record_audit_log
|
||||
@@ -26,6 +27,24 @@ class SiteInviteInvalidError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _send_invite_email(db: AsyncSession, inviter_username: str, base_url: str, email: str, raw_token: str) -> None:
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
# No theme_user -- the invitee doesn't have an account yet, so there's
|
||||
# no theme of theirs to use (#68). Default palette, same as any
|
||||
# logged-out page.
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join DS Chat",
|
||||
[
|
||||
f"You've been invited to join DS Chat by {inviter_username}.",
|
||||
"This link expires in 7 days.",
|
||||
],
|
||||
cta_label="Set up your account",
|
||||
cta_url=signup_link,
|
||||
)
|
||||
|
||||
|
||||
async def create_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, email: str
|
||||
) -> SiteInvite:
|
||||
@@ -39,21 +58,19 @@ async def create_site_invite(
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join DS Chat",
|
||||
f"You've been invited to join DS Chat by {actor.username}.\n\n"
|
||||
f"Set up your account here:\n{signup_link}\n\n"
|
||||
f"This link expires in 7 days.",
|
||||
)
|
||||
await _send_invite_email(db, actor.username, base_url, email, raw_token)
|
||||
return invite
|
||||
|
||||
|
||||
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]:
|
||||
# Pending only (#61) -- the admin UI's only consumer of this list labels
|
||||
# it "Pending invites" and had no way to drop a row once it was accepted
|
||||
# or revoked, since the backend returned every invite ever sent forever.
|
||||
# An accepted/revoked invite has nothing further to act on here; its
|
||||
# history already lives in the audit log ("user.invite"/"invite.revoke").
|
||||
result = await db.execute(
|
||||
select(SiteInvite)
|
||||
.where(SiteInvite.status == InviteStatus.pending)
|
||||
.options(selectinload(SiteInvite.inviter))
|
||||
.order_by(SiteInvite.created_at.desc())
|
||||
)
|
||||
@@ -74,6 +91,31 @@ async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID
|
||||
return invite
|
||||
|
||||
|
||||
async def resend_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, invite_id: uuid.UUID
|
||||
) -> SiteInvite:
|
||||
invite = await db.get(SiteInvite, invite_id)
|
||||
if invite is None:
|
||||
raise SiteInviteNotFoundError()
|
||||
if invite.status != InviteStatus.pending:
|
||||
raise SiteInviteNotPendingError()
|
||||
|
||||
# A fresh token and a reset 7-day expiry, not just re-sending the same
|
||||
# link -- the old link stops working the moment this runs (same
|
||||
# "rotate, don't just repeat" instinct as a password-reset resend), and
|
||||
# it means resending something close to expiring actually buys the
|
||||
# full week again instead of whatever was left.
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
invite.token_hash = hash_token(raw_token)
|
||||
invite.expires_at = datetime.now(timezone.utc) + DEFAULT_SITE_INVITE_LIFETIME
|
||||
record_audit_log(db, actor, "invite.resend", "invite", invite.id, {"email": invite.email})
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
await _send_invite_email(db, actor.username, base_url, invite.email, raw_token)
|
||||
return invite
|
||||
|
||||
|
||||
async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
|
||||
result = await db.execute(
|
||||
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User
|
||||
from app.security import generate_token, hash_password
|
||||
|
||||
# #74: one well-known, auto-provisioned bot account the app itself posts as
|
||||
# for automated first-party messages (the #72 welcome message, and whatever
|
||||
# comes next) -- distinct from bot_service.py's admin-created integration
|
||||
# bots, which each need a human actor and audit-log entry for creating them.
|
||||
# There's no actor here: this account is provisioned lazily, the first time
|
||||
# something needs to post as it.
|
||||
SYSTEM_USERNAME = "system"
|
||||
|
||||
|
||||
async def get_or_create_system_user(db: AsyncSession) -> User:
|
||||
result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
# Same placeholder-email/discarded-password shape as bot_service.create_bot
|
||||
# -- this account never logs in, email just satisfies the NOT NULL/unique
|
||||
# column.
|
||||
user = User(
|
||||
username=SYSTEM_USERNAME,
|
||||
email=f"{SYSTEM_USERNAME}@bots.example.com",
|
||||
password_hash=hash_password(generate_token()),
|
||||
is_bot=True,
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Two concurrent requests both found no existing row and raced to
|
||||
# create one -- the loser just reads back the winner's row instead
|
||||
# of erroring.
|
||||
await db.rollback()
|
||||
result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME))
|
||||
return result.scalar_one()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,46 @@
|
||||
def describe_user_agent(user_agent: str | None) -> str:
|
||||
"""A short, human-readable "Browser on OS" label for the sessions list
|
||||
-- no dependency pulled in for this (the app has stayed on a
|
||||
zero-subdependency-when-possible diet, see MessageContent.tsx's
|
||||
markdown-to-jsx choice), just substring checks against the handful of
|
||||
tokens that actually distinguish the browsers/platforms this app's
|
||||
users run. Order matters: Electron and Edge both also contain
|
||||
"Chrome/", and Chrome-on-iOS/Safari-on-iOS both contain "Safari/", so
|
||||
the more specific token has to be checked first.
|
||||
"""
|
||||
if not user_agent:
|
||||
return "Unknown device"
|
||||
|
||||
if "Electron/" in user_agent:
|
||||
browser = "DS Chat Desktop"
|
||||
elif "Edg/" in user_agent:
|
||||
browser = "Edge"
|
||||
elif "OPR/" in user_agent:
|
||||
browser = "Opera"
|
||||
elif "Firefox/" in user_agent:
|
||||
browser = "Firefox"
|
||||
elif "Chrome/" in user_agent:
|
||||
browser = "Chrome"
|
||||
elif "CriOS/" in user_agent:
|
||||
browser = "Chrome"
|
||||
elif "Safari/" in user_agent:
|
||||
browser = "Safari"
|
||||
else:
|
||||
browser = "Unknown browser"
|
||||
|
||||
if "Windows" in user_agent:
|
||||
os_name = "Windows"
|
||||
elif "Mac OS X" in user_agent and ("iPhone" in user_agent or "iPad" in user_agent):
|
||||
os_name = "iOS"
|
||||
elif "Mac OS X" in user_agent:
|
||||
os_name = "macOS"
|
||||
elif "Android" in user_agent:
|
||||
os_name = "Android"
|
||||
elif "Linux" in user_agent:
|
||||
os_name = "Linux"
|
||||
else:
|
||||
os_name = "Unknown OS"
|
||||
|
||||
if browser == "DS Chat Desktop":
|
||||
return f"{browser} ({os_name})"
|
||||
return f"{browser} on {os_name}"
|
||||
@@ -27,6 +27,10 @@ class SubscriptionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RoomArchivedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_incoming_webhook(
|
||||
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
|
||||
) -> WebhookIncoming:
|
||||
@@ -82,6 +86,11 @@ async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[
|
||||
webhook = result.scalar_one_or_none()
|
||||
if webhook is None:
|
||||
raise WebhookNotFoundError()
|
||||
# #57: same read-only rule as a human posting from the composer -- an
|
||||
# archived room shouldn't gain new messages through a bot integration
|
||||
# either.
|
||||
if webhook.room.is_archived:
|
||||
raise RoomArchivedError()
|
||||
|
||||
message = await create_message(db, webhook.room_id, webhook.created_by, content)
|
||||
return message, webhook.room, webhook.creator
|
||||
|
||||
@@ -25,6 +25,19 @@ ALLOWED_IMAGE_CONTENT_TYPES: dict[str, tuple[str, str]] = {
|
||||
"image/webp": (".webp", "WEBP"),
|
||||
}
|
||||
|
||||
# #65: browser-natively-playable video formats -- used to decide whether a
|
||||
# stored MessageFile gets served inline (a <video> tag can actually play
|
||||
# it) or forced to download like every other non-image attachment (see
|
||||
# rooms.py's file-serve endpoint). Deliberately a strict allowlist, not
|
||||
# "every video/* type": .mov (video/quicktime) has spotty <video> support
|
||||
# outside Safari, and more importantly this is the one thing standing
|
||||
# between "serve with the browser trusting our declared Content-Type" and
|
||||
# reopening the same-origin-script-execution risk Content-Disposition:
|
||||
# attachment exists to close off for arbitrary uploads -- it must only
|
||||
# ever contain types a <video> tag renders as media, never as something
|
||||
# that could execute script.
|
||||
INLINE_SAFE_VIDEO_CONTENT_TYPES = frozenset({"video/mp4", "video/webm", "video/ogg"})
|
||||
|
||||
|
||||
class UploadTooLargeError(Exception):
|
||||
pass
|
||||
|
||||
+134
-14
@@ -6,10 +6,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
|
||||
from app.models import ApiToken, Message, MessageFile, MessageImage, Room, RoomMembership, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.message_events import (
|
||||
broadcast_dm_presence_update,
|
||||
broadcast_member_updated,
|
||||
broadcast_message_delete,
|
||||
broadcast_message_update,
|
||||
broadcast_new_message,
|
||||
broadcast_reaction_update,
|
||||
@@ -18,10 +20,12 @@ from app.services.message_service import (
|
||||
MessageNotFoundError,
|
||||
NotMessageAuthorError,
|
||||
create_message,
|
||||
delete_message,
|
||||
edit_message,
|
||||
toggle_reaction,
|
||||
)
|
||||
from app.services.room_service import mark_room_read
|
||||
from app.services.session_service import resolve_session
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
@@ -36,6 +40,7 @@ class ClientEnvelope(BaseModel):
|
||||
file_id: uuid.UUID | None = None
|
||||
message_id: uuid.UUID | None = None
|
||||
emoji: str | None = None
|
||||
focused: bool | None = None
|
||||
|
||||
|
||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
@@ -47,6 +52,11 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _is_room_archived(db: AsyncSession, room_id: uuid.UUID) -> bool:
|
||||
room = await db.get(Room, room_id)
|
||||
return room is not None and room.is_archived
|
||||
|
||||
|
||||
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
|
||||
return api_token is not None and scope not in api_token.scopes
|
||||
|
||||
@@ -66,11 +76,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
return
|
||||
user, api_token = resolved
|
||||
else:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
session_id_raw = websocket.session.get("session_id")
|
||||
if not session_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
session = await resolve_session(db, uuid.UUID(session_id_raw))
|
||||
if session is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user = await db.get(User, session.user_id)
|
||||
if user is None or not user.is_active:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
@@ -79,8 +93,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
manager = websocket.app.state.connection_manager
|
||||
presence = websocket.app.state.presence
|
||||
global_presence = websocket.app.state.global_presence
|
||||
focus_presence = websocket.app.state.focus_presence
|
||||
broadcaster = websocket.app.state.broadcaster
|
||||
joined_rooms: set[uuid.UUID] = set()
|
||||
# Tracks this connection's last-reported focus state (see the "focus"
|
||||
# envelope below) so the disconnect cleanup can release FocusPresence's
|
||||
# refcount if the socket closes while still blurred -- mirroring how
|
||||
# joined_rooms tracks per-connection room membership for its own cleanup.
|
||||
is_blurred = False
|
||||
manager.register_user(user.id, websocket)
|
||||
# Only broadcast on a genuine offline->online transition (this user's
|
||||
# first open connection), not for every extra tab -- broadcast_member_
|
||||
@@ -89,10 +109,23 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
# visible.
|
||||
if await global_presence.connect(user.id):
|
||||
await broadcast_member_updated(db, broadcaster, user.id)
|
||||
await broadcast_dm_presence_update(db, broadcaster, user.id, online=True)
|
||||
# This session is shared for the connection's entire lifetime (which can
|
||||
# be hours) -- SQLAlchemy opens a transaction implicitly on first use,
|
||||
# and every read above (the auth lookup, broadcast_member_updated's own
|
||||
# query) leaves it open with nothing to ever close it otherwise. Left
|
||||
# uncommitted, that transaction sits "idle in transaction" holding locks
|
||||
# for as long as the socket stays open -- confirmed in production
|
||||
# blocking unrelated schema migrations on the same tables for 30+
|
||||
# minutes. Committing here, and again after every frame below, means
|
||||
# the connection is never sitting on an open transaction while merely
|
||||
# waiting for the next one.
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_json()
|
||||
try:
|
||||
try:
|
||||
envelope = ClientEnvelope.model_validate(raw)
|
||||
except ValidationError:
|
||||
@@ -121,6 +154,25 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
await presence.leave(envelope.room_id, user.id)
|
||||
joined_rooms.discard(envelope.room_id)
|
||||
|
||||
elif envelope.type == "focus":
|
||||
# Sent only by the desktop client (#59), independent of
|
||||
# room join/leave -- see FocusPresence's docstring for
|
||||
# why widening desktop_notification eligibility this
|
||||
# way no longer needs to touch live room delivery at
|
||||
# all, unlike the "leave the room's channel on blur"
|
||||
# approach this replaced.
|
||||
if envelope.focused is None:
|
||||
await websocket.send_json({"type": "error", "detail": "focused required"})
|
||||
continue
|
||||
if envelope.focused:
|
||||
if is_blurred:
|
||||
await focus_presence.mark_focused(user.id)
|
||||
is_blurred = False
|
||||
else:
|
||||
if not is_blurred:
|
||||
await focus_presence.mark_blurred(user.id)
|
||||
is_blurred = True
|
||||
|
||||
elif envelope.type == "message":
|
||||
if envelope.room_id is None or (
|
||||
not envelope.content
|
||||
@@ -146,6 +198,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
if await _is_room_archived(db, envelope.room_id):
|
||||
# #57: history stays fully readable (joining/reading
|
||||
# an archived room's channel is untouched above),
|
||||
# this is the one gate that actually makes archiving
|
||||
# do something for people who were already members.
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "This room has been archived and is read-only"}
|
||||
)
|
||||
continue
|
||||
image_id = None
|
||||
if envelope.image_id is not None:
|
||||
image = await db.get(MessageImage, envelope.image_id)
|
||||
@@ -163,16 +224,27 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
message = await create_message(
|
||||
db, envelope.room_id, user.id, envelope.content, image_id, file_id
|
||||
)
|
||||
# Sending implies having seen the room as of now -- without
|
||||
# this, GET /rooms/mine would show the sender's own room as
|
||||
# unread the instant they send into it (last_read_at isn't
|
||||
# otherwise bumped until the frontend's own message echo
|
||||
# triggers a mark-read call, which is a real but avoidable
|
||||
# race). Deliberately not done in create_message() itself:
|
||||
# the incoming-webhook path also calls it, and a webhook's
|
||||
# Sending implies having seen the room as of now --
|
||||
# without this, GET /rooms/mine would show the sender's
|
||||
# own room as unread the instant they send into it
|
||||
# (last_read_at isn't otherwise bumped until the
|
||||
# frontend's own message echo triggers a mark-read
|
||||
# call, which is a real but avoidable race).
|
||||
# Deliberately not done in create_message() itself: the
|
||||
# incoming-webhook path also calls it, and a webhook's
|
||||
# attributed sender may not actually be watching.
|
||||
await mark_room_read(db, envelope.room_id, user.id)
|
||||
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
|
||||
await broadcast_new_message(
|
||||
db,
|
||||
broadcaster,
|
||||
presence,
|
||||
focus_presence,
|
||||
global_presence,
|
||||
str(websocket.base_url),
|
||||
envelope.room_id,
|
||||
message,
|
||||
user,
|
||||
)
|
||||
|
||||
elif envelope.type == "edit":
|
||||
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
|
||||
@@ -204,12 +276,46 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
continue
|
||||
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
|
||||
|
||||
elif envelope.type == "delete":
|
||||
if envelope.room_id is None or envelope.message_id is None:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id and message_id required"}
|
||||
)
|
||||
continue
|
||||
if _missing_scope(api_token, "write:messages"):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
await delete_message(db, envelope.message_id, user.id)
|
||||
except MessageNotFoundError:
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
except NotMessageAuthorError:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "You can only delete your own messages"}
|
||||
)
|
||||
continue
|
||||
await broadcast_message_delete(broadcaster, envelope.room_id, envelope.message_id)
|
||||
|
||||
elif envelope.type == "reaction":
|
||||
if (
|
||||
envelope.room_id is None
|
||||
or envelope.message_id is None
|
||||
or not envelope.emoji
|
||||
or len(envelope.emoji) > 8
|
||||
# #18: a raw unicode glyph never gets close to this,
|
||||
# but a custom emoji reaction is stored as its
|
||||
# literal `:shortcode:` text (see MessageReaction.emoji's
|
||||
# String(32) column, which this matches exactly).
|
||||
or len(envelope.emoji) > 32
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id, message_id, and emoji required"}
|
||||
@@ -228,7 +334,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
)
|
||||
continue
|
||||
target_message = await db.get(Message, envelope.message_id)
|
||||
if target_message is None or target_message.room_id != envelope.room_id:
|
||||
if (
|
||||
target_message is None
|
||||
or target_message.room_id != envelope.room_id
|
||||
or target_message.deleted_at is not None
|
||||
):
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)
|
||||
@@ -240,6 +350,13 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
|
||||
)
|
||||
finally:
|
||||
# See the comment on the pre-loop commit above -- guarantees
|
||||
# every single frame, on every exit path (including the
|
||||
# many `continue`s above, which still run a `finally`
|
||||
# before actually looping), leaves nothing open while this
|
||||
# blocks on the next receive_json().
|
||||
await db.commit()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
@@ -248,5 +365,8 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
manager.unregister_user(user.id, websocket)
|
||||
for room_id in joined_rooms:
|
||||
await presence.leave(room_id, user.id)
|
||||
if is_blurred:
|
||||
await focus_presence.mark_focused(user.id)
|
||||
if await global_presence.disconnect(user.id):
|
||||
await broadcast_member_updated(db, broadcaster, user.id)
|
||||
await broadcast_dm_presence_update(db, broadcaster, user.id, online=False)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
class FocusPresence:
|
||||
"""Cross-instance "is this desktop-mode user's window currently
|
||||
unfocused," used only to widen desktop_notification/push eligibility
|
||||
beyond plain room-connection state (#59). A Redis hash (field =
|
||||
user_id, value = refcount of that user's currently-blurred desktop
|
||||
connections), parallel to Presence/GlobalPresence.
|
||||
|
||||
Absence from this hash is the default and means "focused." That default
|
||||
is also exactly right for every browser-tab connection: only the
|
||||
desktop client ever sends focus/blur frames at all (see chat.py's
|
||||
"focus" envelope handling), so a browser user never appears here --
|
||||
their attention is already fully captured by Presence's room-connection
|
||||
state, which stays visibility-gated with no separate focus signal.
|
||||
|
||||
Refcounted for the same multi-connection reason as Presence/
|
||||
GlobalPresence, with the same known simplification: two desktop windows
|
||||
for one user, one focused and one blurred, count as "unfocused" here
|
||||
(refcount > 0) even though the user does have attention somewhere. That
|
||||
errs toward notifying rather than silently missing one, which is the
|
||||
safer failure mode for a notification.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
|
||||
def _key(self) -> str:
|
||||
return "presence:unfocused"
|
||||
|
||||
async def mark_blurred(self, user_id: uuid.UUID) -> None:
|
||||
await self._redis.hincrby(self._key(), str(user_id), 1)
|
||||
|
||||
async def mark_focused(self, user_id: uuid.UUID) -> None:
|
||||
key = self._key()
|
||||
field = str(user_id)
|
||||
remaining = await self._redis.hincrby(key, field, -1)
|
||||
if remaining <= 0:
|
||||
await self._redis.hdel(key, field)
|
||||
|
||||
async def is_unfocused(self, user_id: uuid.UUID) -> bool:
|
||||
return await self._redis.hexists(self._key(), str(user_id))
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ds-chat"
|
||||
version = "1.0.0"
|
||||
version = "2026.9.4"
|
||||
description = "DS Chat backend service"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
requires-python = ">=3.11"
|
||||
@@ -15,7 +15,14 @@ dependencies = [
|
||||
"pydantic-settings>=2.6",
|
||||
"argon2-cffi>=23.1",
|
||||
"itsdangerous>=2.2",
|
||||
"pywebpush>=2.0",
|
||||
# #56: >=2.0 let the production venv sit on an old 2.0.x with no real
|
||||
# downside to bumping the floor -- worth keeping current regardless.
|
||||
# Doesn't by itself fix WNS (Windows/Edge push): despite
|
||||
# web-push-libs/pywebpush#162's discussion, even the latest release
|
||||
# (2.4.0) has no WNS-specific header handling in its own source. The
|
||||
# actual fix is app/services/push_service.py adding the required
|
||||
# X-WNS-Cache-Policy header itself via webpush()'s `headers` param.
|
||||
"pywebpush>=2.4.0",
|
||||
"redis>=5.0",
|
||||
"httpx>=0.27",
|
||||
"gunicorn>=23.0",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import uuid
|
||||
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _make_admin_ws(ws_client, user_id: str) -> None:
|
||||
async def _promote():
|
||||
async with ws_client.session_factory() as session:
|
||||
user = await session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await session.commit()
|
||||
|
||||
ws_client.portal.call(_promote)
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
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()
|
||||
|
||||
|
||||
async def test_archived_room_flag_reaches_existing_members(client, db_session):
|
||||
# #57: is_archived used to only ever reach the admin portal's own
|
||||
# AdminRoom schema -- a member's own view of the room (GET /rooms/mine)
|
||||
# had no way to know it was archived at all.
|
||||
admin = await register_and_login(client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
|
||||
resp = await client.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
assert resp.status_code == 200
|
||||
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert entry["is_archived"] is True
|
||||
|
||||
# History stays fully readable for an existing member.
|
||||
messages_resp = await client.get(f"/api/rooms/{room['id']}/messages")
|
||||
assert messages_resp.status_code == 200
|
||||
|
||||
resp = await client.post(f"/api/admin/rooms/{room['id']}/unarchive")
|
||||
assert resp.status_code == 200
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert entry["is_archived"] is False
|
||||
|
||||
|
||||
def test_ws_message_rejected_in_archived_room(ws_client_factory, db_session):
|
||||
admin_ws = ws_client_factory()
|
||||
admin = _register_ws(admin_ws, _unique("admin"))
|
||||
_make_admin_ws(admin_ws, admin["id"])
|
||||
room = admin_ws.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
archive_resp = admin_ws.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
assert archive_resp.status_code == 200
|
||||
|
||||
with admin_ws.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "should not send"})
|
||||
received = ws.receive_json()
|
||||
assert received["type"] == "error"
|
||||
assert "archived" in received["detail"].lower()
|
||||
|
||||
|
||||
def test_ws_message_allowed_again_after_unarchive(ws_client_factory):
|
||||
admin_ws = ws_client_factory()
|
||||
admin = _register_ws(admin_ws, _unique("admin"))
|
||||
_make_admin_ws(admin_ws, admin["id"])
|
||||
room = admin_ws.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
admin_ws.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
admin_ws.post(f"/api/admin/rooms/{room['id']}/unarchive")
|
||||
|
||||
with admin_ws.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "back online"})
|
||||
received = ws.receive_json()
|
||||
assert received["type"] == "message"
|
||||
assert received["content"] == "back online"
|
||||
|
||||
|
||||
async def test_incoming_webhook_rejected_in_archived_room(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
|
||||
|
||||
await client.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "should not post"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "archived" in resp.json()["detail"].lower()
|
||||
@@ -8,21 +8,25 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "dm_presence_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
"""Reads the next frame, transparently discarding presence-change
|
||||
broadcasts (member_updated, and #63's dm_presence_update) -- another
|
||||
connection sharing a room or a DM going online/offline is real,
|
||||
expected noise these tests aren't about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
async def fake(db, to, subject, paragraphs, **kwargs):
|
||||
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||
return calls
|
||||
@@ -146,6 +150,65 @@ def test_add_member_notifies_target_user_via_websocket(ws_client_factory, monkey
|
||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_start_dm_notifies_other_participant_via_websocket(ws_client_factory):
|
||||
# A production report: bob had no idea a DM existed until he reloaded --
|
||||
# find_or_create_dm was creating the room/membership correctly but never
|
||||
# sending this signal, unlike every other "you're now in a room" path
|
||||
# (add_member, above). Same shape as that test: bob is only ever
|
||||
# "connected," never "joined," proving the signal alone is what tells
|
||||
# his client the room exists at all.
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
resp = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
room = resp.json()
|
||||
|
||||
received = bob_ws.receive_json()
|
||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_new_message_notifies_recipient_who_hid_the_dm_via_websocket(ws_client_factory):
|
||||
# A second production report on the same underlying gap: hiding a DM
|
||||
# correctly clears out of GET /rooms/mine, but when the other person
|
||||
# messages again, the *only* existing signal for that (unread_update)
|
||||
# does `setRooms(prev => prev.map(...))` -- a no-op for a room that
|
||||
# isn't in `prev` at all, which a hidden DM by definition isn't. Needs
|
||||
# the same room_added signal a brand new DM gets, not just a DB-level
|
||||
# un-hide.
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
room = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
resp = instance2.post(f"/api/rooms/{room['id']}/hide")
|
||||
assert resp.status_code == 204
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_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"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "you there?"})
|
||||
alice_ws.receive_json()
|
||||
# Sync barrier (see test_mentions.py's identical helper): the
|
||||
# message ack only proves the room-level broadcast happened,
|
||||
# not that broadcast_new_message's own continuation (which
|
||||
# un-hides the room and publishes room_added) has finished --
|
||||
# a second frame's own ack proves that before this connection
|
||||
# closes underneath it.
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
received = _recv(bob_ws)
|
||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_profile_update_notifies_room_members_via_websocket(ws_client_factory, monkeypatch):
|
||||
# Only reaches clients that have the room's own channel joined --
|
||||
# exactly the case where a stale avatar/display name would actually be
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.models import User
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _png_bytes(size: tuple[int, int] = (10, 10), color: tuple[int, int, int] = (255, 0, 0)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color=color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_upload_custom_emoji_succeeds(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "party-parrot"},
|
||||
files={"file": ("parrot.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["shortcode"] == "party-parrot"
|
||||
assert "id" in body
|
||||
assert "created_at" in body
|
||||
|
||||
|
||||
async def test_upload_normalizes_shortcode_case(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": " PartyParrot "},
|
||||
files={"file": ("parrot.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["shortcode"] == "partyparrot"
|
||||
|
||||
|
||||
async def test_upload_rejects_invalid_shortcode(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "a"}, # too short
|
||||
files={"file": ("x.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_upload_rejects_duplicate_shortcode(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
first = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "dupe-test"},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
|
||||
second = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "dupe-test"},
|
||||
files={"file": ("b.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
async def test_upload_rejects_non_image(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "not-an-image"},
|
||||
files={"file": ("x.txt", b"hello", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_upload_rejects_oversized(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
oversized = b"0" * (9 * 1024 * 1024)
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "too-big"},
|
||||
files={"file": ("huge.png", oversized, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
async def test_list_custom_emoji(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("listed")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
resp = await client.get("/api/custom-emoji")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
|
||||
async def test_serve_custom_emoji_image_by_shortcode(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
shortcode = _unique("served")
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
|
||||
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
async def test_serve_custom_emoji_image_forces_revalidation(client, db_session):
|
||||
# A timed cache (the original `max-age=300`) meant a browser that had
|
||||
# already fetched a shortcode's image kept serving those bytes for up
|
||||
# to 5 minutes after a delete-and-reupload swapped in a different file
|
||||
# under the same URL -- confirmed live: re-adding an emoji under a
|
||||
# just-deleted shortcode showed the old image. `no-cache` forces
|
||||
# revalidation on every use instead (still cheap: FileResponse's own
|
||||
# ETag/Last-Modified make an actually-unchanged file a 304, not a full
|
||||
# re-transfer).
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
shortcode = _unique("revalidated")
|
||||
await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
|
||||
assert "no-cache" in resp.headers["cache-control"]
|
||||
assert "max-age" not in resp.headers["cache-control"]
|
||||
|
||||
|
||||
async def test_reuploading_a_deleted_shortcode_serves_the_new_image(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
shortcode = _unique("reused")
|
||||
first = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("red.png", _png_bytes(color=(255, 0, 0)), "image/png")},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
await client.delete(f"/api/custom-emoji/{first.json()['id']}")
|
||||
|
||||
second = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("blue.png", _png_bytes(color=(0, 0, 255)), "image/png")},
|
||||
)
|
||||
assert second.status_code == 201
|
||||
|
||||
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
|
||||
served = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
assert served.getpixel((0, 0)) == (0, 0, 255)
|
||||
|
||||
|
||||
async def test_serve_unknown_shortcode_404s(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.get("/api/custom-emoji/no-such-emoji/image")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_uploader_can_delete_own_emoji(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("deleteme")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
emoji_id = upload.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/custom-emoji/{emoji_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
listed = (await client.get("/api/custom-emoji")).json()
|
||||
assert emoji_id not in [e["id"] for e in listed]
|
||||
|
||||
|
||||
async def test_non_uploader_non_admin_cannot_delete(client, app, db_session):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("guarded")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
emoji_id = upload.json()["id"]
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
|
||||
await register_and_login(bob_client, db_session, username=_unique("bob"))
|
||||
resp = await bob_client.delete(f"/api/custom-emoji/{emoji_id}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
listed = (await client.get("/api/custom-emoji")).json()
|
||||
assert emoji_id in [e["id"] for e in listed]
|
||||
|
||||
|
||||
async def test_site_admin_can_delete_others_emoji(client, app, db_session):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
alice = await register_and_login(client, db_session, username=_unique("alice"))
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("admin-deletable")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
emoji_id = upload.json()["id"]
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as admin_client:
|
||||
admin = await register_and_login(admin_client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
resp = await admin_client.delete(f"/api/custom-emoji/{emoji_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert alice["id"]
|
||||
|
||||
|
||||
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 test_reaction_accepts_custom_emoji_shortcode_reference(ws_client_factory):
|
||||
instance = ws_client_factory()
|
||||
_register_ws(instance, _unique("alice"))
|
||||
room = instance.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with instance.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
# A custom emoji reaction is stored as its literal `:shortcode:`
|
||||
# text (14 chars here) -- well past the old 8-char cap that only
|
||||
# ever needed to fit a raw unicode glyph.
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "reaction",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"emoji": ":party-parrot:",
|
||||
}
|
||||
)
|
||||
reaction_update = ws.receive_json()
|
||||
assert reaction_update["type"] == "reaction_update"
|
||||
assert reaction_update["reactions"][0]["emoji"] == ":party-parrot:"
|
||||
@@ -128,6 +128,87 @@ def test_desktop_notification_not_sent_to_room_member_who_is_present(ws_client_f
|
||||
assert live_message["type"] == "message"
|
||||
|
||||
|
||||
def test_desktop_notification_sent_to_connected_but_blurred_member(ws_client_factory):
|
||||
# #59: bob keeps the room's channel joined (so live delivery to a room
|
||||
# actually open on screen never stops) but reports his desktop window
|
||||
# as unfocused via a "focus" frame -- the whole point of this fix is
|
||||
# that notification eligibility no longer needs the client to fake
|
||||
# "offline" by leaving the room's channel, which used to also break
|
||||
# live delivery until the room was manually left and rejoined.
|
||||
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 instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
bob_ws.send_json({"type": "focus", "focused": False})
|
||||
# Sync barrier -- see test_mentions.py's identical pattern: a
|
||||
# second (idempotent) join only acks once the prior "focus"
|
||||
# frame's own handling (and commit) has completed.
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
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"], "you awake?")
|
||||
assert message["type"] == "message"
|
||||
|
||||
# Live delivery still works -- bob's room channel was never left.
|
||||
live_message = _recv(bob_ws)
|
||||
assert live_message["type"] == "message"
|
||||
assert live_message["content"] == "you awake?"
|
||||
|
||||
# ...and he's still notified, despite being "connected" to the room.
|
||||
desktop_note = _recv(bob_ws)
|
||||
assert desktop_note == {
|
||||
"type": "desktop_notification",
|
||||
"id": message["id"],
|
||||
"room_id": room["id"],
|
||||
"title": f"#{room['name']}",
|
||||
"body": f"{alice['username']}: you awake?",
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_notification_not_sent_after_refocus(ws_client_factory):
|
||||
# Proves the "focus" signal is a live toggle, not one-way -- blurring
|
||||
# and then refocusing before the message arrives must fully cancel out.
|
||||
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 instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
bob_ws.send_json({"type": "focus", "focused": False})
|
||||
bob_ws.send_json({"type": "focus", "focused": True})
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], "hello again")
|
||||
|
||||
# Refocused before the message arrived -- only the live broadcast,
|
||||
# same as test_desktop_notification_not_sent_to_room_member_who_is_present.
|
||||
live_message = _recv(bob_ws)
|
||||
assert live_message["type"] == "message"
|
||||
|
||||
|
||||
def test_desktop_notification_not_sent_to_non_member(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Room, RoomMembership
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.room_service import dm_room_name
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
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()
|
||||
|
||||
|
||||
async def test_start_dm_creates_private_room_with_both_members(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
room = resp.json()
|
||||
assert room["is_dm"] is True
|
||||
assert room["is_private"] is True
|
||||
# The internal name is never meant to be shown, but its scheme is part
|
||||
# of the contract find_or_create_dm relies on -- pin it here so a
|
||||
# future refactor can't silently change it without this test noticing.
|
||||
assert room["name"] == dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"]))
|
||||
|
||||
result = await db_session.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == uuid.UUID(room["id"]))
|
||||
)
|
||||
member_ids = {str(row[0]) for row in result.all()}
|
||||
assert member_ids == {alice["id"], bob["id"]}
|
||||
|
||||
|
||||
async def test_start_dm_is_idempotent_regardless_of_who_initiates(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
|
||||
resp1 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp1.status_code == 201
|
||||
room_id = resp1.json()["id"]
|
||||
|
||||
# bob -> alice again should return the same room, not create a second one.
|
||||
resp2 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp2.status_code == 201
|
||||
assert resp2.json()["id"] == room_id
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
# alice -> bob (reversed direction) should also find the same room.
|
||||
resp3 = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp3.status_code == 201
|
||||
assert resp3.json()["id"] == room_id
|
||||
|
||||
|
||||
async def test_start_dm_rejects_self(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_start_dm_404s_for_unknown_user(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": str(uuid.uuid4())})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_dm_excluded_from_browse_rooms(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
|
||||
# A third user should never see the DM in the open-rooms listing, even
|
||||
# though find_or_create_dm sets is_private=True (which alone would
|
||||
# already exclude it) -- confirms the belt-and-suspenders is_dm filter
|
||||
# in list_open_rooms is doing something, not just is_private.
|
||||
await client.post("/api/auth/logout")
|
||||
await register_and_login(client, db_session, username="carol")
|
||||
resp = await client.get("/api/rooms")
|
||||
assert resp.status_code == 200
|
||||
assert all(not r["is_dm"] for r in resp.json())
|
||||
|
||||
|
||||
async def test_dm_excluded_from_admin_room_list(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
admin = await register_and_login(client, db_session, username="dave")
|
||||
from app.models import User
|
||||
|
||||
user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/admin/rooms")
|
||||
assert resp.status_code == 200
|
||||
names = [r["name"] for r in resp.json()]
|
||||
assert dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"])) not in names
|
||||
|
||||
|
||||
async def test_dm_appears_in_mine_with_partner_info(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob", password="password123")
|
||||
# Give bob a display name so the partner payload's precedence is checked
|
||||
# for something other than the fallback username.
|
||||
await client.patch("/api/auth/me", json={"display_name": "Bobby"})
|
||||
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
dm_entry = next(r for r in mine if r["id"] == dm["id"])
|
||||
assert dm_entry["is_dm"] is True
|
||||
assert dm_entry["dm_partner"]["user_id"] == bob["id"]
|
||||
assert dm_entry["dm_partner"]["username"] == "bob"
|
||||
assert dm_entry["dm_partner"]["display_name"] == "Bobby"
|
||||
|
||||
# A regular room's dm_partner is always null.
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
room_entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert room_entry["is_dm"] is False
|
||||
assert room_entry["dm_partner"] is None
|
||||
|
||||
|
||||
async def test_dm_cannot_be_updated(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
# bob is a plain 'member' of the DM (no admin/owner role exists for a
|
||||
# DM), so this 403s on the ordinary role gate before ever reaching
|
||||
# update_room's own is_dm guard.
|
||||
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# A site admin bypasses that role gate (see #48) -- confirms the
|
||||
# explicit is_dm guard inside update_room itself is what stops this,
|
||||
# not just incidental role-based protection.
|
||||
await client.post("/api/auth/logout")
|
||||
admin = await register_and_login(client, db_session, username="carol")
|
||||
from app.models import User
|
||||
|
||||
user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_dm_rejects_add_member_and_join(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
carol = await register_and_login(client, db_session, username="carol")
|
||||
|
||||
# Neither participant has admin/owner role in a DM, so adding a third
|
||||
# person 403s on the existing role gate.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "bob")
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/members", json={"user_id": carol["id"]})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# is_private=True on the DM already blocks the plain join endpoint too.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "carol")
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/join")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_hide_dm_removes_it_from_mine_for_that_user_only(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/hide")
|
||||
assert resp.status_code == 204
|
||||
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert all(r["id"] != dm["id"] for r in mine)
|
||||
|
||||
# Alice never hid it -- still sees it, proving this is per-viewer, not
|
||||
# something that touched the room or bob's membership for everyone.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert any(r["id"] == dm["id"] for r in mine)
|
||||
|
||||
|
||||
async def test_hide_dm_rejects_regular_rooms(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
resp = await client.post(f"/api/rooms/{room['id']}/hide")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_starting_a_dm_again_unhides_it(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post(f"/api/rooms/{dm['id']}/hide")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert all(r["id"] != dm["id"] for r in mine)
|
||||
|
||||
# bob clicking alice in the People list again -- find_or_create_dm
|
||||
# resolves to the same room and un-hides it for him.
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["id"] == dm["id"]
|
||||
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert any(r["id"] == dm["id"] for r in mine)
|
||||
|
||||
|
||||
def test_new_message_unhides_dm_for_both_participants(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
bob = _register_ws(ws_client, _unique("bob")) # ws_client is now logged in as bob
|
||||
dm = ws_client.post("/api/rooms/dm", json={"other_user_id": alice["id"]}).json()
|
||||
|
||||
ws_client.post(f"/api/rooms/{dm['id']}/hide")
|
||||
assert all(r["id"] != dm["id"] for r in ws_client.get("/api/rooms/mine").json())
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": dm["id"], "content": "you there?"})
|
||||
ws.receive_json()
|
||||
# Sync barrier (see test_mentions.py's identical helper): the
|
||||
# message ack only proves the room-level broadcast happened, not
|
||||
# that broadcast_new_message's own continuation (which un-hides
|
||||
# the room) has finished -- a second frame's own ack proves that.
|
||||
ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
# bob never re-opened the DM himself -- alice's message alone unhid it.
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
assert any(r["id"] == dm["id"] for r in ws_client.get("/api/rooms/mine").json())
|
||||
|
||||
|
||||
def test_dm_partner_presence_update_delivered_without_room_open(ws_client_factory):
|
||||
# #63: alice never joins the DM's own room channel anywhere in this
|
||||
# test -- exactly the normal state for a DM sitting in the sidebar that
|
||||
# isn't the currently open room. member_updated's room-channel broadcast
|
||||
# would never reach her in that state (Presence gates it on having that
|
||||
# specific room joined); this signal has to arrive on her own per-user
|
||||
# channel instead, same as room_added.
|
||||
#
|
||||
# Only the connect ("online") side is exercised here, not disconnect --
|
||||
# see test_presence.py's module docstring for why the offline half
|
||||
# isn't reliably testable via a `with websocket_connect(...)` block
|
||||
# closing (TestClient cancels the server task rather than delivering a
|
||||
# real disconnect, which can interrupt a `finally` block's own awaits
|
||||
# in tests only, never in production).
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
# Sync barrier: alice's own websocket_connect() returning only
|
||||
# proves the handshake completed, not that chat.py's connection
|
||||
# setup (register_user, in particular -- required before bob's
|
||||
# connect can reach her per-user channel at all) has finished.
|
||||
# Any reply -- even an error -- proves the connection has reached
|
||||
# its main frame loop, which setup always completes before.
|
||||
alice_ws.send_json({"type": "__sync_barrier__"})
|
||||
assert alice_ws.receive_json()["type"] == "error"
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
online_update = alice_ws.receive_json()
|
||||
assert online_update == {
|
||||
"type": "dm_presence_update",
|
||||
"user_id": bob["id"],
|
||||
"status": "online",
|
||||
}
|
||||
|
||||
|
||||
def test_dm_presence_update_not_sent_to_non_partner(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
instance3 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
_register_ws(instance3, _unique("outsider"))
|
||||
instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
|
||||
with instance3.websocket_connect("/ws/chat") as outsider_ws:
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
pass
|
||||
|
||||
# Nothing should ever arrive for an outsider who shares no DM with
|
||||
# bob. Prove the socket stayed quiet the same way
|
||||
# test_desktop_notifications.py's non-member test does: a harmless
|
||||
# self-targeted join, whose prompt "joined" ack proves nothing else
|
||||
# was already queued ahead of it.
|
||||
room = instance3.post("/api/rooms", json={"name": _unique("outsiders-room")}).json()
|
||||
outsider_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
joined = outsider_ws.receive_json()
|
||||
assert joined == {"type": "joined", "room_id": room["id"]}
|
||||
@@ -0,0 +1,207 @@
|
||||
import uuid
|
||||
|
||||
from app.models import SmtpSettings
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _fake_smtp(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append({"message": message, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
# Monkeypatches get_smtp_settings directly rather than configuring it
|
||||
# for real through the admin endpoint (like test_smtp_settings.py's own
|
||||
# _configure_smtp does) -- ws_client_factory-based tests commit for
|
||||
# real, no rollback, and SmtpSettings is a genuine single global row.
|
||||
# Configuring it for real here previously leaked into every later test
|
||||
# in the same run, breaking test_smtp_settings.py's "starts
|
||||
# unconfigured" assumption. This never touches the DB at all.
|
||||
fake_settings = SmtpSettings(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="bot",
|
||||
password_encrypted=None,
|
||||
from_address="noreply@example.com",
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
async def fake_get_smtp_settings(db):
|
||||
return fake_settings
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
|
||||
return calls
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
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 _send_and_sync(ws, room_id: str, content: str) -> dict:
|
||||
"""Sync barrier -- see test_mentions.py's identical helper. The
|
||||
"message" ack fires the instant broadcaster.publish() runs, the very
|
||||
first line of broadcast_new_message -- it proves nothing about whether
|
||||
_maybe_email_dm_notification (awaited afterward, in the same handler)
|
||||
has finished. A second, idempotent join's own ack only arrives once
|
||||
the whole prior frame's handling -- including the email step -- is
|
||||
done, since one connection processes frames strictly sequentially."""
|
||||
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_dm_message_emails_globally_offline_recipient(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
# bob never connects via WS at all -- genuinely offline, not just
|
||||
# absent from this room's own channel.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "hey, you there?")
|
||||
|
||||
assert len(calls) == 1
|
||||
email = calls[0]["message"]
|
||||
assert email["To"] == bob["email"]
|
||||
assert f"New message from {alice['username']}" in email["Subject"]
|
||||
# #68: the email is now multipart/alternative (HTML + plain-text
|
||||
# fallback) -- get_body(preferencelist=...) reaches a specific part,
|
||||
# unlike get_content() which has no handler for the multipart itself.
|
||||
body = email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert f"{alice['username']}: hey, you there?" in body
|
||||
assert f"/rooms/{dm['id']}" in body
|
||||
|
||||
|
||||
def test_dm_message_does_not_email_online_recipient(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob has an open connection -- genuinely online -- even though he
|
||||
# never joins the DM's own room channel.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "hey")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_dm_message_emails_appear_offline_recipient_even_when_connected(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
resp = instance2.patch("/api/auth/me", json={"appear_offline": True})
|
||||
assert resp.status_code == 200
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob is connected (genuinely online) but lurking -- appear_offline
|
||||
# should still count as "email me," matching how it already
|
||||
# overrides the presence dot everywhere else.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "hey")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["message"]["To"] == bob["email"]
|
||||
|
||||
|
||||
def test_regular_room_message_does_not_email_offline_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_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")
|
||||
# bob never connects -- genuinely offline, same as the DM case -- but
|
||||
# this isn't a DM, so #66's email notification is out of scope here.
|
||||
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], "hello room")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_dm_email_debounced_to_first_unread_then_resets_after_read(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
_send_and_sync(alice_ws, dm["id"], "message one")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A second message while bob still hasn't read the first -- no
|
||||
# second email for the same burst.
|
||||
_send_and_sync(alice_ws, dm["id"], "message two")
|
||||
assert len(calls) == 1
|
||||
|
||||
# bob "reads" the conversation via REST -- he never has to have been
|
||||
# connected via WS for this to be meaningful, mark-read is independent
|
||||
# of live connection state.
|
||||
instance2.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
read_resp = instance2.post(f"/api/rooms/{dm['id']}/read")
|
||||
assert read_resp.status_code == 204
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "message three")
|
||||
|
||||
assert len(calls) == 2
|
||||
@@ -99,6 +99,43 @@ async def test_serve_file_forces_download(client, db_session):
|
||||
assert "notes.txt" in disposition
|
||||
|
||||
|
||||
async def test_serve_allowlisted_video_inline(client, db_session):
|
||||
# #65: a <video> tag can't play something the browser is forced to
|
||||
# download instead -- browser-playable video types are the one carve-
|
||||
# out from test_serve_file_forces_download's rule above.
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
upload = await client.post(
|
||||
f"/api/rooms/{room['id']}/files",
|
||||
files={"file": ("clip.mp4", b"not a real mp4", "video/mp4")},
|
||||
)
|
||||
file_id = upload.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
||||
assert resp.status_code == 200
|
||||
assert "content-disposition" not in resp.headers
|
||||
assert resp.headers["content-type"] == "video/mp4"
|
||||
|
||||
|
||||
async def test_serve_non_allowlisted_video_still_forces_download(client, db_session):
|
||||
# video/quicktime (.mov) has spotty <video> support outside Safari, and
|
||||
# more importantly this proves the carve-out is a strict allowlist, not
|
||||
# "every video/* content type" -- the security-relevant boundary from
|
||||
# test_serve_file_forces_download must still hold for anything not on
|
||||
# INLINE_SAFE_VIDEO_CONTENT_TYPES.
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
upload = await client.post(
|
||||
f"/api/rooms/{room['id']}/files",
|
||||
files={"file": ("clip.mov", b"not a real mov", "video/quicktime")},
|
||||
)
|
||||
file_id = upload.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
||||
assert resp.status_code == 200
|
||||
assert "attachment" in resp.headers["content-disposition"]
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.database import engine as _link_preview_engine
|
||||
from app.models import LinkPreview
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.link_preview_service import extract_first_url
|
||||
@@ -294,3 +298,66 @@ async def test_link_preview_reused_across_messages_with_same_url(client, db_sess
|
||||
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||
assert len(history) == 2
|
||||
assert all(m["link_preview"]["title"] == "Example Article" for m in history)
|
||||
|
||||
|
||||
async def test_link_preview_refetches_after_cache_expires(client, db_session, monkeypatch):
|
||||
# #70: a real report -- re-posting a URL whose title had genuinely
|
||||
# changed kept showing the stale first-fetch preview, because the
|
||||
# cache TTL used to be 7 days. Simulates that expiry directly (rather
|
||||
# than actually sleeping 5+ minutes) by backdating the cached row's
|
||||
# fetched_at past the TTL, then confirms a second post of the same URL
|
||||
# picks up new content instead of the stale cached title.
|
||||
captured_tasks: list[asyncio.Task] = []
|
||||
real_create_task = asyncio.create_task
|
||||
|
||||
def fake_create_task(coro):
|
||||
task = real_create_task(coro)
|
||||
captured_tasks.append(task)
|
||||
return task
|
||||
|
||||
monkeypatch.setattr("app.services.message_events.asyncio.create_task", fake_create_task)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.link_preview_service.httpx.AsyncClient",
|
||||
_fake_client_factory(call_log=calls),
|
||||
)
|
||||
url = _unique_url()
|
||||
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
|
||||
|
||||
resp1 = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"see {url}"}
|
||||
)
|
||||
assert resp1.status_code == 204
|
||||
await asyncio.gather(*captured_tasks)
|
||||
captured_tasks.clear()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
row = (
|
||||
await session.execute(select(LinkPreview).where(LinkPreview.url == url))
|
||||
).scalar_one()
|
||||
row.fetched_at = datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
await session.commit()
|
||||
|
||||
updated_html = _OG_HTML.replace(b"Example Article", b"Updated Article")
|
||||
monkeypatch.setattr(
|
||||
"app.services.link_preview_service.httpx.AsyncClient",
|
||||
_fake_client_factory(html=updated_html, call_log=calls),
|
||||
)
|
||||
|
||||
resp2 = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"again: {url}"}
|
||||
)
|
||||
assert resp2.status_code == 204
|
||||
await asyncio.gather(*captured_tasks)
|
||||
|
||||
assert len(calls) == 2 # the expired cache forced a second real fetch
|
||||
|
||||
# Cached by URL, not by message (see link_preview_service.py) -- the
|
||||
# row was refreshed in place, so *both* messages referencing this URL
|
||||
# now show the new title on a history reload, not one each.
|
||||
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||
assert len(history) == 2
|
||||
assert all(m["link_preview"]["title"] == "Updated Article" for m in history)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding presence/offline-
|
||||
notify noise -- see test_message_edit.py's identical helper."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
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 _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (10, 10), color=(255, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_ws_delete_clears_content_and_broadcasts(ws_client):
|
||||
username = _unique("alice")
|
||||
_register_ws(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
|
||||
deleted = ws.receive_json()
|
||||
assert deleted == {"type": "message_deleted", "id": message["id"], "room_id": room["id"]}
|
||||
|
||||
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||
history = resp.json()
|
||||
tombstone = next(m for m in history if m["id"] == message["id"])
|
||||
assert tombstone["content"] is None
|
||||
assert tombstone["deleted_at"] is not None
|
||||
assert tombstone["image_id"] is None
|
||||
assert tombstone["file"] is None
|
||||
|
||||
|
||||
def test_ws_delete_rejects_non_author(ws_client):
|
||||
alice = _register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, username=_unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = alice_ws.receive_json()
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": bob["username"], "password": "password123"},
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
bob_ws.send_json(
|
||||
{"type": "delete", "room_id": room["id"], "message_id": message["id"]}
|
||||
)
|
||||
resp = bob_ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "own messages" in resp["detail"]
|
||||
|
||||
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||
history = resp.json()
|
||||
still_there = next(m for m in history if m["id"] == message["id"])
|
||||
assert still_there["deleted_at"] is None
|
||||
assert still_there["content"] == "hello"
|
||||
|
||||
|
||||
def test_ws_delete_of_unknown_message_errors(ws_client):
|
||||
_register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json(
|
||||
{"type": "delete", "room_id": room["id"], "message_id": str(uuid.uuid4())}
|
||||
)
|
||||
resp = ws.receive_json()
|
||||
assert resp == {"type": "error", "detail": "Message not found"}
|
||||
|
||||
|
||||
def test_deleted_message_cannot_be_edited_or_reacted_to(ws_client):
|
||||
_register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
|
||||
assert ws.receive_json()["type"] == "message_deleted"
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"content": "resurrected",
|
||||
}
|
||||
)
|
||||
assert ws.receive_json() == {"type": "error", "detail": "Message not found"}
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "reaction",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"emoji": "👍",
|
||||
}
|
||||
)
|
||||
assert ws.receive_json() == {"type": "error", "detail": "Message not found"}
|
||||
|
||||
|
||||
def test_delete_fans_out_across_instances(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 instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
|
||||
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"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||
message = alice_ws.receive_json()
|
||||
assert _recv(bob_ws)["type"] == "message"
|
||||
|
||||
alice_ws.send_json(
|
||||
{"type": "delete", "room_id": room["id"], "message_id": message["id"]}
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "message_deleted"
|
||||
|
||||
deleted = _recv(bob_ws)
|
||||
assert deleted == {"type": "message_deleted", "id": message["id"], "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_delete_removes_underlying_image_from_disk(ws_client):
|
||||
username = _unique("alice")
|
||||
_register_ws(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
upload = ws_client.post(
|
||||
f"/api/rooms/{room['id']}/images",
|
||||
files={"file": ("test.png", _png_bytes(), "image/png")},
|
||||
).json()
|
||||
image_id = upload["id"]
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "image_id": image_id})
|
||||
message = ws.receive_json()
|
||||
assert message["image_id"] == image_id
|
||||
|
||||
# Confirm the image actually serves before deleting, so a false
|
||||
# pass (it was never reachable to begin with) can't hide as a true
|
||||
# one below.
|
||||
get_resp = ws_client.get(f"/api/rooms/{room['id']}/images/{image_id}")
|
||||
assert get_resp.status_code == 200
|
||||
|
||||
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
|
||||
assert ws.receive_json()["type"] == "message_deleted"
|
||||
|
||||
# The image is gone -- both the DB row (via the now-404ing serve
|
||||
# endpoint) and, per #53's "delete the file too" choice, the file
|
||||
# actually unlinked from disk (not just detached and orphaned).
|
||||
get_resp = ws_client.get(f"/api/rooms/{room['id']}/images/{image_id}")
|
||||
assert get_resp.status_code == 404
|
||||
@@ -8,13 +8,19 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
"""Reads the next frame, transparently discarding presence/offline-
|
||||
notify noise -- another connection in the same room going online/
|
||||
offline, or a per-user-channel side effect of an earlier offline
|
||||
member's own message, can legitimately arrive right as a connection is
|
||||
established, before its own "joined" ack. Not what these tests are
|
||||
about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
@@ -90,7 +96,7 @@ def test_ws_edit_rejects_non_author(ws_client):
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
bob_ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
@@ -116,7 +122,7 @@ def test_edit_fans_out_across_instances(ws_client_factory):
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
|
||||
@@ -10,8 +10,17 @@ from tests.conftest import register_and_login
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
|
||||
calls.append(
|
||||
{
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"paragraphs": paragraphs,
|
||||
"cta_label": cta_label,
|
||||
"cta_url": cta_url,
|
||||
"theme_user": theme_user,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.services.password_service.send_email", fake)
|
||||
return calls
|
||||
@@ -91,7 +100,7 @@ async def test_reset_password_flow_end_to_end(client, db_session, monkeypatch):
|
||||
await client.post("/api/auth/logout")
|
||||
|
||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||
token = _extract_token(calls[0]["body"])
|
||||
token = _extract_token(calls[0]["cta_url"])
|
||||
|
||||
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
|
||||
assert validate.status_code == 204
|
||||
@@ -133,7 +142,7 @@ async def test_reset_password_expired_token_rejected(client, db_session, monkeyp
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||
token = _extract_token(calls[0]["body"])
|
||||
token = _extract_token(calls[0]["cta_url"])
|
||||
|
||||
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
|
||||
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
@@ -150,7 +159,7 @@ async def test_reset_password_used_token_cannot_be_reused(client, db_session, mo
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||
token = _extract_token(calls[0]["body"])
|
||||
token = _extract_token(calls[0]["cta_url"])
|
||||
|
||||
first = await client.post(
|
||||
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
|
||||
|
||||
@@ -102,6 +102,62 @@ async def test_theme_custom_rejected_on_generic_profile_update(client, db_sessio
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_text_scale_persists(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"text_scale": "large"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["text_scale"] == "large"
|
||||
|
||||
me = await client.get("/api/auth/me")
|
||||
assert me.json()["text_scale"] == "large"
|
||||
|
||||
|
||||
async def test_invalid_text_scale_rejected(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"text_scale": "huge"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_updating_text_scale_does_not_clobber_theme(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await client.patch("/api/auth/me", json={"theme": "sunset"})
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"text_scale": "xlarge"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["theme"] == "sunset"
|
||||
assert resp.json()["text_scale"] == "xlarge"
|
||||
|
||||
|
||||
async def test_update_emoji_scale_persists(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"emoji_scale": "xlarge"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["emoji_scale"] == "xlarge"
|
||||
|
||||
me = await client.get("/api/auth/me")
|
||||
assert me.json()["emoji_scale"] == "xlarge"
|
||||
|
||||
|
||||
async def test_invalid_emoji_scale_rejected(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"emoji_scale": "huge"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_updating_emoji_scale_does_not_clobber_text_scale(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await client.patch("/api/auth/me", json={"text_scale": "large"})
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"emoji_scale": "small"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["text_scale"] == "large"
|
||||
assert resp.json()["emoji_scale"] == "small"
|
||||
|
||||
|
||||
async def test_avatar_upload_succeeds_and_persists(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
|
||||
@@ -218,3 +218,115 @@ def test_expired_subscription_is_cleaned_up(ws_client, monkeypatch):
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
assert _fetch_subscriptions(ws_client, bob["id"]) == []
|
||||
|
||||
|
||||
def test_non_gone_push_failure_logs_response_detail_and_keeps_subscription(ws_client, monkeypatch):
|
||||
# #56: a real WNS 400 carries its actual reason in a response *header*,
|
||||
# not the body -- str(WebPushException) alone (what used to be logged)
|
||||
# would have shown neither, which is exactly why that bug took a DB
|
||||
# dump + journalctl correlation to diagnose instead of one log line.
|
||||
class FakeResponse:
|
||||
status_code = 400
|
||||
text = "Bad Request"
|
||||
headers = {"X-WNS-Error-Description": "Ttl value conflicts with X-WNS-Cache-Policy"}
|
||||
|
||||
def fake_webpush(**kwargs):
|
||||
raise WebPushException("Push failed: 400 Bad Request", response=FakeResponse())
|
||||
|
||||
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
||||
|
||||
# caplog's handler capture isn't reliable here -- the actual push send
|
||||
# (and its logger.warning call) runs on ws_client_factory's background
|
||||
# portal thread (see that fixture's own docstring), not pytest's main
|
||||
# thread. Patching the logger call directly sidesteps that instead of
|
||||
# depending on cross-thread log propagation.
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.push_service.logger.warning",
|
||||
lambda msg, *args: calls.append(msg % args),
|
||||
)
|
||||
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
assert ws.receive_json()["type"] == "message"
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
# A 400 isn't "gone" (404/410) -- the subscription stays, unlike the
|
||||
# expired-subscription case above.
|
||||
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
||||
|
||||
assert len(calls) == 1
|
||||
assert "Bad Request" in calls[0]
|
||||
assert "Ttl value conflicts with X-WNS-Cache-Policy" in calls[0]
|
||||
|
||||
|
||||
def test_wns_endpoint_gets_cache_policy_header(ws_client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
ws_client.post(
|
||||
"/api/push/subscribe",
|
||||
json={
|
||||
"endpoint": f"https://wns2-by3p.notify.windows.com/w/{_unique('bob')}",
|
||||
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
|
||||
},
|
||||
)
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
assert ws.receive_json()["type"] == "message"
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["headers"] == {"X-WNS-Cache-Policy": "no-cache"}
|
||||
|
||||
|
||||
def test_non_wns_endpoint_gets_no_extra_headers(ws_client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
assert ws.receive_json()["type"] == "message"
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["headers"] is None
|
||||
|
||||
@@ -9,13 +9,19 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
"""Reads the next frame, transparently discarding presence/offline-
|
||||
notify noise -- another connection in the same room going online/
|
||||
offline, or a per-user-channel side effect of an earlier offline
|
||||
member's own message, can legitimately arrive right as a connection is
|
||||
established, before its own "joined" ack. Not what these tests are
|
||||
about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
@@ -122,7 +128,7 @@ def test_reaction_broadcasts_to_other_room_members(ws_client):
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import uuid
|
||||
|
||||
from app.models import SmtpSettings
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _fake_smtp(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append({"message": message, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
# See test_dm_email_notifications.py's identical helper for why this
|
||||
# monkeypatches get_smtp_settings directly instead of configuring a real
|
||||
# row through the admin endpoint.
|
||||
fake_settings = SmtpSettings(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="bot",
|
||||
password_encrypted=None,
|
||||
from_address="noreply@example.com",
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
async def fake_get_smtp_settings(db):
|
||||
return fake_settings
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
|
||||
return calls
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
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 _send_and_sync(ws, room_id: str, content: str) -> dict:
|
||||
"""See test_dm_email_notifications.py's identical helper -- the
|
||||
"message" ack alone proves nothing about whether the email step
|
||||
(awaited afterward in the same handler) has finished; a second,
|
||||
idempotent join's ack only arrives once the whole frame is done."""
|
||||
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 _subscribe(ws_client, room_id: str, username: str, password: str = "password123") -> None:
|
||||
login = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": password}
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
resp = ws_client.patch(f"/api/rooms/{room_id}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_room_first_message_emails_offline_subscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
# bob never connects via WS -- genuinely offline.
|
||||
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], "no mention here, just a plain message")
|
||||
|
||||
assert len(calls) == 1
|
||||
email = calls[0]["message"]
|
||||
assert email["To"] == bob["email"]
|
||||
assert f"New message in #{room['name']}" in email["Subject"]
|
||||
body = email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert alice["username"] in body
|
||||
assert f"/rooms/{room['id']}" in body
|
||||
|
||||
|
||||
def test_room_second_plain_message_does_not_reemail_before_read(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
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"
|
||||
|
||||
_send_and_sync(alice_ws, room["id"], "message one")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A second plain message while bob still hasn't read the first --
|
||||
# no second email for the same unread burst.
|
||||
_send_and_sync(alice_ws, room["id"], "message two")
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_room_mention_always_emails_even_mid_unread_burst(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
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"
|
||||
|
||||
# First unread message (plain) -- emails once, uses up the debounce.
|
||||
_send_and_sync(alice_ws, room["id"], "hey everyone")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A mention arriving while that first message is still unread --
|
||||
# must email anyway, unlike a second plain message.
|
||||
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} specifically you")
|
||||
assert len(calls) == 2
|
||||
|
||||
mention_email = calls[1]["message"]
|
||||
assert mention_email["To"] == bob["email"]
|
||||
assert f"New mention in #{room['name']}" in mention_email["Subject"]
|
||||
body = mention_email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert f"{alice['username']} mentioned you" in body
|
||||
|
||||
|
||||
def test_room_mention_does_not_email_unsubscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
# bob never opts in -- email_notifications defaults to False.
|
||||
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_message_does_not_email_online_subscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob has an open connection -- genuinely online -- even though he
|
||||
# never joins this room's own channel.
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_notifications_reset_after_read(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], "message one")
|
||||
assert len(calls) == 1
|
||||
|
||||
instance2.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
read_resp = instance2.post(f"/api/rooms/{room['id']}/read")
|
||||
assert read_resp.status_code == 204
|
||||
|
||||
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"
|
||||
_send_and_sync(alice_ws, room["id"], "message two")
|
||||
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_enabling_notifications_rejected_for_dm(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
resp = instance1.patch(f"/api/rooms/{dm['id']}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_notifications_setting_reflected_in_my_rooms(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
rooms = instance1.get("/api/rooms/mine").json()
|
||||
mine = next(r for r in rooms if r["id"] == room["id"])
|
||||
assert mine["email_notifications"] is False
|
||||
|
||||
resp = instance1.patch(f"/api/rooms/{room['id']}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 204
|
||||
|
||||
rooms = instance1.get("/api/rooms/mine").json()
|
||||
mine = next(r for r in rooms if r["id"] == room["id"])
|
||||
assert mine["email_notifications"] is True
|
||||
+147
-3
@@ -1,11 +1,52 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Room, RoomMembership, RoomRole, User
|
||||
from app.models import (
|
||||
EventSubscription,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageRoomReference,
|
||||
Room,
|
||||
RoomMembership,
|
||||
RoomRole,
|
||||
User,
|
||||
WebhookIncoming,
|
||||
)
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.storage import UPLOADS_DIR
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (10, 10), color=(255, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
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()
|
||||
|
||||
|
||||
async def test_create_room_requires_auth(client):
|
||||
resp = await client.post("/api/rooms", json={"name": "general"})
|
||||
assert resp.status_code == 401
|
||||
@@ -210,6 +251,88 @@ async def test_delete_room_owner_only(client, db_session):
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
def test_delete_room_with_attachments_integrations_and_cross_room_reference(ws_client):
|
||||
# Reproduces #55: every table below has a room_id (or message_id, for a
|
||||
# room being deleted) foreign key with no ON DELETE CASCADE at the DB
|
||||
# level, so a room that's ever had an attachment, an integration, or
|
||||
# been #referenced from another room's message used to 500 on delete.
|
||||
# This sets up one of each and confirms delete_room cleans all of them
|
||||
# up, not just whichever one originally surfaced the bug.
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
target_name = _unique("target-room")
|
||||
other_name = _unique("other-room")
|
||||
target = ws_client.post("/api/rooms", json={"name": target_name}).json()
|
||||
other = ws_client.post("/api/rooms", json={"name": other_name}).json()
|
||||
|
||||
image_id = ws_client.post(
|
||||
f"/api/rooms/{target['id']}/images",
|
||||
files={"file": ("test.png", _png_bytes(), "image/png")},
|
||||
).json()["id"]
|
||||
file_upload = ws_client.post(
|
||||
f"/api/rooms/{target['id']}/files",
|
||||
files={"file": ("report.pdf", b"%PDF-1.4 not real", "application/pdf")},
|
||||
).json()
|
||||
file_id = file_upload["id"]
|
||||
|
||||
webhook = ws_client.post(f"/api/rooms/{target['id']}/webhooks/incoming", json={}).json()
|
||||
sub = ws_client.post(
|
||||
f"/api/rooms/{target['id']}/event-subscriptions",
|
||||
json={"event_types": ["message.created"], "target_url": "http://8.8.8.8/hook"},
|
||||
).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": target["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": target["id"], "image_id": image_id})
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "message", "room_id": target["id"], "file_id": file_id})
|
||||
ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "join", "room_id": other["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
# References target-room from a message that belongs to a
|
||||
# *different* room -- the direction that originally 500'd, since
|
||||
# it's keyed by the referenced room's id, not the message's room.
|
||||
ws.send_json({"type": "message", "room_id": other["id"], "content": f"check out #{target_name}"})
|
||||
other_message = ws.receive_json()
|
||||
|
||||
async def _storage_filenames():
|
||||
async with ws_client.session_factory() as session:
|
||||
image = await session.get(MessageImage, uuid.UUID(image_id))
|
||||
file = await session.get(MessageFile, uuid.UUID(file_id))
|
||||
return image.storage_filename, file.storage_filename
|
||||
|
||||
image_filename, file_filename = ws_client.portal.call(_storage_filenames)
|
||||
assert (UPLOADS_DIR / image_filename).exists()
|
||||
assert (UPLOADS_DIR / file_filename).exists()
|
||||
|
||||
resp = ws_client.delete(f"/api/rooms/{target['id']}")
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
async def _assert_cleaned_up():
|
||||
async with ws_client.session_factory() as session:
|
||||
target_id = uuid.UUID(target["id"])
|
||||
for model in (MessageImage, MessageFile, WebhookIncoming, EventSubscription):
|
||||
result = await session.execute(select(model).where(model.room_id == target_id))
|
||||
assert result.scalar_one_or_none() is None, model.__name__
|
||||
result = await session.execute(
|
||||
select(MessageRoomReference).where(MessageRoomReference.room_id == target_id)
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
ws_client.portal.call(_assert_cleaned_up)
|
||||
|
||||
# The physical files were unlinked too, not just the DB rows.
|
||||
assert not (UPLOADS_DIR / image_filename).exists()
|
||||
assert not (UPLOADS_DIR / file_filename).exists()
|
||||
|
||||
# Sanity: deleting target-room didn't touch the unrelated other-room or
|
||||
# its message -- the cross-room reference cleanup is scoped correctly.
|
||||
history = ws_client.get(f"/api/rooms/{other['id']}/messages").json()
|
||||
assert any(m["id"] == other_message["id"] for m in history)
|
||||
assert webhook["id"] and sub["id"] # created successfully, not otherwise asserted above
|
||||
|
||||
|
||||
async def test_leave_room(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||
@@ -327,8 +450,8 @@ async def test_change_member_role_owner_only(client, db_session):
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
async def fake(db, to, subject, paragraphs, **kwargs):
|
||||
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||
return calls
|
||||
@@ -361,6 +484,27 @@ async def test_add_member_directly(client, db_session, monkeypatch):
|
||||
assert "added" in calls[0]["subject"].lower()
|
||||
|
||||
|
||||
async def test_add_member_posts_welcome_message(client, db_session, monkeypatch):
|
||||
# #74: posted as the auto-provisioned "system" account, not the admin
|
||||
# who added them -- mirrors test_add_member_directly's setup.
|
||||
_fake_send_email(monkeypatch)
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
history = (await client.get(f"/api/rooms/{room_id}/messages")).json()
|
||||
welcome_messages = [m for m in history if m["username"] == "system"]
|
||||
assert len(welcome_messages) == 1
|
||||
assert welcome_messages[0]["content"] == "Welcome to #general, bob!"
|
||||
|
||||
|
||||
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
|
||||
_fake_send_email(monkeypatch)
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import uuid
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
async def test_login_creates_a_session(client, db_session):
|
||||
username = _unique("alice")
|
||||
await register_user(
|
||||
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": username, "password": "password123"},
|
||||
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
sessions = (await client.get("/api/auth/sessions")).json()
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["is_current"] is True
|
||||
assert sessions[0]["device_label"] == "Chrome on Windows"
|
||||
assert sessions[0]["ip_address"]
|
||||
|
||||
|
||||
async def test_logout_revokes_the_session(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
# The important part is server-side: the session row itself is gone
|
||||
# from the active list (this request also 401s since this client's own
|
||||
# cookie was cleared, but that alone wouldn't prove the *row* is dead).
|
||||
resp = await client.get("/api/auth/sessions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_x_forwarded_for_takes_priority_over_direct_peer(client, db_session):
|
||||
username = _unique("alice")
|
||||
await register_user(
|
||||
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": username, "password": "password123"},
|
||||
headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"},
|
||||
)
|
||||
|
||||
sessions = (await client.get("/api/auth/sessions")).json()
|
||||
assert sessions[0]["ip_address"] == "203.0.113.7"
|
||||
|
||||
|
||||
async def test_revoking_another_session_logs_it_out(client, app, db_session):
|
||||
username = _unique("alice")
|
||||
await register_and_login(client, db_session, username=username)
|
||||
|
||||
# A second "device" -- a separate client hitting the same app instance
|
||||
# (so it shares the DB-override wiring), its own independent cookie.
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other_device:
|
||||
await login_as(other_device, username)
|
||||
assert len((await other_device.get("/api/auth/sessions")).json()) == 2
|
||||
|
||||
mine = (await client.get("/api/auth/sessions")).json()
|
||||
assert len(mine) == 2
|
||||
not_current = next(s for s in mine if not s["is_current"])
|
||||
|
||||
revoke = await client.delete(f"/api/auth/sessions/{not_current['id']}")
|
||||
assert revoke.status_code == 204
|
||||
|
||||
# The other device's own cookie is now dead.
|
||||
resp = await other_device.get("/api/auth/sessions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# And the revoked session no longer shows up for the account at all.
|
||||
remaining = (await client.get("/api/auth/sessions")).json()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0]["is_current"] is True
|
||||
|
||||
|
||||
async def test_cannot_revoke_another_users_session(client, app, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
my_session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
|
||||
await register_and_login(bob_client, db_session, username=_unique("bob"))
|
||||
resp = await bob_client.delete(f"/api/auth/sessions/{my_session_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Untouched -- still exactly one active session for alice.
|
||||
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
||||
|
||||
|
||||
async def test_revoking_own_current_session_works_then_logs_this_client_out(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
||||
|
||||
# Revoking your own *current* session is allowed (a remote sign-out of
|
||||
# this same device is a legitimate, if odd, thing to do).
|
||||
first = await client.delete(f"/api/auth/sessions/{session_id}")
|
||||
assert first.status_code == 204
|
||||
|
||||
# A second call with the same (now-dead) cookie can't even reach the
|
||||
# revoke check -- get_current_user itself already 401s.
|
||||
second = await client.delete(f"/api/auth/sessions/{session_id}")
|
||||
assert second.status_code == 401
|
||||
@@ -43,6 +43,14 @@ def _extract_token(body: str) -> str:
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _plain_text(message) -> str:
|
||||
# #68: the email is now multipart/alternative (HTML + plain-text
|
||||
# fallback) -- .get_content() has no handler for a multipart message
|
||||
# itself, get_body(preferencelist=...) is the standard way to reach a
|
||||
# specific alternative part.
|
||||
return message.get_body(preferencelist=("plain",)).get_content()
|
||||
|
||||
|
||||
async def test_create_site_invite_requires_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
|
||||
@@ -62,7 +70,7 @@ async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
|
||||
assert invite["status"] == "pending"
|
||||
|
||||
assert len(calls) == 1
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
validate = await client.get(f"/api/signup/validate?token={token}")
|
||||
assert validate.status_code == 200
|
||||
@@ -87,7 +95,7 @@ async def test_signup_rejects_mismatched_password_confirmation(client, db_sessio
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "typo@example.com"})
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
@@ -127,7 +135,7 @@ async def test_expired_token_rejected(client, db_session, monkeypatch):
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
|
||||
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
@@ -147,7 +155,7 @@ async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "once@example.com"})
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
first = await client.post(
|
||||
"/api/signup",
|
||||
@@ -170,7 +178,7 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
@@ -183,6 +191,79 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
|
||||
assert complete.status_code == 400
|
||||
|
||||
|
||||
async def test_resend_site_invite_requires_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_resend_site_invite_unknown_id_404s(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_resend_site_invite_new_link_works_and_old_one_doesnt(client, db_session, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "resend-me@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
original_expires_at = datetime.fromisoformat(resp.json()["expires_at"])
|
||||
old_token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
|
||||
assert resend.status_code == 200, resend.text
|
||||
assert resend.json()["id"] == invite_id
|
||||
assert resend.json()["status"] == "pending"
|
||||
# A fresh 7-day window, not whatever was left on the original.
|
||||
new_expires_at = datetime.fromisoformat(resend.json()["expires_at"])
|
||||
assert new_expires_at > original_expires_at
|
||||
|
||||
assert len(calls) == 2
|
||||
new_token = _extract_token(_plain_text(calls[1]["message"]))
|
||||
assert new_token != old_token
|
||||
|
||||
# The old link is dead -- resending rotates the token, it doesn't just
|
||||
# repeat it.
|
||||
old_validate = await client.get(f"/api/signup/validate?token={old_token}")
|
||||
assert old_validate.status_code == 400
|
||||
|
||||
new_validate = await client.get(f"/api/signup/validate?token={new_token}")
|
||||
assert new_validate.status_code == 200
|
||||
assert new_validate.json()["email"] == "resend-me@example.com"
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={
|
||||
"token": new_token,
|
||||
"username": "resentuser",
|
||||
"password": "password123",
|
||||
"password_confirm": "password123",
|
||||
},
|
||||
)
|
||||
assert complete.status_code == 200, complete.text
|
||||
|
||||
|
||||
async def test_resend_site_invite_rejects_non_pending(client, db_session, monkeypatch):
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "already-revoked@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
|
||||
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
|
||||
assert resend.status_code == 400
|
||||
|
||||
|
||||
async def test_list_site_invites(client, db_session, monkeypatch):
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
@@ -194,3 +275,54 @@ async def test_list_site_invites(client, db_session, monkeypatch):
|
||||
assert resp.status_code == 200
|
||||
emails = [i["email"] for i in resp.json()]
|
||||
assert "listed@example.com" in emails
|
||||
|
||||
|
||||
async def test_list_site_invites_excludes_revoked_invite(client, db_session, monkeypatch):
|
||||
# #61: the admin UI labels this list "Pending invites" -- a revoked
|
||||
# invite has nothing left to act on and must actually drop out of it,
|
||||
# not just get relabeled in place.
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "revoked-from-list@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
|
||||
listed = await client.get("/api/admin/invites")
|
||||
emails = [i["email"] for i in listed.json()]
|
||||
assert "revoked-from-list@example.com" not in emails
|
||||
|
||||
|
||||
async def test_list_site_invites_excludes_accepted_invite(client, db_session, monkeypatch):
|
||||
# Same gap, the other trigger: completing signup accepts the invite
|
||||
# out-of-band from the admin's own session, but it must still be gone
|
||||
# from the pending list on the admin's next fetch.
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "accepted-from-list@example.com"})
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={
|
||||
"token": token,
|
||||
"username": "acceptedfromlist",
|
||||
"password": "password123",
|
||||
"password_confirm": "password123",
|
||||
},
|
||||
)
|
||||
assert complete.status_code == 200, complete.text
|
||||
|
||||
# Signup logs the new user's session in on `client` -- switch back to
|
||||
# the admin to check the list the way the admin actually would.
|
||||
await login_as(client, "admin1")
|
||||
listed = await client.get("/api/admin/invites")
|
||||
emails = [i["email"] for i in listed.json()]
|
||||
assert "accepted-from-list@example.com" not in emails
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from app.services.system_user_service import SYSTEM_USERNAME, get_or_create_system_user
|
||||
|
||||
|
||||
async def test_get_or_create_system_user_creates_bot_account(db_session):
|
||||
user = await get_or_create_system_user(db_session)
|
||||
assert user.username == SYSTEM_USERNAME
|
||||
assert user.is_bot is True
|
||||
|
||||
|
||||
async def test_get_or_create_system_user_is_idempotent(db_session):
|
||||
first = await get_or_create_system_user(db_session)
|
||||
second = await get_or_create_system_user(db_session)
|
||||
assert first.id == second.id
|
||||
+11
-2
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
|
||||
FRONTEND_DIR="${REPO_DIR}/frontend"
|
||||
ENV_FILE="/etc/ds-chat/env"
|
||||
|
||||
echo "==> Pulling latest code"
|
||||
echo "==> Fetching latest release"
|
||||
cd "$REPO_DIR"
|
||||
git pull --ff-only
|
||||
git fetch --tags --force
|
||||
LATEST_TAG="$(git tag --sort=-creatordate | head -n1)"
|
||||
if [[ -z "$LATEST_TAG" ]]; then
|
||||
echo "No tags found -- nothing to deploy" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Deploying $LATEST_TAG"
|
||||
# Detached HEAD, not a branch checkout -- this directory only ever runs a
|
||||
# tagged release, never whatever the default branch's tip happens to be.
|
||||
git checkout --quiet --detach "$LATEST_TAG"
|
||||
|
||||
echo "==> Installing backend dependencies"
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Shown as the "Source code" link in the app's About screen -- required
|
||||
# for AGPL-3.0 section 13 compliance once you deploy this (a link so users
|
||||
# interacting with the app over the network can get the actual source,
|
||||
# including any modifications you've made). The default below points at
|
||||
# the upstream project -- fine if you're running it unmodified, but if
|
||||
# you've forked or patched the code, point this at *your* copy instead.
|
||||
VITE_SOURCE_URL=https://github.com/ds-ksmith/DS-Chat
|
||||
+44
-24
@@ -1,13 +1,15 @@
|
||||
# DS Chat frontend
|
||||
|
||||
React 19 + TypeScript + Vite PWA. The full client for DS Chat: auth and
|
||||
invite-based signup, room CRUD with roles/invites, real-time WebSocket chat
|
||||
(Markdown, @mentions, reactions, image/file attachments with previews,
|
||||
message editing), unread indicators and presence, per-user theming
|
||||
(presets plus a custom theme builder), Web Push notifications, offline
|
||||
caching and an auto-update banner via a custom service worker, and a
|
||||
site-admin portal — wired to the backend's REST API and `/ws/chat` WebSocket
|
||||
endpoint. See [`../README.md`](../README.md) and
|
||||
invite-based signup, room CRUD with roles/invites, direct messages,
|
||||
real-time WebSocket chat (Markdown, @mentions, reactions, built-in and
|
||||
custom emoji, image/video/file attachments with previews, message editing
|
||||
and deletion), unread indicators and presence, per-user theming (presets
|
||||
plus a custom theme builder), Web Push/email notifications and a desktop-
|
||||
notification bridge, an active-sessions view for managing where you're
|
||||
logged in, offline caching and an auto-update banner via a custom service
|
||||
worker, and a site-admin portal — wired to the backend's REST API and
|
||||
`/ws/chat` WebSocket endpoint. See [`../README.md`](../README.md) and
|
||||
[`../backend/README.md`](../backend/README.md) for full local setup.
|
||||
|
||||
## Dev
|
||||
@@ -39,20 +41,24 @@ worker.
|
||||
```
|
||||
src/
|
||||
main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password,
|
||||
/rooms, /rooms/:roomId, /admin (AdminRoute-gated); mounts
|
||||
UpdateBanner globally and ChatSocketProvider once authed
|
||||
/rooms, /rooms/:roomId, /admin (AdminRoute-gated), /help;
|
||||
mounts UpdateBanner globally, ChatSocketProvider and
|
||||
CustomEmojiProvider once authed
|
||||
types.ts shared request/response/WS-envelope types, mirroring the
|
||||
backend's Pydantic schemas
|
||||
|
||||
api/ fetch wrappers, one file per backend resource: client
|
||||
(base fetch/error handling), auth, signup, rooms, users,
|
||||
bots, webhooks, push, admin, customThemes, uploads
|
||||
(base fetch/error handling), auth (incl. active sessions),
|
||||
signup, rooms (incl. DMs), users, bots, webhooks, push,
|
||||
admin, customThemes, customEmoji, uploads
|
||||
ws/useChatSocket.ts the WebSocket hook: connect/reconnect with backoff,
|
||||
join/leave rooms, send/edit/react, visibility-gated
|
||||
join/leave rooms, send/edit/delete/react, visibility-gated
|
||||
presence, triggers an SW update check on reconnect
|
||||
context/
|
||||
AuthContext.tsx current-user state, hydrated via GET /api/auth/me
|
||||
ChatSocketContext.tsx shares one useChatSocket instance across the app
|
||||
CustomEmojiContext.tsx fetches the site's custom emoji once, exposes a
|
||||
shortcode lookup + a refresh() called after upload/delete
|
||||
|
||||
lib/
|
||||
avatar.ts deterministic accent-color cycling for avatars
|
||||
@@ -61,7 +67,9 @@ src/
|
||||
fileSize.ts human-readable byte formatting
|
||||
lastUser.ts cached "who was I last logged in as" for offline shell render
|
||||
messageGrouping.ts groups consecutive messages by sender/time, presence lookup
|
||||
push.ts PushManager subscribe/unsubscribe, VAPID key conversion
|
||||
push.ts PushManager subscribe/unsubscribe, VAPID key conversion,
|
||||
timeout-guarded so a browser that never settles the
|
||||
permission prompt can't leave the UI stuck forever
|
||||
swUpdate.ts bridges the SW registration to useChatSocket's reconnect hook
|
||||
theme.ts applies preset/custom themes as CSS custom properties
|
||||
|
||||
@@ -73,26 +81,38 @@ src/
|
||||
|
||||
components/
|
||||
ProtectedRoute.tsx, AdminRoute.tsx auth/site-admin route guards
|
||||
TopBar.tsx, Sidebar.tsx, RoomRow.tsx room list chrome
|
||||
TopBar.tsx, Sidebar.tsx, RoomRow.tsx room list chrome (DMs and Rooms as
|
||||
independently collapsible sections)
|
||||
ChatPane.tsx, MessageList.tsx, Composer.tsx chat view: history+live merge,
|
||||
message rendering, composer/attach/send
|
||||
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering + @mention highlighting/autocomplete
|
||||
ImageLightbox.tsx, FilePreviewModal.tsx attachment viewers (image/PDF/text/Markdown)
|
||||
EmojiPicker.tsx reaction/composer emoji picker
|
||||
RoomInfoPanel.tsx room details/members/roles panel
|
||||
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member picking
|
||||
message rendering (incl. deleted-message
|
||||
tombstones), composer/attach/send
|
||||
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering (mentions, room
|
||||
links, custom emoji `:shortcode:`,
|
||||
heading ids, sub/superscript) +
|
||||
@mention highlighting/autocomplete
|
||||
ImageLightbox.tsx, VideoLightbox.tsx,
|
||||
FilePreviewModal.tsx attachment viewers (image/video/PDF/
|
||||
text/Markdown)
|
||||
EmojiPicker.tsx, CustomEmojiUploadModal.tsx reaction/composer emoji picker
|
||||
(built-in + site's custom emoji) and
|
||||
its upload dialog
|
||||
RoomInfoPanel.tsx room details/members/roles/email-
|
||||
notification-toggle panel
|
||||
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member
|
||||
picking (also how a DM starts)
|
||||
ProfileModal.tsx, ThemeBuilderModal.tsx, CustomThemePreview.tsx
|
||||
profile settings + the custom theme editor
|
||||
(opened in its own wide dialog) with a live,
|
||||
profile settings (incl. active-sessions
|
||||
list) + the custom theme editor (opened
|
||||
in its own wide dialog) with a live,
|
||||
hoverable mockup of the real UI
|
||||
RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot)
|
||||
OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt
|
||||
|
||||
pages/
|
||||
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
||||
ChatShellPage.tsx, AdminPage.tsx
|
||||
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
|
||||
|
||||
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.)
|
||||
styles/tokens.css design tokens (default theme: colors, spacing, etc.)
|
||||
sw.ts custom service worker (injectManifest): app-shell
|
||||
precache + NetworkFirst runtime caching, push/notificationclick
|
||||
handlers, SKIP_WAITING messaging for the update-prompt flow
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DS Chat</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "2026.9.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { ChatSocketProvider } from './context/ChatSocketContext'
|
||||
import { CustomEmojiProvider } from './context/CustomEmojiContext'
|
||||
import { AdminRoute } from './components/AdminRoute'
|
||||
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
@@ -67,8 +68,10 @@ function AppRoutes() {
|
||||
if (!user) return routes
|
||||
return (
|
||||
<ChatSocketProvider key={user.id}>
|
||||
<CustomEmojiProvider>
|
||||
<DesktopNotificationBridge />
|
||||
{routes}
|
||||
</CustomEmojiProvider>
|
||||
</ChatSocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
|
||||
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// #60: same invite row, not a new one -- a fresh token/expiry, and the old
|
||||
// link stops working the moment this is called (see the backend's own
|
||||
// resend_site_invite for why).
|
||||
export function resendSiteInvite(inviteId: string): Promise<SiteInvite> {
|
||||
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}/resend`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSmtpSettings(): Promise<SmtpSettings | null> {
|
||||
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch, ApiError, NetworkError } from './client'
|
||||
import type { User } from '../types'
|
||||
import type { EmojiScale, TextScale, User, UserSession } from '../types'
|
||||
|
||||
// No register() here: this is an invite-only site. Accounts are created by
|
||||
// an operator via the backend CLI (`python -m app.cli create-user`), not
|
||||
@@ -16,6 +16,16 @@ export function logout(): Promise<void> {
|
||||
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
// #69: every device/browser currently logged into this account, newest
|
||||
// last-seen first -- see backend's app/schemas/session.py.
|
||||
export function listSessions(): Promise<UserSession[]> {
|
||||
return apiFetch<UserSession[]>('/api/auth/sessions')
|
||||
}
|
||||
|
||||
export function revokeSession(sessionId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function me(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me')
|
||||
}
|
||||
@@ -40,6 +50,23 @@ export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Pr
|
||||
})
|
||||
}
|
||||
|
||||
// #71: its own call, same reasoning as updateTheme above -- the backend
|
||||
// only applies fields actually present in the request body, so this can't
|
||||
// clobber theme (or vice versa).
|
||||
export function updateTextScale(textScale: TextScale): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ text_scale: textScale }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateEmojiScale(emojiScale: EmojiScale): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ emoji_scale: emojiScale }),
|
||||
})
|
||||
}
|
||||
|
||||
export function removeAvatar(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiFetch, ApiError, NetworkError } from './client'
|
||||
import type { CustomEmoji } from '../types'
|
||||
|
||||
export function listCustomEmoji(): Promise<CustomEmoji[]> {
|
||||
return apiFetch<CustomEmoji[]>('/api/custom-emoji')
|
||||
}
|
||||
|
||||
export function getCustomEmojiUrl(shortcode: string): string {
|
||||
return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image`
|
||||
}
|
||||
|
||||
export function deleteCustomEmoji(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/custom-emoji/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Raw fetch, not apiFetch -- same multipart-boundary reason as
|
||||
// uploadAvatar/uploadRoomImage (a manually-set Content-Type header would
|
||||
// omit the boundary the browser generates for FormData).
|
||||
export async function uploadCustomEmoji(shortcode: string, file: File): Promise<CustomEmoji> {
|
||||
const formData = new FormData()
|
||||
formData.append('shortcode', shortcode)
|
||||
formData.append('file', file)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch('/api/custom-emoji', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
} catch {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
try {
|
||||
const body = await response.json()
|
||||
detail = body.detail ?? detail
|
||||
} catch {
|
||||
// response had no JSON body
|
||||
}
|
||||
throw new ApiError(response.status, detail)
|
||||
}
|
||||
|
||||
return (await response.json()) as CustomEmoji
|
||||
}
|
||||
@@ -29,6 +29,15 @@ export function createRoom(
|
||||
})
|
||||
}
|
||||
|
||||
// #52: find-or-create -- returns the existing DM with this person if one
|
||||
// already exists, rather than always creating a new room.
|
||||
export function startDm(otherUserId: string): Promise<Room> {
|
||||
return apiFetch<Room>('/api/rooms/dm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ other_user_id: otherUserId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRoom(
|
||||
roomId: string,
|
||||
data: { name?: string; description?: string; is_private?: boolean },
|
||||
@@ -51,6 +60,25 @@ export function leaveRoom(roomId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/leave`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// #52 follow-up: only for DMs -- hides it from this user's own sidebar
|
||||
// without touching the other participant's copy. Reversible: messaging
|
||||
// again (startDm, above) or a new message from the other person un-hides
|
||||
// it automatically.
|
||||
export function hideDm(roomId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// #67: per-viewer opt-in for email on this room's first unread message
|
||||
// and every mention. 400s for a DM (backend rejects it -- see
|
||||
// set_room_email_notifications), so callers should only expose the
|
||||
// toggle for a non-DM room.
|
||||
export function updateRoomNotifications(roomId: string, emailNotifications: boolean): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/notifications`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ email_notifications: emailNotifications }),
|
||||
})
|
||||
}
|
||||
|
||||
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ interface AboutModalProps {
|
||||
// users remotely through a computer network, you should also make sure
|
||||
// that it provides a way for users to get its source... its interface
|
||||
// could display a 'Source' link" -- this modal is that link, not just a
|
||||
// courtesy credits screen.
|
||||
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat'
|
||||
// courtesy credits screen. Deliberately not a hardcoded URL: whoever
|
||||
// deploys this needs to point it at *their* copy of the repo (including
|
||||
// any modifications), not the upstream project -- see frontend/.env.example.
|
||||
const SOURCE_URL = import.meta.env.VITE_SOURCE_URL as string | undefined
|
||||
|
||||
export function AboutModal({ onClose }: AboutModalProps) {
|
||||
return (
|
||||
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{SOURCE_URL && (
|
||||
<p className="about-modal-line">
|
||||
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
||||
Source code
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
|
||||
@@ -171,6 +171,21 @@ export function ChatPane({
|
||||
setLive((prev) =>
|
||||
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
|
||||
)
|
||||
} else if (envelope.type === 'message_deleted' && envelope.room_id === room.id) {
|
||||
// Mirrors what the server already did to the row (see
|
||||
// message_service.delete_message) -- content/image/file/preview
|
||||
// cleared, deleted_at set. `reactions` is left alone; MessageList
|
||||
// just doesn't render it once deleted_at is set, same as it
|
||||
// doesn't render anything else here.
|
||||
const tombstone = {
|
||||
content: null,
|
||||
image_id: null,
|
||||
file: null,
|
||||
link_preview: null,
|
||||
deleted_at: new Date().toISOString(),
|
||||
}
|
||||
setHistory((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
|
||||
setLive((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
|
||||
} else if (envelope.type === 'error') {
|
||||
setWsError(envelope.detail)
|
||||
}
|
||||
@@ -212,6 +227,10 @@ export function ChatPane({
|
||||
(messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji),
|
||||
[socket, room.id],
|
||||
)
|
||||
const sendDelete = useCallback(
|
||||
(messageId: string) => socket.sendDelete(room.id, messageId),
|
||||
[socket, room.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="chat-pane">
|
||||
@@ -224,8 +243,16 @@ export function ChatPane({
|
||||
</button>
|
||||
)}
|
||||
<div className="chat-pane-title-block">
|
||||
<div className="chat-pane-title">#{room.name}</div>
|
||||
<div className="chat-pane-subtitle">{members.length} member{members.length === 1 ? '' : 's'}</div>
|
||||
<div className="chat-pane-title">
|
||||
{room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : `#${room.name}`}
|
||||
</div>
|
||||
<div className="chat-pane-subtitle">
|
||||
{room.dm_partner
|
||||
? room.dm_partner.status === 'online'
|
||||
? 'Online'
|
||||
: 'Offline'
|
||||
: `${members.length} member${members.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -256,13 +283,16 @@ export function ChatPane({
|
||||
myRooms={myRooms}
|
||||
onEdit={sendEdit}
|
||||
onReact={sendReaction}
|
||||
onDelete={sendDelete}
|
||||
/>
|
||||
<Composer
|
||||
roomId={room.id}
|
||||
roomName={room.name}
|
||||
roomName={room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : room.name}
|
||||
isDm={room.is_dm}
|
||||
members={members}
|
||||
rooms={rooms}
|
||||
disabled={!connected}
|
||||
archived={room.is_archived}
|
||||
onSend={send}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -12,9 +12,13 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
||||
import { getUploadLimit } from '../api/uploads'
|
||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
|
||||
import { formatFileSize } from '../lib/fileSize'
|
||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
||||
import type { MyRoomItem, RoomMember } from '../types'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
|
||||
import { MentionAutocomplete } from './MentionAutocomplete'
|
||||
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
|
||||
import './Composer.css'
|
||||
@@ -22,6 +26,7 @@ import './Composer.css'
|
||||
interface ComposerProps {
|
||||
roomId: string
|
||||
roomName: string
|
||||
isDm?: boolean
|
||||
members: RoomMember[]
|
||||
// #47: rooms this user belongs to, for the #roomname autocomplete --
|
||||
// deliberately the same list ChatPane already resolves message-display
|
||||
@@ -29,6 +34,12 @@ interface ComposerProps {
|
||||
// while typing and what actually renders as a link later agree.
|
||||
rooms: MyRoomItem[]
|
||||
disabled?: boolean
|
||||
// #57: an archived room is permanently read-only, not just transiently
|
||||
// disconnected -- kept as its own prop rather than folded into `disabled`
|
||||
// so the placeholder/status text can say why, instead of the connecting/
|
||||
// offline copy below (which would be actively misleading here: waiting
|
||||
// won't ever re-enable this).
|
||||
archived?: boolean
|
||||
onSend: (content: string, imageId?: string, fileId?: string) => void
|
||||
}
|
||||
|
||||
@@ -62,6 +73,19 @@ function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery |
|
||||
return detectTriggerQuery(text, cursor, '#')
|
||||
}
|
||||
|
||||
// #54: a dedicated scan rather than detectTriggerQuery(text, cursor, ':')
|
||||
// -- shortcode names (see emojiShortcodes.ts) can contain '+'/'-' (':+1:',
|
||||
// ':t-rex:') but never '.', the reverse of what the shared @/# charset
|
||||
// allows, so it doesn't fit that helper's single fixed charset.
|
||||
function detectEmojiQuery(text: string, cursor: number): TriggerQuery | null {
|
||||
let i = cursor - 1
|
||||
while (i >= 0 && /[a-zA-Z0-9_+-]/.test(text[i])) i--
|
||||
if (i < 0 || text[i] !== ':') return null
|
||||
const prevChar = text[i - 1]
|
||||
if (prevChar && /\w/.test(prevChar)) return null
|
||||
return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
|
||||
}
|
||||
|
||||
interface AttachMenuProps {
|
||||
onPickPhoto: () => void
|
||||
onPickFile: () => void
|
||||
@@ -89,7 +113,12 @@ function AttachMenu({ onPickPhoto, onPickFile, onClose }: AttachMenuProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Composer({ roomId, roomName, members, rooms, disabled, onSend }: ComposerProps) {
|
||||
export function Composer({ roomId, roomName, isDm, members, rooms, disabled, archived, onSend }: ComposerProps) {
|
||||
// Every gate below (attach/emoji buttons, textarea, send button) reads
|
||||
// this instead of the raw `disabled` prop -- an archived room must be
|
||||
// just as unwritable as a disconnected one, it just says why differently
|
||||
// (see the placeholder/status text further down).
|
||||
const isDisabled = disabled || archived
|
||||
const [value, setValue] = useState('')
|
||||
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
|
||||
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
|
||||
@@ -103,8 +132,11 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
|
||||
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
|
||||
const [roomActiveIndex, setRoomActiveIndex] = useState(0)
|
||||
const [emojiQuery, setEmojiQuery] = useState<TriggerQuery | null>(null)
|
||||
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
||||
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
// #29: a separate input with an image/video accept hint, so mobile
|
||||
@@ -135,6 +167,39 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
return rooms.filter((r) => r.name.toLowerCase().startsWith(q)).slice(0, 8)
|
||||
}, [roomQuery, rooms])
|
||||
|
||||
const emojiMatches = useMemo((): EmojiShortcodeMatch[] => {
|
||||
if (!emojiQuery) return []
|
||||
const q = emojiQuery.text.toLowerCase()
|
||||
// A bare ":" with nothing typed yet -- suggest recently-used emoji
|
||||
// (already capped to 8, see recentEmoji.ts) rather than an arbitrary
|
||||
// slice of the ~950 known shortcodes. A recent custom-emoji pick is
|
||||
// stored as its literal `:shortcode:` (see recordEmojiUsed's call
|
||||
// sites) -- resolved against the live registry the same way, so a
|
||||
// since-deleted one just doesn't show up here.
|
||||
if (!q) {
|
||||
return getRecentEmoji()
|
||||
.map((value) => {
|
||||
const customMatch = /^:([a-z0-9_-]+):$/.exec(value)
|
||||
if (customMatch && customEmojiByShortcode.has(customMatch[1])) {
|
||||
return { shortcode: customMatch[1], glyph: null }
|
||||
}
|
||||
const shortcode = SHORTCODE_BY_GLYPH[value]
|
||||
return shortcode ? { shortcode, glyph: value } : null
|
||||
})
|
||||
.filter((match): match is EmojiShortcodeMatch => match !== null)
|
||||
}
|
||||
// Custom emoji surface first -- a smaller, more specific set, and the
|
||||
// whole reason this app has an upload feature at all is for them to be
|
||||
// reachable as easily as the built-in set.
|
||||
const customMatches: EmojiShortcodeMatch[] = [...customEmojiByShortcode.keys()]
|
||||
.filter((shortcode) => shortcode.startsWith(q))
|
||||
.map((shortcode) => ({ shortcode, glyph: null }))
|
||||
const builtinMatches: EmojiShortcodeMatch[] = Object.keys(EMOJI_SHORTCODES)
|
||||
.filter((shortcode) => shortcode.startsWith(q))
|
||||
.map((shortcode) => ({ shortcode, glyph: EMOJI_SHORTCODES[shortcode] }))
|
||||
return [...customMatches, ...builtinMatches].slice(0, 8)
|
||||
}, [emojiQuery, customEmojiByShortcode])
|
||||
|
||||
useEffect(() => {
|
||||
getUploadLimit()
|
||||
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
|
||||
@@ -158,6 +223,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
setValue('')
|
||||
setMentionQuery(null)
|
||||
setRoomQuery(null)
|
||||
setEmojiQuery(null)
|
||||
removePendingImage()
|
||||
setPendingFile(null)
|
||||
requestAnimationFrame(autoGrow)
|
||||
@@ -195,6 +261,34 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
})
|
||||
}
|
||||
|
||||
function selectEmojiShortcode(shortcode: string) {
|
||||
const query = emojiQuery
|
||||
if (!query) return
|
||||
// A custom emoji has no unicode glyph to substitute -- its literal
|
||||
// `:shortcode:` text is what actually gets stored/rendered (see
|
||||
// MessageContent.tsx's convertCustomEmojiShortcodes), so that's what
|
||||
// goes in the textarea instead of a glyph.
|
||||
const isCustom = customEmojiByShortcode.has(shortcode)
|
||||
const glyph = EMOJI_SHORTCODES[shortcode]
|
||||
if (!isCustom && !glyph) return
|
||||
const inserted = isCustom ? `:${shortcode}:` : glyph
|
||||
// Matches EmojiPicker's own insertEmoji -- a shortcode-completed emoji
|
||||
// counts as "used" the same as one picked from the picker, so it
|
||||
// shows up there too next time.
|
||||
recordEmojiUsed(inserted)
|
||||
const el = textareaRef.current
|
||||
const next = value.slice(0, query.start) + inserted + ' ' + value.slice(query.end)
|
||||
setValue(next)
|
||||
setEmojiQuery(null)
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return
|
||||
el.focus()
|
||||
const cursor = query.start + inserted.length + 1 // inserted text + trailing space
|
||||
el.setSelectionRange(cursor, cursor)
|
||||
autoGrow()
|
||||
})
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (mentionQuery && mentionMatches.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -240,6 +334,28 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
return
|
||||
}
|
||||
}
|
||||
if (emojiQuery && emojiMatches.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setEmojiActiveIndex((i) => (i + 1) % emojiMatches.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setEmojiActiveIndex((i) => (i - 1 + emojiMatches.length) % emojiMatches.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
selectEmojiShortcode(emojiMatches[emojiActiveIndex].shortcode)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setEmojiQuery(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
@@ -257,6 +373,8 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
setMentionActiveIndex(0)
|
||||
setRoomQuery(detectRoomReferenceQuery(el.value, cursor))
|
||||
setRoomActiveIndex(0)
|
||||
setEmojiQuery(detectEmojiQuery(el.value, cursor))
|
||||
setEmojiActiveIndex(0)
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
@@ -294,7 +412,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
|
||||
function handleDragEnter(e: DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault()
|
||||
if (disabled) return
|
||||
if (isDisabled) return
|
||||
dragCounterRef.current++
|
||||
setDragActive(true)
|
||||
}
|
||||
@@ -316,7 +434,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
e.preventDefault()
|
||||
dragCounterRef.current = 0
|
||||
setDragActive(false)
|
||||
if (disabled) return
|
||||
if (isDisabled) return
|
||||
// Only the first dropped file, matching the existing single-attachment-
|
||||
// per-message limit (the button-triggered file input isn't `multiple`
|
||||
// either).
|
||||
@@ -418,7 +536,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
type="button"
|
||||
className="composer-attach"
|
||||
onClick={() => setAttachMenuOpen((v) => !v)}
|
||||
disabled={disabled || uploading}
|
||||
disabled={isDisabled || uploading}
|
||||
aria-label="Attach a photo or file"
|
||||
aria-expanded={attachMenuOpen}
|
||||
>
|
||||
@@ -457,7 +575,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
type="button"
|
||||
className="composer-emoji-trigger"
|
||||
onClick={() => setEmojiPickerOpen((v) => !v)}
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
aria-label="Insert an emoji"
|
||||
>
|
||||
🙂
|
||||
@@ -476,7 +594,7 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
autoGrow()
|
||||
@@ -485,10 +603,20 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
setMentionActiveIndex(0)
|
||||
setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor))
|
||||
setRoomActiveIndex(0)
|
||||
setEmojiQuery(detectEmojiQuery(e.target.value, cursor))
|
||||
setEmojiActiveIndex(0)
|
||||
}}
|
||||
onSelect={handleSelectionChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
|
||||
placeholder={
|
||||
archived
|
||||
? 'This room has been archived'
|
||||
: disabled
|
||||
? online
|
||||
? 'Connecting…'
|
||||
: "You're offline"
|
||||
: `Message ${isDm ? roomName : `#${roomName}`}`
|
||||
}
|
||||
spellCheck
|
||||
/>
|
||||
{mentionQuery && mentionMatches.length > 0 && (
|
||||
@@ -507,12 +635,20 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
onHover={setRoomActiveIndex}
|
||||
/>
|
||||
)}
|
||||
{emojiQuery && emojiMatches.length > 0 && (
|
||||
<EmojiShortcodeAutocomplete
|
||||
matches={emojiMatches}
|
||||
activeIndex={emojiActiveIndex}
|
||||
onPick={selectEmojiShortcode}
|
||||
onHover={setEmojiActiveIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-send"
|
||||
onClick={handleSend}
|
||||
disabled={disabled || (!value.trim() && !pendingImage && !pendingFile)}
|
||||
disabled={isDisabled || (!value.trim() && !pendingImage && !pendingFile)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
|
||||
@@ -520,8 +656,12 @@ export function Composer({ roomId, roomName, members, rooms, disabled, onSend }:
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{disabled && (
|
||||
{archived ? (
|
||||
<div className="composer-status">This room has been archived and is read-only</div>
|
||||
) : (
|
||||
disabled && (
|
||||
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -40,6 +40,18 @@
|
||||
color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.composer-autocomplete-emoji-glyph {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.composer-autocomplete-custom-emoji {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.composer-autocomplete-secondary {
|
||||
font-size: 0.76rem;
|
||||
color: var(--ds-muted);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { deleteCustomEmoji } from '../api/customEmoji'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||
import type { CustomEmoji } from '../types'
|
||||
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
|
||||
import { EmojiGlyph } from './MessageContent'
|
||||
import './Modal.css'
|
||||
|
||||
interface CustomEmojiManageModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// Moved out of the reaction/composer emoji picker -- that grid packs items
|
||||
// 9-to-a-row with a delete "x" overlapping the glyph itself, which on a
|
||||
// touch screen is far too easy to hit by accident while just trying to
|
||||
// react. A dedicated list with a normal-sized "Delete" button (plus the
|
||||
// same confirm() every other destructive action in this app uses) needs a
|
||||
// deliberate tap to actually delete something.
|
||||
export function CustomEmojiManageModal({ onClose }: CustomEmojiManageModalProps) {
|
||||
const { user } = useAuth()
|
||||
const { list, refresh } = useCustomEmoji()
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
async function handleDelete(emoji: CustomEmoji) {
|
||||
if (!confirm(`Delete :${emoji.shortcode}:? This can't be undone.`)) return
|
||||
setDeletingId(emoji.id)
|
||||
try {
|
||||
await deleteCustomEmoji(emoji.id)
|
||||
await refresh()
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-scrim" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Custom emoji</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-field-label">Site emoji</div>
|
||||
{list.length === 0 ? (
|
||||
<p className="modal-empty">No custom emoji yet.</p>
|
||||
) : (
|
||||
list.map((emoji) => {
|
||||
const canDelete = user?.id === emoji.uploaded_by || user?.is_site_admin
|
||||
return (
|
||||
<div key={emoji.id} className="modal-list-row">
|
||||
<div className="modal-list-row-body">
|
||||
<div className="modal-list-row-title">
|
||||
<EmojiGlyph value={`:${emoji.shortcode}:`} /> :{emoji.shortcode}:
|
||||
</div>
|
||||
<div className="modal-list-row-sub">
|
||||
Added {new Date(emoji.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-list-row-action"
|
||||
disabled={deletingId === emoji.id}
|
||||
onClick={() => handleDelete(emoji)}
|
||||
>
|
||||
{deletingId === emoji.id ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={() => setUploadOpen(true)}>
|
||||
Add emoji
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadOpen && (
|
||||
<CustomEmojiUploadModal
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onUploaded={() => {
|
||||
refresh()
|
||||
setUploadOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { uploadCustomEmoji } from '../api/customEmoji'
|
||||
import { ApiError } from '../api/client'
|
||||
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
||||
import './Modal.css'
|
||||
|
||||
interface CustomEmojiUploadModalProps {
|
||||
onClose: () => void
|
||||
onUploaded: () => void
|
||||
}
|
||||
|
||||
// Mirrors the shortcode charset the backend actually enforces (see
|
||||
// backend/app/services/custom_emoji_service.py's SHORTCODE_PATTERN) --
|
||||
// checked here too so a bad name shows up immediately next to the field
|
||||
// instead of only after a round trip.
|
||||
const SHORTCODE_PATTERN = /^[a-z0-9_-]{2,30}$/
|
||||
|
||||
export function CustomEmojiUploadModal({ onClose, onUploaded }: CustomEmojiUploadModalProps) {
|
||||
const [shortcode, setShortcode] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||
const selected = e.target.files?.[0] ?? null
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
setFile(selected)
|
||||
setPreviewUrl(selected ? URL.createObjectURL(selected) : null)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const normalizedShortcode = shortcode.trim().toLowerCase()
|
||||
const shortcodeValid = SHORTCODE_PATTERN.test(normalizedShortcode)
|
||||
// A built-in shortcode always wins when :name: is typed in a message
|
||||
// (see MessageContent.tsx's convertShortcodes, which runs first) -- a
|
||||
// custom emoji uploaded under a colliding name would still upload fine,
|
||||
// but could never actually be *reached* by typing its shortcode. Not a
|
||||
// hard block (site-admin-free upload means no server-side authority to
|
||||
// enforce this against ~950 names), just steered away from here.
|
||||
const collidesWithBuiltin = shortcodeValid && normalizedShortcode in EMOJI_SHORTCODES
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!file || !shortcodeValid) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await uploadCustomEmoji(normalizedShortcode, file)
|
||||
onUploaded()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-scrim" onClick={handleClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Add custom emoji</h2>
|
||||
<button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-field-label">Shortcode</div>
|
||||
<input
|
||||
type="text"
|
||||
value={shortcode}
|
||||
onChange={(e) => setShortcode(e.target.value)}
|
||||
placeholder="party-parrot"
|
||||
autoFocus
|
||||
/>
|
||||
{shortcode && !shortcodeValid && (
|
||||
<p className="modal-error">
|
||||
2-30 characters: lowercase letters, numbers, hyphens, underscores
|
||||
</p>
|
||||
)}
|
||||
{collidesWithBuiltin && (
|
||||
<p className="modal-error">
|
||||
:{normalizedShortcode}: is already a built-in emoji -- typing it will always show that
|
||||
one instead of yours
|
||||
</p>
|
||||
)}
|
||||
<div className="modal-field-label">Image</div>
|
||||
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleFileSelected} />
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="Preview" className="custom-emoji-upload-preview" />
|
||||
)}
|
||||
{error && <p className="modal-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={submitting || !file || !shortcodeValid}>
|
||||
{submitting ? 'Uploading…' : 'Add emoji'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
|
||||
import { EMOJI_NAMES } from '../lib/emojiNames'
|
||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
||||
import { EmojiGlyph } from './MessageContent'
|
||||
import './EmojiPicker.css'
|
||||
|
||||
interface EmojiPickerProps {
|
||||
@@ -17,7 +19,17 @@ interface EmojiPickerProps {
|
||||
// available viewport space) need this to know how much room to check for.
|
||||
export const EMOJI_PICKER_MAX_HEIGHT = 380
|
||||
|
||||
function searchEmoji(query: string): string[] {
|
||||
// Every emoji this picker deals with -- built-in or custom -- is just a
|
||||
// string from here on: a raw unicode glyph, or a custom emoji's literal
|
||||
// `:shortcode:` reference (see EmojiGlyph in MessageContent.tsx, which
|
||||
// resolves either into the right thing to render). Keeping both kinds in
|
||||
// the same list/search/recent machinery means there's exactly one grid
|
||||
// rendering path instead of a parallel one for custom emoji.
|
||||
function titleFor(value: string): string {
|
||||
return EMOJI_NAMES[value]?.name ?? value
|
||||
}
|
||||
|
||||
function searchEmoji(query: string, customShortcodes: string[]): string[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return []
|
||||
const seen = new Set<string>()
|
||||
@@ -32,13 +44,18 @@ function searchEmoji(query: string): string[] {
|
||||
results.push(emoji)
|
||||
}
|
||||
}
|
||||
for (const shortcode of customShortcodes) {
|
||||
if (shortcode.toLowerCase().includes(q)) results.push(`:${shortcode}:`)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
|
||||
useEscapeKey(onClose)
|
||||
const { list: customEmoji } = useCustomEmoji()
|
||||
const [query, setQuery] = useState('')
|
||||
const searchResults = useMemo(() => searchEmoji(query), [query])
|
||||
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
|
||||
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
|
||||
const searching = query.trim().length > 0
|
||||
// A snapshot taken once when the picker opens, not live-updating as picks
|
||||
// happen within this same session -- picking an emoji always closes the
|
||||
@@ -75,10 +92,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="emoji-picker-item"
|
||||
title={EMOJI_NAMES[emoji]?.name}
|
||||
title={titleFor(emoji)}
|
||||
onClick={() => pick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
<EmojiGlyph value={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -87,6 +104,25 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{customEmoji.length > 0 && (
|
||||
<div className="emoji-picker-category">
|
||||
<div className="emoji-picker-category-label">Custom</div>
|
||||
<div className="emoji-picker-grid">
|
||||
{customEmoji.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="emoji-picker-item"
|
||||
title={`:${e.shortcode}:`}
|
||||
onClick={() => pick(`:${e.shortcode}:`)}
|
||||
>
|
||||
<EmojiGlyph value={`:${e.shortcode}:`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<div className="emoji-picker-category">
|
||||
<div className="emoji-picker-category-label">Recently used</div>
|
||||
@@ -97,10 +133,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="emoji-picker-item"
|
||||
title={EMOJI_NAMES[emoji]?.name}
|
||||
title={titleFor(emoji)}
|
||||
onClick={() => pick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
<EmojiGlyph value={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -116,10 +152,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="emoji-picker-item"
|
||||
title={EMOJI_NAMES[emoji]?.name}
|
||||
title={titleFor(emoji)}
|
||||
onClick={() => pick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
<EmojiGlyph value={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getCustomEmojiUrl } from '../api/customEmoji'
|
||||
import './ComposerAutocomplete.css'
|
||||
|
||||
export interface EmojiShortcodeMatch {
|
||||
shortcode: string
|
||||
// null for a custom emoji -- there's no unicode glyph to show, so the
|
||||
// row renders its uploaded image instead (see getCustomEmojiUrl below).
|
||||
glyph: string | null
|
||||
}
|
||||
|
||||
interface EmojiShortcodeAutocompleteProps {
|
||||
matches: EmojiShortcodeMatch[]
|
||||
activeIndex: number
|
||||
onPick: (shortcode: string) => void
|
||||
onHover: (index: number) => void
|
||||
}
|
||||
|
||||
export function EmojiShortcodeAutocomplete({
|
||||
matches,
|
||||
activeIndex,
|
||||
onPick,
|
||||
onHover,
|
||||
}: EmojiShortcodeAutocompleteProps) {
|
||||
return (
|
||||
<div className="composer-autocomplete" role="listbox">
|
||||
{matches.map((match, i) => (
|
||||
<button
|
||||
key={match.shortcode}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`composer-autocomplete-item${i === activeIndex ? ' composer-autocomplete-item-active' : ''}`}
|
||||
// Selecting must survive the textarea's blur (which would
|
||||
// otherwise fire first and could dismiss the dropdown) --
|
||||
// onMouseDown fires before blur, onClick fires after.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onPick(match.shortcode)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<span className="composer-autocomplete-emoji-glyph">
|
||||
{match.glyph ?? (
|
||||
<img
|
||||
src={getCustomEmojiUrl(match.shortcode)}
|
||||
alt=""
|
||||
className="composer-autocomplete-custom-emoji"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="composer-autocomplete-primary">:{match.shortcode}:</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Markdown from 'markdown-to-jsx'
|
||||
import { getRoomFileUrl } from '../api/rooms'
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||
import type { MessageFileInfo } from '../types'
|
||||
import { MARKDOWN_OPTIONS } from './MessageContent'
|
||||
import { createMarkdownOptions, preprocessMarkdown } from './MessageContent'
|
||||
import './FilePreviewModal.css'
|
||||
|
||||
export type PreviewKind = 'markdown' | 'text' | 'pdf'
|
||||
@@ -33,6 +33,12 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileUrl = getRoomFileUrl(roomId, file.id)
|
||||
// #21: subscript/superscript and heading-id support -- see MessageContent
|
||||
// for why this needs to run before the Markdown component sees the text.
|
||||
const markdownPreview = useMemo(
|
||||
() => (content !== null ? preprocessMarkdown(content) : null),
|
||||
[content],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -102,9 +108,11 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
|
||||
<div className={`file-preview-body${kind === 'pdf' ? ' file-preview-body-pdf' : ''}`}>
|
||||
{error && <p className="file-preview-error">{error}</p>}
|
||||
{!error && kind !== 'pdf' && content === null && <p className="file-preview-loading">Loading…</p>}
|
||||
{!error && content !== null && kind === 'markdown' && (
|
||||
{!error && kind === 'markdown' && markdownPreview && (
|
||||
<div className="message-text file-preview-markdown">
|
||||
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
|
||||
<Markdown options={createMarkdownOptions(markdownPreview.headingIds)}>
|
||||
{markdownPreview.text}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* #18: em-relative, deliberately -- renders correctly inline in message
|
||||
text, inside a reaction pill, and inside the emoji picker's grid without
|
||||
a separate override per context, since each of those already sets its
|
||||
own font-size and this just tracks it. Kept in this file (imported
|
||||
directly by MessageContent.tsx) rather than MessageList.css so it's
|
||||
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
|
||||
included, not just the message list.
|
||||
|
||||
#71: also multiplied by --emoji-scale, the manual "make emoji bigger"
|
||||
preference -- but that variable is only ever set on MessageContent's own
|
||||
wrapper div (inline style, scoped to that element and its descendants),
|
||||
never at :root, so var(..., 1) correctly falls back to a no-op multiplier
|
||||
everywhere else this class is reused (the picker's grid, reaction pills)
|
||||
instead of also inflating those and breaking their fixed-size layout. */
|
||||
.message-custom-emoji {
|
||||
height: calc(1.2em * var(--emoji-scale, 1));
|
||||
width: calc(1.2em * var(--emoji-scale, 1));
|
||||
object-fit: contain;
|
||||
vertical-align: -0.25em;
|
||||
}
|
||||
|
||||
/* #71: a raw unicode emoji wrapped by wrapEmojiGlyphs -- same --emoji-scale
|
||||
multiplier as the custom-emoji image above, so "make emoji bigger"
|
||||
applies uniformly regardless of which kind of emoji it is. */
|
||||
.inline-emoji {
|
||||
display: inline-block;
|
||||
font-size: calc(1em * var(--emoji-scale, 1));
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import Markdown from 'markdown-to-jsx'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getCustomEmojiUrl } from '../api/customEmoji'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
||||
import './MessageContent.css'
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string
|
||||
@@ -49,8 +53,11 @@ interface MarkdownLinkProps {
|
||||
// span instead of an actual anchor. highlightRoomReferences does the same
|
||||
// trick for #roomname, but a room reference *is* meant to be navigable, so
|
||||
// it becomes a real (client-side-routed) Link instead of an inert span.
|
||||
// Everything else renders as a real external link, same as before mentions
|
||||
// existed.
|
||||
// convertSubSuperscript (#21) reuses the identical trick for `~sub~`/`^sup^`
|
||||
// -- markdown-to-jsx has no plugin hook for new inline syntax, but a link is
|
||||
// something it already parses correctly, so `sub:`/`sup:` "URLs" are just
|
||||
// another carrier for meaning the parser was never told about. Everything
|
||||
// else renders as a real external link, same as before mentions existed.
|
||||
function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
||||
if (href?.startsWith('mention:')) {
|
||||
return <span className="message-mention">{children}</span>
|
||||
@@ -62,6 +69,31 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (href === 'sub:') {
|
||||
return <sub>{children}</sub>
|
||||
}
|
||||
if (href === 'sup:') {
|
||||
return <sup>{children}</sup>
|
||||
}
|
||||
if (href?.startsWith('emoji:')) {
|
||||
const shortcode = href.slice('emoji:'.length)
|
||||
return (
|
||||
<img
|
||||
src={getCustomEmojiUrl(shortcode)}
|
||||
alt={`:${shortcode}:`}
|
||||
title={`:${shortcode}:`}
|
||||
className="message-custom-emoji"
|
||||
/>
|
||||
)
|
||||
}
|
||||
// #71: a raw unicode emoji has no element of its own to size independently
|
||||
// of the surrounding text -- it's just characters in a string. Wrapping
|
||||
// each one individually (see wrapEmojiGlyphs below) gives it one, purely
|
||||
// so the emoji-size preference can scale it via CSS the same way it
|
||||
// already scales a custom emoji's <img>.
|
||||
if (href === 'glyph:') {
|
||||
return <span className="inline-emoji">{children}</span>
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
@@ -101,6 +133,137 @@ function convertShortcodes(text: string): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// #21: single tilde/caret delimiters, no spaces inside, and not doubled --
|
||||
// `~~text~~` is strikethrough (already natively supported) so a leading or
|
||||
// trailing extra `~` excludes the match, matching markdownguide.org's
|
||||
// extended syntax for both constructs. Converts a complete `~sub~`/`^sup^`
|
||||
// span to `[sub](sub:)`/`[sup](sup:)` -- see MarkdownLink's comment for why
|
||||
// a link is the carrier.
|
||||
const SUBSCRIPT_PATTERN = /(?<!~)~([^~\s]+)~(?!~)/g
|
||||
const SUPERSCRIPT_PATTERN = /\^([^^\s]+)\^/g
|
||||
|
||||
function convertSubSuperscript(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
let inFence = false
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence
|
||||
return line
|
||||
}
|
||||
if (inFence) return line
|
||||
return line
|
||||
.split(/(`+[^`]*`+)/g)
|
||||
.map((part, i) =>
|
||||
i % 2 === 0
|
||||
? part
|
||||
.replace(SUBSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sub:)`)
|
||||
.replace(SUPERSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sup:)`)
|
||||
: part,
|
||||
)
|
||||
.join('')
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// #21: markdown-to-jsx has no option for an explicit heading anchor --
|
||||
// every heading already gets an auto-generated slug from its own text
|
||||
// (useful for linking within a message), and `{#custom-id}` is meant to
|
||||
// *override* that slug, not add a second id next to it. There's no plugin
|
||||
// hook for new block syntax either, so this strips the marker from the
|
||||
// heading's own text (same fence-skipping convention as the functions
|
||||
// above) and remembers the association by that now-bare text -- the one
|
||||
// hook markdown-to-jsx *does* expose, `slugify` (see createMarkdownOptions
|
||||
// below), gets called with exactly that text, letting the requested id
|
||||
// stand in for the auto-generated one.
|
||||
const HEADING_ID_PATTERN = /^(#{1,6}\s+.*?)\s*\{#([a-zA-Z0-9_-]+)\}\s*$/
|
||||
|
||||
function extractHeadingIds(text: string): { text: string; headingIds: Map<string, string> } {
|
||||
const headingIds = new Map<string, string>()
|
||||
const lines = text.split('\n')
|
||||
let inFence = false
|
||||
const nextLines = lines.map((line) => {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence
|
||||
return line
|
||||
}
|
||||
if (inFence) return line
|
||||
const match = line.match(HEADING_ID_PATTERN)
|
||||
if (!match) return line
|
||||
const [, headingLine, customId] = match
|
||||
headingIds.set(headingLine.replace(/^#{1,6}\s+/, ''), customId)
|
||||
return headingLine
|
||||
})
|
||||
return { text: nextLines.join('\n'), headingIds }
|
||||
}
|
||||
|
||||
// #18: a *complete* `:name:` that survived convertShortcodes above (it only
|
||||
// replaces names it recognizes, so an unmatched one -- built-in or not --
|
||||
// passes through untouched) and matches a shortcode this install actually
|
||||
// has a custom emoji for. Turns it into `[:name:](emoji:name)`, the same
|
||||
// link-trick MarkdownLink's other branches use -- deliberately reusing the
|
||||
// exact fence/code-span-skip convention every other converter in this file
|
||||
// follows, for the same reason (a pasted `:some_key:` in code shouldn't
|
||||
// light up as an emoji any more than an unrelated one should).
|
||||
const CUSTOM_EMOJI_PATTERN = /:([a-z0-9_-]+):/g
|
||||
|
||||
function convertCustomEmojiShortcodes(text: string, shortcodes: Set<string>): string {
|
||||
if (shortcodes.size === 0) return text
|
||||
const lines = text.split('\n')
|
||||
let inFence = false
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence
|
||||
return line
|
||||
}
|
||||
if (inFence) return line
|
||||
return line
|
||||
.split(/(`+[^`]*`+)/g)
|
||||
.map((part, i) =>
|
||||
i % 2 === 0
|
||||
? part.replace(CUSTOM_EMOJI_PATTERN, (match, name) =>
|
||||
shortcodes.has(name) ? `[${match}](emoji:${name})` : match,
|
||||
)
|
||||
: part,
|
||||
)
|
||||
.join('')
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Reaction pills and the "recently used" emoji row don't go through the
|
||||
// markdown pipeline at all -- they render a single stored value directly.
|
||||
// A custom emoji's value there is its literal `:shortcode:` (see
|
||||
// backend's MessageReaction.emoji); this is the equivalent one-value
|
||||
// resolution for those spots, so a deleted-since-reacted-with custom
|
||||
// emoji degrades to plain `:shortcode:` text instead of a broken image.
|
||||
interface EmojiGlyphProps {
|
||||
value: string
|
||||
}
|
||||
|
||||
export function EmojiGlyph({ value }: EmojiGlyphProps) {
|
||||
const { byShortcode } = useCustomEmoji()
|
||||
const match = /^:([a-z0-9_-]+):$/.exec(value)
|
||||
const shortcode = match?.[1]
|
||||
if (shortcode && byShortcode.has(shortcode)) {
|
||||
return (
|
||||
<img
|
||||
src={getCustomEmojiUrl(shortcode)}
|
||||
alt={value}
|
||||
title={value}
|
||||
className="message-custom-emoji"
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Wrapped the same way wrapEmojiGlyphs wraps a raw emoji in message text
|
||||
// (see .inline-emoji), so a --emoji-scale set on an ancestor (the
|
||||
// reaction pill's own span in MessageList.tsx) scales this the same way
|
||||
// it scales the .message-custom-emoji img above -- and falls back to a
|
||||
// no-op 1x everywhere else (the picker) with no --emoji-scale set at all.
|
||||
return <span className="inline-emoji">{value}</span>
|
||||
}
|
||||
|
||||
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
||||
|
||||
// Turns a validated @username into `[@username](mention:username)` --
|
||||
@@ -194,9 +357,13 @@ function preserveLineBreaks(text: string): string {
|
||||
}
|
||||
|
||||
// Shared with FilePreviewModal so both render paths carry the exact same
|
||||
// XSS mitigation (disableParsingRawHTML) -- duplicating this object would
|
||||
// risk the two drifting out of sync if one gets edited later.
|
||||
export const MARKDOWN_OPTIONS = {
|
||||
// XSS mitigation (disableParsingRawHTML) and #21's heading-id/sub/superscript
|
||||
// support -- duplicating this would risk the two drifting out of sync if
|
||||
// one gets edited later. A function, not a plain constant, since `slugify`
|
||||
// needs each render's own headingIds map (see extractHeadingIds above) --
|
||||
// there's no per-render state to close over in a module-level object.
|
||||
export function createMarkdownOptions(headingIds: Map<string, string>) {
|
||||
return {
|
||||
// The core XSS mitigation: raw HTML in message content is escaped
|
||||
// and printed literally instead of being parsed into elements.
|
||||
disableParsingRawHTML: true,
|
||||
@@ -204,10 +371,79 @@ export const MARKDOWN_OPTIONS = {
|
||||
a: { component: MarkdownLink },
|
||||
img: { component: MarkdownImageLink },
|
||||
},
|
||||
slugify: (input: string, defaultFn: (input: string) => string) =>
|
||||
headingIds.get(input) ?? defaultFn(input),
|
||||
}
|
||||
}
|
||||
|
||||
// #21: preprocessing shared by MessageContent and FilePreviewModal --
|
||||
// subscript/superscript and heading-id overrides are general markdown
|
||||
// features, not chat-specific like mentions/shortcodes/room-references, so
|
||||
// a plain file preview gets them too.
|
||||
export function preprocessMarkdown(text: string): { text: string; headingIds: Map<string, string> } {
|
||||
return extractHeadingIds(convertSubSuperscript(text))
|
||||
}
|
||||
|
||||
// #71: gives every individual unicode emoji its own element (see
|
||||
// MarkdownLink's `glyph:` branch) purely so the emoji-size preference can
|
||||
// scale it independently of the surrounding text -- a raw emoji is just
|
||||
// characters in a string otherwise, with nothing CSS can address on its
|
||||
// own. Runs after convertShortcodes so a built-in `:name:` that just
|
||||
// became a glyph is wrapped too ("all emoji", not just ones typed as
|
||||
// literal unicode); same fence/code-span skip convention as every other
|
||||
// converter here.
|
||||
const EMOJI_GLYPH_PATTERN = /\p{Extended_Pictographic}(?:\p{Emoji_Modifier}|\u200D\p{Extended_Pictographic}|\uFE0F)*/gu
|
||||
|
||||
function wrapEmojiGlyphs(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
let inFence = false
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence
|
||||
return line
|
||||
}
|
||||
if (inFence) return line
|
||||
return line
|
||||
.split(/(`+[^`]*`+)/g)
|
||||
.map((part, i) => (i % 2 === 0 ? part.replace(EMOJI_GLYPH_PATTERN, (match) => `[${match}](glyph:)`) : part))
|
||||
.join('')
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Exported so MessageList's reaction pills can apply the same viewer
|
||||
// preference to their own EmojiGlyph -- reactions render outside the
|
||||
// markdown pipeline entirely (see EmojiGlyph's own comment above), so they
|
||||
// need this looked up independently rather than inheriting --emoji-scale
|
||||
// from this component's wrapper div.
|
||||
export const EMOJI_SCALE_MULTIPLIER: Record<string, number> = {
|
||||
small: 0.8,
|
||||
normal: 1,
|
||||
large: 1.5,
|
||||
xlarge: 2,
|
||||
}
|
||||
|
||||
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
|
||||
const { user } = useAuth()
|
||||
const { byShortcode } = useCustomEmoji()
|
||||
const customShortcodes = new Set(byShortcode.keys())
|
||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
||||
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withRoomRefs))}</Markdown>
|
||||
const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
|
||||
const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
|
||||
const { text, headingIds } = preprocessMarkdown(withEmojiGlyphs)
|
||||
// #71: scoped to this element (not a :root-level variable) so it only
|
||||
// ever affects emoji rendered in message text -- not the same
|
||||
// .message-custom-emoji/EmojiGlyph markup reused by the emoji picker's
|
||||
// grid, where a bigger image would just break its fixed-size layout
|
||||
// instead of doing anything useful. Reaction pills DO scale too, but via
|
||||
// their own inline --emoji-scale in MessageList.tsx, not by inheriting
|
||||
// this one -- a pill isn't a descendant of this wrapper div.
|
||||
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
|
||||
return (
|
||||
<div style={{ '--emoji-scale': emojiScale } as CSSProperties}>
|
||||
<Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,8 +53,13 @@
|
||||
|
||||
.message-image {
|
||||
display: block;
|
||||
max-width: min(320px, 100%);
|
||||
max-height: 240px;
|
||||
/* #71: rem, not px -- scales with the text-size setting (see
|
||||
lib/theme.ts's applyTextScale), same as every other size in this app.
|
||||
min(...) still caps against the viewport in absolute px, since a
|
||||
percentage-of-viewport constraint isn't something a root font-size
|
||||
change should affect. */
|
||||
max-width: min(20rem, 100%);
|
||||
max-height: 15rem;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--ds-border);
|
||||
@@ -62,6 +67,44 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-video-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: min(20rem, 100%);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 15rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--ds-border);
|
||||
background: var(--ds-void);
|
||||
}
|
||||
|
||||
.message-video-expand {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.message-video-wrap:hover .message-video-expand,
|
||||
.message-video-expand:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-file-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -73,7 +116,7 @@
|
||||
margin-bottom: 4px;
|
||||
color: var(--ds-text);
|
||||
text-decoration: none;
|
||||
max-width: min(320px, 100%);
|
||||
max-width: min(20rem, 100%);
|
||||
}
|
||||
|
||||
.message-file-attachment:hover {
|
||||
@@ -304,6 +347,25 @@
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.message-delete-link {
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
color: var(--ds-muted);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.message-delete-link:hover {
|
||||
color: var(--ds-danger);
|
||||
border-color: var(--ds-danger);
|
||||
}
|
||||
|
||||
.message-deleted-text {
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.message-reaction-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -8,8 +9,9 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
|
||||
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
import { LinkPreviewCard } from './LinkPreviewCard'
|
||||
import { MessageContent } from './MessageContent'
|
||||
import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import { VideoLightbox } from './VideoLightbox'
|
||||
import './MessageList.css'
|
||||
|
||||
export function FileAttachmentIcon() {
|
||||
@@ -60,6 +62,58 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
|
||||
)
|
||||
}
|
||||
|
||||
// #65: kept in sync with backend/app/storage.py's INLINE_SAFE_VIDEO_
|
||||
// CONTENT_TYPES -- the server only ever serves these particular content
|
||||
// types without a forced download, so a <video> tag pointed at anything
|
||||
// else would just show a broken player instead of playing (or, worse,
|
||||
// trigger a download the moment the browser tries to fetch it).
|
||||
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 {
|
||||
file: MessageFileInfo
|
||||
roomId: string
|
||||
onExpand: () => void
|
||||
}
|
||||
|
||||
// Plays inline via the browser's own <video controls> (no custom overlay
|
||||
// needed for play/pause/volume/seek) -- the one thing it doesn't give a
|
||||
// small inline player is an obvious way to go bigger. The expand button
|
||||
// opens a VideoLightbox (matching how images already expand) rather than
|
||||
// calling the Fullscreen API directly on the video element -- that API is
|
||||
// unreliable in embedded/packaged contexts (e.g. the Electron desktop
|
||||
// build), where a rejected requestFullscreen() promise just does nothing
|
||||
// with no visible error.
|
||||
function VideoAttachment({ file, roomId, onExpand }: VideoAttachmentProps) {
|
||||
return (
|
||||
<div className="message-video-wrap">
|
||||
<video src={getRoomFileUrl(roomId, file.id)} controls className="message-video" />
|
||||
<button
|
||||
type="button"
|
||||
className="message-video-expand"
|
||||
onClick={onExpand}
|
||||
aria-label="Expand video"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 3H3v4M13 3h4v4M3 13v4h4M17 13v4h-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
roomId: string
|
||||
messages: (Message | ChatMessageEnvelope)[]
|
||||
@@ -67,10 +121,23 @@ interface MessageListProps {
|
||||
myRooms: Map<string, string>
|
||||
onEdit: (messageId: string, content: string) => void
|
||||
onReact: (messageId: string, emoji: string) => void
|
||||
onDelete: (messageId: string) => void
|
||||
}
|
||||
|
||||
export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) {
|
||||
export function MessageList({
|
||||
roomId,
|
||||
messages,
|
||||
members,
|
||||
myRooms,
|
||||
onEdit,
|
||||
onReact,
|
||||
onDelete,
|
||||
}: MessageListProps) {
|
||||
const { user } = useAuth()
|
||||
// #71: same viewer preference MessageContent applies to in-text emoji,
|
||||
// looked up separately here since a reaction pill isn't a descendant of
|
||||
// that component's wrapper div (see EmojiGlyph's own comment).
|
||||
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
// Whether the view should be pinned to the latest message -- true right
|
||||
@@ -81,6 +148,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
||||
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
|
||||
const [reactingId, setReactingId] = useState<string | null>(null)
|
||||
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
|
||||
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
|
||||
@@ -133,6 +201,14 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
function handleDelete(messageId: string) {
|
||||
// Matches the confirm() pattern already used for other destructive
|
||||
// actions in this app (RoomInfoPanel's leave/delete-room,
|
||||
// ProfileModal's delete-theme) rather than a custom dialog.
|
||||
if (!confirm("Delete this message? This can't be undone.")) return
|
||||
onDelete(messageId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list" ref={containerRef}>
|
||||
{messages.map((msg, i) => {
|
||||
@@ -141,9 +217,15 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
// Mattermost-style grouping: every message shows who sent it, but
|
||||
// consecutive messages from the same sender only repeat the
|
||||
// avatar/name/timestamp header on the first one in the run --
|
||||
// applies uniformly, including to your own messages.
|
||||
const isGroupStart = !prev || prev.user_id !== msg.user_id
|
||||
// applies uniformly, including to your own messages. Also breaks on
|
||||
// a long gap (see GROUP_BREAK_MS) so a message sent well after the
|
||||
// rest of the run still gets its own visible timestamp.
|
||||
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 deleted = !!msg.deleted_at
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
|
||||
@@ -166,7 +248,11 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
{deleted ? (
|
||||
<div className="message-text message-deleted-text">
|
||||
<em>This message was deleted</em>
|
||||
</div>
|
||||
) : editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={Math.min(10, draft.split('\n').length)}
|
||||
@@ -193,7 +279,19 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
onClick={() => setLightboxSrc(getRoomImageUrl(roomId, msg.image_id!))}
|
||||
/>
|
||||
)}
|
||||
{msg.file && (
|
||||
{msg.file && PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
|
||||
<VideoAttachment
|
||||
file={msg.file}
|
||||
roomId={roomId}
|
||||
onExpand={() =>
|
||||
setVideoLightbox({
|
||||
src: getRoomFileUrl(roomId, msg.file!.id),
|
||||
filename: msg.file!.filename,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{msg.file && !PLAYABLE_VIDEO_CONTENT_TYPES.has(msg.file.content_type) && (
|
||||
<FileAttachmentCard
|
||||
file={msg.file}
|
||||
roomId={roomId}
|
||||
@@ -221,7 +319,12 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
title={r.user_ids.map(displayNameForUserId).join(', ')}
|
||||
onClick={() => onReact(msg.id, r.emoji)}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
{/* No .inline-emoji here -- EmojiGlyph's own fallback branch
|
||||
already applies it, and stacking it here too would double
|
||||
the font-size multiplication for a custom-emoji img. */}
|
||||
<span style={{ '--emoji-scale': emojiScale } as CSSProperties}>
|
||||
<EmojiGlyph value={r.emoji} />
|
||||
</span>
|
||||
<span>{r.count}</span>
|
||||
</button>
|
||||
)
|
||||
@@ -231,7 +334,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
{!editing && !deleted && (
|
||||
<div className="message-row-actions">
|
||||
<div className="message-reaction-wrap">
|
||||
<button
|
||||
@@ -277,6 +380,16 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{mine && (
|
||||
<button
|
||||
type="button"
|
||||
className="message-delete-link"
|
||||
onClick={() => handleDelete(msg.id)}
|
||||
aria-label="Delete message"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -284,6 +397,13 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
|
||||
{videoLightbox && (
|
||||
<VideoLightbox
|
||||
src={videoLightbox.src}
|
||||
filename={videoLightbox.filename}
|
||||
onClose={() => setVideoLightbox(null)}
|
||||
/>
|
||||
)}
|
||||
{previewFile && (
|
||||
<FilePreviewModal
|
||||
roomId={roomId}
|
||||
|
||||
@@ -230,6 +230,45 @@
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.text-scale-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.text-scale-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-scale-option:hover {
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.text-scale-option-selected {
|
||||
border-color: var(--ds-accent);
|
||||
box-shadow: 0 0 0 1px var(--ds-accent);
|
||||
}
|
||||
|
||||
.text-scale-option-preview {
|
||||
font-weight: 700;
|
||||
color: var(--ds-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-scale-option-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.theme-swatch-preview-new {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
@@ -389,6 +428,28 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modal-list-row-button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.modal-list-row-button:hover:not(:disabled) {
|
||||
background: var(--ds-surface-2);
|
||||
}
|
||||
|
||||
.modal-list-row-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-list-row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -407,6 +468,36 @@
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.custom-emoji-upload-preview {
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
margin-top: var(--sp-2);
|
||||
background: var(--ds-surface-2);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--ds-border);
|
||||
}
|
||||
|
||||
.modal-list-row-action {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ds-danger);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.modal-list-row-action:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-list-row-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.profile-modal-avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user