Private
Public Access
Compare commits
6
Commits
v2026.9.23
...
v2026.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb30dc1753 | ||
|
|
6975ed4dc0 | ||
|
|
c54c1bf97d | ||
|
|
3349cadea2 | ||
|
|
047f47d524 | ||
|
|
850d9ec0fa |
No files matched your search
+7
-1
@@ -70,7 +70,13 @@ send. Use **Shift+Enter** to add a line break without sending.
|
||||
|
||||
## Formatting
|
||||
|
||||
Messages support Markdown:
|
||||
Messages support Markdown. Prefer buttons to typing the syntax? Click the
|
||||
**Aa** button next to the emoji button in the composer to open a formatting
|
||||
toolbar — it inserts the markdown below into your message for you (select some
|
||||
text first to wrap it, or click a button again to take the formatting back
|
||||
off). It stays collapsed until you open it, and remembers your choice.
|
||||
|
||||
Or type it yourself:
|
||||
|
||||
- `**bold**`, `*italic*`, `~~strikethrough~~`
|
||||
- `` `inline code` `` and fenced code blocks (three backticks)
|
||||
|
||||
+6
-2
@@ -58,8 +58,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# ASGITransport, used by the plain REST test fixtures -- only
|
||||
# TestClient's websocket_connect-based tests actually run lifespan).
|
||||
# The background listener task genuinely needs a running event loop
|
||||
# though, so that part stays here.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.listen())
|
||||
# though, so that part stays here. run_forever() (not listen() directly)
|
||||
# -- #76: a bare listen() here died permanently and silently the moment
|
||||
# its Redis connection ever dropped (e.g. Redis itself restarting),
|
||||
# with nothing to notice or reconnect; run_forever() supervises it and
|
||||
# restarts on failure instead.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -11,6 +13,14 @@ logger = logging.getLogger(__name__)
|
||||
ROOM_CHANNEL_PREFIX = "room:"
|
||||
USER_CHANNEL_PREFIX = "user:"
|
||||
|
||||
# #76: a run shorter than this before dying doesn't count as "stable" for
|
||||
# resetting the backoff in run_forever() below -- long enough that a real,
|
||||
# working connection has clearly settled in, short enough not to leave a
|
||||
# just-recovered listener exposed to a full backoff climb if Redis blips
|
||||
# again shortly after reconnecting.
|
||||
STABLE_RUN_SECONDS = 60
|
||||
MAX_BACKOFF_SECONDS = 30
|
||||
|
||||
|
||||
class Broadcaster:
|
||||
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
|
||||
@@ -39,6 +49,43 @@ class Broadcaster:
|
||||
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
"""Supervises listen(), restarting it if it ever dies.
|
||||
|
||||
#76's real root cause, found after the per-message try/except below
|
||||
turned out not to be enough: this is the *one* pubsub listener for
|
||||
the whole process (see main.py's lifespan), created once at startup
|
||||
with nothing watching over it afterward. A connection-level failure
|
||||
-- Redis restarting, a network blip between the app and Redis --
|
||||
raises from the `async for` iteration in listen() itself, outside
|
||||
that per-message guard, and previously killed this task permanently
|
||||
and silently: HTTP/REST and login kept working fine (neither
|
||||
touches this), but live message delivery was gone for every room
|
||||
and every user on this instance until the whole process was
|
||||
restarted. Confirmed live in production: Redis got restarted by an
|
||||
OS update, and `PUBSUB NUMPAT` against it afterward showed zero
|
||||
pattern subscriptions across all 4 gunicorn workers -- every one of
|
||||
them had silently lost its listener with nothing to bring it back.
|
||||
"""
|
||||
backoff_seconds = 1.0
|
||||
while True:
|
||||
started_at = asyncio.get_event_loop().time()
|
||||
try:
|
||||
await self.listen()
|
||||
logger.warning("Broadcaster.listen() exited without error -- restarting")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.error("Broadcaster.listen() failed -- restarting", exc_info=True)
|
||||
ran_for = asyncio.get_event_loop().time() - started_at
|
||||
# Sleep at the *current* backoff first, then grow it for next
|
||||
# time -- computing the next value before sleeping would skip
|
||||
# straight past the first (shortest) retry delay.
|
||||
await asyncio.sleep(backoff_seconds)
|
||||
backoff_seconds = (
|
||||
1.0 if ran_for > STABLE_RUN_SECONDS else min(backoff_seconds * 2, MAX_BACKOFF_SECONDS)
|
||||
)
|
||||
|
||||
async def listen(self) -> None:
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
@@ -67,5 +114,16 @@ class Broadcaster:
|
||||
except Exception:
|
||||
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
|
||||
finally:
|
||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
await pubsub.aclose()
|
||||
# #76: reached via a connection that's already dead just as
|
||||
# often as a clean shutdown (that's the whole failure mode
|
||||
# run_forever() now recovers from) -- punsubscribe/aclose over
|
||||
# a broken connection would themselves raise, masking whatever
|
||||
# actually killed the loop above and, since run_forever() only
|
||||
# starts backing off *after* listen() returns/raises, delaying
|
||||
# the reconnect for no benefit (a fresh pubsub object is about
|
||||
# to be created on retry regardless of whether this cleanup
|
||||
# succeeded).
|
||||
with contextlib.suppress(Exception):
|
||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
with contextlib.suppress(Exception):
|
||||
await pubsub.aclose()
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ds-chat"
|
||||
version = "2026.9.23"
|
||||
version = "2026.10.2"
|
||||
description = "DS Chat backend service"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
|
||||
|
||||
async def test_run_forever_restarts_after_listen_raises(monkeypatch):
|
||||
# #76: listen() dying (a dropped Redis connection, most commonly -- e.g.
|
||||
# Redis itself restarting) must not permanently kill live delivery for
|
||||
# the whole process. run_forever() has to notice and restart it, not
|
||||
# just let the background task disappear silently while everything else
|
||||
# (login, REST endpoints) keeps working fine.
|
||||
broadcaster = Broadcaster(redis=None, manager=None)
|
||||
calls = 0
|
||||
|
||||
async def fake_listen():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
raise ConnectionError("redis went away")
|
||||
# Stop the loop once the retry behavior below is proven, rather
|
||||
# than looping forever in a test.
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(broadcaster, "listen", fake_listen)
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
sleep_calls.append(seconds)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await broadcaster.run_forever()
|
||||
|
||||
assert calls == 3
|
||||
# Slept once after each of the first two failures (exponential:
|
||||
# 1s, then 2s) -- not after the third, which is the CancelledError
|
||||
# that stops the loop before reaching the sleep.
|
||||
assert sleep_calls == [1.0, 2.0]
|
||||
|
||||
|
||||
async def test_run_forever_does_not_swallow_cancellation(monkeypatch):
|
||||
# A genuine shutdown (the app's own lifespan cancels this task) must
|
||||
# propagate immediately, not get treated as just another failure to
|
||||
# retry after a backoff.
|
||||
broadcaster = Broadcaster(redis=None, manager=None)
|
||||
|
||||
async def fake_listen():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(broadcaster, "listen", fake_listen)
|
||||
|
||||
async def fail_if_called(seconds):
|
||||
raise AssertionError("should not sleep/retry on cancellation")
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fail_if_called)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await broadcaster.run_forever()
|
||||
Generated
+953
-11
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2026.9.23",
|
||||
"version": "2026.10.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
@@ -18,13 +19,16 @@
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"jsdom": "^30.1.1",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^5.0.2",
|
||||
"workbox-cacheable-response": "^7.4.1",
|
||||
"workbox-expiration": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
|
||||
@@ -105,6 +105,34 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.composer-format-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--ds-muted);
|
||||
border: 1px solid var(--ds-border);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.composer-format-toggle:hover:not(:disabled),
|
||||
.composer-format-toggle-open {
|
||||
color: var(--ds-text);
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.composer-format-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.composer-attach-wrap {
|
||||
position: relative;
|
||||
flex: none;
|
||||
|
||||
@@ -15,10 +15,13 @@ import { getUploadLimit } from '../api/uploads'
|
||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
|
||||
import { formatFileSize } from '../lib/fileSize'
|
||||
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from '../lib/formattingToolbarPref'
|
||||
import { applyFormat, type FormatAction } from '../lib/markdownFormat'
|
||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
||||
import type { MyRoomItem, RoomMember } from '../types'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
|
||||
import { FormattingToolbar } from './FormattingToolbar'
|
||||
import { MentionAutocomplete } from './MentionAutocomplete'
|
||||
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
|
||||
import './Composer.css'
|
||||
@@ -136,6 +139,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
||||
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
||||
const [toolbarOpen, setToolbarOpen] = useState(getFormattingToolbarOpen)
|
||||
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -449,6 +453,29 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
||||
})
|
||||
}
|
||||
|
||||
function toggleToolbar() {
|
||||
const next = !toolbarOpen
|
||||
setToolbarOpen(next)
|
||||
setFormattingToolbarOpen(next)
|
||||
}
|
||||
|
||||
// #75: the toolbar only ever edits the textarea's plain markdown text --
|
||||
// nothing about what gets sent changes.
|
||||
function applyFormatAction(action: FormatAction) {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
const result = applyFormat(
|
||||
{ value, start: el.selectionStart ?? value.length, end: el.selectionEnd ?? value.length },
|
||||
action,
|
||||
)
|
||||
setValue(result.value)
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.setSelectionRange(result.start, result.end)
|
||||
autoGrow()
|
||||
})
|
||||
}
|
||||
|
||||
function insertEmoji(emoji: string) {
|
||||
const el = textareaRef.current
|
||||
setEmojiPickerOpen(false)
|
||||
@@ -517,6 +544,9 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
||||
</div>
|
||||
)}
|
||||
{uploadError && <div className="composer-status composer-error">{uploadError}</div>}
|
||||
{toolbarOpen && (
|
||||
<FormattingToolbar id="composer-formatting-toolbar" disabled={isDisabled} onAction={applyFormatAction} />
|
||||
)}
|
||||
<div className="composer-box">
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
@@ -589,6 +619,18 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-format-toggle${toolbarOpen ? ' composer-format-toggle-open' : ''}`}
|
||||
onClick={toggleToolbar}
|
||||
disabled={isDisabled}
|
||||
aria-label="Formatting options"
|
||||
aria-expanded={toolbarOpen}
|
||||
aria-controls="composer-formatting-toolbar"
|
||||
title="Formatting options"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<div className="composer-textarea-wrap">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.formatting-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
/* Spacing, not divider lines, between groups -- a border on every group
|
||||
after the first leaves a dangling line at the start of the second row
|
||||
whenever the toolbar wraps on a narrow screen. */
|
||||
gap: 2px 14px;
|
||||
}
|
||||
|
||||
.formatting-toolbar-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.formatting-toolbar-btn {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--ds-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formatting-toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--ds-surface-2);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
.formatting-toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FormatAction } from '../lib/markdownFormat'
|
||||
import { FormattingToolbar } from './FormattingToolbar'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const EXPECTED: [string, FormatAction][] = [
|
||||
['Bold', { kind: 'inline', marker: '**' }],
|
||||
['Italic', { kind: 'inline', marker: '*' }],
|
||||
['Strikethrough', { kind: 'inline', marker: '~~' }],
|
||||
['Inline code', { kind: 'inline', marker: '`' }],
|
||||
['Code block', { kind: 'codeBlock' }],
|
||||
['Link', { kind: 'link' }],
|
||||
['Quote', { kind: 'prefix', prefix: 'quote' }],
|
||||
['Bulleted list', { kind: 'prefix', prefix: 'bullet' }],
|
||||
['Numbered list', { kind: 'prefix', prefix: 'ordered' }],
|
||||
['Heading 1', { kind: 'heading', level: 1 }],
|
||||
['Heading 2', { kind: 'heading', level: 2 }],
|
||||
['Heading 3', { kind: 'heading', level: 3 }],
|
||||
['Subscript', { kind: 'inline', marker: '~' }],
|
||||
['Superscript', { kind: 'inline', marker: '^' }],
|
||||
]
|
||||
|
||||
describe('FormattingToolbar', () => {
|
||||
it.each(EXPECTED)('%s button calls onAction with its action', (label, action) => {
|
||||
const onAction = vi.fn()
|
||||
render(<FormattingToolbar id="t" onAction={onAction} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
expect(onAction).toHaveBeenCalledWith(action)
|
||||
})
|
||||
|
||||
it('disables every button when disabled, but leaves the help link usable', () => {
|
||||
render(<FormattingToolbar id="t" disabled onAction={vi.fn()} />)
|
||||
const buttons = screen.getAllByRole('button') as HTMLButtonElement[]
|
||||
expect(buttons).toHaveLength(EXPECTED.length)
|
||||
expect(buttons.every((b) => b.disabled)).toBe(true)
|
||||
expect(screen.getByRole('link', { name: 'Formatting help' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens help in a new tab so the draft in the composer is not lost', () => {
|
||||
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
|
||||
const help = screen.getByRole('link', { name: 'Formatting help' })
|
||||
expect(help.getAttribute('href')).toBe('/help#formatting')
|
||||
expect(help.getAttribute('target')).toBe('_blank')
|
||||
expect(help.getAttribute('rel')).toContain('noopener')
|
||||
})
|
||||
|
||||
it('keeps focus in the textarea by cancelling mousedown on buttons', () => {
|
||||
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
|
||||
// fireEvent returns false when the event's default was prevented.
|
||||
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Bold' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FormatAction, InlineMarker } from '../lib/markdownFormat'
|
||||
import './FormattingToolbar.css'
|
||||
|
||||
interface FormattingToolbarProps {
|
||||
id: string
|
||||
disabled?: boolean
|
||||
onAction: (action: FormatAction) => void
|
||||
}
|
||||
|
||||
interface ToolbarButton {
|
||||
label: string
|
||||
// Shows the syntax the button inserts -- the toolbar doubles as the
|
||||
// "quick guide" for people who'd rather learn to type it themselves.
|
||||
title: string
|
||||
content: ReactNode
|
||||
action: FormatAction
|
||||
}
|
||||
|
||||
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
|
||||
|
||||
const GROUPS: ToolbarButton[][] = [
|
||||
[
|
||||
{ label: 'Bold', title: 'Bold: **text**', content: <b>B</b>, action: inline('**') },
|
||||
{ label: 'Italic', title: 'Italic: *text*', content: <i>I</i>, action: inline('*') },
|
||||
{ label: 'Strikethrough', title: 'Strikethrough: ~~text~~', content: <s>S</s>, action: inline('~~') },
|
||||
],
|
||||
[
|
||||
{ label: 'Inline code', title: 'Inline code: `code`', content: '</>', action: inline('`') },
|
||||
{ label: 'Code block', title: 'Code block: ``` on its own lines', content: '{ }', action: { kind: 'codeBlock' } },
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Link',
|
||||
title: 'Link: [text](url)',
|
||||
content: (
|
||||
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8.5 11.5a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-.9.9M11.5 8.5a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l.9-.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
action: { kind: 'link' },
|
||||
},
|
||||
{ label: 'Quote', title: 'Quote: > text', content: '“', action: { kind: 'prefix', prefix: 'quote' } },
|
||||
],
|
||||
[
|
||||
{ label: 'Bulleted list', title: 'Bulleted list: - item', content: '•', action: { kind: 'prefix', prefix: 'bullet' } },
|
||||
{ label: 'Numbered list', title: 'Numbered list: 1. item', content: '1.', action: { kind: 'prefix', prefix: 'ordered' } },
|
||||
],
|
||||
[
|
||||
{ label: 'Heading 1', title: 'Heading 1: # text', content: 'H1', action: { kind: 'heading', level: 1 } },
|
||||
{ label: 'Heading 2', title: 'Heading 2: ## text', content: 'H2', action: { kind: 'heading', level: 2 } },
|
||||
{ label: 'Heading 3', title: 'Heading 3: ### text', content: 'H3', action: { kind: 'heading', level: 3 } },
|
||||
],
|
||||
[
|
||||
{ label: 'Subscript', title: 'Subscript: ~text~ (no spaces)', content: 'x₂', action: inline('~') },
|
||||
{ label: 'Superscript', title: 'Superscript: ^text^ (no spaces)', content: 'x²', action: inline('^') },
|
||||
],
|
||||
]
|
||||
|
||||
export function FormattingToolbar({ id, disabled, onAction }: FormattingToolbarProps) {
|
||||
return (
|
||||
<div id={id} className="formatting-toolbar" role="toolbar" aria-label="Formatting">
|
||||
{GROUPS.map((group) => (
|
||||
<div key={group[0].label} className="formatting-toolbar-group">
|
||||
{group.map((button) => (
|
||||
<button
|
||||
key={button.label}
|
||||
type="button"
|
||||
className="formatting-toolbar-btn"
|
||||
title={button.title}
|
||||
aria-label={button.label}
|
||||
disabled={disabled}
|
||||
// Keeps focus (and the selection) in the textarea -- a click
|
||||
// on a button would otherwise blur it, and on mobile dismiss
|
||||
// the keyboard.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction(button.action)}
|
||||
>
|
||||
{button.content}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{/* A new tab on purpose: the draft lives in Composer's own state, so
|
||||
navigating this one away would lose it. */}
|
||||
<a
|
||||
className="formatting-toolbar-btn formatting-toolbar-help"
|
||||
href="/help#formatting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Formatting help (opens in a new tab)"
|
||||
aria-label="Formatting help"
|
||||
>
|
||||
?
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as desktopBridge from '../lib/desktopBridge'
|
||||
import { useDesktopRoomBridge } from './useDesktopRoomBridge'
|
||||
|
||||
describe('useDesktopRoomBridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(desktopBridge, 'notifyRoomOpened')
|
||||
vi.spyOn(desktopBridge, 'notifyUnreadCount')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('calls roomOpened once when entering a room', () => {
|
||||
renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||
initialProps: { roomId: 'room-1', count: 0 },
|
||||
})
|
||||
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call roomOpened again on a rerender with the same roomId', () => {
|
||||
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||
initialProps: { roomId: 'room-1', count: 0 },
|
||||
})
|
||||
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Unrelated rerender -- unreadRoomCount changed, roomId did not.
|
||||
rerender({ roomId: 'room-1', count: 3 })
|
||||
rerender({ roomId: 'room-1', count: 3 })
|
||||
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls roomOpened again on a genuine transition to a different room', () => {
|
||||
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||
initialProps: { roomId: 'room-1', count: 0 },
|
||||
})
|
||||
rerender({ roomId: 'room-2', count: 0 })
|
||||
expect(desktopBridge.notifyRoomOpened).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not call roomOpened when there is no room id', () => {
|
||||
renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||
initialProps: { roomId: undefined as string | undefined, count: 0 },
|
||||
})
|
||||
expect(desktopBridge.notifyRoomOpened).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the unread count whenever it changes, independent of roomOpened', () => {
|
||||
const { rerender } = renderHook(({ roomId, count }) => useDesktopRoomBridge(roomId, count), {
|
||||
initialProps: { roomId: undefined as string | undefined, count: 0 },
|
||||
})
|
||||
expect(desktopBridge.notifyUnreadCount).toHaveBeenCalledWith(0)
|
||||
|
||||
rerender({ roomId: undefined, count: 2 })
|
||||
expect(desktopBridge.notifyUnreadCount).toHaveBeenCalledWith(2)
|
||||
expect(desktopBridge.notifyRoomOpened).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop bridge functions with no window.dsDesktop', () => {
|
||||
it('remain optional -- calling them does not throw when the bridge is absent', () => {
|
||||
expect(window.dsDesktop).toBeUndefined()
|
||||
expect(() => desktopBridge.notifyRoomOpened()).not.toThrow()
|
||||
expect(() => desktopBridge.notifyUnreadCount(5)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react'
|
||||
import { notifyRoomOpened, notifyUnreadCount } from '../lib/desktopBridge'
|
||||
|
||||
// Tells the Electron desktop wrapper (a) when the user has genuinely
|
||||
// entered a room, so it can clear that room's tray indicator, and (b) the
|
||||
// current unread-room count, so the tray badge stays accurate. Both are
|
||||
// no-ops in every browser build (see desktopBridge.ts).
|
||||
//
|
||||
// roomOpened must fire only on a genuine transition to a real room id --
|
||||
// never merely because the window was shown, focused, restored, or opened
|
||||
// from the tray, and never when no room is open at all. Depending on just
|
||||
// the id (a primitive, not the room object) is what makes this safe to
|
||||
// call from a component that rerenders often: an unrelated rerender that
|
||||
// leaves activeRoomId unchanged doesn't rerun the effect, so there's no
|
||||
// separate de-duplication to get wrong here -- it falls out of React's own
|
||||
// effect-dependency comparison.
|
||||
export function useDesktopRoomBridge(activeRoomId: string | undefined, unreadRoomCount: number): void {
|
||||
useEffect(() => {
|
||||
if (!activeRoomId) return
|
||||
notifyRoomOpened()
|
||||
}, [activeRoomId])
|
||||
|
||||
useEffect(() => {
|
||||
notifyUnreadCount(unreadRoomCount)
|
||||
}, [unreadRoomCount])
|
||||
}
|
||||
@@ -15,6 +15,12 @@ declare global {
|
||||
interface Window {
|
||||
dsDesktop?: {
|
||||
setUnreadCount?(unreadRoomCount: number): void
|
||||
// Tells the tray/dock icon the user has genuinely entered a room --
|
||||
// the desktop wrapper's own signal to clear that room's tray
|
||||
// indicator. Deliberately not fired just because the window was
|
||||
// shown/focused/restored, or opened from the tray: none of those mean
|
||||
// the user actually looked at the room the unread message is in.
|
||||
roomOpened?(): void
|
||||
showNotification?(notification: DesktopNotificationRequest): void
|
||||
onNotificationClick?(callback: (roomId: string) => void): () => void
|
||||
}
|
||||
@@ -38,6 +44,16 @@ export function isDesktopClickListenerSupported(): boolean {
|
||||
return typeof window.dsDesktop?.onNotificationClick === 'function'
|
||||
}
|
||||
|
||||
// Both no-op silently if the bridge or this specific method isn't present,
|
||||
// same as showDesktopNotification below -- callers never need to guard.
|
||||
export function notifyRoomOpened(): void {
|
||||
window.dsDesktop?.roomOpened?.()
|
||||
}
|
||||
|
||||
export function notifyUnreadCount(unreadRoomCount: number): void {
|
||||
window.dsDesktop?.setUnreadCount?.(unreadRoomCount)
|
||||
}
|
||||
|
||||
// No-ops silently if the bridge or this specific method isn't present --
|
||||
// callers don't need to guard, matching the rest of this module's
|
||||
// capability-detect-per-method philosophy (see #49's "feature-test each
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from './formattingToolbarPref'
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('formatting toolbar preference', () => {
|
||||
it('is collapsed by default', () => {
|
||||
expect(getFormattingToolbarOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('remembers being opened and closed', () => {
|
||||
setFormattingToolbarOpen(true)
|
||||
expect(getFormattingToolbarOpen()).toBe(true)
|
||||
setFormattingToolbarOpen(false)
|
||||
expect(getFormattingToolbarOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to collapsed when storage is unavailable', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
expect(getFormattingToolbarOpen()).toBe(false)
|
||||
expect(() => setFormattingToolbarOpen(true)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
const KEY = 'ds-chat-formatting-toolbar-open'
|
||||
|
||||
// Collapsed unless the user has opened it before -- and remembered, because
|
||||
// Composer remounts on every room switch and would otherwise snap shut each
|
||||
// time.
|
||||
export function getFormattingToolbarOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setFormattingToolbarOpen(open: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(open))
|
||||
} catch {
|
||||
// storage unavailable (private browsing, quota) -- the toolbar just
|
||||
// won't remember its state, not fatal.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyFormat, type FormatAction, type InlineMarker, type TextState } from './markdownFormat'
|
||||
|
||||
// ⟦ and ⟧ mark the selection, so cases read as plain strings -- an empty
|
||||
// pair (⟦⟧) is a bare cursor.
|
||||
const L = '⟦'
|
||||
const R = '⟧'
|
||||
|
||||
function parse(s: string): TextState {
|
||||
const start = s.indexOf(L)
|
||||
const end = s.indexOf(R) - 1
|
||||
return { value: s.replace(L, '').replace(R, ''), start, end }
|
||||
}
|
||||
|
||||
function render({ value, start, end }: TextState): string {
|
||||
return value.slice(0, start) + L + value.slice(start, end) + R + value.slice(end)
|
||||
}
|
||||
|
||||
function fmt(s: string, action: FormatAction): string {
|
||||
return render(applyFormat(parse(s), action))
|
||||
}
|
||||
|
||||
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
|
||||
|
||||
describe('inline wrap', () => {
|
||||
it('wraps the selection and keeps the inner text selected', () => {
|
||||
expect(fmt('say ⟦hi⟧ now', inline('**'))).toBe('say **⟦hi⟧** now')
|
||||
})
|
||||
|
||||
it('inserts an empty pair with the cursor between when nothing is selected', () => {
|
||||
expect(fmt('say ⟦⟧now', inline('**'))).toBe('say **⟦⟧**now')
|
||||
})
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
expect(fmt('say **⟦hi⟧** now', inline('**'))).toBe('say ⟦hi⟧ now')
|
||||
})
|
||||
|
||||
it('unwraps when the selection includes the markers', () => {
|
||||
expect(fmt('say ⟦**hi**⟧ now', inline('**'))).toBe('say ⟦hi⟧ now')
|
||||
})
|
||||
|
||||
it('keeps markers outside leading/trailing whitespace in the selection', () => {
|
||||
expect(fmt('a⟦ hi ⟧b', inline('**'))).toBe('a **⟦hi⟧** b')
|
||||
})
|
||||
|
||||
it('italic on a bold selection adds italic rather than stripping bold', () => {
|
||||
expect(fmt('x **⟦hi⟧** y', inline('*'))).toBe('x ***⟦hi⟧*** y')
|
||||
})
|
||||
|
||||
it('removes just bold from bold+italic', () => {
|
||||
expect(fmt('x ***⟦hi⟧*** y', inline('**'))).toBe('x *⟦hi⟧* y')
|
||||
})
|
||||
|
||||
it('removes just italic from bold+italic', () => {
|
||||
expect(fmt('x ***⟦hi⟧*** y', inline('*'))).toBe('x **⟦hi⟧** y')
|
||||
})
|
||||
|
||||
it('strikethrough toggles off without being confused by subscript', () => {
|
||||
expect(fmt('x ~~⟦hi⟧~~ y', inline('~~'))).toBe('x ⟦hi⟧ y')
|
||||
expect(fmt('a ⟦hi⟧ b', inline('~'))).toBe('a ~⟦hi⟧~ b')
|
||||
})
|
||||
|
||||
it('handles inline code and superscript', () => {
|
||||
expect(fmt('run ⟦ls⟧', inline('`'))).toBe('run `⟦ls⟧`')
|
||||
expect(fmt('x⟦2⟧', inline('^'))).toBe('x^⟦2⟧^')
|
||||
})
|
||||
|
||||
it.each(['**', '*', '~~', '~', '`', '^'] as InlineMarker[])('%s: applying twice restores the original', (marker) => {
|
||||
const original = parse('a ⟦b⟧ c')
|
||||
const once = applyFormat(original, inline(marker))
|
||||
expect(once.value).not.toBe(original.value)
|
||||
expect(applyFormat(once, inline(marker))).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('line prefixes', () => {
|
||||
it('prefixes the current line and moves the cursor with it', () => {
|
||||
expect(fmt('hello⟦⟧', { kind: 'prefix', prefix: 'quote' })).toBe('> hello⟦⟧')
|
||||
expect(fmt('one\ntw⟦⟧o', { kind: 'prefix', prefix: 'quote' })).toBe('one\n> tw⟦⟧o')
|
||||
})
|
||||
|
||||
it('starts a list on a blank line', () => {
|
||||
expect(fmt('⟦⟧', { kind: 'prefix', prefix: 'bullet' })).toBe('- ⟦⟧')
|
||||
})
|
||||
|
||||
it('prefixes every selected line, then toggles them back off', () => {
|
||||
const bullet: FormatAction = { kind: 'prefix', prefix: 'bullet' }
|
||||
expect(fmt('⟦a\nb⟧', bullet)).toBe('⟦- a\n- b⟧')
|
||||
expect(fmt('⟦- a\n- b⟧', bullet)).toBe('⟦a\nb⟧')
|
||||
})
|
||||
|
||||
it('numbers lines sequentially, skipping blank lines, and renumbers existing numbers', () => {
|
||||
const ordered: FormatAction = { kind: 'prefix', prefix: 'ordered' }
|
||||
expect(fmt('⟦a\n\nb⟧', ordered)).toBe('⟦1. a\n\n2. b⟧')
|
||||
expect(fmt('⟦1. a\nb⟧', ordered)).toBe('⟦1. a\n2. b⟧')
|
||||
expect(fmt('⟦1. a\n2. b⟧', ordered)).toBe('⟦a\nb⟧')
|
||||
})
|
||||
|
||||
it('does not include a line the selection only touches at its very start', () => {
|
||||
expect(fmt('a\n⟦b\n⟧c', { kind: 'prefix', prefix: 'bullet' })).toBe('a\n⟦- b⟧\nc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('headings', () => {
|
||||
it('adds, replaces, and toggles off the heading level', () => {
|
||||
expect(fmt('title⟦⟧', { kind: 'heading', level: 2 })).toBe('## title⟦⟧')
|
||||
expect(fmt('⟦# hi⟧', { kind: 'heading', level: 2 })).toBe('⟦## hi⟧')
|
||||
expect(fmt('⟦## hi⟧', { kind: 'heading', level: 1 })).toBe('⟦# hi⟧')
|
||||
expect(fmt('⟦## hi⟧', { kind: 'heading', level: 2 })).toBe('⟦hi⟧')
|
||||
})
|
||||
})
|
||||
|
||||
describe('code block', () => {
|
||||
it('inserts an empty fenced block with the cursor on the blank line', () => {
|
||||
expect(fmt('⟦⟧', { kind: 'codeBlock' })).toBe('```\n⟦⟧\n```')
|
||||
})
|
||||
|
||||
it('puts the fences on their own lines when the selection is mid-line', () => {
|
||||
expect(fmt('see ⟦x = 1⟧ here', { kind: 'codeBlock' })).toBe('see \n```\n⟦x = 1⟧\n```\n here')
|
||||
})
|
||||
|
||||
it('does not double the newline when the selection already ends with one', () => {
|
||||
expect(fmt('⟦a\n⟧b', { kind: 'codeBlock' })).toBe('```\n⟦a⟧\n```\nb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('link', () => {
|
||||
it('wraps the selection and selects the placeholder url', () => {
|
||||
expect(fmt('see ⟦docs⟧', { kind: 'link' })).toBe('see [docs](⟦url⟧)')
|
||||
})
|
||||
|
||||
it('treats a selected url as the target and leaves the cursor in the text brackets', () => {
|
||||
expect(fmt('⟦https://example.com⟧', { kind: 'link' })).toBe('[⟦⟧](https://example.com)')
|
||||
})
|
||||
|
||||
it('inserts a placeholder link with its text selected when nothing is selected', () => {
|
||||
expect(fmt('a ⟦⟧', { kind: 'link' })).toBe('a [⟦link text⟧](url)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
// #75: pure text transforms behind the composer's formatting toolbar. Each
|
||||
// takes the textarea's value plus its selection and returns the same shape,
|
||||
// so Composer just applies the result -- keeping this free of React/DOM is
|
||||
// what makes the fiddly selection math directly unit-testable.
|
||||
export interface TextState {
|
||||
value: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type InlineMarker = '**' | '*' | '~~' | '`' | '~' | '^'
|
||||
|
||||
export type FormatAction =
|
||||
| { kind: 'inline'; marker: InlineMarker }
|
||||
| { kind: 'prefix'; prefix: 'quote' | 'bullet' | 'ordered' }
|
||||
| { kind: 'heading'; level: 1 | 2 | 3 }
|
||||
| { kind: 'codeBlock' }
|
||||
| { kind: 'link' }
|
||||
|
||||
// Markers whose doubled form is a *different* format (`**` bold vs `*`
|
||||
// italic, `~~` strikethrough vs `~` subscript), so "is this already
|
||||
// wrapped?" has to look at how long the run of marker characters is rather
|
||||
// than just whether one is present.
|
||||
const DOUBLED_IS_DIFFERENT = new Set(['*', '~'])
|
||||
|
||||
// A run of 3 `*` is bold+italic: bold (2) and italic (1) are both "present".
|
||||
function hasMarker(marker: string, run: number): boolean {
|
||||
if (marker.length === 2) return run >= 2
|
||||
return DOUBLED_IS_DIFFERENT.has(marker[0]) ? run % 2 === 1 : run >= 1
|
||||
}
|
||||
|
||||
function applyInline(state: TextState, marker: InlineMarker): TextState {
|
||||
const { value, start, end } = state
|
||||
const ch = marker[0]
|
||||
const m = marker.length
|
||||
const sel = value.slice(start, end)
|
||||
|
||||
// Selection includes the markers themselves (e.g. the user selected the
|
||||
// whole `**word**`) -- unwrap inside the selection.
|
||||
if (sel.length > 0) {
|
||||
let lead = 0
|
||||
while (lead < sel.length && sel[lead] === ch) lead++
|
||||
let trail = 0
|
||||
while (trail < sel.length - lead && sel[sel.length - 1 - trail] === ch) trail++
|
||||
const run = Math.min(lead, trail)
|
||||
if (run > 0 && hasMarker(marker, run) && sel.length > 2 * m) {
|
||||
const inner = sel.slice(m, sel.length - m)
|
||||
return { value: value.slice(0, start) + inner + value.slice(end), start, end: start + inner.length }
|
||||
}
|
||||
}
|
||||
|
||||
// Markers sit just outside the selection. Skipped when the selection has
|
||||
// edge whitespace: markers adjacent to it belong to neighbouring spans
|
||||
// (`*a* and *b*` with " and " selected), not to this selection.
|
||||
const edgeWhitespace = sel.length > 0 && (/^\s/.test(sel) || /\s$/.test(sel))
|
||||
if (!edgeWhitespace) {
|
||||
let before = 0
|
||||
while (start - before - 1 >= 0 && value[start - before - 1] === ch) before++
|
||||
let after = 0
|
||||
while (end + after < value.length && value[end + after] === ch) after++
|
||||
const run = Math.min(before, after)
|
||||
if (run > 0 && hasMarker(marker, run)) {
|
||||
return {
|
||||
value: value.slice(0, start - m) + sel + value.slice(end + m),
|
||||
start: start - m,
|
||||
end: end - m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing selected (or only whitespace): drop in an empty pair, cursor between.
|
||||
if (sel.trim() === '') {
|
||||
return {
|
||||
value: value.slice(0, start) + marker + marker + value.slice(start),
|
||||
start: start + m,
|
||||
end: start + m,
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown doesn't treat `** word**` as bold -- keep the markers hugging
|
||||
// the text, outside any whitespace the selection happens to include.
|
||||
const innerStart = start + (sel.length - sel.trimStart().length)
|
||||
const innerEnd = end - (sel.length - sel.trimEnd().length)
|
||||
return {
|
||||
value:
|
||||
value.slice(0, innerStart) + marker + value.slice(innerStart, innerEnd) + marker + value.slice(innerEnd),
|
||||
start: innerStart + m,
|
||||
end: innerEnd + m,
|
||||
}
|
||||
}
|
||||
|
||||
// The whole lines a selection touches. A selection that ends right after a
|
||||
// newline (i.e. at the very start of the next line) doesn't count that line.
|
||||
function lineBounds(value: string, start: number, end: number) {
|
||||
const lineStart = start === 0 ? 0 : value.lastIndexOf('\n', start - 1) + 1
|
||||
const effectiveEnd = end > start && value[end - 1] === '\n' ? end - 1 : end
|
||||
const nextNewline = value.indexOf('\n', effectiveEnd)
|
||||
return { lineStart, lineEnd: nextNewline === -1 ? value.length : nextNewline }
|
||||
}
|
||||
|
||||
function transformLines(state: TextState, transform: (lines: string[]) => string[]): TextState {
|
||||
const { value, start, end } = state
|
||||
const { lineStart, lineEnd } = lineBounds(value, start, end)
|
||||
const oldBlock = value.slice(lineStart, lineEnd)
|
||||
const newBlock = transform(oldBlock.split('\n')).join('\n')
|
||||
const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd)
|
||||
if (start === end) {
|
||||
const cursor = Math.max(lineStart, start + (newBlock.length - oldBlock.length))
|
||||
return { value: next, start: cursor, end: cursor }
|
||||
}
|
||||
// Keep the affected lines selected so the same button toggles it back off.
|
||||
return { value: next, start: lineStart, end: lineStart + newBlock.length }
|
||||
}
|
||||
|
||||
const ORDERED_RE = /^\d+\. /
|
||||
|
||||
function applyPrefix(state: TextState, kind: 'quote' | 'bullet' | 'ordered'): TextState {
|
||||
const marker = kind === 'quote' ? '> ' : '- '
|
||||
const has = (line: string) => (kind === 'ordered' ? ORDERED_RE.test(line) : line.startsWith(marker))
|
||||
const strip = (line: string) => (kind === 'ordered' ? line.replace(ORDERED_RE, '') : line.slice(marker.length))
|
||||
|
||||
return transformLines(state, (lines) => {
|
||||
// Blank lines inside a multi-line selection are paragraph breaks, not
|
||||
// list items -- only a lone blank line (cursor on an empty line) gets one.
|
||||
const multi = lines.length > 1
|
||||
const skip = (line: string) => multi && line === ''
|
||||
const targets = lines.filter((line) => !skip(line))
|
||||
if (targets.length > 0 && targets.every(has)) {
|
||||
return lines.map((line) => (has(line) ? strip(line) : line))
|
||||
}
|
||||
let n = 0
|
||||
return lines.map((line) => {
|
||||
if (skip(line)) return line
|
||||
n++
|
||||
if (kind === 'ordered') return `${n}. ${strip(line)}`
|
||||
return has(line) ? line : marker + line
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const HEADING_RE = /^#{1,6} /
|
||||
|
||||
function applyHeading(state: TextState, level: 1 | 2 | 3): TextState {
|
||||
const prefix = '#'.repeat(level) + ' '
|
||||
return transformLines(state, (lines) => {
|
||||
const multi = lines.length > 1
|
||||
const skip = (line: string) => multi && line === ''
|
||||
const targets = lines.filter((line) => !skip(line))
|
||||
if (targets.length > 0 && targets.every((line) => line.startsWith(prefix))) {
|
||||
return lines.map((line) => (line.startsWith(prefix) ? line.slice(prefix.length) : line))
|
||||
}
|
||||
return lines.map((line) => (skip(line) ? line : prefix + line.replace(HEADING_RE, '')))
|
||||
})
|
||||
}
|
||||
|
||||
// A fence only counts at the start of a line, so mid-line cursors get a
|
||||
// newline inserted first.
|
||||
function applyCodeBlock(state: TextState): TextState {
|
||||
const { value, start, end } = state
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
const sel = value.slice(start, end).replace(/\n$/, '')
|
||||
const lead = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
|
||||
const trail = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
|
||||
const next = `${before}${lead}\`\`\`\n${sel}\n\`\`\`${trail}${after}`
|
||||
const innerStart = before.length + lead.length + 4
|
||||
return { value: next, start: innerStart, end: innerStart + sel.length }
|
||||
}
|
||||
|
||||
function applyLink(state: TextState): TextState {
|
||||
const { value, start, end } = state
|
||||
const sel = value.slice(start, end)
|
||||
let text: string
|
||||
let selStart: number
|
||||
let selEnd: number
|
||||
if (/^https?:\/\/\S+$/.test(sel)) {
|
||||
text = `[](${sel})`
|
||||
selStart = selEnd = start + 1
|
||||
} else if (sel) {
|
||||
text = `[${sel}](url)`
|
||||
selStart = start + sel.length + 3
|
||||
selEnd = selStart + 3
|
||||
} else {
|
||||
text = '[link text](url)'
|
||||
selStart = start + 1
|
||||
selEnd = selStart + 9
|
||||
}
|
||||
return { value: value.slice(0, start) + text + value.slice(end), start: selStart, end: selEnd }
|
||||
}
|
||||
|
||||
export function applyFormat(state: TextState, action: FormatAction): TextState {
|
||||
switch (action.kind) {
|
||||
case 'inline':
|
||||
return applyInline(state, action.marker)
|
||||
case 'prefix':
|
||||
return applyPrefix(state, action.prefix)
|
||||
case 'heading':
|
||||
return applyHeading(state, action.level)
|
||||
case 'codeBlock':
|
||||
return applyCodeBlock(state)
|
||||
case 'link':
|
||||
return applyLink(state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MyRoomItem } from '../types'
|
||||
import { clearRoomIndicators } from './roomIndicators'
|
||||
|
||||
function makeRoom(overrides: Partial<MyRoomItem> & { id: string }): MyRoomItem {
|
||||
return {
|
||||
name: overrides.id,
|
||||
description: null,
|
||||
is_private: false,
|
||||
is_dm: false,
|
||||
is_archived: false,
|
||||
owner_id: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
role: 'member',
|
||||
has_unread: false,
|
||||
has_mention: false,
|
||||
dm_partner: null,
|
||||
email_notifications: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('clearRoomIndicators', () => {
|
||||
it('clears has_unread and has_mention on the matching room', () => {
|
||||
const rooms = [makeRoom({ id: 'room-1', has_unread: true, has_mention: true })]
|
||||
const result = clearRoomIndicators(rooms, 'room-1')
|
||||
expect(result[0].has_unread).toBe(false)
|
||||
expect(result[0].has_mention).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves unread state on every other room', () => {
|
||||
const rooms = [
|
||||
makeRoom({ id: 'room-1', has_unread: true, has_mention: true }),
|
||||
makeRoom({ id: 'room-2', has_unread: true, has_mention: false }),
|
||||
makeRoom({ id: 'room-3', has_unread: false, has_mention: false }),
|
||||
]
|
||||
const result = clearRoomIndicators(rooms, 'room-1')
|
||||
const room2 = result.find((r) => r.id === 'room-2')
|
||||
const room3 = result.find((r) => r.id === 'room-3')
|
||||
expect(room2?.has_unread).toBe(true)
|
||||
expect(room3?.has_unread).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves untouched rooms as the same object reference', () => {
|
||||
const room2 = makeRoom({ id: 'room-2', has_unread: true })
|
||||
const rooms = [makeRoom({ id: 'room-1', has_unread: true }), room2]
|
||||
const result = clearRoomIndicators(rooms, 'room-1')
|
||||
expect(result[1]).toBe(room2)
|
||||
})
|
||||
|
||||
it('is a no-op if the room id is not found', () => {
|
||||
const rooms = [makeRoom({ id: 'room-1', has_unread: true })]
|
||||
const result = clearRoomIndicators(rooms, 'does-not-exist')
|
||||
expect(result[0].has_unread).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MyRoomItem } from '../types'
|
||||
|
||||
// Pulled out of ChatShellPage as a pure function purely so it's directly
|
||||
// unit-testable without rendering the whole page -- behavior is unchanged.
|
||||
// Only touches the matching room; every other room's object reference (and
|
||||
// therefore its unread state) is left untouched.
|
||||
export function clearRoomIndicators(rooms: MyRoomItem[], roomId: string): MyRoomItem[] {
|
||||
return rooms.map((r) => (r.id === roomId ? { ...r, has_unread: false, has_mention: false } : r))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { NetworkError } from '../api/client'
|
||||
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
||||
@@ -12,7 +12,9 @@ import { Sidebar } from '../components/Sidebar'
|
||||
import { TopBar } from '../components/TopBar'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useChatSocketContext } from '../context/ChatSocketContext'
|
||||
import { useDesktopRoomBridge } from '../hooks/useDesktopRoomBridge'
|
||||
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
||||
import { clearRoomIndicators } from '../lib/roomIndicators'
|
||||
import type { MyRoomItem, RoomMember } from '../types'
|
||||
import './ChatShellPage.css'
|
||||
|
||||
@@ -60,10 +62,13 @@ export function ChatShellPage() {
|
||||
|
||||
const socket = useChatSocketContext()
|
||||
|
||||
const clearRoomIndicators = useCallback((id: string) => {
|
||||
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r)))
|
||||
const handleRoomRead = useCallback((id: string) => {
|
||||
setRooms((prev) => clearRoomIndicators(prev, id))
|
||||
}, [])
|
||||
|
||||
const unreadRoomCount = useMemo(() => rooms.filter((r) => r.has_unread).length, [rooms])
|
||||
useDesktopRoomBridge(activeRoom?.id, unreadRoomCount)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
socket.subscribe((envelope) => {
|
||||
@@ -134,7 +139,7 @@ export function ChatShellPage() {
|
||||
onToggleInfo={() => setInfoOpen((v) => !v)}
|
||||
infoOpen={infoOpen}
|
||||
socket={socket}
|
||||
onRoomRead={clearRoomIndicators}
|
||||
onRoomRead={handleRoomRead}
|
||||
/>
|
||||
) : (
|
||||
!isMobile && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Markdown from 'markdown-to-jsx'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent'
|
||||
import { TopBar } from '../components/TopBar'
|
||||
import './HelpPage.css'
|
||||
@@ -20,6 +20,15 @@ export function HelpPage() {
|
||||
[content],
|
||||
)
|
||||
|
||||
// #75: the composer's "?" links straight to /help#formatting, but the guide
|
||||
// is fetched after the page loads -- the browser's own scroll-to-hash has
|
||||
// already run (and found nothing) by the time the headings exist.
|
||||
const { hash } = useLocation()
|
||||
useEffect(() => {
|
||||
if (!markdownPreview || !hash) return
|
||||
document.getElementById(hash.slice(1))?.scrollIntoView()
|
||||
}, [markdownPreview, hash])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(GUIDE_URL)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Deliberately separate from vite.config.ts, not an extension of it -- that
|
||||
// file's VitePWA plugin does real build-time work (service worker
|
||||
// injection, manifest generation) that has nothing to do with running unit
|
||||
// tests and would just slow every test run down for no benefit.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user