Private
Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be8d9d731 | ||
|
|
30e63ffa83 | ||
|
|
019e10ac5c | ||
|
|
6e889b8ea4 | ||
|
|
b4a104f8c6 | ||
|
|
03cc16f236 | ||
|
|
520b971247 | ||
|
|
cd6296d079 |
+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 ###
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ds-chat"
|
name = "ds-chat"
|
||||||
version = "1.1.0"
|
version = "2026.9.3"
|
||||||
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "2026.9.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,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 +70,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 +116,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,3 +1,4 @@
|
|||||||
|
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'
|
||||||
@@ -8,7 +9,7 @@ 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'
|
||||||
@@ -68,6 +69,13 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
|
|||||||
// trigger a download the moment the browser tries to fetch it).
|
// trigger a download the moment the browser tries to fetch it).
|
||||||
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
|
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 {
|
interface VideoAttachmentProps {
|
||||||
file: MessageFileInfo
|
file: MessageFileInfo
|
||||||
roomId: string
|
roomId: string
|
||||||
@@ -126,6 +134,10 @@ export function MessageList({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const { user } = useAuth()
|
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 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,8 +217,13 @@ 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 (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 editing = editingId === msg.id
|
||||||
const deleted = !!msg.deleted_at
|
const deleted = !!msg.deleted_at
|
||||||
|
|
||||||
@@ -302,7 +319,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()
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user