13 Commits
Author SHA1 Message Date
ksmithandClaude Sonnet 5 b1c9a85ab3 Bump to v2026.9.4
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:38:02 -06:00
ksmithandClaude Sonnet 5 62a25c6278 Point public deploy docs at the real GitHub repo
DEPLOYMENT.md's clone instructions and frontend/.env.example's
VITE_SOURCE_URL default now name github.com/ds-ksmith/DS-Chat directly
now that the project actually has a public home there, instead of
generic placeholders -- with a note for anyone deploying from their
own fork. The user's own production deploy is unaffected: it still
pulls from their private Gitea instance, which mirrors to GitHub.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:37:35 -06:00
ksmithandClaude Sonnet 5 7a84d2f09f Deploy and upgrade only from tagged releases, not branch tip
deploy/upgrade.sh now fetches tags and checks out whichever sorts
newest (detached HEAD) instead of git pull --ff-only on a branch, now
that the project has a real release process (dated tags, e.g.
v2026.9.3) and a public mirror. Keeps production from ever landing on
an untagged commit. DEPLOYMENT.md's initial-clone steps and Upgrades
section updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:31:08 -06:00
ksmithandClaude Sonnet 5 d74527a29c Stop tracking .claude/ -- local tool config, not project source
launch.json is just the Browser preview tool's dev-server launch
config (command, port); nothing sensitive in it, but it never should
have been committed in the first place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:31:00 -06:00
ksmithandClaude Sonnet 5 f0e4c76ffd Prepare for public release: de-identify docs, add contributor terms
Removes personal infra baked into copy-paste instructions and the
app's own UI ahead of an eventual public release:

- AboutModal's "Source code" link is now a build-time env var
  (VITE_SOURCE_URL) instead of a hardcoded personal Gitea URL, and
  hides itself when unset rather than pointing somewhere wrong
- DEPLOYMENT.md's clone steps are genericized to any git host
- LICENSE gets its previously-blank copyright/description lines filled in
- CONTRIBUTING.md adds a lightweight contributor-terms note to keep a
  future dual-licensed offering possible once outside PRs start arriving

Deliberately out of scope for now: git commit history (still under the
real author identity) and the actual publish destination -- both still
undecided.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:01:33 -06:00
ksmithandClaude Sonnet 5 3be8d9d731 Bump to v2026.9.3
Switches to a date-based version scheme (YYYY.M.D, with .2/.3/etc.
appended for additional same-day releases) instead of semver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 21:00:56 -06:00
ksmithandClaude Sonnet 5 30e63ffa83 Add an auto-provisioned system account for automated messages (#74)
The #72 welcome message was attributed to whichever admin added the
member, since there was no system/bot sender concept -- every Message
row requires a real user_id. Adds a lazily-created "system" bot
account (reusing the existing is_bot infrastructure) and switches the
welcome message to post as it instead. Excluded from the People
directory and @mention autocomplete for free: the directory already
filters is_bot users, and mention suggestions are sourced from room
membership, which the system account is never added to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:56:10 -06:00
ksmithandClaude Sonnet 5 019e10ac5c Post a welcome message when an admin adds someone to a room (#72)
Attributed to the admin doing the adding rather than a new system/bot
sender concept, since every message today requires a real user_id and
the admin is already a real, in-scope user for the request. Broadcasts
room_added before the welcome message itself, so the new member's
client learns the room exists before it sees an unread update for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:35:09 -06:00
ksmithandClaude Sonnet 5 6e889b8ea4 Break message grouping on a long gap, not just sender change (#73)
Consecutive messages from the same sender only repeated the
avatar/name/timestamp header on the first one in the run, so a
message sent minutes later with nobody else posting in between still
hid under a stale timestamp. Adds a 5-minute gap threshold (Slack's
own cutoff) that starts a new group even for the same sender.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:02:01 -06:00
ksmithandClaude Sonnet 5 b4a104f8c6 Move custom emoji management out of the emoji picker
The picker's delete "x" overlaps the glyph in a tightly packed grid,
which is too easy to hit by accident on a touch screen. Adding and
deleting custom emoji now live in their own modal under the account
menu, with delete gated behind the same confirm() every other
destructive action in the app uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:02:21 -06:00
ksmithandClaude Sonnet 5 03cc16f236 Remove auto-large emoji-only messages, keep manual emoji size (#71)
The manual emoji-size preference already covers this well enough on
its own; the automatic 2.5x bump for emoji-only messages was extra
behavior on top of it that wasn't needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:33:50 -06:00
ksmithandClaude Sonnet 5 520b971247 Add adjustable text size and emoji size preferences (#71)
Text was too small on high-DPI screens with no in-app fix beyond
browser zoom. Adds a text-size setting (scales the whole app via a
root font-size percentage), auto-large rendering for emoji-only
messages, and an independent emoji-size preference that also scales
reaction pills without affecting the emoji picker's fixed-size grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:30:49 -06:00
ksmithandClaude Sonnet 5 cd6296d079 Shorten link preview cache TTL from 7 days to 5 minutes (#70)
Re-posting a URL whose title/content had genuinely changed kept
showing the stale first-fetch preview for up to a week. 5 minutes is
effectively "always fresh" for any realistic re-share cadence, while
still collapsing a burst of near-simultaneous fetches of the same URL
into one and not re-hammering a URL that just failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:50:16 -06:00
38 changed files with 900 additions and 190 deletions
-17
View File
@@ -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
}
]
}
+1
View File
@@ -17,6 +17,7 @@ dist-ssr/
## Editors / OS
.vscode/
.idea/
.claude/
.DS_Store
## Test / coverage
+51
View File
@@ -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
View File
@@ -132,35 +132,45 @@ sudo -u ds-chat ssh-keygen -t ed25519 -f /srv/ds-chat/.ssh/id_ed25519 -N ""
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
```
Add that public key as a **read-only deploy key** on the Gitea repo
(Settings → Deploy Keys), then:
This project is hosted at
**[github.com/ds-ksmith/DS-Chat](https://github.com/ds-ksmith/DS-Chat)**.
Add that public key there as a **read-only deploy key** (Settings → Deploy
Keys on the repo), then:
```bash
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts
sudo -u ds-chat git clone git@git.darksingularity.org:DarkSingularity/ds-chat.git /srv/ds-chat
sudo -u ds-chat ssh-keyscan github.com >> /srv/ds-chat/.ssh/known_hosts
sudo -u ds-chat git clone git@github.com:ds-ksmith/DS-Chat.git /srv/ds-chat
```
(If your Gitea's SSH is on a non-default port, adjust the clone URL and
`ssh-keyscan -p <port>` accordingly.)
(Deploying from your own fork instead? Substitute its clone URL — the same
deploy-key/access-token steps work the same way on GitHub, GitLab, Gitea,
and most other git hosts.)
**Alternative: a personal/deployment-user access token instead of a deploy
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
and clone over HTTPS with the token embedded in the URL:
```bash
sudo -u ds-chat git clone https://<TOKEN>@git.darksingularity.org/DarkSingularity/ds-chat.git /srv/ds-chat
sudo -u ds-chat git clone https://<TOKEN>@github.com/ds-ksmith/DS-Chat.git /srv/ds-chat
```
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
remote -v` shows it) — readable by root and the `ds-chat` user, not by
anyone else under normal file permissions. `deploy/upgrade.sh`'s later
`git pull`s reuse this same authenticated URL automatically, no extra
`git fetch`es reuse this same authenticated URL automatically, no extra
setup needed. Fine as long as the token is scoped to read-only access on
just this repo.
Either way, now that the repo is cloned:
Either way, now that the repo is cloned, check out the latest release tag
rather than deploying whatever the default branch's tip happens to be —
`deploy/upgrade.sh` follows the same rule on every later upgrade (see §6),
so this keeps the very first deploy consistent with all the ones after it:
```bash
cd /srv/ds-chat
sudo -u ds-chat git fetch --tags
LATEST_TAG="$(sudo -u ds-chat git tag --sort=-creatordate | head -n1)"
sudo -u ds-chat git checkout --detach "$LATEST_TAG"
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
```
@@ -232,7 +242,16 @@ admin sets it up.
`/ws`) whenever that directory exists — that's what lets Nginx Proxy
Manager forward the whole domain to one port with no custom path routing.
Before building, copy `frontend/.env.example` to `frontend/.env.production`
and set `VITE_SOURCE_URL` to wherever *your* copy of the repo lives — see
that file's own comment for why this matters (AGPL-3.0 source-availability
compliance). Vite bakes this in at build time, so it needs to be in place
before `npm run build` runs, and needs re-running after any future change
to it.
```bash
sudo -u ds-chat cp /srv/ds-chat/frontend/.env.example /srv/ds-chat/frontend/.env.production
sudo -u ds-chat nano /srv/ds-chat/frontend/.env.production # set VITE_SOURCE_URL
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
```
@@ -320,12 +339,15 @@ This is config in NPM's own UI/database, not a file this repo ships:
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
```
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`,
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops
before restarting anything if an earlier step — most importantly a failed
migration — errors out, so a bad deploy doesn't take down the previously
working one.
Fetches tags and checks out whichever one sorts newest (`git tag
--sort=-creatordate`) — deliberately not the default branch's tip, so
running this between releases is a safe no-op rather than pulling in
whatever's mid-flight on `main`. Then reinstalls backend deps, runs
`alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
curls `/api/health` to confirm it came back up. Fails loudly
(`set -euo pipefail`) and stops before restarting anything if an earlier
step — most importantly a failed migration — errors out, so a bad deploy
doesn't take down the previously working one.
Active users get disconnected for a few seconds during the restart and
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
+2 -2
View File
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
DS Chat, a self-hosted, real-time team chat service.
Copyright (C) 2026 Keith Smith
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
+7 -4
View File
@@ -822,10 +822,13 @@ preview card fetched from that page's Open Graph tags (`og:title`,
`Content-Type` is `text/html`.
- **Cached by URL, not by message** (`link_previews` table, unique on
`url`) — a URL posted by five different people in five different rooms
fetches once. A row also gets written on a *failed* fetch
(`fetch_failed=True`) so a URL that genuinely doesn't unfurl (SSRF
rejection, timeout, no usable title) isn't re-attempted on every message
that references it; both kinds expire after 7 days (`_CACHE_TTL`).
within the same short window fetches once. A row also gets written on a
*failed* fetch (`fetch_failed=True`) so a URL that genuinely doesn't
unfurl (SSRF rejection, timeout, no usable title) isn't re-attempted on
every message that references it; both kinds expire after 5 minutes
(`_CACHE_TTL`#70: was 7 days, confirmed live as far too long, a
re-posted URL whose title/content had genuinely changed kept showing
the stale first-fetch preview for up to a week).
- Parsed with stdlib `html.parser.HTMLParser`, not a new dependency — only
meta-tag scraping is needed, not general HTML parsing.
- Editing a message re-extracts the URL; if it changed or was removed, the
@@ -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 ###
+11
View File
@@ -19,6 +19,17 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20))
# #71: null means "normal" (the pre-existing default before this
# setting existed) -- a preset name, not a raw scale factor, so it's
# validated/enumerable the same way `theme` already is rather than
# accepting an arbitrary float.
text_scale: Mapped[str | None] = mapped_column(String(20))
# #71: independent of text_scale above -- scales emoji rendered in
# message text specifically, not the whole UI (see
# frontend/src/components/MessageContent.tsx's --emoji-scale, scoped
# to message content only so it can't also inflate the emoji picker's
# grid or reaction pills).
emoji_scale: Mapped[str | None] = mapped_column(String(20))
# Only meaningful when theme == "custom" -- which of this user's saved
# CustomTheme rows (app/models/custom_theme.py) is currently active.
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
+4
View File
@@ -100,6 +100,10 @@ async def update_profile(
current_user.display_name = display_name or None
if "theme" in updates:
current_user.theme = updates["theme"]
if "text_scale" in updates:
current_user.text_scale = updates["text_scale"]
if "emoji_scale" in updates:
current_user.emoji_scale = updates["emoji_scale"]
if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"]
await db.commit()
+33 -1
View File
@@ -39,12 +39,14 @@ from app.schemas.webhook import (
WebhookIncomingRead,
)
from app.services.link_preview_service import get_link_previews_for_urls
from app.services.message_events import broadcast_room_added
from app.services.message_events import broadcast_new_message, broadcast_room_added
from app.services.message_service import (
create_message,
get_reactions_for_messages,
list_recent_messages,
list_room_attachments,
)
from app.services.system_user_service import get_or_create_system_user
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.room_service import (
AlreadyMemberError,
@@ -655,7 +657,37 @@ async def add_member_endpoint(
raise HTTPException(status_code=404, detail="No user with that ID")
except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member")
# room_added first -- the new member's client needs to know this room
# exists before it can make sense of an unread_update for it, which the
# welcome message below would otherwise trigger out of order.
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
# #74: posted as the auto-provisioned System account, not the admin who
# did the adding -- "Welcome, bob!" reads as coming from the room/app
# itself, not as something the admin personally typed.
system_user = await get_or_create_system_user(db)
welcome_name = membership.user.display_name or membership.user.username
welcome_message = await create_message(
db, room.id, system_user.id, f"Welcome to #{room.name}, {welcome_name}!"
)
# Same "sending implies having seen the room" reasoning as ws/chat.py's
# own live-message path -- the admin is the one who caused this message,
# and is presumably already looking at this room's member management, so
# without this their own client would show it as unread regardless.
await mark_room_read(db, room.id, current_user.id)
await broadcast_new_message(
db,
request.app.state.broadcaster,
request.app.state.presence,
request.app.state.focus_presence,
request.app.state.global_presence,
str(request.base_url),
room.id,
welcome_message,
system_user,
)
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
return RoomMemberRead(
user_id=membership.user_id,
+6
View File
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
is_site_admin: bool
display_name: str | None
theme: str | None
text_scale: str | None
emoji_scale: str | None
# Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
# #71: kept in sync with frontend/src/lib/theme.ts's TEXT_SCALE_PERCENT map.
text_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
# #71: kept in sync with MessageContent.tsx's EMOJI_SCALE_MULTIPLIER map.
emoji_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
appear_offline: bool | None = Field(default=None)
+11 -1
View File
@@ -27,8 +27,18 @@ _TRAILING_PUNCTUATION = ".,;:!?)'\">"
_FETCH_TIMEOUT_SECONDS = 5.0
_MAX_BYTES = 512 * 1024
_MAX_REDIRECTS = 3
# #70: was 7 days -- confirmed live as too long for how this app actually
# gets used: re-posting a URL whose title/content had genuinely changed
# kept showing the stale first-fetch preview for up to a week. Short
# enough that it's effectively "always fresh" for any realistic human
# posting cadence, while still doing the one thing a cache here is
# actually for -- collapsing a burst of near-simultaneous fetches of the
# same URL (several people pasting the same link within moments of each
# other, or the same person's message history being loaded repeatedly)
# into one, and not hammering a URL that just failed on every message
# that references it.
_USER_AGENT = "ds-chat-link-preview/1.0"
_CACHE_TTL = timedelta(days=7)
_CACHE_TTL = timedelta(minutes=5)
def extract_first_url(content: str | None) -> str | None:
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ds-chat"
version = "1.1.0"
version = "2026.9.4"
description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11"
+67
View File
@@ -1,9 +1,13 @@
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
import pytest_asyncio
from sqlalchemy import select
from app.database import async_session_factory
from app.database import engine as _link_preview_engine
from app.models import LinkPreview
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.services.link_preview_service import extract_first_url
@@ -294,3 +298,66 @@ async def test_link_preview_reused_across_messages_with_same_url(client, db_sess
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
assert len(history) == 2
assert all(m["link_preview"]["title"] == "Example Article" for m in history)
async def test_link_preview_refetches_after_cache_expires(client, db_session, monkeypatch):
# #70: a real report -- re-posting a URL whose title had genuinely
# changed kept showing the stale first-fetch preview, because the
# cache TTL used to be 7 days. Simulates that expiry directly (rather
# than actually sleeping 5+ minutes) by backdating the cached row's
# fetched_at past the TTL, then confirms a second post of the same URL
# picks up new content instead of the stale cached title.
captured_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
def fake_create_task(coro):
task = real_create_task(coro)
captured_tasks.append(task)
return task
monkeypatch.setattr("app.services.message_events.asyncio.create_task", fake_create_task)
calls: list[str] = []
monkeypatch.setattr(
"app.services.link_preview_service.httpx.AsyncClient",
_fake_client_factory(call_log=calls),
)
url = _unique_url()
await register_and_login(client, db_session, username="alice")
room = (await client.post("/api/rooms", json={"name": "general"})).json()
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
resp1 = await client.post(
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"see {url}"}
)
assert resp1.status_code == 204
await asyncio.gather(*captured_tasks)
captured_tasks.clear()
async with async_session_factory() as session:
row = (
await session.execute(select(LinkPreview).where(LinkPreview.url == url))
).scalar_one()
row.fetched_at = datetime.now(timezone.utc) - timedelta(minutes=10)
await session.commit()
updated_html = _OG_HTML.replace(b"Example Article", b"Updated Article")
monkeypatch.setattr(
"app.services.link_preview_service.httpx.AsyncClient",
_fake_client_factory(html=updated_html, call_log=calls),
)
resp2 = await client.post(
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"again: {url}"}
)
assert resp2.status_code == 204
await asyncio.gather(*captured_tasks)
assert len(calls) == 2 # the expired cache forced a second real fetch
# Cached by URL, not by message (see link_preview_service.py) -- the
# row was refreshed in place, so *both* messages referencing this URL
# now show the new title on a history reload, not one each.
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
assert len(history) == 2
assert all(m["link_preview"]["title"] == "Updated Article" for m in history)
+56
View File
@@ -102,6 +102,62 @@ async def test_theme_custom_rejected_on_generic_profile_update(client, db_sessio
assert resp.status_code == 422
async def test_update_text_scale_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"text_scale": "large"})
assert resp.status_code == 200, resp.text
assert resp.json()["text_scale"] == "large"
me = await client.get("/api/auth/me")
assert me.json()["text_scale"] == "large"
async def test_invalid_text_scale_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"text_scale": "huge"})
assert resp.status_code == 422
async def test_updating_text_scale_does_not_clobber_theme(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"theme": "sunset"})
resp = await client.patch("/api/auth/me", json={"text_scale": "xlarge"})
assert resp.status_code == 200
assert resp.json()["theme"] == "sunset"
assert resp.json()["text_scale"] == "xlarge"
async def test_update_emoji_scale_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"emoji_scale": "xlarge"})
assert resp.status_code == 200, resp.text
assert resp.json()["emoji_scale"] == "xlarge"
me = await client.get("/api/auth/me")
assert me.json()["emoji_scale"] == "xlarge"
async def test_invalid_emoji_scale_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"emoji_scale": "huge"})
assert resp.status_code == 422
async def test_updating_emoji_scale_does_not_clobber_text_scale(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"text_scale": "large"})
resp = await client.patch("/api/auth/me", json={"emoji_scale": "small"})
assert resp.status_code == 200
assert resp.json()["text_scale"] == "large"
assert resp.json()["emoji_scale"] == "small"
async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
+21
View File
@@ -484,6 +484,27 @@ async def test_add_member_directly(client, db_session, monkeypatch):
assert "added" in calls[0]["subject"].lower()
async def test_add_member_posts_welcome_message(client, db_session, monkeypatch):
# #74: posted as the auto-provisioned "system" account, not the admin
# who added them -- mirrors test_add_member_directly's setup.
_fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
await client.post("/api/auth/logout")
bob = await register_and_login(client, db_session, username="bob")
await client.post("/api/auth/logout")
await login_as(client, "alice")
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]})
assert resp.status_code == 201, resp.text
history = (await client.get(f"/api/rooms/{room_id}/messages")).json()
welcome_messages = [m for m in history if m["username"] == "system"]
assert len(welcome_messages) == 1
assert welcome_messages[0]["content"] == "Welcome to #general, bob!"
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
_fake_send_email(monkeypatch)
await register_and_login(client, db_session, username="alice")
+13
View File
@@ -0,0 +1,13 @@
from app.services.system_user_service import SYSTEM_USERNAME, get_or_create_system_user
async def test_get_or_create_system_user_creates_bot_account(db_session):
user = await get_or_create_system_user(db_session)
assert user.username == SYSTEM_USERNAME
assert user.is_bot is True
async def test_get_or_create_system_user_is_idempotent(db_session):
first = await get_or_create_system_user(db_session)
second = await get_or_create_system_user(db_session)
assert first.id == second.id
+11 -2
View File
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
FRONTEND_DIR="${REPO_DIR}/frontend"
ENV_FILE="/etc/ds-chat/env"
echo "==> Pulling latest code"
echo "==> Fetching latest release"
cd "$REPO_DIR"
git pull --ff-only
git fetch --tags --force
LATEST_TAG="$(git tag --sort=-creatordate | head -n1)"
if [[ -z "$LATEST_TAG" ]]; then
echo "No tags found -- nothing to deploy" >&2
exit 1
fi
echo "Deploying $LATEST_TAG"
# Detached HEAD, not a branch checkout -- this directory only ever runs a
# tagged release, never whatever the default branch's tip happens to be.
git checkout --quiet --detach "$LATEST_TAG"
echo "==> Installing backend dependencies"
cd "$BACKEND_DIR"
+7
View File
@@ -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
View File
@@ -112,7 +112,7 @@ src/
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.)
styles/tokens.css design tokens (default theme: colors, spacing, etc.)
sw.ts custom service worker (injectManifest): app-shell
precache + NetworkFirst runtime caching, push/notificationclick
handlers, SKIP_WAITING messaging for the update-prompt flow
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.1.0",
"version": "2026.9.4",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+18 -1
View File
@@ -1,5 +1,5 @@
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
// 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> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
}
+6 -2
View File
@@ -10,8 +10,10 @@ interface AboutModalProps {
// users remotely through a computer network, you should also make sure
// that it provides a way for users to get its source... its interface
// could display a 'Source' link" -- this modal is that link, not just a
// courtesy credits screen.
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat'
// courtesy credits screen. Deliberately not a hardcoded URL: whoever
// deploys this needs to point it at *their* copy of the repo (including
// any modifications), not the upstream project -- see frontend/.env.example.
const SOURCE_URL = import.meta.env.VITE_SOURCE_URL as string | undefined
export function AboutModal({ onClose }: AboutModalProps) {
return (
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
</a>
.
</p>
{SOURCE_URL && (
<p className="about-modal-line">
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
Source code
</a>
</p>
)}
<div className="modal-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}>
@@ -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">
&times;
</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>
)
}
-46
View File
@@ -69,26 +69,6 @@
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 {
display: grid;
grid-template-columns: repeat(9, 1fr);
@@ -109,32 +89,6 @@
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
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
+8 -58
View File
@@ -1,12 +1,9 @@
import { useMemo, useState, type MouseEvent } from 'react'
import { deleteCustomEmoji } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useMemo, useState } from 'react'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { useEscapeKey } from '../hooks/useEscapeKey'
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
import { EMOJI_NAMES } from '../lib/emojiNames'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
import { EmojiGlyph } from './MessageContent'
import './EmojiPicker.css'
@@ -55,11 +52,8 @@ function searchEmoji(query: string, customShortcodes: string[]): string[] {
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose)
const { user } = useAuth()
const { list: customEmoji, refresh: refreshCustomEmoji } = useCustomEmoji()
const { list: customEmoji } = useCustomEmoji()
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 searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
const searching = query.trim().length > 0
@@ -74,18 +68,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
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 (
<>
<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">
<div className="emoji-picker-category-label-row">
<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-category">
<div className="emoji-picker-category-label">Custom</div>
<div className="emoji-picker-grid">
{customEmoji.map((e) => {
const canDelete = user?.id === e.uploaded_by || user?.is_site_admin
return (
{customEmoji.map((e) => (
<button
key={e.id}
type="button"
role="menuitem"
className="emoji-picker-item emoji-picker-item-custom"
className="emoji-picker-item"
title={`:${e.shortcode}:`}
onClick={() => pick(`:${e.shortcode}:`)}
>
<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>
{recent.length > 0 && (
<div className="emoji-picker-category">
<div className="emoji-picker-category-label">Recently used</div>
@@ -205,15 +164,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
</>
)}
</div>
{uploadOpen && (
<CustomEmojiUploadModal
onClose={() => setUploadOpen(false)}
onUploaded={() => {
refreshCustomEmoji()
setUploadOpen(false)
}}
/>
)}
</>
)
}
+18 -3
View File
@@ -4,10 +4,25 @@
own font-size and this just tracks it. Kept in this file (imported
directly by MessageContent.tsx) rather than MessageList.css so it's
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
included, not just the message list. */
included, not just the message list.
#71: also multiplied by --emoji-scale, the manual "make emoji bigger"
preference -- but that variable is only ever set on MessageContent's own
wrapper div (inline style, scoped to that element and its descendants),
never at :root, so var(..., 1) correctly falls back to a no-op multiplier
everywhere else this class is reused (the picker's grid, reaction pills)
instead of also inflating those and breaking their fixed-size layout. */
.message-custom-emoji {
height: 1.2em;
width: 1.2em;
height: calc(1.2em * var(--emoji-scale, 1));
width: calc(1.2em * var(--emoji-scale, 1));
object-fit: contain;
vertical-align: -0.25em;
}
/* #71: a raw unicode emoji wrapped by wrapEmojiGlyphs -- same --emoji-scale
multiplier as the custom-emoji image above, so "make emoji bigger"
applies uniformly regardless of which kind of emoji it is. */
.inline-emoji {
display: inline-block;
font-size: calc(1em * var(--emoji-scale, 1));
}
+73 -7
View File
@@ -1,7 +1,8 @@
import Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { getCustomEmojiUrl } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './MessageContent.css'
@@ -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 (
<a href={href} target="_blank" rel="noopener noreferrer">
{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
@@ -370,14 +384,66 @@ export function preprocessMarkdown(text: string): { text: string; headingIds: Ma
return extractHeadingIds(convertSubSuperscript(text))
}
// #71: gives every individual unicode emoji its own element (see
// MarkdownLink's `glyph:` branch) purely so the emoji-size preference can
// scale it independently of the surrounding text -- a raw emoji is just
// characters in a string otherwise, with nothing CSS can address on its
// own. Runs after convertShortcodes so a built-in `:name:` that just
// became a glyph is wrapped too ("all emoji", not just ones typed as
// literal unicode); same fence/code-span skip convention as every other
// converter here.
const EMOJI_GLYPH_PATTERN = /\p{Extended_Pictographic}(?:\p{Emoji_Modifier}|\u200D\p{Extended_Pictographic}|\uFE0F)*/gu
function wrapEmojiGlyphs(text: string): string {
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) => (i % 2 === 0 ? part.replace(EMOJI_GLYPH_PATTERN, (match) => `[${match}](glyph:)`) : part))
.join('')
})
.join('\n')
}
// Exported so MessageList's reaction pills can apply the same viewer
// preference to their own EmojiGlyph -- reactions render outside the
// markdown pipeline entirely (see EmojiGlyph's own comment above), so they
// need this looked up independently rather than inheriting --emoji-scale
// from this component's wrapper div.
export const EMOJI_SCALE_MULTIPLIER: Record<string, number> = {
small: 0.8,
normal: 1,
large: 1.5,
xlarge: 2,
}
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
const { user } = useAuth()
const { byShortcode } = useCustomEmoji()
const customShortcodes = new Set(byShortcode.keys())
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
const withCustomEmoji = convertCustomEmojiShortcodes(
convertShortcodes(withRoomRefs),
new Set(byShortcode.keys()),
const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
const { text, headingIds } = preprocessMarkdown(withEmojiGlyphs)
// #71: scoped to this element (not a :root-level variable) so it only
// ever affects emoji rendered in message text -- not the same
// .message-custom-emoji/EmojiGlyph markup reused by the emoji picker's
// grid, where a bigger image would just break its fixed-size layout
// instead of doing anything useful. Reaction pills DO scale too, but via
// their own inline --emoji-scale in MessageList.tsx, not by inheriting
// this one -- a pill isn't a descendant of this wrapper div.
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
return (
<div style={{ '--emoji-scale': emojiScale } as CSSProperties}>
<Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
</div>
)
const { text, headingIds } = preprocessMarkdown(withCustomEmoji)
return <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
}
+10 -5
View File
@@ -53,8 +53,13 @@
.message-image {
display: block;
max-width: min(320px, 100%);
max-height: 240px;
/* #71: rem, not px -- scales with the text-size setting (see
lib/theme.ts's applyTextScale), same as every other size in this app.
min(...) still caps against the viewport in absolute px, since a
percentage-of-viewport constraint isn't something a root font-size
change should affect. */
max-width: min(20rem, 100%);
max-height: 15rem;
object-fit: contain;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
@@ -65,14 +70,14 @@
.message-video-wrap {
position: relative;
display: inline-block;
max-width: min(320px, 100%);
max-width: min(20rem, 100%);
margin-bottom: 4px;
}
.message-video {
display: block;
width: 100%;
max-height: 240px;
max-height: 15rem;
border-radius: var(--radius);
border: 1px solid var(--ds-border);
background: var(--ds-void);
@@ -111,7 +116,7 @@
margin-bottom: 4px;
color: var(--ds-text);
text-decoration: none;
max-width: min(320px, 100%);
max-width: min(20rem, 100%);
}
.message-file-attachment:hover {
+24 -4
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
@@ -8,7 +9,7 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { EmojiGlyph, MessageContent } from './MessageContent'
import { EMOJI_SCALE_MULTIPLIER, EmojiGlyph, MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox'
import './MessageList.css'
@@ -68,6 +69,13 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
// trigger a download the moment the browser tries to fetch it).
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
const GROUP_BREAK_MS = 5 * 60 * 1000
interface VideoAttachmentProps {
file: MessageFileInfo
roomId: string
@@ -126,6 +134,10 @@ export function MessageList({
onDelete,
}: MessageListProps) {
const { user } = useAuth()
// #71: same viewer preference MessageContent applies to in-text emoji,
// looked up separately here since a reaction pill isn't a descendant of
// that component's wrapper div (see EmojiGlyph's own comment).
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
const containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
// Whether the view should be pinned to the latest message -- true right
@@ -205,8 +217,13 @@ export function MessageList({
// Mattermost-style grouping: every message shows who sent it, but
// consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages.
const isGroupStart = !prev || prev.user_id !== msg.user_id
// applies uniformly, including to your own messages. Also breaks on
// a long gap (see GROUP_BREAK_MS) so a message sent well after the
// rest of the run still gets its own visible timestamp.
const isGroupStart =
!prev ||
prev.user_id !== msg.user_id ||
new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS
const editing = editingId === msg.id
const deleted = !!msg.deleted_at
@@ -302,7 +319,10 @@ export function MessageList({
title={r.user_ids.map(displayNameForUserId).join(', ')}
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} />
</span>
<span>{r.count}</span>
+39
View File
@@ -230,6 +230,45 @@
color: var(--ds-muted);
}
.text-scale-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-2);
margin-bottom: var(--sp-4);
}
.text-scale-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 10px 4px;
cursor: pointer;
}
.text-scale-option:hover {
border-color: var(--ds-accent);
}
.text-scale-option-selected {
border-color: var(--ds-accent);
box-shadow: 0 0 0 1px var(--ds-accent);
}
.text-scale-option-preview {
font-weight: 700;
color: var(--ds-text);
line-height: 1;
}
.text-scale-option-label {
font-size: 0.7rem;
color: var(--ds-muted);
}
.theme-swatch-preview-new {
background: transparent;
border-style: dashed;
+94 -2
View File
@@ -5,7 +5,9 @@ import {
me,
removeAvatar,
revokeSession,
updateEmojiScale,
updateProfile,
updateTextScale,
updateTheme,
uploadAvatar,
} from '../api/auth'
@@ -20,8 +22,8 @@ import {
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors, UserSession } from '../types'
import { applyTextScale, applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomTheme, CustomThemeColors, EmojiScale, TextScale, UserSession } from '../types'
import { ThemeBuilderModal } from './ThemeBuilderModal'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
@@ -33,6 +35,26 @@ const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: st
{ 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 }[] = [
{ key: 'void', label: 'Background' },
{ key: 'void_2', label: 'Sidebar background' },
@@ -60,6 +82,8 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(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 [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) {
applyTheme('custom', theme.colors)
setThemeError(null)
@@ -360,6 +410,48 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
</div>
{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="theme-swatch-grid">
{customThemes.map((theme) => {
+13
View File
@@ -13,6 +13,7 @@ import {
} from '../lib/desktopBridge'
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
import { AboutModal } from './AboutModal'
import { CustomEmojiManageModal } from './CustomEmojiManageModal'
import { ProfileModal } from './ProfileModal'
import { UserAvatar } from './UserAvatar'
import './TopBar.css'
@@ -28,6 +29,7 @@ export function TopBar() {
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false)
const [customEmojiModalOpen, setCustomEmojiModalOpen] = useState(false)
const [aboutModalOpen, setAboutModalOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false)
@@ -124,6 +126,16 @@ export function TopBar() {
>
Profile settings
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setCustomEmojiModalOpen(true)
}}
>
Custom emoji
</button>
<button
type="button"
role="menuitem"
@@ -190,6 +202,7 @@ export function TopBar() {
)}
</div>
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
{customEmojiModalOpen && <CustomEmojiManageModal onClose={() => setCustomEmojiModalOpen(false)} />}
{aboutModalOpen && <AboutModal onClose={() => setAboutModalOpen(false)} />}
</header>
)
+5 -1
View File
@@ -3,7 +3,7 @@ import * as authApi from '../api/auth'
import { ApiError, NetworkError } from '../api/client'
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
import { unsubscribeFromPush } from '../lib/push'
import { applyTheme } from '../lib/theme'
import { applyTextScale, applyTheme } from '../lib/theme'
import type { User } from '../types'
interface AuthContextValue {
@@ -26,6 +26,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
}, [user?.theme, user?.active_custom_theme])
useEffect(() => {
applyTextScale(user?.text_scale ?? null)
}, [user?.text_scale])
useEffect(() => {
authApi
.me()
+19 -1
View File
@@ -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
// 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)
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']
}
+9
View File
@@ -1,5 +1,12 @@
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
// overrides per built-in preset -- kept in sync with
// 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
// in backend/app/schemas/user.py.
active_custom_theme: CustomTheme | null
text_scale: TextScale | null
emoji_scale: EmojiScale | null
avatar_filename: string | null
appear_offline: boolean
created_at: string