Author SHA1 Message Date
ksmithandClaude Sonnet 5.5 7ff7f2ec0e Bump to v2026.10.9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:25:31 -06:00
ksmithandClaude Sonnet 5.5 62b5eec2fb Show the date on message timestamps that aren't from today
A bare "4:04 PM" gave no way to tell today's message from one sent last
week. Today stays time-only; older messages gain just enough date to
place them: "Yesterday 4:04 PM", a weekday for the past week ("Mon
4:04 PM"), then "Oct 2, 4:04 PM" (with the year if it isn't this one).
Hovering a time shows the full date. All in the viewer's local time.

A new calendar day now always starts a new message group. Previously a
same-sender run that crossed midnight within the 5-minute window (11:58
PM then 12:01 AM) stayed under one header, which would have put the
later message under the wrong day's timestamp. The grouping rule moves
out of MessageList into messageGrouping.ts so it's unit-tested.

useLocalDay refreshes "today" at local midnight and when a hidden tab
becomes visible again, so a tab left open overnight doesn't keep showing
yesterday's messages as a bare time. .message-time no longer wraps, and
.message-author can shrink, so a long display name can't push the longer
label out of the row on a phone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 15:25:22 -06:00
ksmithandClaude Sonnet 5.5 eb30dc1753 Bump to v2026.10.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:07:07 -06:00
ksmithandClaude Sonnet 5.5 6975ed4dc0 Add a collapsible markdown formatting toolbar to the composer (#75)
Markdown support was already complete and documented, but nothing in the
composer pointed at it -- unlike @, # and : (which advertise themselves
when typed), formatting syntax was only discoverable via Help, buried in
the account menu.

The composer stays a plain markdown textarea; a new "Aa" button opens a
toolbar that inserts the syntax for you: bold, italic, strikethrough,
inline code, code block, link, quote, bulleted/numbered lists, H1-H3,
sub/superscript. Collapsed by default, remembered across reloads and
room switches. Clicking a button again removes the formatting (native
undo doesn't track programmatic edits), and bold/italic/sub/strike
toggles are run-length aware so "***x***" is handled correctly. The
selection math is pure functions in lib/markdownFormat.ts, unit-tested.

The "?" opens /help#formatting in a new tab so the draft isn't lost;
HelpPage now scrolls to the hash once the guide has loaded, since the
browser's own hash scroll fired before the async-fetched headings
existed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:06:56 -06:00
ksmithandClaude Sonnet 5 c54c1bf97d Bump to v2026.9.28
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:22:21 -06:00
ksmithandClaude Sonnet 5 3349cadea2 Fix desktop tray unread indicator: clear only on genuine room entry
The tray indicator must stay visible while a message is unread and
clear only when the user actually opens the room containing it --
never merely because the window was shown, focused, restored, or
opened from the tray.

Adds window.dsDesktop.roomOpened() (called exactly once per genuine
transition to a real room id, via useDesktopRoomBridge) and wires up
window.dsDesktop.setUnreadCount() (declared but never actually called
before this) to fire whenever the unread-room count changes. Both are
no-ops in browser builds, matching this bridge's existing optional-
chaining convention.

Frontend unread state stays authoritative -- clearRoomIndicators is
pulled out of ChatShellPage as a pure function (same behavior, now
directly testable) so opening one room only clears that room's
unread/mention flags, leaving every other room's state untouched.

Also adds a frontend test runner (vitest + React Testing Library) --
none existed in this project before. 10 new tests cover: roomOpened
firing once on entry, not refiring on an unrelated rerender with the
same room id, refiring on a genuine room-to-room transition, never
firing with no room id, setUnreadCount firing independently of
roomOpened, clearing one room preserving every other room's unread
state, and the desktop bridge remaining fully optional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:21:45 -06:00
29 changed files with 2162 additions and 39 deletions

No files matched your search

+12 -1
View File
@@ -68,9 +68,20 @@ new message, or if you message them again yourself.
Type in the message box at the bottom of a room and press **Enter** to
send. Use **Shift+Enter** to add a line break without sending.
Each message shows when it was sent, in your local time. Messages from today
show just the time; older ones add the day — "Yesterday 4:04 PM", a weekday
like "Mon 4:04 PM" for the past week, then the date ("Oct 2, 4:04 PM", with
the year if it's not this one). Hover a time to see the full date.
## Formatting
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)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ds-chat"
version = "2026.9.26"
version = "2026.10.9"
description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11"
+953 -11
View File
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -1,14 +1,15 @@
{
"name": "frontend",
"private": true,
"version": "2026.9.26",
"version": "2026.10.9",
"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",
+28
View File
@@ -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;
+42
View File
@@ -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>
)
}
+5
View File
@@ -40,12 +40,17 @@
}
.message-author {
/* Lets a very long display name shrink/wrap instead of pushing the
(now longer, e.g. "Yesterday 4:04 PM") timestamp out of the row. */
min-width: 0;
font-size: 0.86rem;
font-weight: 700;
color: var(--ds-text);
}
.message-time {
flex: none;
white-space: nowrap;
font-size: 0.68rem;
color: var(--ds-muted);
font-family: var(--mono);
+11 -19
View File
@@ -2,8 +2,10 @@ import type { CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { useLocalDay } from '../hooks/useLocalDay'
import { formatFileSize } from '../lib/fileSize'
import { avatarUrlFor, displayNameFor, senderColorIndex, statusFor } from '../lib/messageGrouping'
import { formatMessageTime, formatMessageTimeTitle } from '../lib/formatMessageTime'
import { avatarUrlFor, displayNameFor, isGroupStart, senderColorIndex, statusFor } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
@@ -69,13 +71,6 @@ function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps
// trigger a download the moment the browser tries to fetch it).
const PLAYABLE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm', 'video/ogg'])
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
const GROUP_BREAK_MS = 5 * 60 * 1000
interface VideoAttachmentProps {
file: MessageFileInfo
roomId: string
@@ -134,6 +129,7 @@ export function MessageList({
onDelete,
}: MessageListProps) {
const { user } = useAuth()
const now = useLocalDay()
// #71: same viewer preference MessageContent applies to in-text emoji,
// looked up separately here since a reaction pill isn't a descendant of
// that component's wrapper div (see EmojiGlyph's own comment).
@@ -218,19 +214,15 @@ export function MessageList({
// consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages. Also breaks on
// a long gap (see GROUP_BREAK_MS) so a message sent well after the
// rest of the run still gets its own visible timestamp.
const isGroupStart =
!prev ||
prev.user_id !== msg.user_id ||
new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS
// a long gap or a new calendar day -- see isGroupStart.
const groupStart = isGroupStart(prev, msg)
const editing = editingId === msg.id
const deleted = !!msg.deleted_at
return (
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
<div key={msg.id} className={`message-row${groupStart ? ' message-row-start' : ''}`}>
<div className="message-avatar-slot">
{isGroupStart && (
{groupStart && (
<UserAvatar
username={msg.username}
colorIndex={senderColorIndex(msg.username, members)}
@@ -240,11 +232,11 @@ export function MessageList({
)}
</div>
<div className="message-content">
{isGroupStart && (
{groupStart && (
<div className="message-header">
<span className="message-author">{displayNameFor(msg.username, members)}</span>
<span className="message-time">
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
<span className="message-time" title={formatMessageTimeTitle(msg.created_at)}>
{formatMessageTime(msg.created_at, now)}
</span>
</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])
}
+67
View File
@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocalDay } from './useLocalDay'
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
}
describe('useLocalDay', () => {
beforeEach(() => {
vi.useFakeTimers()
setVisibility('visible')
})
afterEach(() => {
vi.useRealTimers()
// Remove the own-property override so jsdom's real getter shows through again.
delete (document as unknown as Record<string, unknown>).visibilityState
})
it('rolls over to the next day when local midnight passes', () => {
vi.setSystemTime(new Date(2026, 9, 9, 23, 59, 30))
const { result } = renderHook(() => useLocalDay())
expect(result.current.getDate()).toBe(9)
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(result.current.getDate()).toBe(10)
})
it('refreshes when the tab becomes visible after the clock moved a day (e.g. a sleeping laptop)', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
// The clock jumps without any timer firing in between.
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.getDate()).toBe(10)
})
it('keeps the same value (no rerender) when visibility changes on the same day', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
const first = result.current
vi.setSystemTime(new Date(2026, 9, 9, 18, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current).toBe(first)
})
it('ignores visibility changes while the tab is hidden', () => {
vi.setSystemTime(new Date(2026, 9, 9, 12, 0))
const { result } = renderHook(() => useLocalDay())
setVisibility('hidden')
vi.setSystemTime(new Date(2026, 9, 10, 12, 0))
act(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.getDate()).toBe(9)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { isSameLocalDay } from '../lib/formatMessageTime'
function msUntilNextLocalMidnight(now: Date): number {
// One second past, so the timer never lands a hair before midnight.
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1)
return next.getTime() - now.getTime()
}
// A `now` that only changes when the local calendar day does -- at midnight,
// and when a tab becomes visible again (a laptop that slept through
// midnight). Without it, a tab left open overnight keeps showing yesterday's
// messages as a bare "4:04 PM" until something unrelated rerenders the list.
export function useLocalDay(): Date {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
function refresh() {
const current = new Date()
// Same state object when the day hasn't changed, so no rerender.
setNow((prev) => (isSameLocalDay(prev, current) ? prev : current))
}
function schedule() {
timer = setTimeout(() => {
refresh()
schedule()
}, msUntilNextLocalMidnight(new Date()))
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') refresh()
}
schedule()
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
clearTimeout(timer)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
return now
}
+16
View File
@@ -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,82 @@
import { describe, expect, it } from 'vitest'
import { formatMessageTime, formatMessageTimeTitle, isSameLocalDay } from './formatMessageTime'
// Local-time constructors throughout, and expected text built with the same
// locale calls the code uses -- so these hold in any timezone and any ICU
// locale instead of hardcoding "3:00 PM".
const NOW = new Date(2026, 9, 9, 15, 0) // Fri Oct 9 2026, 3:00 PM local
const iso = (d: Date) => d.toISOString()
const timeText = (d: Date) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
const weekday = (d: Date) => d.toLocaleDateString([], { weekday: 'short' })
describe('formatMessageTime', () => {
it('shows just the time for a message sent today', () => {
const morning = new Date(2026, 9, 9, 8, 5)
expect(formatMessageTime(iso(morning), NOW)).toBe(timeText(morning))
})
it('prefixes "Yesterday" for yesterday', () => {
const d = new Date(2026, 9, 8, 23, 59)
expect(formatMessageTime(iso(d), NOW)).toBe(`Yesterday ${timeText(d)}`)
})
it('treats 11:59 PM vs 12:03 AM as different days despite being minutes apart', () => {
const justBeforeMidnight = new Date(2026, 9, 8, 23, 59)
const justAfterMidnight = new Date(2026, 9, 9, 0, 3)
expect(formatMessageTime(iso(justBeforeMidnight), justAfterMidnight)).toBe(
`Yesterday ${timeText(justBeforeMidnight)}`,
)
expect(formatMessageTime(iso(justAfterMidnight), justAfterMidnight)).toBe(timeText(justAfterMidnight))
})
it('uses the short weekday for 2 to 6 days ago', () => {
const twoDays = new Date(2026, 9, 7, 12, 0)
const sixDays = new Date(2026, 9, 3, 12, 0)
expect(formatMessageTime(iso(twoDays), NOW)).toBe(`${weekday(twoDays)} ${timeText(twoDays)}`)
expect(formatMessageTime(iso(sixDays), NOW)).toBe(`${weekday(sixDays)} ${timeText(sixDays)}`)
})
it('switches to a month and day at 7 days, without the year when it is this year', () => {
const d = new Date(2026, 9, 2, 12, 0)
const label = formatMessageTime(iso(d), NOW)
expect(label).toBe(`${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${timeText(d)}`)
expect(label).not.toContain('2026')
})
it('adds the year for a message from a different year', () => {
const d = new Date(2025, 11, 25, 9, 30)
const label = formatMessageTime(iso(d), NOW)
expect(label).toBe(
`${d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })}, ${timeText(d)}`,
)
expect(label).toContain('2025')
})
it('counts Dec 31 -> Jan 1 as yesterday', () => {
const d = new Date(2026, 11, 31, 22, 0)
expect(formatMessageTime(iso(d), new Date(2027, 0, 1, 10, 0))).toBe(`Yesterday ${timeText(d)}`)
})
it('treats a slightly-future timestamp (clock skew) as today', () => {
const tomorrow = new Date(2026, 9, 10, 9, 0)
expect(formatMessageTime(iso(tomorrow), NOW)).toBe(timeText(tomorrow))
})
})
describe('formatMessageTimeTitle', () => {
it('includes the full date, with the year', () => {
expect(formatMessageTimeTitle(iso(new Date(2026, 9, 2, 16, 4)))).toContain('2026')
})
})
describe('isSameLocalDay', () => {
it('is false across midnight and true within a day', () => {
expect(isSameLocalDay(new Date(2026, 9, 8, 23, 59), new Date(2026, 9, 9, 0, 1))).toBe(false)
expect(isSameLocalDay(new Date(2026, 9, 9, 0, 1), new Date(2026, 9, 9, 23, 59))).toBe(true)
})
it('accepts ISO strings as well as Dates', () => {
expect(isSameLocalDay(iso(new Date(2026, 9, 9, 8, 0)), new Date(2026, 9, 9, 20, 0))).toBe(true)
})
})
+44
View File
@@ -0,0 +1,44 @@
// "Today" is the viewer's local calendar day, so everything here goes through
// local-time getters, never UTC.
type DateLike = Date | string
function toDate(d: DateLike): Date {
return d instanceof Date ? d : new Date(d)
}
// A calendar-day number, not elapsed milliseconds: built from the local
// y/m/d via Date.UTC so a 23- or 25-hour DST day still counts as one day,
// and 11:58 PM vs 12:01 AM (3 minutes apart) correctly counts as 1 day apart.
function dayNumber(d: Date): number {
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000)
}
export function isSameLocalDay(a: DateLike, b: DateLike): boolean {
return dayNumber(toDate(a)) === dayNumber(toDate(b))
}
// A bare "4:04 PM" gives no way to tell today's message from one sent last
// week. Today stays time-only; anything older gains just enough date to
// place it.
export function formatMessageTime(iso: string, now: Date = new Date()): string {
const sent = new Date(iso)
const time = sent.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
// <= 0 also covers a timestamp slightly in the future (clock skew between
// server and this device) -- better shown as today than as a negative age.
const daysAgo = dayNumber(now) - dayNumber(sent)
if (daysAgo <= 0) return time
if (daysAgo === 1) return `Yesterday ${time}`
// Under 7 so the weekday is never the same as today's (7 days ago would be).
if (daysAgo < 7) return `${sent.toLocaleDateString([], { weekday: 'short' })} ${time}`
const date = sent.toLocaleDateString([], {
month: 'short',
day: 'numeric',
year: sent.getFullYear() === now.getFullYear() ? undefined : 'numeric',
})
return `${date}, ${time}`
}
// Hover text: the one place the full, unambiguous date is always available.
export function formatMessageTimeTitle(iso: string): string {
return new Date(iso).toLocaleString([], { dateStyle: 'full', timeStyle: 'short' })
}
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getFormattingToolbarOpen, setFormattingToolbarOpen } from './formattingToolbarPref'
afterEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('formatting toolbar preference', () => {
it('is collapsed by default', () => {
expect(getFormattingToolbarOpen()).toBe(false)
})
it('remembers being opened and closed', () => {
setFormattingToolbarOpen(true)
expect(getFormattingToolbarOpen()).toBe(true)
setFormattingToolbarOpen(false)
expect(getFormattingToolbarOpen()).toBe(false)
})
it('falls back to collapsed when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(getFormattingToolbarOpen()).toBe(false)
expect(() => setFormattingToolbarOpen(true)).not.toThrow()
})
})
+21
View File
@@ -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.
}
}
+139
View File
@@ -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)')
})
})
+204
View File
@@ -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)
}
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { GROUP_BREAK_MS, isGroupStart } from './messageGrouping'
const msg = (user: string, d: Date) => ({ user_id: user, created_at: d.toISOString() })
describe('isGroupStart', () => {
const t = new Date(2026, 9, 9, 10, 0, 0)
const later = (ms: number) => new Date(t.getTime() + ms)
it('starts a group for the first message', () => {
expect(isGroupStart(undefined, msg('a', t))).toBe(true)
})
it('starts a group when the sender changes', () => {
expect(isGroupStart(msg('a', t), msg('b', later(1000)))).toBe(true)
})
it('keeps the same sender grouped within the break window', () => {
expect(isGroupStart(msg('a', t), msg('a', later(60_000)))).toBe(false)
})
it('groups at exactly the break threshold and splits just past it', () => {
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS)))).toBe(false)
expect(isGroupStart(msg('a', t), msg('a', later(GROUP_BREAK_MS + 1000)))).toBe(true)
})
it('measures the gap from the previous message, not the first in the run', () => {
const fourMin = 4 * 60_000
expect(isGroupStart(msg('a', later(fourMin)), msg('a', later(2 * fourMin)))).toBe(false)
expect(isGroupStart(msg('a', later(2 * fourMin)), msg('a', later(3 * fourMin)))).toBe(false)
})
it('starts a new group when the same sender crosses midnight, even a few minutes apart', () => {
const before = new Date(2026, 9, 8, 23, 58)
const after = new Date(2026, 9, 9, 0, 1)
expect(after.getTime() - before.getTime()).toBeLessThan(GROUP_BREAK_MS)
expect(isGroupStart(msg('a', before), msg('a', after))).toBe(true)
})
})
+24
View File
@@ -1,7 +1,31 @@
import { getUserAvatarUrl } from '../api/users'
import { hashIndex } from './avatar'
import { isSameLocalDay } from './formatMessageTime'
import type { RoomMember } from '../types'
// #73: Slack's own threshold for the same "still grouped, but it's been a
// while" call -- past this gap a same-sender message starts a new group
// (its own avatar/name/timestamp) even with nobody else posting in
// between, so a message sent minutes later doesn't hide under a stale
// timestamp from the start of the run.
export const GROUP_BREAK_MS = 5 * 60 * 1000
interface Groupable {
user_id: string
created_at: string
}
// Whether `msg` gets its own avatar/name/timestamp header, vs. continuing
// `prev`'s group. A new calendar day always breaks the group: now that
// timestamps can carry a date, a same-sender run that crosses midnight
// (11:58 PM, then 12:01 AM) would otherwise leave the later message under a
// header showing the wrong day.
export function isGroupStart(prev: Groupable | undefined, msg: Groupable): boolean {
if (!prev || prev.user_id !== msg.user_id) return true
if (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime() > GROUP_BREAK_MS) return true
return !isSameLocalDay(prev.created_at, msg.created_at)
}
export function senderColorIndex(username: string, members: RoomMember[]): number {
const idx = members.findIndex((m) => m.username === username)
if (idx >= 0) return idx
+56
View File
@@ -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)
})
})
+9
View File
@@ -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))
}
+9 -4
View File
@@ -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 && (
+10 -1
View File
@@ -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)
+13
View File
@@ -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',
},
})