Private
Public Access
Compare commits
23
Commits
v1.1.0
..
v2026.10.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff7f2ec0e | ||
|
|
62b5eec2fb | ||
|
|
eb30dc1753 | ||
|
|
6975ed4dc0 | ||
|
|
c54c1bf97d | ||
|
|
3349cadea2 | ||
|
|
047f47d524 | ||
|
|
850d9ec0fa | ||
|
|
86584660fb | ||
|
|
6f67f36578 | ||
|
|
b1c9a85ab3 | ||
|
|
62a25c6278 | ||
|
|
7a84d2f09f | ||
|
|
d74527a29c | ||
|
|
f0e4c76ffd | ||
|
|
3be8d9d731 | ||
|
|
30e63ffa83 | ||
|
|
019e10ac5c | ||
|
|
6e889b8ea4 | ||
|
|
b4a104f8c6 | ||
|
|
03cc16f236 | ||
|
|
520b971247 | ||
|
|
cd6296d079 |
No files matched your search
@@ -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
|
## Editors / OS
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
## Test / coverage
|
## Test / coverage
|
||||||
|
|||||||
@@ -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.*
|
||||||
+37
-15
@@ -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
|
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
|
||||||
```
|
```
|
||||||
|
|
||||||
Add that public key as a **read-only deploy key** on the Gitea repo
|
This project is hosted at
|
||||||
(Settings → Deploy Keys), then:
|
**[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
|
```bash
|
||||||
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts
|
sudo -u ds-chat ssh-keyscan github.com >> /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 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
|
(Deploying from your own fork instead? Substitute its clone URL — the same
|
||||||
`ssh-keyscan -p <port>` accordingly.)
|
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
|
**Alternative: a personal/deployment-user access token instead of a deploy
|
||||||
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
|
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
|
||||||
and clone over HTTPS with the token embedded in the URL:
|
and clone over HTTPS with the token embedded in the URL:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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
|
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
|
setup needed. Fine as long as the token is scoped to read-only access on
|
||||||
just this repo.
|
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
|
```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
|
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
|
`/ws`) whenever that directory exists — that's what lets Nginx Proxy
|
||||||
Manager forward the whole domain to one port with no custom path routing.
|
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
|
```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'
|
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
|
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`,
|
Fetches tags and checks out whichever one sorts newest (`git tag
|
||||||
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to
|
--sort=-creatordate`) — deliberately not the default branch's tip, so
|
||||||
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops
|
running this between releases is a safe no-op rather than pulling in
|
||||||
before restarting anything if an earlier step — most importantly a failed
|
whatever's mid-flight on `main`. Then reinstalls backend deps, runs
|
||||||
migration — errors out, so a bad deploy doesn't take down the previously
|
`alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
|
||||||
working one.
|
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
|
Active users get disconnected for a few seconds during the restart and
|
||||||
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
|
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
|
||||||
|
|||||||
@@ -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
|
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.
|
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.>
|
DS Chat, a self-hosted, real-time team chat service.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) 2026 Keith Smith
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
|||||||
+12
-1
@@ -68,9 +68,20 @@ new message, or if you message them again yourself.
|
|||||||
Type in the message box at the bottom of a room and press **Enter** to
|
Type in the message box at the bottom of a room and press **Enter** to
|
||||||
send. Use **Shift+Enter** to add a line break without sending.
|
send. Use **Shift+Enter** to add a line break without sending.
|
||||||
|
|
||||||
|
Each message shows when it was sent, in your local time. Messages from today
|
||||||
|
show just the time; older ones add the day — "Yesterday 4:04 PM", a weekday
|
||||||
|
like "Mon 4:04 PM" for the past week, then the date ("Oct 2, 4:04 PM", with
|
||||||
|
the year if it's not this one). Hover a time to see the full date.
|
||||||
|
|
||||||
## Formatting
|
## Formatting
|
||||||
|
|
||||||
Messages support Markdown:
|
Messages support Markdown. Prefer buttons to typing the syntax? Click the
|
||||||
|
**Aa** button next to the emoji button in the composer to open a formatting
|
||||||
|
toolbar — it inserts the markdown below into your message for you (select some
|
||||||
|
text first to wrap it, or click a button again to take the formatting back
|
||||||
|
off). It stays collapsed until you open it, and remembers your choice.
|
||||||
|
|
||||||
|
Or type it yourself:
|
||||||
|
|
||||||
- `**bold**`, `*italic*`, `~~strikethrough~~`
|
- `**bold**`, `*italic*`, `~~strikethrough~~`
|
||||||
- `` `inline code` `` and fenced code blocks (three backticks)
|
- `` `inline code` `` and fenced code blocks (three backticks)
|
||||||
|
|||||||
+7
-4
@@ -822,10 +822,13 @@ preview card fetched from that page's Open Graph tags (`og:title`,
|
|||||||
`Content-Type` is `text/html`.
|
`Content-Type` is `text/html`.
|
||||||
- **Cached by URL, not by message** (`link_previews` table, unique on
|
- **Cached by URL, not by message** (`link_previews` table, unique on
|
||||||
`url`) — a URL posted by five different people in five different rooms
|
`url`) — a URL posted by five different people in five different rooms
|
||||||
fetches once. A row also gets written on a *failed* fetch
|
within the same short window fetches once. A row also gets written on a
|
||||||
(`fetch_failed=True`) so a URL that genuinely doesn't unfurl (SSRF
|
*failed* fetch (`fetch_failed=True`) so a URL that genuinely doesn't
|
||||||
rejection, timeout, no usable title) isn't re-attempted on every message
|
unfurl (SSRF rejection, timeout, no usable title) isn't re-attempted on
|
||||||
that references it; both kinds expire after 7 days (`_CACHE_TTL`).
|
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
|
- Parsed with stdlib `html.parser.HTMLParser`, not a new dependency — only
|
||||||
meta-tag scraping is needed, not general HTML parsing.
|
meta-tag scraping is needed, not general HTML parsing.
|
||||||
- Editing a message re-extracts the URL; if it changed or was removed, the
|
- Editing a message re-extracts the URL; if it changed or was removed, the
|
||||||
|
|||||||
@@ -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,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 ###
|
||||||
+6
-2
@@ -58,8 +58,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
# ASGITransport, used by the plain REST test fixtures -- only
|
# ASGITransport, used by the plain REST test fixtures -- only
|
||||||
# TestClient's websocket_connect-based tests actually run lifespan).
|
# TestClient's websocket_connect-based tests actually run lifespan).
|
||||||
# The background listener task genuinely needs a running event loop
|
# The background listener task genuinely needs a running event loop
|
||||||
# though, so that part stays here.
|
# though, so that part stays here. run_forever() (not listen() directly)
|
||||||
listener_task = asyncio.create_task(app.state.broadcaster.listen())
|
# -- #76: a bare listen() here died permanently and silently the moment
|
||||||
|
# its Redis connection ever dropped (e.g. Redis itself restarting),
|
||||||
|
# with nothing to notice or reconnect; run_forever() supervises it and
|
||||||
|
# restarts on failure instead.
|
||||||
|
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ class User(Base):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(50))
|
display_name: Mapped[str | None] = mapped_column(String(50))
|
||||||
theme: Mapped[str | None] = mapped_column(String(20))
|
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
|
# Only meaningful when theme == "custom" -- which of this user's saved
|
||||||
# CustomTheme rows (app/models/custom_theme.py) is currently active.
|
# CustomTheme rows (app/models/custom_theme.py) is currently active.
|
||||||
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
|
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ async def update_profile(
|
|||||||
current_user.display_name = display_name or None
|
current_user.display_name = display_name or None
|
||||||
if "theme" in updates:
|
if "theme" in updates:
|
||||||
current_user.theme = updates["theme"]
|
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:
|
if "appear_offline" in updates:
|
||||||
current_user.appear_offline = updates["appear_offline"]
|
current_user.appear_offline = updates["appear_offline"]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ from app.schemas.webhook import (
|
|||||||
WebhookIncomingRead,
|
WebhookIncomingRead,
|
||||||
)
|
)
|
||||||
from app.services.link_preview_service import get_link_previews_for_urls
|
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 (
|
from app.services.message_service import (
|
||||||
|
create_message,
|
||||||
get_reactions_for_messages,
|
get_reactions_for_messages,
|
||||||
list_recent_messages,
|
list_recent_messages,
|
||||||
list_room_attachments,
|
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.upload_settings_service import format_mb, get_upload_settings
|
||||||
from app.services.room_service import (
|
from app.services.room_service import (
|
||||||
AlreadyMemberError,
|
AlreadyMemberError,
|
||||||
@@ -655,7 +657,37 @@ async def add_member_endpoint(
|
|||||||
raise HTTPException(status_code=404, detail="No user with that ID")
|
raise HTTPException(status_code=404, detail="No user with that ID")
|
||||||
except AlreadyMemberError:
|
except AlreadyMemberError:
|
||||||
raise HTTPException(status_code=409, detail="That user is already a member")
|
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)
|
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])
|
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
||||||
return RoomMemberRead(
|
return RoomMemberRead(
|
||||||
user_id=membership.user_id,
|
user_id=membership.user_id,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
|
|||||||
is_site_admin: bool
|
is_site_admin: bool
|
||||||
display_name: str | None
|
display_name: str | None
|
||||||
theme: 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
|
# Resolved, not just an id -- the frontend needs the actual palette to
|
||||||
# paint on load without a second round trip (see lib/theme.ts).
|
# paint on load without a second round trip (see lib/theme.ts).
|
||||||
active_custom_theme: CustomThemeRead | None
|
active_custom_theme: CustomThemeRead | None
|
||||||
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
|
|||||||
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
|
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
|
||||||
# bare theme name with nothing to point it at.
|
# bare theme name with nothing to point it at.
|
||||||
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
|
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)
|
appear_offline: bool | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,18 @@ _TRAILING_PUNCTUATION = ".,;:!?)'\">"
|
|||||||
_FETCH_TIMEOUT_SECONDS = 5.0
|
_FETCH_TIMEOUT_SECONDS = 5.0
|
||||||
_MAX_BYTES = 512 * 1024
|
_MAX_BYTES = 512 * 1024
|
||||||
_MAX_REDIRECTS = 3
|
_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"
|
_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:
|
def extract_first_url(content: str | None) -> str | None:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from app.ws.connection_manager import ConnectionManager
|
from app.ws.connection_manager import ConnectionManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ROOM_CHANNEL_PREFIX = "room:"
|
ROOM_CHANNEL_PREFIX = "room:"
|
||||||
USER_CHANNEL_PREFIX = "user:"
|
USER_CHANNEL_PREFIX = "user:"
|
||||||
|
|
||||||
|
# #76: a run shorter than this before dying doesn't count as "stable" for
|
||||||
|
# resetting the backoff in run_forever() below -- long enough that a real,
|
||||||
|
# working connection has clearly settled in, short enough not to leave a
|
||||||
|
# just-recovered listener exposed to a full backoff climb if Redis blips
|
||||||
|
# again shortly after reconnecting.
|
||||||
|
STABLE_RUN_SECONDS = 60
|
||||||
|
MAX_BACKOFF_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class Broadcaster:
|
class Broadcaster:
|
||||||
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
|
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
|
||||||
@@ -36,6 +49,43 @@ class Broadcaster:
|
|||||||
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||||
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
|
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
|
||||||
|
|
||||||
|
async def run_forever(self) -> None:
|
||||||
|
"""Supervises listen(), restarting it if it ever dies.
|
||||||
|
|
||||||
|
#76's real root cause, found after the per-message try/except below
|
||||||
|
turned out not to be enough: this is the *one* pubsub listener for
|
||||||
|
the whole process (see main.py's lifespan), created once at startup
|
||||||
|
with nothing watching over it afterward. A connection-level failure
|
||||||
|
-- Redis restarting, a network blip between the app and Redis --
|
||||||
|
raises from the `async for` iteration in listen() itself, outside
|
||||||
|
that per-message guard, and previously killed this task permanently
|
||||||
|
and silently: HTTP/REST and login kept working fine (neither
|
||||||
|
touches this), but live message delivery was gone for every room
|
||||||
|
and every user on this instance until the whole process was
|
||||||
|
restarted. Confirmed live in production: Redis got restarted by an
|
||||||
|
OS update, and `PUBSUB NUMPAT` against it afterward showed zero
|
||||||
|
pattern subscriptions across all 4 gunicorn workers -- every one of
|
||||||
|
them had silently lost its listener with nothing to bring it back.
|
||||||
|
"""
|
||||||
|
backoff_seconds = 1.0
|
||||||
|
while True:
|
||||||
|
started_at = asyncio.get_event_loop().time()
|
||||||
|
try:
|
||||||
|
await self.listen()
|
||||||
|
logger.warning("Broadcaster.listen() exited without error -- restarting")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.error("Broadcaster.listen() failed -- restarting", exc_info=True)
|
||||||
|
ran_for = asyncio.get_event_loop().time() - started_at
|
||||||
|
# Sleep at the *current* backoff first, then grow it for next
|
||||||
|
# time -- computing the next value before sleeping would skip
|
||||||
|
# straight past the first (shortest) retry delay.
|
||||||
|
await asyncio.sleep(backoff_seconds)
|
||||||
|
backoff_seconds = (
|
||||||
|
1.0 if ran_for > STABLE_RUN_SECONDS else min(backoff_seconds * 2, MAX_BACKOFF_SECONDS)
|
||||||
|
)
|
||||||
|
|
||||||
async def listen(self) -> None:
|
async def listen(self) -> None:
|
||||||
pubsub = self._redis.pubsub()
|
pubsub = self._redis.pubsub()
|
||||||
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||||
@@ -43,14 +93,37 @@ class Broadcaster:
|
|||||||
async for message in pubsub.listen():
|
async for message in pubsub.listen():
|
||||||
if message["type"] != "pmessage":
|
if message["type"] != "pmessage":
|
||||||
continue
|
continue
|
||||||
channel = message["channel"]
|
# #76: this is the one pubsub listener for the whole process
|
||||||
payload = json.loads(message["data"])
|
# (see main.py's lifespan) -- ConnectionManager.broadcast/
|
||||||
if channel.startswith(ROOM_CHANNEL_PREFIX):
|
# send_to_user already guard against one dead socket taking
|
||||||
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
# the rest of a single fan-out down, but this belt-and-
|
||||||
await self._manager.broadcast(room_id, payload)
|
# suspenders catch is for anything else unexpected (a
|
||||||
elif channel.startswith(USER_CHANNEL_PREFIX):
|
# malformed payload, e.g.) doing the same. An uncaught
|
||||||
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
|
# exception here previously meant *this whole async for*
|
||||||
await self._manager.send_to_user(user_id, payload)
|
# loop died silently -- no more live delivery to any room on
|
||||||
|
# this instance, for anyone, until the process restarted.
|
||||||
|
try:
|
||||||
|
channel = message["channel"]
|
||||||
|
payload = json.loads(message["data"])
|
||||||
|
if channel.startswith(ROOM_CHANNEL_PREFIX):
|
||||||
|
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
||||||
|
await self._manager.broadcast(room_id, payload)
|
||||||
|
elif channel.startswith(USER_CHANNEL_PREFIX):
|
||||||
|
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
|
||||||
|
await self._manager.send_to_user(user_id, payload)
|
||||||
|
except Exception:
|
||||||
|
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
# #76: reached via a connection that's already dead just as
|
||||||
await pubsub.aclose()
|
# often as a clean shutdown (that's the whole failure mode
|
||||||
|
# run_forever() now recovers from) -- punsubscribe/aclose over
|
||||||
|
# a broken connection would themselves raise, masking whatever
|
||||||
|
# actually killed the loop above and, since run_forever() only
|
||||||
|
# starts backing off *after* listen() returns/raises, delaying
|
||||||
|
# the reconnect for no benefit (a fresh pubsub object is about
|
||||||
|
# to be created on retry regardless of whether this cleanup
|
||||||
|
# succeeded).
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await pubsub.aclose()
|
||||||
+33
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||||
@@ -31,6 +32,18 @@ router = APIRouter(tags=["ws"])
|
|||||||
|
|
||||||
WS_UNAUTHENTICATED = 4401
|
WS_UNAUTHENTICATED = 4401
|
||||||
|
|
||||||
|
# #76: comfortably under the ~60s idle-connection timeout common to reverse
|
||||||
|
# proxies/load balancers sitting in front of this endpoint, and short enough
|
||||||
|
# that the frontend's own zombie-detection watchdog (useChatSocket.ts) can
|
||||||
|
# notice a dead connection and reconnect well within a session -- a socket
|
||||||
|
# can go silently dead (no close frame at all, e.g. a laptop sleeping or a
|
||||||
|
# NAT dropping an idle mapping) without this, and no live message reaches it
|
||||||
|
# again until *something* else finally triggers a close, which used to be
|
||||||
|
# "switch rooms and back" purely by accident (that remounts the message
|
||||||
|
# list, which refetches history over plain REST -- unrelated to the socket
|
||||||
|
# itself actually recovering).
|
||||||
|
WS_PING_INTERVAL_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class ClientEnvelope(BaseModel):
|
class ClientEnvelope(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
@@ -124,7 +137,18 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await websocket.receive_json()
|
try:
|
||||||
|
raw = await asyncio.wait_for(
|
||||||
|
websocket.receive_json(), timeout=WS_PING_INTERVAL_SECONDS
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Nothing received in a while -- ping rather than assume the
|
||||||
|
# connection's still good. A genuinely dead socket fails this
|
||||||
|
# send (raising, caught below same as any other disconnect);
|
||||||
|
# a merely quiet one just gets proded and the loop continues
|
||||||
|
# waiting.
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
envelope = ClientEnvelope.model_validate(raw)
|
envelope = ClientEnvelope.model_validate(raw)
|
||||||
@@ -146,6 +170,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
joined_rooms.add(envelope.room_id)
|
joined_rooms.add(envelope.room_id)
|
||||||
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
||||||
|
|
||||||
|
elif envelope.type == "pong":
|
||||||
|
# #76: the client's reply to our "ping" above -- nothing
|
||||||
|
# to do with it. Its only purpose is resetting the
|
||||||
|
# frontend's own liveness watchdog; the timeout on the
|
||||||
|
# receive_json() above already gets reset by receiving
|
||||||
|
# any frame at all, this one included.
|
||||||
|
pass
|
||||||
|
|
||||||
elif envelope.type == "leave":
|
elif envelope.type == "leave":
|
||||||
if envelope.room_id is None:
|
if envelope.room_id is None:
|
||||||
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
"""Local, single-process WebSocket socket registry.
|
"""Local, single-process WebSocket socket registry.
|
||||||
@@ -43,8 +46,22 @@ class ConnectionManager:
|
|||||||
|
|
||||||
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||||
for websocket in list(self._rooms.get(room_id, ())):
|
for websocket in list(self._rooms.get(room_id, ())):
|
||||||
await websocket.send_json(payload)
|
# #76: one dead/racing socket (e.g. its own disconnect cleanup
|
||||||
|
# hasn't finished removing it from _rooms yet) must not take the
|
||||||
|
# rest of this broadcast down with it -- this runs inside
|
||||||
|
# Broadcaster.listen()'s single, never-restarted per-process
|
||||||
|
# pubsub loop, so an unhandled exception here previously meant
|
||||||
|
# one bad socket silently killed *live delivery for every room
|
||||||
|
# on this instance*, not just the one socket, until the process
|
||||||
|
# was restarted.
|
||||||
|
try:
|
||||||
|
await websocket.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Dropping dead socket during broadcast to room %s", room_id, exc_info=True)
|
||||||
|
|
||||||
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||||
for websocket in list(self._users.get(user_id, ())):
|
for websocket in list(self._users.get(user_id, ())):
|
||||||
await websocket.send_json(payload)
|
try:
|
||||||
|
await websocket.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Dropping dead socket sending to user %s", user_id, exc_info=True)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ds-chat"
|
name = "ds-chat"
|
||||||
version = "1.1.0"
|
version = "2026.10.9"
|
||||||
description = "DS Chat backend service"
|
description = "DS Chat backend service"
|
||||||
license = { text = "AGPL-3.0-or-later" }
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.ws.broadcaster import Broadcaster
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_forever_restarts_after_listen_raises(monkeypatch):
|
||||||
|
# #76: listen() dying (a dropped Redis connection, most commonly -- e.g.
|
||||||
|
# Redis itself restarting) must not permanently kill live delivery for
|
||||||
|
# the whole process. run_forever() has to notice and restart it, not
|
||||||
|
# just let the background task disappear silently while everything else
|
||||||
|
# (login, REST endpoints) keeps working fine.
|
||||||
|
broadcaster = Broadcaster(redis=None, manager=None)
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def fake_listen():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls < 3:
|
||||||
|
raise ConnectionError("redis went away")
|
||||||
|
# Stop the loop once the retry behavior below is proven, rather
|
||||||
|
# than looping forever in a test.
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
|
monkeypatch.setattr(broadcaster, "listen", fake_listen)
|
||||||
|
sleep_calls = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds):
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await broadcaster.run_forever()
|
||||||
|
|
||||||
|
assert calls == 3
|
||||||
|
# Slept once after each of the first two failures (exponential:
|
||||||
|
# 1s, then 2s) -- not after the third, which is the CancelledError
|
||||||
|
# that stops the loop before reaching the sleep.
|
||||||
|
assert sleep_calls == [1.0, 2.0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_forever_does_not_swallow_cancellation(monkeypatch):
|
||||||
|
# A genuine shutdown (the app's own lifespan cancels this task) must
|
||||||
|
# propagate immediately, not get treated as just another failure to
|
||||||
|
# retry after a backoff.
|
||||||
|
broadcaster = Broadcaster(redis=None, manager=None)
|
||||||
|
|
||||||
|
async def fake_listen():
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
|
monkeypatch.setattr(broadcaster, "listen", fake_listen)
|
||||||
|
|
||||||
|
async def fail_if_called(seconds):
|
||||||
|
raise AssertionError("should not sleep/retry on cancellation")
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "sleep", fail_if_called)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await broadcaster.run_forever()
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest_asyncio
|
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.database import engine as _link_preview_engine
|
||||||
|
from app.models import LinkPreview
|
||||||
from app.schemas.user import UserCreate
|
from app.schemas.user import UserCreate
|
||||||
from app.services.auth_service import register_user
|
from app.services.auth_service import register_user
|
||||||
from app.services.link_preview_service import extract_first_url
|
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()
|
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||||
assert len(history) == 2
|
assert len(history) == 2
|
||||||
assert all(m["link_preview"]["title"] == "Example Article" for m in history)
|
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)
|
||||||
@@ -102,6 +102,62 @@ async def test_theme_custom_rejected_on_generic_profile_update(client, db_sessio
|
|||||||
assert resp.status_code == 422
|
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):
|
async def test_avatar_upload_succeeds_and_persists(client, db_session):
|
||||||
await register_and_login(client, db_session, username=_unique("alice"))
|
await register_and_login(client, db_session, username=_unique("alice"))
|
||||||
|
|
||||||
|
|||||||
@@ -484,6 +484,27 @@ async def test_add_member_directly(client, db_session, monkeypatch):
|
|||||||
assert "added" in calls[0]["subject"].lower()
|
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):
|
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
|
||||||
_fake_send_email(monkeypatch)
|
_fake_send_email(monkeypatch)
|
||||||
await register_and_login(client, db_session, username="alice")
|
await register_and_login(client, db_session, username="alice")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -109,6 +109,24 @@ def test_history_tiebreaks_identical_timestamps_by_id(ws_client):
|
|||||||
assert [m["content"] for m in history] == [m["content"] for m in expected_order]
|
assert [m["content"] for m in history] == [m["content"] for m in expected_order]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_sends_heartbeat_ping(ws_client, monkeypatch):
|
||||||
|
# #76: with nothing else sent on the connection, a "ping" must arrive
|
||||||
|
# proactively once the idle timeout elapses -- shrunk here so the test
|
||||||
|
# doesn't actually wait the real 30s. Replying "pong" (the frontend's
|
||||||
|
# own behavior) must be accepted silently and leave the connection
|
||||||
|
# fully usable afterward, not treated as an unknown message type.
|
||||||
|
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
|
||||||
|
username = _unique("alice")
|
||||||
|
_register(ws_client, username=username)
|
||||||
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|
||||||
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||||
|
assert ws.receive_json() == {"type": "ping"}
|
||||||
|
ws.send_json({"type": "pong"})
|
||||||
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||||
|
assert ws.receive_json() == {"type": "joined", "room_id": room["id"]}
|
||||||
|
|
||||||
|
|
||||||
def test_ws_message_without_join_errors(ws_client):
|
def test_ws_message_without_join_errors(ws_client):
|
||||||
_register(ws_client, username=_unique("alice"))
|
_register(ws_client, username=_unique("alice"))
|
||||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||||
|
|||||||
+11
-2
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
|
|||||||
FRONTEND_DIR="${REPO_DIR}/frontend"
|
FRONTEND_DIR="${REPO_DIR}/frontend"
|
||||||
ENV_FILE="/etc/ds-chat/env"
|
ENV_FILE="/etc/ds-chat/env"
|
||||||
|
|
||||||
echo "==> Pulling latest code"
|
echo "==> Fetching latest release"
|
||||||
cd "$REPO_DIR"
|
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"
|
echo "==> Installing backend dependencies"
|
||||||
cd "$BACKEND_DIR"
|
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
|
||||||
+1
-1
@@ -112,7 +112,7 @@ src/
|
|||||||
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
||||||
ChatShellPage.tsx, AdminPage.tsx, HelpPage.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
|
sw.ts custom service worker (injectManifest): app-shell
|
||||||
precache + NetworkFirst runtime caching, push/notificationclick
|
precache + NetworkFirst runtime caching, push/notificationclick
|
||||||
handlers, SKIP_WAITING messaging for the update-prompt flow
|
handlers, SKIP_WAITING messaging for the update-prompt flow
|
||||||
|
|||||||
Generated
+953
-11
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "2026.10.9",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"markdown-to-jsx": "^9.10.2",
|
"markdown-to-jsx": "^9.10.2",
|
||||||
@@ -18,13 +19,16 @@
|
|||||||
"vite-plugin-pwa": "^1.3.0"
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
"jsdom": "^30.1.1",
|
||||||
"oxlint": "^1.75.0",
|
"oxlint": "^1.75.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.0",
|
"vite": "^8.2.0",
|
||||||
|
"vitest": "^5.0.2",
|
||||||
"workbox-cacheable-response": "^7.4.1",
|
"workbox-cacheable-response": "^7.4.1",
|
||||||
"workbox-expiration": "^7.4.1",
|
"workbox-expiration": "^7.4.1",
|
||||||
"workbox-precaching": "^7.4.1",
|
"workbox-precaching": "^7.4.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiFetch, ApiError, NetworkError } from './client'
|
import { apiFetch, ApiError, NetworkError } from './client'
|
||||||
import type { User, UserSession } from '../types'
|
import type { EmojiScale, TextScale, User, UserSession } from '../types'
|
||||||
|
|
||||||
// No register() here: this is an invite-only site. Accounts are created by
|
// 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
|
// an operator via the backend CLI (`python -m app.cli create-user`), not
|
||||||
@@ -50,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> {
|
export function removeAvatar(): Promise<User> {
|
||||||
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
|
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ interface AboutModalProps {
|
|||||||
// users remotely through a computer network, you should also make sure
|
// users remotely through a computer network, you should also make sure
|
||||||
// that it provides a way for users to get its source... its interface
|
// 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
|
// could display a 'Source' link" -- this modal is that link, not just a
|
||||||
// courtesy credits screen.
|
// courtesy credits screen. Deliberately not a hardcoded URL: whoever
|
||||||
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat'
|
// 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) {
|
export function AboutModal({ onClose }: AboutModalProps) {
|
||||||
return (
|
return (
|
||||||
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
|
|||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<p className="about-modal-line">
|
{SOURCE_URL && (
|
||||||
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
<p className="about-modal-line">
|
||||||
Source code
|
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
||||||
</a>
|
Source code
|
||||||
</p>
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
||||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||||
|
|||||||
@@ -105,6 +105,34 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-format-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ds-muted);
|
||||||
|
border: 1px solid var(--ds-border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-format-toggle:hover:not(:disabled),
|
||||||
|
.composer-format-toggle-open {
|
||||||
|
color: var(--ds-text);
|
||||||
|
border-color: var(--ds-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-format-toggle:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-attach-wrap {
|
.composer-attach-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import { getUploadLimit } from '../api/uploads'
|
|||||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||||
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
|
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
|
||||||
import { formatFileSize } from '../lib/fileSize'
|
import { formatFileSize } from '../lib/fileSize'
|
||||||
|
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from '../lib/formattingToolbarPref'
|
||||||
|
import { applyFormat, type FormatAction } from '../lib/markdownFormat'
|
||||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
||||||
import type { MyRoomItem, RoomMember } from '../types'
|
import type { MyRoomItem, RoomMember } from '../types'
|
||||||
import { EmojiPicker } from './EmojiPicker'
|
import { EmojiPicker } from './EmojiPicker'
|
||||||
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
|
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
|
||||||
|
import { FormattingToolbar } from './FormattingToolbar'
|
||||||
import { MentionAutocomplete } from './MentionAutocomplete'
|
import { MentionAutocomplete } from './MentionAutocomplete'
|
||||||
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
|
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
|
||||||
import './Composer.css'
|
import './Composer.css'
|
||||||
@@ -136,6 +139,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
|||||||
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
|
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
|
||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragActive, setDragActive] = useState(false)
|
||||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
||||||
|
const [toolbarOpen, setToolbarOpen] = useState(getFormattingToolbarOpen)
|
||||||
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
|
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -449,6 +453,29 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleToolbar() {
|
||||||
|
const next = !toolbarOpen
|
||||||
|
setToolbarOpen(next)
|
||||||
|
setFormattingToolbarOpen(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #75: the toolbar only ever edits the textarea's plain markdown text --
|
||||||
|
// nothing about what gets sent changes.
|
||||||
|
function applyFormatAction(action: FormatAction) {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
const result = applyFormat(
|
||||||
|
{ value, start: el.selectionStart ?? value.length, end: el.selectionEnd ?? value.length },
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
setValue(result.value)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus()
|
||||||
|
el.setSelectionRange(result.start, result.end)
|
||||||
|
autoGrow()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function insertEmoji(emoji: string) {
|
function insertEmoji(emoji: string) {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
setEmojiPickerOpen(false)
|
setEmojiPickerOpen(false)
|
||||||
@@ -517,6 +544,9 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadError && <div className="composer-status composer-error">{uploadError}</div>}
|
{uploadError && <div className="composer-status composer-error">{uploadError}</div>}
|
||||||
|
{toolbarOpen && (
|
||||||
|
<FormattingToolbar id="composer-formatting-toolbar" disabled={isDisabled} onAction={applyFormatAction} />
|
||||||
|
)}
|
||||||
<div className="composer-box">
|
<div className="composer-box">
|
||||||
<input
|
<input
|
||||||
ref={photoInputRef}
|
ref={photoInputRef}
|
||||||
@@ -589,6 +619,18 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`composer-format-toggle${toolbarOpen ? ' composer-format-toggle-open' : ''}`}
|
||||||
|
onClick={toggleToolbar}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label="Formatting options"
|
||||||
|
aria-expanded={toolbarOpen}
|
||||||
|
aria-controls="composer-formatting-toolbar"
|
||||||
|
title="Formatting options"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</button>
|
||||||
<div className="composer-textarea-wrap">
|
<div className="composer-textarea-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -69,26 +69,6 @@
|
|||||||
padding: 4px 4px 2px;
|
padding: 4px 4px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-picker-category-label-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-picker-add-custom {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--ds-accent);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-picker-add-custom:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-picker-grid {
|
.emoji-picker-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(9, 1fr);
|
grid-template-columns: repeat(9, 1fr);
|
||||||
@@ -109,32 +89,6 @@
|
|||||||
background: var(--ds-surface-2);
|
background: var(--ds-surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-picker-item-custom {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-picker-item-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
right: -2px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--ds-danger);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-picker-item-custom:hover .emoji-picker-item-remove {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The picker is positioned absolutely relative to its trigger button, which
|
/* The picker is positioned absolutely relative to its trigger button, which
|
||||||
can sit close enough to a narrow viewport's edge that the full 320px
|
can sit close enough to a narrow viewport's edge that the full 320px
|
||||||
width runs off-screen (e.g. the composer's emoji trigger, near the left
|
width runs off-screen (e.g. the composer's emoji trigger, near the left
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useMemo, useState, type MouseEvent } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { deleteCustomEmoji } from '../api/customEmoji'
|
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||||
import { useEscapeKey } from '../hooks/useEscapeKey'
|
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
|
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
|
||||||
import { EMOJI_NAMES } from '../lib/emojiNames'
|
import { EMOJI_NAMES } from '../lib/emojiNames'
|
||||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
||||||
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
|
|
||||||
import { EmojiGlyph } from './MessageContent'
|
import { EmojiGlyph } from './MessageContent'
|
||||||
import './EmojiPicker.css'
|
import './EmojiPicker.css'
|
||||||
|
|
||||||
@@ -55,11 +52,8 @@ function searchEmoji(query: string, customShortcodes: string[]): string[] {
|
|||||||
|
|
||||||
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
|
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
|
||||||
useEscapeKey(onClose)
|
useEscapeKey(onClose)
|
||||||
const { user } = useAuth()
|
const { list: customEmoji } = useCustomEmoji()
|
||||||
const { list: customEmoji, refresh: refreshCustomEmoji } = useCustomEmoji()
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
||||||
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
|
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
|
||||||
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
|
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
|
||||||
const searching = query.trim().length > 0
|
const searching = query.trim().length > 0
|
||||||
@@ -74,18 +68,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
onPick(emoji)
|
onPick(emoji)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteCustomEmoji(e: MouseEvent, emojiId: string) {
|
|
||||||
// Delete, not pick -- must never bubble to the button's own onClick.
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeletingId(emojiId)
|
|
||||||
try {
|
|
||||||
await deleteCustomEmoji(emojiId)
|
|
||||||
await refreshCustomEmoji()
|
|
||||||
} finally {
|
|
||||||
setDeletingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="emoji-picker-scrim" onClick={onClose} />
|
<div className="emoji-picker-scrim" onClick={onClose} />
|
||||||
@@ -122,48 +104,25 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="emoji-picker-category">
|
{customEmoji.length > 0 && (
|
||||||
<div className="emoji-picker-category-label-row">
|
<div className="emoji-picker-category">
|
||||||
<div className="emoji-picker-category-label">Custom</div>
|
<div className="emoji-picker-category-label">Custom</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="emoji-picker-add-custom"
|
|
||||||
onClick={() => setUploadOpen(true)}
|
|
||||||
>
|
|
||||||
+ Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{customEmoji.length > 0 && (
|
|
||||||
<div className="emoji-picker-grid">
|
<div className="emoji-picker-grid">
|
||||||
{customEmoji.map((e) => {
|
{customEmoji.map((e) => (
|
||||||
const canDelete = user?.id === e.uploaded_by || user?.is_site_admin
|
<button
|
||||||
return (
|
key={e.id}
|
||||||
<button
|
type="button"
|
||||||
key={e.id}
|
role="menuitem"
|
||||||
type="button"
|
className="emoji-picker-item"
|
||||||
role="menuitem"
|
title={`:${e.shortcode}:`}
|
||||||
className="emoji-picker-item emoji-picker-item-custom"
|
onClick={() => pick(`:${e.shortcode}:`)}
|
||||||
title={`:${e.shortcode}:`}
|
>
|
||||||
onClick={() => pick(`:${e.shortcode}:`)}
|
<EmojiGlyph value={`:${e.shortcode}:`} />
|
||||||
>
|
</button>
|
||||||
<EmojiGlyph value={`:${e.shortcode}:`} />
|
))}
|
||||||
{canDelete && (
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
aria-label={`Remove :${e.shortcode}:`}
|
|
||||||
className="emoji-picker-item-remove"
|
|
||||||
onClick={(ev) => handleDeleteCustomEmoji(ev, e.id)}
|
|
||||||
style={deletingId === e.id ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
{recent.length > 0 && (
|
{recent.length > 0 && (
|
||||||
<div className="emoji-picker-category">
|
<div className="emoji-picker-category">
|
||||||
<div className="emoji-picker-category-label">Recently used</div>
|
<div className="emoji-picker-category-label">Recently used</div>
|
||||||
@@ -205,15 +164,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{uploadOpen && (
|
|
||||||
<CustomEmojiUploadModal
|
|
||||||
onClose={() => setUploadOpen(false)}
|
|
||||||
onUploaded={() => {
|
|
||||||
refreshCustomEmoji()
|
|
||||||
setUploadOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.formatting-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
/* Spacing, not divider lines, between groups -- a border on every group
|
||||||
|
after the first leaves a dangling line at the start of the second row
|
||||||
|
whenever the toolbar wraps on a narrow screen. */
|
||||||
|
gap: 2px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatting-toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatting-toolbar-btn {
|
||||||
|
min-width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ds-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatting-toolbar-btn:hover:not(:disabled) {
|
||||||
|
background: var(--ds-surface-2);
|
||||||
|
color: var(--ds-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formatting-toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { FormatAction } from '../lib/markdownFormat'
|
||||||
|
import { FormattingToolbar } from './FormattingToolbar'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
const EXPECTED: [string, FormatAction][] = [
|
||||||
|
['Bold', { kind: 'inline', marker: '**' }],
|
||||||
|
['Italic', { kind: 'inline', marker: '*' }],
|
||||||
|
['Strikethrough', { kind: 'inline', marker: '~~' }],
|
||||||
|
['Inline code', { kind: 'inline', marker: '`' }],
|
||||||
|
['Code block', { kind: 'codeBlock' }],
|
||||||
|
['Link', { kind: 'link' }],
|
||||||
|
['Quote', { kind: 'prefix', prefix: 'quote' }],
|
||||||
|
['Bulleted list', { kind: 'prefix', prefix: 'bullet' }],
|
||||||
|
['Numbered list', { kind: 'prefix', prefix: 'ordered' }],
|
||||||
|
['Heading 1', { kind: 'heading', level: 1 }],
|
||||||
|
['Heading 2', { kind: 'heading', level: 2 }],
|
||||||
|
['Heading 3', { kind: 'heading', level: 3 }],
|
||||||
|
['Subscript', { kind: 'inline', marker: '~' }],
|
||||||
|
['Superscript', { kind: 'inline', marker: '^' }],
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('FormattingToolbar', () => {
|
||||||
|
it.each(EXPECTED)('%s button calls onAction with its action', (label, action) => {
|
||||||
|
const onAction = vi.fn()
|
||||||
|
render(<FormattingToolbar id="t" onAction={onAction} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||||
|
expect(onAction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onAction).toHaveBeenCalledWith(action)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables every button when disabled, but leaves the help link usable', () => {
|
||||||
|
render(<FormattingToolbar id="t" disabled onAction={vi.fn()} />)
|
||||||
|
const buttons = screen.getAllByRole('button') as HTMLButtonElement[]
|
||||||
|
expect(buttons).toHaveLength(EXPECTED.length)
|
||||||
|
expect(buttons.every((b) => b.disabled)).toBe(true)
|
||||||
|
expect(screen.getByRole('link', { name: 'Formatting help' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens help in a new tab so the draft in the composer is not lost', () => {
|
||||||
|
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
|
||||||
|
const help = screen.getByRole('link', { name: 'Formatting help' })
|
||||||
|
expect(help.getAttribute('href')).toBe('/help#formatting')
|
||||||
|
expect(help.getAttribute('target')).toBe('_blank')
|
||||||
|
expect(help.getAttribute('rel')).toContain('noopener')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps focus in the textarea by cancelling mousedown on buttons', () => {
|
||||||
|
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
|
||||||
|
// fireEvent returns false when the event's default was prevented.
|
||||||
|
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Bold' }))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { FormatAction, InlineMarker } from '../lib/markdownFormat'
|
||||||
|
import './FormattingToolbar.css'
|
||||||
|
|
||||||
|
interface FormattingToolbarProps {
|
||||||
|
id: string
|
||||||
|
disabled?: boolean
|
||||||
|
onAction: (action: FormatAction) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolbarButton {
|
||||||
|
label: string
|
||||||
|
// Shows the syntax the button inserts -- the toolbar doubles as the
|
||||||
|
// "quick guide" for people who'd rather learn to type it themselves.
|
||||||
|
title: string
|
||||||
|
content: ReactNode
|
||||||
|
action: FormatAction
|
||||||
|
}
|
||||||
|
|
||||||
|
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
|
||||||
|
|
||||||
|
const GROUPS: ToolbarButton[][] = [
|
||||||
|
[
|
||||||
|
{ label: 'Bold', title: 'Bold: **text**', content: <b>B</b>, action: inline('**') },
|
||||||
|
{ label: 'Italic', title: 'Italic: *text*', content: <i>I</i>, action: inline('*') },
|
||||||
|
{ label: 'Strikethrough', title: 'Strikethrough: ~~text~~', content: <s>S</s>, action: inline('~~') },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'Inline code', title: 'Inline code: `code`', content: '</>', action: inline('`') },
|
||||||
|
{ label: 'Code block', title: 'Code block: ``` on its own lines', content: '{ }', action: { kind: 'codeBlock' } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Link',
|
||||||
|
title: 'Link: [text](url)',
|
||||||
|
content: (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8.5 11.5a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-.9.9M11.5 8.5a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l.9-.9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
action: { kind: 'link' },
|
||||||
|
},
|
||||||
|
{ label: 'Quote', title: 'Quote: > text', content: '“', action: { kind: 'prefix', prefix: 'quote' } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'Bulleted list', title: 'Bulleted list: - item', content: '•', action: { kind: 'prefix', prefix: 'bullet' } },
|
||||||
|
{ label: 'Numbered list', title: 'Numbered list: 1. item', content: '1.', action: { kind: 'prefix', prefix: 'ordered' } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'Heading 1', title: 'Heading 1: # text', content: 'H1', action: { kind: 'heading', level: 1 } },
|
||||||
|
{ label: 'Heading 2', title: 'Heading 2: ## text', content: 'H2', action: { kind: 'heading', level: 2 } },
|
||||||
|
{ label: 'Heading 3', title: 'Heading 3: ### text', content: 'H3', action: { kind: 'heading', level: 3 } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: 'Subscript', title: 'Subscript: ~text~ (no spaces)', content: 'x₂', action: inline('~') },
|
||||||
|
{ label: 'Superscript', title: 'Superscript: ^text^ (no spaces)', content: 'x²', action: inline('^') },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function FormattingToolbar({ id, disabled, onAction }: FormattingToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div id={id} className="formatting-toolbar" role="toolbar" aria-label="Formatting">
|
||||||
|
{GROUPS.map((group) => (
|
||||||
|
<div key={group[0].label} className="formatting-toolbar-group">
|
||||||
|
{group.map((button) => (
|
||||||
|
<button
|
||||||
|
key={button.label}
|
||||||
|
type="button"
|
||||||
|
className="formatting-toolbar-btn"
|
||||||
|
title={button.title}
|
||||||
|
aria-label={button.label}
|
||||||
|
disabled={disabled}
|
||||||
|
// Keeps focus (and the selection) in the textarea -- a click
|
||||||
|
// on a button would otherwise blur it, and on mobile dismiss
|
||||||
|
// the keyboard.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onAction(button.action)}
|
||||||
|
>
|
||||||
|
{button.content}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* A new tab on purpose: the draft lives in Composer's own state, so
|
||||||
|
navigating this one away would lose it. */}
|
||||||
|
<a
|
||||||
|
className="formatting-toolbar-btn formatting-toolbar-help"
|
||||||
|
href="/help#formatting"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Formatting help (opens in a new tab)"
|
||||||
|
aria-label="Formatting help"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,10 +4,25 @@
|
|||||||
own font-size and this just tracks it. Kept in this file (imported
|
own font-size and this just tracks it. Kept in this file (imported
|
||||||
directly by MessageContent.tsx) rather than MessageList.css so it's
|
directly by MessageContent.tsx) rather than MessageList.css so it's
|
||||||
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
|
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
|
||||||
included, not just the message list. */
|
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 {
|
.message-custom-emoji {
|
||||||
height: 1.2em;
|
height: calc(1.2em * var(--emoji-scale, 1));
|
||||||
width: 1.2em;
|
width: calc(1.2em * var(--emoji-scale, 1));
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
vertical-align: -0.25em;
|
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,8 @@
|
|||||||
import Markdown from 'markdown-to-jsx'
|
import Markdown from 'markdown-to-jsx'
|
||||||
import type { ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { getCustomEmojiUrl } from '../api/customEmoji'
|
import { getCustomEmojiUrl } from '../api/customEmoji'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||||
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
||||||
import './MessageContent.css'
|
import './MessageContent.css'
|
||||||
@@ -85,6 +86,14 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// #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 (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
{children}
|
{children}
|
||||||
@@ -247,7 +256,12 @@ export function EmojiGlyph({ value }: EmojiGlyphProps) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <>{value}</>
|
// 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
|
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
||||||
@@ -370,14 +384,66 @@ export function preprocessMarkdown(text: string): { text: string; headingIds: Ma
|
|||||||
return extractHeadingIds(convertSubSuperscript(text))
|
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) {
|
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
const { byShortcode } = useCustomEmoji()
|
const { byShortcode } = useCustomEmoji()
|
||||||
|
const customShortcodes = new Set(byShortcode.keys())
|
||||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||||
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
||||||
const withCustomEmoji = convertCustomEmojiShortcodes(
|
const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
|
||||||
convertShortcodes(withRoomRefs),
|
const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
|
||||||
new Set(byShortcode.keys()),
|
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>
|
||||||
)
|
)
|
||||||
const { text, headingIds } = preprocessMarkdown(withCustomEmoji)
|
|
||||||
return <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
|
|
||||||
}
|
}
|
||||||
@@ -40,12 +40,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-author {
|
.message-author {
|
||||||
|
/* Lets a very long display name shrink/wrap instead of pushing the
|
||||||
|
(now longer, e.g. "Yesterday 4:04 PM") timestamp out of the row. */
|
||||||
|
min-width: 0;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ds-text);
|
color: var(--ds-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--ds-muted);
|
color: var(--ds-muted);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
@@ -53,8 +58,13 @@
|
|||||||
|
|
||||||
.message-image {
|
.message-image {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: min(320px, 100%);
|
/* #71: rem, not px -- scales with the text-size setting (see
|
||||||
max-height: 240px;
|
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;
|
object-fit: contain;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--ds-border);
|
border: 1px solid var(--ds-border);
|
||||||
@@ -65,14 +75,14 @@
|
|||||||
.message-video-wrap {
|
.message-video-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: min(320px, 100%);
|
max-width: min(20rem, 100%);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-video {
|
.message-video {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 240px;
|
max-height: 15rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--ds-border);
|
border: 1px solid var(--ds-border);
|
||||||
background: var(--ds-void);
|
background: var(--ds-void);
|
||||||
@@ -111,7 +121,7 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: var(--ds-text);
|
color: var(--ds-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
max-width: min(320px, 100%);
|
max-width: min(20rem, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-file-attachment:hover {
|
.message-file-attachment:hover {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useLocalDay } from '../hooks/useLocalDay'
|
||||||
import { formatFileSize } from '../lib/fileSize'
|
import { formatFileSize } from '../lib/fileSize'
|
||||||
import { avatarUrlFor, displayNameFor, senderColorIndex, statusFor } from '../lib/messageGrouping'
|
import { formatMessageTime, formatMessageTimeTitle } from '../lib/formatMessageTime'
|
||||||
|
import { avatarUrlFor, displayNameFor, isGroupStart, senderColorIndex, statusFor } from '../lib/messageGrouping'
|
||||||
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
|
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
|
||||||
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
|
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
|
||||||
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
|
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
|
||||||
import { ImageLightbox } from './ImageLightbox'
|
import { ImageLightbox } from './ImageLightbox'
|
||||||
import { LinkPreviewCard } from './LinkPreviewCard'
|
import { LinkPreviewCard } from './LinkPreviewCard'
|
||||||
import { EmojiGlyph, MessageContent } from './MessageContent'
|
import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import { VideoLightbox } from './VideoLightbox'
|
import { VideoLightbox } from './VideoLightbox'
|
||||||
import './MessageList.css'
|
import './MessageList.css'
|
||||||
@@ -126,6 +129,11 @@ export function MessageList({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const now = useLocalDay()
|
||||||
|
// #71: same viewer preference MessageContent applies to in-text emoji,
|
||||||
|
// 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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
// Whether the view should be pinned to the latest message -- true right
|
// Whether the view should be pinned to the latest message -- true right
|
||||||
@@ -205,15 +213,16 @@ export function MessageList({
|
|||||||
// Mattermost-style grouping: every message shows who sent it, but
|
// Mattermost-style grouping: every message shows who sent it, but
|
||||||
// consecutive messages from the same sender only repeat the
|
// consecutive messages from the same sender only repeat the
|
||||||
// avatar/name/timestamp header on the first one in the run --
|
// avatar/name/timestamp header on the first one in the run --
|
||||||
// applies uniformly, including to your own messages.
|
// applies uniformly, including to your own messages. Also breaks on
|
||||||
const isGroupStart = !prev || prev.user_id !== msg.user_id
|
// a long gap or a new calendar day -- see isGroupStart.
|
||||||
|
const groupStart = isGroupStart(prev, msg)
|
||||||
const editing = editingId === msg.id
|
const editing = editingId === msg.id
|
||||||
const deleted = !!msg.deleted_at
|
const deleted = !!msg.deleted_at
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
|
<div key={msg.id} className={`message-row${groupStart ? ' message-row-start' : ''}`}>
|
||||||
<div className="message-avatar-slot">
|
<div className="message-avatar-slot">
|
||||||
{isGroupStart && (
|
{groupStart && (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
username={msg.username}
|
username={msg.username}
|
||||||
colorIndex={senderColorIndex(msg.username, members)}
|
colorIndex={senderColorIndex(msg.username, members)}
|
||||||
@@ -223,11 +232,11 @@ export function MessageList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="message-content">
|
<div className="message-content">
|
||||||
{isGroupStart && (
|
{groupStart && (
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<span className="message-author">{displayNameFor(msg.username, members)}</span>
|
<span className="message-author">{displayNameFor(msg.username, members)}</span>
|
||||||
<span className="message-time">
|
<span className="message-time" title={formatMessageTimeTitle(msg.created_at)}>
|
||||||
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
{formatMessageTime(msg.created_at, now)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -302,7 +311,10 @@ export function MessageList({
|
|||||||
title={r.user_ids.map(displayNameForUserId).join(', ')}
|
title={r.user_ids.map(displayNameForUserId).join(', ')}
|
||||||
onClick={() => onReact(msg.id, r.emoji)}
|
onClick={() => onReact(msg.id, 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} />
|
<EmojiGlyph value={r.emoji} />
|
||||||
</span>
|
</span>
|
||||||
<span>{r.count}</span>
|
<span>{r.count}</span>
|
||||||
|
|||||||
@@ -230,6 +230,45 @@
|
|||||||
color: var(--ds-muted);
|
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 {
|
.theme-swatch-preview-new {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
me,
|
me,
|
||||||
removeAvatar,
|
removeAvatar,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
|
updateEmojiScale,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
updateTextScale,
|
||||||
updateTheme,
|
updateTheme,
|
||||||
uploadAvatar,
|
uploadAvatar,
|
||||||
} from '../api/auth'
|
} from '../api/auth'
|
||||||
@@ -20,8 +22,8 @@ import {
|
|||||||
import { getUserAvatarUrl } from '../api/users'
|
import { getUserAvatarUrl } from '../api/users'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { hashIndex } from '../lib/avatar'
|
import { hashIndex } from '../lib/avatar'
|
||||||
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
|
import { applyTextScale, applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
|
||||||
import type { CustomTheme, CustomThemeColors, UserSession } from '../types'
|
import type { CustomTheme, CustomThemeColors, EmojiScale, TextScale, UserSession } from '../types'
|
||||||
import { ThemeBuilderModal } from './ThemeBuilderModal'
|
import { ThemeBuilderModal } from './ThemeBuilderModal'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import './Modal.css'
|
import './Modal.css'
|
||||||
@@ -33,6 +35,26 @@ const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: st
|
|||||||
{ name: 'sunset', label: 'Sunset' },
|
{ name: 'sunset', label: 'Sunset' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// #71: the "Aa" preview scales with each option's own size, the standard
|
||||||
|
// way a text-size picker shows what it does without a separate demo area.
|
||||||
|
const TEXT_SCALE_OPTIONS: { name: TextScale; label: string; previewSize: string }[] = [
|
||||||
|
{ name: 'small', label: 'Small', previewSize: '0.8rem' },
|
||||||
|
{ name: 'normal', label: 'Normal', previewSize: '1rem' },
|
||||||
|
{ name: 'large', label: 'Large', previewSize: '1.25rem' },
|
||||||
|
{ name: 'xlarge', label: 'Extra large', previewSize: '1.5rem' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// #71: independent of text size -- only scales emoji rendered in message
|
||||||
|
// text (see MessageContent.tsx's --emoji-scale). The preview uses an
|
||||||
|
// actual emoji so it demonstrates itself the same way the text-size
|
||||||
|
// options do with "Aa".
|
||||||
|
const EMOJI_SCALE_OPTIONS: { name: EmojiScale; label: string; previewSize: string }[] = [
|
||||||
|
{ name: 'small', label: 'Small', previewSize: '1rem' },
|
||||||
|
{ name: 'normal', label: 'Normal', previewSize: '1.25rem' },
|
||||||
|
{ name: 'large', label: 'Large', previewSize: '1.6rem' },
|
||||||
|
{ name: 'xlarge', label: 'Extra large', previewSize: '2rem' },
|
||||||
|
]
|
||||||
|
|
||||||
const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; label: string }[] = [
|
const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; label: string }[] = [
|
||||||
{ key: 'void', label: 'Background' },
|
{ key: 'void', label: 'Background' },
|
||||||
{ key: 'void_2', label: 'Sidebar background' },
|
{ key: 'void_2', label: 'Sidebar background' },
|
||||||
@@ -60,6 +82,8 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [themeError, setThemeError] = useState<string | null>(null)
|
const [themeError, setThemeError] = useState<string | null>(null)
|
||||||
|
const [textScaleError, setTextScaleError] = useState<string | null>(null)
|
||||||
|
const [emojiScaleError, setEmojiScaleError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
|
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
|
||||||
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
|
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
|
||||||
@@ -150,6 +174,32 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSelectTextScale(scale: TextScale) {
|
||||||
|
// Same instant-apply-then-persist pattern as handleSelectPreset above.
|
||||||
|
applyTextScale(scale)
|
||||||
|
setTextScaleError(null)
|
||||||
|
try {
|
||||||
|
const updated = await updateTextScale(scale)
|
||||||
|
updateUser(updated)
|
||||||
|
} catch (err) {
|
||||||
|
applyTextScale(user?.text_scale ?? null)
|
||||||
|
setTextScaleError(err instanceof ApiError ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectEmojiScale(scale: EmojiScale) {
|
||||||
|
// No instant-apply DOM mutation here (unlike theme/text scale) -- it's
|
||||||
|
// just a value MessageContent reads from `user` on its next render, so
|
||||||
|
// persisting and updating that is the whole job.
|
||||||
|
setEmojiScaleError(null)
|
||||||
|
try {
|
||||||
|
const updated = await updateEmojiScale(scale)
|
||||||
|
updateUser(updated)
|
||||||
|
} catch (err) {
|
||||||
|
setEmojiScaleError(err instanceof ApiError ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleActivateCustomTheme(theme: CustomTheme) {
|
async function handleActivateCustomTheme(theme: CustomTheme) {
|
||||||
applyTheme('custom', theme.colors)
|
applyTheme('custom', theme.colors)
|
||||||
setThemeError(null)
|
setThemeError(null)
|
||||||
@@ -360,6 +410,48 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
{themeError && <p className="modal-error">{themeError}</p>}
|
{themeError && <p className="modal-error">{themeError}</p>}
|
||||||
|
|
||||||
|
<div className="modal-field-label">Text size</div>
|
||||||
|
<div className="text-scale-options">
|
||||||
|
{TEXT_SCALE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.name}
|
||||||
|
type="button"
|
||||||
|
className={`text-scale-option${
|
||||||
|
(user.text_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectTextScale(option.name)}
|
||||||
|
aria-pressed={(user.text_scale ?? 'normal') === option.name}
|
||||||
|
>
|
||||||
|
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
|
||||||
|
Aa
|
||||||
|
</span>
|
||||||
|
<span className="text-scale-option-label">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{textScaleError && <p className="modal-error">{textScaleError}</p>}
|
||||||
|
|
||||||
|
<div className="modal-field-label">Emoji size</div>
|
||||||
|
<div className="text-scale-options">
|
||||||
|
{EMOJI_SCALE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.name}
|
||||||
|
type="button"
|
||||||
|
className={`text-scale-option${
|
||||||
|
(user.emoji_scale ?? 'normal') === option.name ? ' text-scale-option-selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectEmojiScale(option.name)}
|
||||||
|
aria-pressed={(user.emoji_scale ?? 'normal') === option.name}
|
||||||
|
>
|
||||||
|
<span className="text-scale-option-preview" style={{ fontSize: option.previewSize }}>
|
||||||
|
🎉
|
||||||
|
</span>
|
||||||
|
<span className="text-scale-option-label">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{emojiScaleError && <p className="modal-error">{emojiScaleError}</p>}
|
||||||
|
|
||||||
<div className="modal-field-label">My custom themes</div>
|
<div className="modal-field-label">My custom themes</div>
|
||||||
<div className="theme-swatch-grid">
|
<div className="theme-swatch-grid">
|
||||||
{customThemes.map((theme) => {
|
{customThemes.map((theme) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../lib/desktopBridge'
|
} from '../lib/desktopBridge'
|
||||||
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
|
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
|
||||||
import { AboutModal } from './AboutModal'
|
import { AboutModal } from './AboutModal'
|
||||||
|
import { CustomEmojiManageModal } from './CustomEmojiManageModal'
|
||||||
import { ProfileModal } from './ProfileModal'
|
import { ProfileModal } from './ProfileModal'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import './TopBar.css'
|
import './TopBar.css'
|
||||||
@@ -28,6 +29,7 @@ export function TopBar() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
||||||
|
const [customEmojiModalOpen, setCustomEmojiModalOpen] = useState(false)
|
||||||
const [aboutModalOpen, setAboutModalOpen] = useState(false)
|
const [aboutModalOpen, setAboutModalOpen] = useState(false)
|
||||||
const [pushSubscribed, setPushSubscribed] = useState(false)
|
const [pushSubscribed, setPushSubscribed] = useState(false)
|
||||||
const [pushBusy, setPushBusy] = useState(false)
|
const [pushBusy, setPushBusy] = useState(false)
|
||||||
@@ -124,6 +126,16 @@ export function TopBar() {
|
|||||||
>
|
>
|
||||||
Profile settings
|
Profile settings
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setCustomEmojiModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Custom emoji
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -190,6 +202,7 @@ export function TopBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
|
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
|
||||||
|
{customEmojiModalOpen && <CustomEmojiManageModal onClose={() => setCustomEmojiModalOpen(false)} />}
|
||||||
{aboutModalOpen && <AboutModal onClose={() => setAboutModalOpen(false)} />}
|
{aboutModalOpen && <AboutModal onClose={() => setAboutModalOpen(false)} />}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as authApi from '../api/auth'
|
|||||||
import { ApiError, NetworkError } from '../api/client'
|
import { ApiError, NetworkError } from '../api/client'
|
||||||
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
|
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
|
||||||
import { unsubscribeFromPush } from '../lib/push'
|
import { unsubscribeFromPush } from '../lib/push'
|
||||||
import { applyTheme } from '../lib/theme'
|
import { applyTextScale, applyTheme } from '../lib/theme'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -26,6 +26,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
|
applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
|
||||||
}, [user?.theme, user?.active_custom_theme])
|
}, [user?.theme, user?.active_custom_theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTextScale(user?.text_scale ?? null)
|
||||||
|
}, [user?.text_scale])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authApi
|
authApi
|
||||||
.me()
|
.me()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as desktopBridge from '../lib/desktopBridge'
|
||||||
|
import { useDesktopRoomBridge } from './useDesktopRoomBridge'
|
||||||
|
|
||||||
|
describe('useDesktopRoomBridge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(desktopBridge, 'notifyRoomOpened')
|
||||||
|
vi.spyOn(desktopBridge, 'notifyUnreadCount')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls roomOpened once when entering a room', () => {
|
||||||
|
renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||||
|
initialProps: { roomId: 'room-1', count: 0 },
|
||||||
|
})
|
||||||
|
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call roomOpened again on a rerender with the same roomId', () => {
|
||||||
|
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||||
|
initialProps: { roomId: 'room-1', count: 0 },
|
||||||
|
})
|
||||||
|
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Unrelated rerender -- unreadRoomCount changed, roomId did not.
|
||||||
|
rerender({ roomId: 'room-1', count: 3 })
|
||||||
|
rerender({ roomId: 'room-1', count: 3 })
|
||||||
|
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls roomOpened again on a genuine transition to a different room', () => {
|
||||||
|
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||||
|
initialProps: { roomId: 'room-1', count: 0 },
|
||||||
|
})
|
||||||
|
rerender({ roomId: 'room-2', count: 0 })
|
||||||
|
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call roomOpened when there is no room id', () => {
|
||||||
|
renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||||
|
initialProps: { roomId: undefined as string | undefined, count: 0 },
|
||||||
|
})
|
||||||
|
expect(desktopBridge.notifyRoomOpened).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the unread count whenever it changes, independent of roomOpened', () => {
|
||||||
|
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||||
|
initialProps: { roomId: undefined as string | undefined, count: 0 },
|
||||||
|
})
|
||||||
|
expect(desktopBridge.notifyUnreadCount).toHaveBeenCalledWith(0)
|
||||||
|
|
||||||
|
rerender({ roomId: undefined, count: 2 })
|
||||||
|
expect(desktopBridge.notifyUnreadCount).toHaveBeenCalledWith(2)
|
||||||
|
expect(desktopBridge.notifyRoomOpened).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('desktop bridge functions with no window.dsDesktop', () => {
|
||||||
|
it('remain optional -- calling them does not throw when the bridge is absent', () => {
|
||||||
|
expect(window.dsDesktop).toBeUndefined()
|
||||||
|
expect(() => desktopBridge.notifyRoomOpened()).not.toThrow()
|
||||||
|
expect(() => desktopBridge.notifyUnreadCount(5)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { notifyRoomOpened, notifyUnreadCount } from '../lib/desktopBridge'
|
||||||
|
|
||||||
|
// Tells the Electron desktop wrapper (a) when the user has genuinely
|
||||||
|
// entered a room, so it can clear that room's tray indicator, and (b) the
|
||||||
|
// current unread-room count, so the tray badge stays accurate. Both are
|
||||||
|
// no-ops in every browser build (see desktopBridge.ts).
|
||||||
|
//
|
||||||
|
// roomOpened must fire only on a genuine transition to a real room id --
|
||||||
|
// never merely because the window was shown, focused, restored, or opened
|
||||||
|
// from the tray, and never when no room is open at all. Depending on just
|
||||||
|
// the id (a primitive, not the room object) is what makes this safe to
|
||||||
|
// call from a component that rerenders often: an unrelated rerender that
|
||||||
|
// leaves activeRoomId unchanged doesn't rerun the effect, so there's no
|
||||||
|
// separate de-duplication to get wrong here -- it falls out of React's own
|
||||||
|
// effect-dependency comparison.
|
||||||
|
export function useDesktopRoomBridge(activeRoomId: string | undefined, unreadRoomCount: number): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeRoomId) return
|
||||||
|
notifyRoomOpened()
|
||||||
|
}, [activeRoomId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
notifyUnreadCount(unreadRoomCount)
|
||||||
|
}, [unreadRoomCount])
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useLocalDay } from './useLocalDay'
|
||||||
|
|
||||||
|
function setVisibility(state: 'visible' | 'hidden') {
|
||||||
|
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLocalDay', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
setVisibility('visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
// Remove the own-property override so jsdom's real getter shows through again.
|
||||||
|
delete (document as unknown as Record<string, unknown>).visibilityState
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls over to the next day when local midnight passes', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 9, 23, 59, 30))
|
||||||
|
const { result } = renderHook(() => useLocalDay())
|
||||||
|
expect(result.current.getDate()).toBe(9)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
})
|
||||||
|
expect(result.current.getDate()).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes when the tab becomes visible after the clock moved a day (e.g. a sleeping laptop)', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
|
||||||
|
const { result } = renderHook(() => useLocalDay())
|
||||||
|
|
||||||
|
// The clock jumps without any timer firing in between.
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
})
|
||||||
|
expect(result.current.getDate()).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the same value (no rerender) when visibility changes on the same day', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
|
||||||
|
const { result } = renderHook(() => useLocalDay())
|
||||||
|
const first = result.current
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 9, 18, 0))
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
})
|
||||||
|
expect(result.current).toBe(first)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores visibility changes while the tab is hidden', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
|
||||||
|
const { result } = renderHook(() => useLocalDay())
|
||||||
|
|
||||||
|
setVisibility('hidden')
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
})
|
||||||
|
expect(result.current.getDate()).toBe(9)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { isSameLocalDay } from '../lib/formatMessageTime'
|
||||||
|
|
||||||
|
function msUntilNextLocalMidnight(now: Date): number {
|
||||||
|
// One second past, so the timer never lands a hair before midnight.
|
||||||
|
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1)
|
||||||
|
return next.getTime() - now.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A `now` that only changes when the local calendar day does -- at midnight,
|
||||||
|
// and when a tab becomes visible again (a laptop that slept through
|
||||||
|
// midnight). Without it, a tab left open overnight keeps showing yesterday's
|
||||||
|
// messages as a bare "4:04 PM" until something unrelated rerenders the list.
|
||||||
|
export function useLocalDay(): Date {
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const current = new Date()
|
||||||
|
// Same state object when the day hasn't changed, so no rerender.
|
||||||
|
setNow((prev) => (isSameLocalDay(prev, current) ? prev : current))
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
refresh()
|
||||||
|
schedule()
|
||||||
|
}, msUntilNextLocalMidnight(new Date()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'visible') refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule()
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return now
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
dsDesktop?: {
|
dsDesktop?: {
|
||||||
setUnreadCount?(unreadRoomCount: number): void
|
setUnreadCount?(unreadRoomCount: number): void
|
||||||
|
// Tells the tray/dock icon the user has genuinely entered a room --
|
||||||
|
// the desktop wrapper's own signal to clear that room's tray
|
||||||
|
// indicator. Deliberately not fired just because the window was
|
||||||
|
// shown/focused/restored, or opened from the tray: none of those mean
|
||||||
|
// the user actually looked at the room the unread message is in.
|
||||||
|
roomOpened?(): void
|
||||||
showNotification?(notification: DesktopNotificationRequest): void
|
showNotification?(notification: DesktopNotificationRequest): void
|
||||||
onNotificationClick?(callback: (roomId: string) => void): () => void
|
onNotificationClick?(callback: (roomId: string) => void): () => void
|
||||||
}
|
}
|
||||||
@@ -38,6 +44,16 @@ export function isDesktopClickListenerSupported(): boolean {
|
|||||||
return typeof window.dsDesktop?.onNotificationClick === 'function'
|
return typeof window.dsDesktop?.onNotificationClick === 'function'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both no-op silently if the bridge or this specific method isn't present,
|
||||||
|
// same as showDesktopNotification below -- callers never need to guard.
|
||||||
|
export function notifyRoomOpened(): void {
|
||||||
|
window.dsDesktop?.roomOpened?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUnreadCount(unreadRoomCount: number): void {
|
||||||
|
window.dsDesktop?.setUnreadCount?.(unreadRoomCount)
|
||||||
|
}
|
||||||
|
|
||||||
// No-ops silently if the bridge or this specific method isn't present --
|
// No-ops silently if the bridge or this specific method isn't present --
|
||||||
// callers don't need to guard, matching the rest of this module's
|
// callers don't need to guard, matching the rest of this module's
|
||||||
// capability-detect-per-method philosophy (see #49's "feature-test each
|
// capability-detect-per-method philosophy (see #49's "feature-test each
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatMessageTime, formatMessageTimeTitle, isSameLocalDay } from './formatMessageTime'
|
||||||
|
|
||||||
|
// Local-time constructors throughout, and expected text built with the same
|
||||||
|
// locale calls the code uses -- so these hold in any timezone and any ICU
|
||||||
|
// locale instead of hardcoding "3:00 PM".
|
||||||
|
const NOW = new Date(2026, 9, 9, 15, 0) // Fri Oct 9 2026, 3:00 PM local
|
||||||
|
|
||||||
|
const iso = (d: Date) => d.toISOString()
|
||||||
|
const timeText = (d: Date) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
const weekday = (d: Date) => d.toLocaleDateString([], { weekday: 'short' })
|
||||||
|
|
||||||
|
describe('formatMessageTime', () => {
|
||||||
|
it('shows just the time for a message sent today', () => {
|
||||||
|
const morning = new Date(2026, 9, 9, 8, 5)
|
||||||
|
expect(formatMessageTime(iso(morning), NOW)).toBe(timeText(morning))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefixes "Yesterday" for yesterday', () => {
|
||||||
|
const d = new Date(2026, 9, 8, 23, 59)
|
||||||
|
expect(formatMessageTime(iso(d), NOW)).toBe(`Yesterday ${timeText(d)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats 11:59 PM vs 12:03 AM as different days despite being minutes apart', () => {
|
||||||
|
const justBeforeMidnight = new Date(2026, 9, 8, 23, 59)
|
||||||
|
const justAfterMidnight = new Date(2026, 9, 9, 0, 3)
|
||||||
|
expect(formatMessageTime(iso(justBeforeMidnight), justAfterMidnight)).toBe(
|
||||||
|
`Yesterday ${timeText(justBeforeMidnight)}`,
|
||||||
|
)
|
||||||
|
expect(formatMessageTime(iso(justAfterMidnight), justAfterMidnight)).toBe(timeText(justAfterMidnight))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the short weekday for 2 to 6 days ago', () => {
|
||||||
|
const twoDays = new Date(2026, 9, 7, 12, 0)
|
||||||
|
const sixDays = new Date(2026, 9, 3, 12, 0)
|
||||||
|
expect(formatMessageTime(iso(twoDays), NOW)).toBe(`${weekday(twoDays)} ${timeText(twoDays)}`)
|
||||||
|
expect(formatMessageTime(iso(sixDays), NOW)).toBe(`${weekday(sixDays)} ${timeText(sixDays)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to a month and day at 7 days, without the year when it is this year', () => {
|
||||||
|
const d = new Date(2026, 9, 2, 12, 0)
|
||||||
|
const label = formatMessageTime(iso(d), NOW)
|
||||||
|
expect(label).toBe(`${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${timeText(d)}`)
|
||||||
|
expect(label).not.toContain('2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds the year for a message from a different year', () => {
|
||||||
|
const d = new Date(2025, 11, 25, 9, 30)
|
||||||
|
const label = formatMessageTime(iso(d), NOW)
|
||||||
|
expect(label).toBe(
|
||||||
|
`${d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })}, ${timeText(d)}`,
|
||||||
|
)
|
||||||
|
expect(label).toContain('2025')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts Dec 31 -> Jan 1 as yesterday', () => {
|
||||||
|
const d = new Date(2026, 11, 31, 22, 0)
|
||||||
|
expect(formatMessageTime(iso(d), new Date(2027, 0, 1, 10, 0))).toBe(`Yesterday ${timeText(d)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a slightly-future timestamp (clock skew) as today', () => {
|
||||||
|
const tomorrow = new Date(2026, 9, 10, 9, 0)
|
||||||
|
expect(formatMessageTime(iso(tomorrow), NOW)).toBe(timeText(tomorrow))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMessageTimeTitle', () => {
|
||||||
|
it('includes the full date, with the year', () => {
|
||||||
|
expect(formatMessageTimeTitle(iso(new Date(2026, 9, 2, 16, 4)))).toContain('2026')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isSameLocalDay', () => {
|
||||||
|
it('is false across midnight and true within a day', () => {
|
||||||
|
expect(isSameLocalDay(new Date(2026, 9, 8, 23, 59), new Date(2026, 9, 9, 0, 1))).toBe(false)
|
||||||
|
expect(isSameLocalDay(new Date(2026, 9, 9, 0, 1), new Date(2026, 9, 9, 23, 59))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts ISO strings as well as Dates', () => {
|
||||||
|
expect(isSameLocalDay(iso(new Date(2026, 9, 9, 8, 0)), new Date(2026, 9, 9, 20, 0))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// "Today" is the viewer's local calendar day, so everything here goes through
|
||||||
|
// local-time getters, never UTC.
|
||||||
|
type DateLike = Date | string
|
||||||
|
|
||||||
|
function toDate(d: DateLike): Date {
|
||||||
|
return d instanceof Date ? d : new Date(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A calendar-day number, not elapsed milliseconds: built from the local
|
||||||
|
// y/m/d via Date.UTC so a 23- or 25-hour DST day still counts as one day,
|
||||||
|
// and 11:58 PM vs 12:01 AM (3 minutes apart) correctly counts as 1 day apart.
|
||||||
|
function dayNumber(d: Date): number {
|
||||||
|
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameLocalDay(a: DateLike, b: DateLike): boolean {
|
||||||
|
return dayNumber(toDate(a)) === dayNumber(toDate(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare "4:04 PM" gives no way to tell today's message from one sent last
|
||||||
|
// week. Today stays time-only; anything older gains just enough date to
|
||||||
|
// place it.
|
||||||
|
export function formatMessageTime(iso: string, now: Date = new Date()): string {
|
||||||
|
const sent = new Date(iso)
|
||||||
|
const time = sent.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
// <= 0 also covers a timestamp slightly in the future (clock skew between
|
||||||
|
// server and this device) -- better shown as today than as a negative age.
|
||||||
|
const daysAgo = dayNumber(now) - dayNumber(sent)
|
||||||
|
if (daysAgo <= 0) return time
|
||||||
|
if (daysAgo === 1) return `Yesterday ${time}`
|
||||||
|
// Under 7 so the weekday is never the same as today's (7 days ago would be).
|
||||||
|
if (daysAgo < 7) return `${sent.toLocaleDateString([], { weekday: 'short' })} ${time}`
|
||||||
|
const date = sent.toLocaleDateString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: sent.getFullYear() === now.getFullYear() ? undefined : 'numeric',
|
||||||
|
})
|
||||||
|
return `${date}, ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover text: the one place the full, unambiguous date is always available.
|
||||||
|
export function formatMessageTimeTitle(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString([], { dateStyle: 'full', timeStyle: 'short' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from './formattingToolbarPref'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatting toolbar preference', () => {
|
||||||
|
it('is collapsed by default', () => {
|
||||||
|
expect(getFormattingToolbarOpen()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remembers being opened and closed', () => {
|
||||||
|
setFormattingToolbarOpen(true)
|
||||||
|
expect(getFormattingToolbarOpen()).toBe(true)
|
||||||
|
setFormattingToolbarOpen(false)
|
||||||
|
expect(getFormattingToolbarOpen()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to collapsed when storage is unavailable', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
})
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked')
|
||||||
|
})
|
||||||
|
expect(getFormattingToolbarOpen()).toBe(false)
|
||||||
|
expect(() => setFormattingToolbarOpen(true)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const KEY = 'ds-chat-formatting-toolbar-open'
|
||||||
|
|
||||||
|
// Collapsed unless the user has opened it before -- and remembered, because
|
||||||
|
// Composer remounts on every room switch and would otherwise snap shut each
|
||||||
|
// time.
|
||||||
|
export function getFormattingToolbarOpen(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFormattingToolbarOpen(open: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(open))
|
||||||
|
} catch {
|
||||||
|
// storage unavailable (private browsing, quota) -- the toolbar just
|
||||||
|
// won't remember its state, not fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { applyFormat, type FormatAction, type InlineMarker, type TextState } from './markdownFormat'
|
||||||
|
|
||||||
|
// ⟦ and ⟧ mark the selection, so cases read as plain strings -- an empty
|
||||||
|
// pair (⟦⟧) is a bare cursor.
|
||||||
|
const L = '⟦'
|
||||||
|
const R = '⟧'
|
||||||
|
|
||||||
|
function parse(s: string): TextState {
|
||||||
|
const start = s.indexOf(L)
|
||||||
|
const end = s.indexOf(R) - 1
|
||||||
|
return { value: s.replace(L, '').replace(R, ''), start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render({ value, start, end }: TextState): string {
|
||||||
|
return value.slice(0, start) + L + value.slice(start, end) + R + value.slice(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(s: string, action: FormatAction): string {
|
||||||
|
return render(applyFormat(parse(s), action))
|
||||||
|
}
|
||||||
|
|
||||||
|
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
|
||||||
|
|
||||||
|
describe('inline wrap', () => {
|
||||||
|
it('wraps the selection and keeps the inner text selected', () => {
|
||||||
|
expect(fmt('say ⟦hi⟧ now', inline('**'))).toBe('say **⟦hi⟧** now')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts an empty pair with the cursor between when nothing is selected', () => {
|
||||||
|
expect(fmt('say ⟦⟧now', inline('**'))).toBe('say **⟦⟧**now')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unwraps when the markers sit just outside the selection', () => {
|
||||||
|
expect(fmt('say **⟦hi⟧** now', inline('**'))).toBe('say ⟦hi⟧ now')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unwraps when the selection includes the markers', () => {
|
||||||
|
expect(fmt('say ⟦**hi**⟧ now', inline('**'))).toBe('say ⟦hi⟧ now')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps markers outside leading/trailing whitespace in the selection', () => {
|
||||||
|
expect(fmt('a⟦ hi ⟧b', inline('**'))).toBe('a **⟦hi⟧** b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('italic on a bold selection adds italic rather than stripping bold', () => {
|
||||||
|
expect(fmt('x **⟦hi⟧** y', inline('*'))).toBe('x ***⟦hi⟧*** y')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes just bold from bold+italic', () => {
|
||||||
|
expect(fmt('x ***⟦hi⟧*** y', inline('**'))).toBe('x *⟦hi⟧* y')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes just italic from bold+italic', () => {
|
||||||
|
expect(fmt('x ***⟦hi⟧*** y', inline('*'))).toBe('x **⟦hi⟧** y')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strikethrough toggles off without being confused by subscript', () => {
|
||||||
|
expect(fmt('x ~~⟦hi⟧~~ y', inline('~~'))).toBe('x ⟦hi⟧ y')
|
||||||
|
expect(fmt('a ⟦hi⟧ b', inline('~'))).toBe('a ~⟦hi⟧~ b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles inline code and superscript', () => {
|
||||||
|
expect(fmt('run ⟦ls⟧', inline('`'))).toBe('run `⟦ls⟧`')
|
||||||
|
expect(fmt('x⟦2⟧', inline('^'))).toBe('x^⟦2⟧^')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['**', '*', '~~', '~', '`', '^'] as InlineMarker[])('%s: applying twice restores the original', (marker) => {
|
||||||
|
const original = parse('a ⟦b⟧ c')
|
||||||
|
const once = applyFormat(original, inline(marker))
|
||||||
|
expect(once.value).not.toBe(original.value)
|
||||||
|
expect(applyFormat(once, inline(marker))).toEqual(original)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('line prefixes', () => {
|
||||||
|
it('prefixes the current line and moves the cursor with it', () => {
|
||||||
|
expect(fmt('hello⟦⟧', { kind: 'prefix', prefix: 'quote' })).toBe('> hello⟦⟧')
|
||||||
|
expect(fmt('one\ntw⟦⟧o', { kind: 'prefix', prefix: 'quote' })).toBe('one\n> tw⟦⟧o')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a list on a blank line', () => {
|
||||||
|
expect(fmt('⟦⟧', { kind: 'prefix', prefix: 'bullet' })).toBe('- ⟦⟧')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefixes every selected line, then toggles them back off', () => {
|
||||||
|
const bullet: FormatAction = { kind: 'prefix', prefix: 'bullet' }
|
||||||
|
expect(fmt('⟦a\nb⟧', bullet)).toBe('⟦- a\n- b⟧')
|
||||||
|
expect(fmt('⟦- a\n- b⟧', bullet)).toBe('⟦a\nb⟧')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('numbers lines sequentially, skipping blank lines, and renumbers existing numbers', () => {
|
||||||
|
const ordered: FormatAction = { kind: 'prefix', prefix: 'ordered' }
|
||||||
|
expect(fmt('⟦a\n\nb⟧', ordered)).toBe('⟦1. a\n\n2. b⟧')
|
||||||
|
expect(fmt('⟦1. a\nb⟧', ordered)).toBe('⟦1. a\n2. b⟧')
|
||||||
|
expect(fmt('⟦1. a\n2. b⟧', ordered)).toBe('⟦a\nb⟧')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include a line the selection only touches at its very start', () => {
|
||||||
|
expect(fmt('a\n⟦b\n⟧c', { kind: 'prefix', prefix: 'bullet' })).toBe('a\n⟦- b⟧\nc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('headings', () => {
|
||||||
|
it('adds, replaces, and toggles off the heading level', () => {
|
||||||
|
expect(fmt('title⟦⟧', { kind: 'heading', level: 2 })).toBe('## title⟦⟧')
|
||||||
|
expect(fmt('⟦# hi⟧', { kind: 'heading', level: 2 })).toBe('⟦## hi⟧')
|
||||||
|
expect(fmt('⟦## hi⟧', { kind: 'heading', level: 1 })).toBe('⟦# hi⟧')
|
||||||
|
expect(fmt('⟦## hi⟧', { kind: 'heading', level: 2 })).toBe('⟦hi⟧')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('code block', () => {
|
||||||
|
it('inserts an empty fenced block with the cursor on the blank line', () => {
|
||||||
|
expect(fmt('⟦⟧', { kind: 'codeBlock' })).toBe('```\n⟦⟧\n```')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the fences on their own lines when the selection is mid-line', () => {
|
||||||
|
expect(fmt('see ⟦x = 1⟧ here', { kind: 'codeBlock' })).toBe('see \n```\n⟦x = 1⟧\n```\n here')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not double the newline when the selection already ends with one', () => {
|
||||||
|
expect(fmt('⟦a\n⟧b', { kind: 'codeBlock' })).toBe('```\n⟦a⟧\n```\nb')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('link', () => {
|
||||||
|
it('wraps the selection and selects the placeholder url', () => {
|
||||||
|
expect(fmt('see ⟦docs⟧', { kind: 'link' })).toBe('see [docs](⟦url⟧)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a selected url as the target and leaves the cursor in the text brackets', () => {
|
||||||
|
expect(fmt('⟦https://example.com⟧', { kind: 'link' })).toBe('[⟦⟧](https://example.com)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts a placeholder link with its text selected when nothing is selected', () => {
|
||||||
|
expect(fmt('a ⟦⟧', { kind: 'link' })).toBe('a [⟦link text⟧](url)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// #75: pure text transforms behind the composer's formatting toolbar. Each
|
||||||
|
// takes the textarea's value plus its selection and returns the same shape,
|
||||||
|
// so Composer just applies the result -- keeping this free of React/DOM is
|
||||||
|
// what makes the fiddly selection math directly unit-testable.
|
||||||
|
export interface TextState {
|
||||||
|
value: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InlineMarker = '**' | '*' | '~~' | '`' | '~' | '^'
|
||||||
|
|
||||||
|
export type FormatAction =
|
||||||
|
| { kind: 'inline'; marker: InlineMarker }
|
||||||
|
| { kind: 'prefix'; prefix: 'quote' | 'bullet' | 'ordered' }
|
||||||
|
| { kind: 'heading'; level: 1 | 2 | 3 }
|
||||||
|
| { kind: 'codeBlock' }
|
||||||
|
| { kind: 'link' }
|
||||||
|
|
||||||
|
// Markers whose doubled form is a *different* format (`**` bold vs `*`
|
||||||
|
// italic, `~~` strikethrough vs `~` subscript), so "is this already
|
||||||
|
// wrapped?" has to look at how long the run of marker characters is rather
|
||||||
|
// than just whether one is present.
|
||||||
|
const DOUBLED_IS_DIFFERENT = new Set(['*', '~'])
|
||||||
|
|
||||||
|
// A run of 3 `*` is bold+italic: bold (2) and italic (1) are both "present".
|
||||||
|
function hasMarker(marker: string, run: number): boolean {
|
||||||
|
if (marker.length === 2) return run >= 2
|
||||||
|
return DOUBLED_IS_DIFFERENT.has(marker[0]) ? run % 2 === 1 : run >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInline(state: TextState, marker: InlineMarker): TextState {
|
||||||
|
const { value, start, end } = state
|
||||||
|
const ch = marker[0]
|
||||||
|
const m = marker.length
|
||||||
|
const sel = value.slice(start, end)
|
||||||
|
|
||||||
|
// Selection includes the markers themselves (e.g. the user selected the
|
||||||
|
// whole `**word**`) -- unwrap inside the selection.
|
||||||
|
if (sel.length > 0) {
|
||||||
|
let lead = 0
|
||||||
|
while (lead < sel.length && sel[lead] === ch) lead++
|
||||||
|
let trail = 0
|
||||||
|
while (trail < sel.length - lead && sel[sel.length - 1 - trail] === ch) trail++
|
||||||
|
const run = Math.min(lead, trail)
|
||||||
|
if (run > 0 && hasMarker(marker, run) && sel.length > 2 * m) {
|
||||||
|
const inner = sel.slice(m, sel.length - m)
|
||||||
|
return { value: value.slice(0, start) + inner + value.slice(end), start, end: start + inner.length }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markers sit just outside the selection. Skipped when the selection has
|
||||||
|
// edge whitespace: markers adjacent to it belong to neighbouring spans
|
||||||
|
// (`*a* and *b*` with " and " selected), not to this selection.
|
||||||
|
const edgeWhitespace = sel.length > 0 && (/^\s/.test(sel) || /\s$/.test(sel))
|
||||||
|
if (!edgeWhitespace) {
|
||||||
|
let before = 0
|
||||||
|
while (start - before - 1 >= 0 && value[start - before - 1] === ch) before++
|
||||||
|
let after = 0
|
||||||
|
while (end + after < value.length && value[end + after] === ch) after++
|
||||||
|
const run = Math.min(before, after)
|
||||||
|
if (run > 0 && hasMarker(marker, run)) {
|
||||||
|
return {
|
||||||
|
value: value.slice(0, start - m) + sel + value.slice(end + m),
|
||||||
|
start: start - m,
|
||||||
|
end: end - m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing selected (or only whitespace): drop in an empty pair, cursor between.
|
||||||
|
if (sel.trim() === '') {
|
||||||
|
return {
|
||||||
|
value: value.slice(0, start) + marker + marker + value.slice(start),
|
||||||
|
start: start + m,
|
||||||
|
end: start + m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown doesn't treat `** word**` as bold -- keep the markers hugging
|
||||||
|
// the text, outside any whitespace the selection happens to include.
|
||||||
|
const innerStart = start + (sel.length - sel.trimStart().length)
|
||||||
|
const innerEnd = end - (sel.length - sel.trimEnd().length)
|
||||||
|
return {
|
||||||
|
value:
|
||||||
|
value.slice(0, innerStart) + marker + value.slice(innerStart, innerEnd) + marker + value.slice(innerEnd),
|
||||||
|
start: innerStart + m,
|
||||||
|
end: innerEnd + m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole lines a selection touches. A selection that ends right after a
|
||||||
|
// newline (i.e. at the very start of the next line) doesn't count that line.
|
||||||
|
function lineBounds(value: string, start: number, end: number) {
|
||||||
|
const lineStart = start === 0 ? 0 : value.lastIndexOf('\n', start - 1) + 1
|
||||||
|
const effectiveEnd = end > start && value[end - 1] === '\n' ? end - 1 : end
|
||||||
|
const nextNewline = value.indexOf('\n', effectiveEnd)
|
||||||
|
return { lineStart, lineEnd: nextNewline === -1 ? value.length : nextNewline }
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLines(state: TextState, transform: (lines: string[]) => string[]): TextState {
|
||||||
|
const { value, start, end } = state
|
||||||
|
const { lineStart, lineEnd } = lineBounds(value, start, end)
|
||||||
|
const oldBlock = value.slice(lineStart, lineEnd)
|
||||||
|
const newBlock = transform(oldBlock.split('\n')).join('\n')
|
||||||
|
const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd)
|
||||||
|
if (start === end) {
|
||||||
|
const cursor = Math.max(lineStart, start + (newBlock.length - oldBlock.length))
|
||||||
|
return { value: next, start: cursor, end: cursor }
|
||||||
|
}
|
||||||
|
// Keep the affected lines selected so the same button toggles it back off.
|
||||||
|
return { value: next, start: lineStart, end: lineStart + newBlock.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDERED_RE = /^\d+\. /
|
||||||
|
|
||||||
|
function applyPrefix(state: TextState, kind: 'quote' | 'bullet' | 'ordered'): TextState {
|
||||||
|
const marker = kind === 'quote' ? '> ' : '- '
|
||||||
|
const has = (line: string) => (kind === 'ordered' ? ORDERED_RE.test(line) : line.startsWith(marker))
|
||||||
|
const strip = (line: string) => (kind === 'ordered' ? line.replace(ORDERED_RE, '') : line.slice(marker.length))
|
||||||
|
|
||||||
|
return transformLines(state, (lines) => {
|
||||||
|
// Blank lines inside a multi-line selection are paragraph breaks, not
|
||||||
|
// list items -- only a lone blank line (cursor on an empty line) gets one.
|
||||||
|
const multi = lines.length > 1
|
||||||
|
const skip = (line: string) => multi && line === ''
|
||||||
|
const targets = lines.filter((line) => !skip(line))
|
||||||
|
if (targets.length > 0 && targets.every(has)) {
|
||||||
|
return lines.map((line) => (has(line) ? strip(line) : line))
|
||||||
|
}
|
||||||
|
let n = 0
|
||||||
|
return lines.map((line) => {
|
||||||
|
if (skip(line)) return line
|
||||||
|
n++
|
||||||
|
if (kind === 'ordered') return `${n}. ${strip(line)}`
|
||||||
|
return has(line) ? line : marker + line
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADING_RE = /^#{1,6} /
|
||||||
|
|
||||||
|
function applyHeading(state: TextState, level: 1 | 2 | 3): TextState {
|
||||||
|
const prefix = '#'.repeat(level) + ' '
|
||||||
|
return transformLines(state, (lines) => {
|
||||||
|
const multi = lines.length > 1
|
||||||
|
const skip = (line: string) => multi && line === ''
|
||||||
|
const targets = lines.filter((line) => !skip(line))
|
||||||
|
if (targets.length > 0 && targets.every((line) => line.startsWith(prefix))) {
|
||||||
|
return lines.map((line) => (line.startsWith(prefix) ? line.slice(prefix.length) : line))
|
||||||
|
}
|
||||||
|
return lines.map((line) => (skip(line) ? line : prefix + line.replace(HEADING_RE, '')))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fence only counts at the start of a line, so mid-line cursors get a
|
||||||
|
// newline inserted first.
|
||||||
|
function applyCodeBlock(state: TextState): TextState {
|
||||||
|
const { value, start, end } = state
|
||||||
|
const before = value.slice(0, start)
|
||||||
|
const after = value.slice(end)
|
||||||
|
const sel = value.slice(start, end).replace(/\n$/, '')
|
||||||
|
const lead = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
|
||||||
|
const trail = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
|
||||||
|
const next = `${before}${lead}\`\`\`\n${sel}\n\`\`\`${trail}${after}`
|
||||||
|
const innerStart = before.length + lead.length + 4
|
||||||
|
return { value: next, start: innerStart, end: innerStart + sel.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLink(state: TextState): TextState {
|
||||||
|
const { value, start, end } = state
|
||||||
|
const sel = value.slice(start, end)
|
||||||
|
let text: string
|
||||||
|
let selStart: number
|
||||||
|
let selEnd: number
|
||||||
|
if (/^https?:\/\/\S+$/.test(sel)) {
|
||||||
|
text = `[](${sel})`
|
||||||
|
selStart = selEnd = start + 1
|
||||||
|
} else if (sel) {
|
||||||
|
text = `[${sel}](url)`
|
||||||
|
selStart = start + sel.length + 3
|
||||||
|
selEnd = selStart + 3
|
||||||
|
} else {
|
||||||
|
text = '[link text](url)'
|
||||||
|
selStart = start + 1
|
||||||
|
selEnd = selStart + 9
|
||||||
|
}
|
||||||
|
return { value: value.slice(0, start) + text + value.slice(end), start: selStart, end: selEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFormat(state: TextState, action: FormatAction): TextState {
|
||||||
|
switch (action.kind) {
|
||||||
|
case 'inline':
|
||||||
|
return applyInline(state, action.marker)
|
||||||
|
case 'prefix':
|
||||||
|
return applyPrefix(state, action.prefix)
|
||||||
|
case 'heading':
|
||||||
|
return applyHeading(state, action.level)
|
||||||
|
case 'codeBlock':
|
||||||
|
return applyCodeBlock(state)
|
||||||
|
case 'link':
|
||||||
|
return applyLink(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { GROUP_BREAK_MS, isGroupStart } from './messageGrouping'
|
||||||
|
|
||||||
|
const msg = (user: string, d: Date) => ({ user_id: user, created_at: d.toISOString() })
|
||||||
|
|
||||||
|
describe('isGroupStart', () => {
|
||||||
|
const t = new Date(2026, 9, 9, 10, 0, 0)
|
||||||
|
const later = (ms: number) => new Date(t.getTime() + ms)
|
||||||
|
|
||||||
|
it('starts a group for the first message', () => {
|
||||||
|
expect(isGroupStart(undefined, msg('a', t))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a group when the sender changes', () => {
|
||||||
|
expect(isGroupStart(msg('a', t), msg('b', later(1000)))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the same sender grouped within the break window', () => {
|
||||||
|
expect(isGroupStart(msg('a', t), msg('a', later(60_000)))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups at exactly the break threshold and splits just past it', () => {
|
||||||
|
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS)))).toBe(false)
|
||||||
|
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS + 1000)))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('measures the gap from the previous message, not the first in the run', () => {
|
||||||
|
const fourMin = 4 * 60_000
|
||||||
|
expect(isGroupStart(msg('a', later(fourMin)), msg('a', later(2 * fourMin)))).toBe(false)
|
||||||
|
expect(isGroupStart(msg('a', later(2 * fourMin)), msg('a', later(3 * fourMin)))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a new group when the same sender crosses midnight, even a few minutes apart', () => {
|
||||||
|
const before = new Date(2026, 9, 8, 23, 58)
|
||||||
|
const after = new Date(2026, 9, 9, 0, 1)
|
||||||
|
expect(after.getTime() - before.getTime()).toBeLessThan(GROUP_BREAK_MS)
|
||||||
|
expect(isGroupStart(msg('a', before), msg('a', after))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,31 @@
|
|||||||
import { getUserAvatarUrl } from '../api/users'
|
import { getUserAvatarUrl } from '../api/users'
|
||||||
import { hashIndex } from './avatar'
|
import { hashIndex } from './avatar'
|
||||||
|
import { isSameLocalDay } from './formatMessageTime'
|
||||||
import type { RoomMember } from '../types'
|
import type { RoomMember } from '../types'
|
||||||
|
|
||||||
|
// #73: Slack's own threshold for the same "still grouped, but it's been a
|
||||||
|
// while" call -- past this gap a same-sender message starts a new group
|
||||||
|
// (its own avatar/name/timestamp) even with nobody else posting in
|
||||||
|
// between, so a message sent minutes later doesn't hide under a stale
|
||||||
|
// timestamp from the start of the run.
|
||||||
|
export const GROUP_BREAK_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
interface Groupable {
|
||||||
|
user_id: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether `msg` gets its own avatar/name/timestamp header, vs. continuing
|
||||||
|
// `prev`'s group. A new calendar day always breaks the group: now that
|
||||||
|
// timestamps can carry a date, a same-sender run that crosses midnight
|
||||||
|
// (11:58 PM, then 12:01 AM) would otherwise leave the later message under a
|
||||||
|
// header showing the wrong day.
|
||||||
|
export function isGroupStart(prev: Groupable | undefined, msg: Groupable): boolean {
|
||||||
|
if (!prev || prev.user_id !== msg.user_id) return true
|
||||||
|
if (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS) return true
|
||||||
|
return !isSameLocalDay(prev.created_at, msg.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
export function senderColorIndex(username: string, members: RoomMember[]): number {
|
export function senderColorIndex(username: string, members: RoomMember[]): number {
|
||||||
const idx = members.findIndex((m) => m.username === username)
|
const idx = members.findIndex((m) => m.username === username)
|
||||||
if (idx >= 0) return idx
|
if (idx >= 0) return idx
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { MyRoomItem } from '../types'
|
||||||
|
import { clearRoomIndicators } from './roomIndicators'
|
||||||
|
|
||||||
|
function makeRoom(overrides: Partial<MyRoomItem> & { id: string }): MyRoomItem {
|
||||||
|
return {
|
||||||
|
name: overrides.id,
|
||||||
|
description: null,
|
||||||
|
is_private: false,
|
||||||
|
is_dm: false,
|
||||||
|
is_archived: false,
|
||||||
|
owner_id: 'owner',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
role: 'member',
|
||||||
|
has_unread: false,
|
||||||
|
has_mention: false,
|
||||||
|
dm_partner: null,
|
||||||
|
email_notifications: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('clearRoomIndicators', () => {
|
||||||
|
it('clears has_unread and has_mention on the matching room', () => {
|
||||||
|
const rooms = [makeRoom({ id: 'room-1', has_unread: true, has_mention: true })]
|
||||||
|
const result = clearRoomIndicators(rooms, 'room-1')
|
||||||
|
expect(result[0].has_unread).toBe(false)
|
||||||
|
expect(result[0].has_mention).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves unread state on every other room', () => {
|
||||||
|
const rooms = [
|
||||||
|
makeRoom({ id: 'room-1', has_unread: true, has_mention: true }),
|
||||||
|
makeRoom({ id: 'room-2', has_unread: true, has_mention: false }),
|
||||||
|
makeRoom({ id: 'room-3', has_unread: false, has_mention: false }),
|
||||||
|
]
|
||||||
|
const result = clearRoomIndicators(rooms, 'room-1')
|
||||||
|
const room2 = result.find((r) => r.id === 'room-2')
|
||||||
|
const room3 = result.find((r) => r.id === 'room-3')
|
||||||
|
expect(room2?.has_unread).toBe(true)
|
||||||
|
expect(room3?.has_unread).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves untouched rooms as the same object reference', () => {
|
||||||
|
const room2 = makeRoom({ id: 'room-2', has_unread: true })
|
||||||
|
const rooms = [makeRoom({ id: 'room-1', has_unread: true }), room2]
|
||||||
|
const result = clearRoomIndicators(rooms, 'room-1')
|
||||||
|
expect(result[1]).toBe(room2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op if the room id is not found', () => {
|
||||||
|
const rooms = [makeRoom({ id: 'room-1', has_unread: true })]
|
||||||
|
const result = clearRoomIndicators(rooms, 'does-not-exist')
|
||||||
|
expect(result[0].has_unread).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { MyRoomItem } from '../types'
|
||||||
|
|
||||||
|
// Pulled out of ChatShellPage as a pure function purely so it's directly
|
||||||
|
// unit-testable without rendering the whole page -- behavior is unchanged.
|
||||||
|
// Only touches the matching room; every other room's object reference (and
|
||||||
|
// therefore its unread state) is left untouched.
|
||||||
|
export function clearRoomIndicators(rooms: MyRoomItem[], roomId: string): MyRoomItem[] {
|
||||||
|
return rooms.map((r) => (r.id === roomId ? { ...r, has_unread: false, has_mention: false } : r))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CustomThemeColors, ThemeName } from '../types'
|
import type { CustomThemeColors, TextScale, ThemeName } from '../types'
|
||||||
|
|
||||||
// The inline custom properties a custom theme sets on :root -- must be
|
// The inline custom properties a custom theme sets on :root -- must be
|
||||||
// removed explicitly when switching to a preset, since an inline style
|
// removed explicitly when switching to a preset, since an inline style
|
||||||
@@ -76,3 +76,21 @@ export function applyTheme(theme: ThemeName | null, customColors: CustomThemeCol
|
|||||||
for (const varName of CUSTOM_THEME_VARS) root.style.removeProperty(varName)
|
for (const varName of CUSTOM_THEME_VARS) root.style.removeProperty(varName)
|
||||||
root.style.removeProperty('color-scheme')
|
root.style.removeProperty('color-scheme')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #71: percentages, not fixed px -- stacks on top of the browser/OS's own
|
||||||
|
// zoom or accessibility text-size setting instead of overriding it. Every
|
||||||
|
// component in this app already sizes itself in rem (see tokens.css),
|
||||||
|
// which is relative to this root value, so setting it here is the one
|
||||||
|
// change that scales text *and* the message-image/video max-size caps
|
||||||
|
// (also converted to rem -- see MessageList.css) uniformly, with no
|
||||||
|
// per-component work.
|
||||||
|
const TEXT_SCALE_PERCENT: Record<TextScale, string> = {
|
||||||
|
small: '87.5%',
|
||||||
|
normal: '100%',
|
||||||
|
large: '112.5%',
|
||||||
|
xlarge: '125%',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTextScale(scale: TextScale | null): void {
|
||||||
|
document.documentElement.style.fontSize = TEXT_SCALE_PERCENT[scale ?? 'normal']
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { NetworkError } from '../api/client'
|
import { NetworkError } from '../api/client'
|
||||||
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
||||||
@@ -12,7 +12,9 @@ import { Sidebar } from '../components/Sidebar'
|
|||||||
import { TopBar } from '../components/TopBar'
|
import { TopBar } from '../components/TopBar'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useChatSocketContext } from '../context/ChatSocketContext'
|
import { useChatSocketContext } from '../context/ChatSocketContext'
|
||||||
|
import { useDesktopRoomBridge } from '../hooks/useDesktopRoomBridge'
|
||||||
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
||||||
|
import { clearRoomIndicators } from '../lib/roomIndicators'
|
||||||
import type { MyRoomItem, RoomMember } from '../types'
|
import type { MyRoomItem, RoomMember } from '../types'
|
||||||
import './ChatShellPage.css'
|
import './ChatShellPage.css'
|
||||||
|
|
||||||
@@ -60,10 +62,13 @@ export function ChatShellPage() {
|
|||||||
|
|
||||||
const socket = useChatSocketContext()
|
const socket = useChatSocketContext()
|
||||||
|
|
||||||
const clearRoomIndicators = useCallback((id: string) => {
|
const handleRoomRead = useCallback((id: string) => {
|
||||||
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r)))
|
setRooms((prev) => clearRoomIndicators(prev, id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const unreadRoomCount = useMemo(() => rooms.filter((r) => r.has_unread).length, [rooms])
|
||||||
|
useDesktopRoomBridge(activeRoom?.id, unreadRoomCount)
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
socket.subscribe((envelope) => {
|
socket.subscribe((envelope) => {
|
||||||
@@ -134,7 +139,7 @@ export function ChatShellPage() {
|
|||||||
onToggleInfo={() => setInfoOpen((v) => !v)}
|
onToggleInfo={() => setInfoOpen((v) => !v)}
|
||||||
infoOpen={infoOpen}
|
infoOpen={infoOpen}
|
||||||
socket={socket}
|
socket={socket}
|
||||||
onRoomRead={clearRoomIndicators}
|
onRoomRead={handleRoomRead}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
!isMobile && (
|
!isMobile && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Markdown from 'markdown-to-jsx'
|
import Markdown from 'markdown-to-jsx'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent'
|
import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent'
|
||||||
import { TopBar } from '../components/TopBar'
|
import { TopBar } from '../components/TopBar'
|
||||||
import './HelpPage.css'
|
import './HelpPage.css'
|
||||||
@@ -20,6 +20,15 @@ export function HelpPage() {
|
|||||||
[content],
|
[content],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// #75: the composer's "?" links straight to /help#formatting, but the guide
|
||||||
|
// is fetched after the page loads -- the browser's own scroll-to-hash has
|
||||||
|
// already run (and found nothing) by the time the headings exist.
|
||||||
|
const { hash } = useLocation()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markdownPreview || !hash) return
|
||||||
|
document.getElementById(hash.slice(1))?.scrollIntoView()
|
||||||
|
}, [markdownPreview, hash])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(GUIDE_URL)
|
fetch(GUIDE_URL)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom'
|
export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom'
|
||||||
|
|
||||||
|
// #71: null means "normal" -- see lib/theme.ts's TEXT_SCALE_PERCENT map.
|
||||||
|
export type TextScale = 'small' | 'normal' | 'large' | 'xlarge'
|
||||||
|
|
||||||
|
// #71: independent of TextScale -- see MessageContent.tsx's
|
||||||
|
// EMOJI_SCALE_MULTIPLIER map. Same preset shape for UI consistency.
|
||||||
|
export type EmojiScale = 'small' | 'normal' | 'large' | 'xlarge'
|
||||||
|
|
||||||
// Matches exactly the CSS custom properties frontend/src/styles/themes.css
|
// Matches exactly the CSS custom properties frontend/src/styles/themes.css
|
||||||
// overrides per built-in preset -- kept in sync with
|
// overrides per built-in preset -- kept in sync with
|
||||||
// backend/app/schemas/custom_theme.py's CustomThemeColors.
|
// backend/app/schemas/custom_theme.py's CustomThemeColors.
|
||||||
@@ -45,6 +52,8 @@ export interface User {
|
|||||||
// Only non-null when theme === 'custom' -- see UserRead's model_validator
|
// Only non-null when theme === 'custom' -- see UserRead's model_validator
|
||||||
// in backend/app/schemas/user.py.
|
// in backend/app/schemas/user.py.
|
||||||
active_custom_theme: CustomTheme | null
|
active_custom_theme: CustomTheme | null
|
||||||
|
text_scale: TextScale | null
|
||||||
|
emoji_scale: EmojiScale | null
|
||||||
avatar_filename: string | null
|
avatar_filename: string | null
|
||||||
appear_offline: boolean
|
appear_offline: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ interface UseChatSocketOptions {
|
|||||||
const RECONNECT_BASE_DELAY_MS = 1000
|
const RECONNECT_BASE_DELAY_MS = 1000
|
||||||
const RECONNECT_MAX_DELAY_MS = 30000
|
const RECONNECT_MAX_DELAY_MS = 30000
|
||||||
|
|
||||||
|
// #76: the backend pings every 30s (WS_PING_INTERVAL_SECONDS in chat.py) --
|
||||||
|
// this needs to comfortably tolerate one missed ping plus ordinary network
|
||||||
|
// jitter before declaring the connection dead, not fire on the very first
|
||||||
|
// late beat.
|
||||||
|
const ZOMBIE_TIMEOUT_MS = 65000
|
||||||
|
const ZOMBIE_CHECK_INTERVAL_MS = 10000
|
||||||
|
|
||||||
const desktopMode = isDesktopNotificationsSupported()
|
const desktopMode = isDesktopNotificationsSupported()
|
||||||
|
|
||||||
// Room join/leave (live message delivery) depends only on visibility --
|
// Room join/leave (live message delivery) depends only on visibility --
|
||||||
@@ -51,6 +58,16 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
// live WebSocket delivery the user can't actually see will do the job.
|
// live WebSocket delivery the user can't actually see will do the job.
|
||||||
const desiredRoomsRef = useRef(new Set<string>())
|
const desiredRoomsRef = useRef(new Set<string>())
|
||||||
const isVisibleRef = useRef(isVisible())
|
const isVisibleRef = useRef(isVisible())
|
||||||
|
// #76: last time *anything* arrived on the socket (a real message, a
|
||||||
|
// "ping", doesn't matter which) -- the only signal that a connection
|
||||||
|
// sitting at readyState OPEN is actually still alive rather than a
|
||||||
|
// zombie. Without this, a connection that dies without ever sending a
|
||||||
|
// close frame (laptop sleep, a NAT silently dropping an idle mapping)
|
||||||
|
// looks identical to a healthy-but-quiet one: onclose never fires, so the
|
||||||
|
// already-correct reconnect+rejoin logic below never runs, and new
|
||||||
|
// messages just stop arriving until something unrelated (e.g. switching
|
||||||
|
// rooms, which refetches history over plain REST) papers over it.
|
||||||
|
const lastActivityRef = useRef(Date.now())
|
||||||
|
|
||||||
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
|
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
|
||||||
const ws = socketRef.current
|
const ws = socketRef.current
|
||||||
@@ -99,6 +116,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
if (hasConnectedBefore) checkForUpdate()
|
if (hasConnectedBefore) checkForUpdate()
|
||||||
hasConnectedBefore = true
|
hasConnectedBefore = true
|
||||||
reconnectDelay = RECONNECT_BASE_DELAY_MS
|
reconnectDelay = RECONNECT_BASE_DELAY_MS
|
||||||
|
lastActivityRef.current = Date.now()
|
||||||
setConnected(true)
|
setConnected(true)
|
||||||
// Re-join whatever rooms were joined before a reconnect -- the
|
// Re-join whatever rooms were joined before a reconnect -- the
|
||||||
// server has no memory of a dropped connection's prior state. Only
|
// server has no memory of a dropped connection's prior state. Only
|
||||||
@@ -122,7 +140,17 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (socketRef.current !== ws) return
|
if (socketRef.current !== ws) return
|
||||||
const envelope = JSON.parse(event.data) as ServerEnvelope
|
lastActivityRef.current = Date.now()
|
||||||
|
const parsed = JSON.parse(event.data)
|
||||||
|
// #76: the server's heartbeat -- purely transport-level, not a
|
||||||
|
// domain event, so it's answered and swallowed right here instead
|
||||||
|
// of being forwarded to subscribers (ServerEnvelope's own type
|
||||||
|
// doesn't include it for exactly that reason).
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const envelope = parsed as ServerEnvelope
|
||||||
for (const handler of subscribersRef.current) handler(envelope)
|
for (const handler of subscribersRef.current) handler(envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +171,9 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
if (stopped) return
|
if (stopped) return
|
||||||
|
|
||||||
// Unexpected close -- a deploy restarting the app server, a brief
|
// Unexpected close -- a deploy restarting the app server, a brief
|
||||||
// network blip, or (absent any app-level ping/pong) an idle
|
// network blip, or an idle connection getting recycled by a reverse
|
||||||
// connection getting recycled by a reverse proxy in front of it.
|
// proxy in front of it. Retry with exponential backoff instead of
|
||||||
// Retry with exponential backoff instead of leaving the chat
|
// leaving the chat silently dead until the user manually reloads.
|
||||||
// silently dead until the user manually reloads.
|
|
||||||
reconnectTimer = setTimeout(connect, reconnectDelay)
|
reconnectTimer = setTimeout(connect, reconnectDelay)
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
|
||||||
}
|
}
|
||||||
@@ -154,9 +181,21 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
|
|||||||
|
|
||||||
connect()
|
connect()
|
||||||
|
|
||||||
|
// #76: catches the case onclose can't -- a connection that dies without
|
||||||
|
// ever sending a close frame at all (readyState stays OPEN forever) is
|
||||||
|
// otherwise invisible to this hook. Forcing a close here just routes it
|
||||||
|
// through the exact same onclose/reconnect/rejoin path as a normal
|
||||||
|
// disconnect, rather than needing separate recovery logic of its own.
|
||||||
|
const zombieCheckTimer = setInterval(() => {
|
||||||
|
if (Date.now() - lastActivityRef.current > ZOMBIE_TIMEOUT_MS) {
|
||||||
|
socketRef.current?.close()
|
||||||
|
}
|
||||||
|
}, ZOMBIE_CHECK_INTERVAL_MS)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
clearInterval(zombieCheckTimer)
|
||||||
socketRef.current?.close()
|
socketRef.current?.close()
|
||||||
socketRef.current = null
|
socketRef.current = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// Deliberately separate from vite.config.ts, not an extension of it -- that
|
||||||
|
// file's VitePWA plugin does real build-time work (service worker
|
||||||
|
// injection, manifest generation) that has nothing to do with running unit
|
||||||
|
// tests and would just slow every test run down for no benefit.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in new issue
Block a user