Compare commits

...
6 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 a8c99f2a45 a11y(polls): drop redundant aria-label on max-selections input
CI / Trigger Desktop Build (push) Successful in 6s
CI / Build & Quality Checks (push) Successful in 11m12s
Review noted the number input had both an htmlFor-associated visible label
("Voters can pick up to") and an aria-label, so the aria-label won and the
visible label was not announced. Remove the aria-label so the accessible name
matches the visible label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:09:01 -04:00
jaredandClaude Opus 4.8 a4660a8163 feat(polls): let creators set max selections for multiple-choice
The poll creator only offered single (max_selections 1) or multiple = pick ALL
options — no way to run a "pick your top 2" poll, even though the display side
already enforces an arbitrary max_selections ("Select up to N"). Add a "Voters
can pick up to N of M options" control shown for multiple-choice polls. Defaults
to the option count (preserving the old select-all behavior) until lowered;
clamped to [2, filled option count] on submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:06:32 -04:00
jaredandClaude Opus 4.8 85ac8de5d9 style: apply prettier across fork files
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 -04:00
jaredandClaude Opus 4.8 d727e7a7ab refactor(schedule): dedup formatSendAt into shared formatFriendlyDateTime
ScheduleMessageModal had a local formatSendAt(Date) byte-equivalent to the
tested formatFriendlyDateTime (utils/datetimeInput). Reuse the shared, unit-
tested helper instead of a second copy — identical output. (Also prettier-clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:29 -04:00
jaredandClaude Opus 4.8 3a1c626bc8 feat(stickers): "recently used" row in the sticker picker
The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the
shared EmojiBoard did not — you had to hunt through packs to re-send a sticker.
Add recent stickers, mirroring recentGifs:

- New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by
  url, capped 16) + pure addRecentSticker with 4 unit tests.
- EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar
  icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into
  minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they
  render + re-send like pack stickers.
- Recorded on select in the shared delegated click handler, covering both the
  grouped and search paths.

Blast radius is the sticker tab only (reactions/status use the emoji tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:43:17 -04:00
jaredandClaude Opus 4.8 fb8e0c6e14 fix(threads): enable slash commands in the thread composer
The thread composer already showed the /command autocomplete (RoomInput.tsx:954
was never gated), but the interpreter was disabled (:523), so /me, /shrug,
/invite, etc. sent literally in threads - a confusing inconsistency and the
other half of the threads "v1" limitation.

Remove the thread gate: content-transform commands (/me, /notice, /shrug,
/tableflip, /unflip) flow into the normal send path, which already routes to the
thread via threadRootId; the rest are room-level actions. No command sends a
mis-routed timeline message (verified against useCommands). Scheduling stays
disabled in threads for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:34:24 -04:00
36 changed files with 581 additions and 361 deletions
+21 -9
View File
@@ -679,6 +679,17 @@ The indicator is hidden once the server confirms the event (when the internal st
- Picker UI is styled with TDS variables when the TDS theme is active
- Located at `src/app/components/GifPicker.tsx`
### Sticker Picker — Recently used
The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar
`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`),
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker`
(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s
(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack
stickers. Recorded on select for both the grouped and search paths (shared delegated click).
### Message Forwarding
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
@@ -799,12 +810,12 @@ resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:
| kind | shape | providers |
| ----------- | ------------------------ | ----------------------------------------------------------------- |
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
| kind | shape | providers |
| ----------- | ------------------------- | ------------------------------------------------------------------ |
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
**Privacy-friendly facade.** The tile first shows the homeserver's cached
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
@@ -855,6 +866,7 @@ player.kick).
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
- Supports 2 to 10 answer options; single-choice or multiple-choice
- **Max selections** — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets `max_selections` (2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N")
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
- Accessible via the `Icons.OrderList` button in the composer toolbar
@@ -900,9 +912,9 @@ Root messages in the main timeline show a **"N replies · time"** chip (server-a
### Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). **Slash commands work in threads** — content-transform commands (`/me`, `/notice`, `/shrug`, `/tableflip`, `/unflip`) route into the thread via the normal send path, and room-level commands (`/invite`, `/kick`, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply *in that thread* — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply _in that thread_ — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
### Notifications (Slack-style, P4-1)
@@ -1015,7 +1027,7 @@ Hook: `src/app/hooks/useUserNotes.ts`
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room *before* the forwarded message (sequenced per room; a room counts as failed if either send fails).
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room _before_ the forwarded message (sequenced per room; a room counts as failed if either send fails).
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.
### Live Bookmark Previews (P6-3)
+1 -1
View File
@@ -235,7 +235,7 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the *unstable* `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
+1 -2
View File
@@ -7,8 +7,7 @@ import { BlockType } from './types';
// Loose Slate node builders for the test.
const txt = (text: string, marks: Record<string, unknown> = {}) =>
({ text, ...marks }) as unknown as Descendant;
const el = (type: BlockType, children: unknown[]) =>
({ type, children }) as unknown as Descendant;
const el = (type: BlockType, children: unknown[]) => ({ type, children }) as unknown as Descendant;
const OPTS = {
allowTextFormatting: true,
+1 -1
View File
@@ -42,7 +42,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
.map((seg) =>
seg.type === 'text'
? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false })
: mathToCustomHtml(seg.value, seg.type === 'block')
: mathToCustomHtml(seg.value, seg.type === 'block'),
)
.join('');
}
+34 -2
View File
@@ -14,7 +14,7 @@ import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
@@ -29,6 +29,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc
import { useDebounce } from '../../hooks/useDebounce';
import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
@@ -102,6 +103,7 @@ const useGroups = (
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 21);
const recentStickers = useAtomValue(recentStickersAtom);
const labels = useEmojiGroupLabels();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
@@ -143,6 +145,16 @@ const useGroups = (
const g: StickerGroupItem[] = [];
if (tab !== EmojiBoardTab.Sticker) return g;
if (recentStickers.length > 0) {
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
// StickerItem only reads url/shortcode/body, so a minimal PackImageReader
// reconstructed from the stored data renders and re-sends correctly.
items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
});
}
imagePacks.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
@@ -157,7 +169,7 @@ const useGroups = (
});
return g;
}, [mx, imagePacks, tab]);
}, [mx, imagePacks, tab, recentStickers]);
return [emojiGroupItems, stickerGroupItems];
};
@@ -289,6 +301,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
const recentStickers = useAtomValue(recentStickersAtom);
const usage = ImageUsage.Sticker;
const packLabels = useMemo(() => {
@@ -308,6 +321,17 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
return (
<Sidebar>
{recentStickers.length > 0 && (
<SidebarStack>
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
onClick={handleScrollToGroup}
/>
</SidebarStack>
)}
<SidebarStack>
{packs.map((pack) => {
const label = packLabels.get(pack.id);
@@ -435,6 +459,7 @@ export function EmojiBoard({
);
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const setRecentStickers = useSetAtom(recentStickersAtom);
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
@@ -494,6 +519,13 @@ export function EmojiBoard({
}
if (emojiInfo.type === EmojiType.Sticker) {
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
setRecentStickers((prev) =>
addRecentSticker(prev, {
url: emojiInfo.data,
shortcode: emojiInfo.shortcode,
body: emojiInfo.label,
}),
);
}
if (!evt.altKey && !evt.shiftKey) requestClose();
};
@@ -46,7 +46,13 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const { address } = imagePack;
if (!address) return;
await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
await sendStateEvent(
mx,
address.roomId,
StateEvent.PoniesRoomEmotes,
packContent,
address.stateKey,
);
},
[mx, imagePack],
);
@@ -432,9 +432,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
}
const filename = content.filename ?? content.body ?? 'Audio';
const waveform = (
content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
)['org.matrix.msc1767.audio']?.waveform;
const waveform = (content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } })[
'org.matrix.msc1767.audio'
]?.waveform;
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
+112 -112
View File
@@ -36,7 +36,7 @@ function computePollState(
mx: ReturnType<typeof useMatrixClient>,
room: Room,
eventId: string,
parsed: ParsedPoll
parsed: ParsedPoll,
): PollState {
const relations = room.getUnfilteredTimelineSet().relations;
const myUserId = mx.getSafeUserId();
@@ -80,7 +80,7 @@ function computePollState(
const answerIds = validateSelections(
parseResponseAnswerIds(ev.getContent()),
validIds,
parsed.maxSelections
parsed.maxSelections,
);
responses.push({ sender, ts, answerIds });
}
@@ -131,10 +131,14 @@ export function PollContent({
relations.getChildEventsForEvent(
eventId,
'm.reference',
'org.matrix.msc3381.poll.response' as any
'org.matrix.msc3381.poll.response' as any,
),
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end' as any),
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.end' as any),
relations.getChildEventsForEvent(
eventId,
'm.reference',
'org.matrix.msc3381.poll.end' as any,
),
];
relObjs.forEach((r) => {
r?.on(RelationsEvent.Add, refresh);
@@ -252,7 +256,7 @@ export function PollContent({
if (!nav.includes(evt.key)) return;
evt.preventDefault();
const buttons = Array.from(
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]')
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]'),
);
if (buttons.length === 0) return;
const current = buttons.findIndex((b) => b === document.activeElement);
@@ -324,123 +328,119 @@ export function PollContent({
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id);
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple
? 0
: selected || (myVotes.size === 0 && i === 0)
? 0
: -1;
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
return (
<React.Fragment key={id}>
<button
type="button"
data-poll-answer
data-selected={selected}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined}
aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}`
: undefined
}
tabIndex={tabIndex}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{showResults && total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
<span
<button
type="button"
data-poll-answer
data-selected={selected}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined}
aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}`
: undefined
}
tabIndex={tabIndex}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
flexDirection: 'column',
gap: config.space.S100,
width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: toRem(14),
height: toRem(14),
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.Primary.ContainerLine
}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.Primary.OnMain,
transition: 'all 0.15s',
}}
>
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
</Text>
{isWinner && (
<Icon
size="50"
src={Icons.Check}
style={{ flexShrink: 0, color: color.Success.Main }}
{showResults && total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
{showResults && total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
<span
style={{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
position: 'relative',
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: toRem(14),
height: toRem(14),
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.Primary.ContainerLine
}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.Primary.OnMain,
transition: 'all 0.15s',
}}
>
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
</Text>
)}
</span>
</button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text
id={`poll-voters-${eventId}-${id}`}
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
>
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text>
)}
{isWinner && (
<Icon
size="50"
src={Icons.Check}
style={{ flexShrink: 0, color: color.Success.Main }}
/>
)}
{showResults && total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
</Text>
)}
</span>
</button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text
id={`poll-voters-${eventId}-${id}`}
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
>
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text>
)}
</React.Fragment>
);
})}
@@ -1,7 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
as,
color,
config,
} from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -1360,9 +1372,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
onClick={start}
disabled={resolving}
aria-busy={resolving}
aria-label={
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
}
aria-label={resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</button>
@@ -1,11 +1,4 @@
import React, {
ChangeEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
+3 -1
View File
@@ -1065,7 +1065,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
// audio/ogg) so the decrypted blob actually plays.
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
const filename = body.includes('.') ? body : `${body}.${mimeTypeToExt(mimeType)}`;
const filename = body.includes('.')
? body
: `${body}.${mimeTypeToExt(mimeType)}`;
const waveform = (
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
)['org.matrix.msc1767.audio']?.waveform;
+35 -5
View File
@@ -34,6 +34,10 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState<string[]>(['', '']);
const [isMultiple, setIsMultiple] = useState(false);
// For multiple-choice polls: the most options a voter may pick. Defaults high
// so an untouched multiple poll means "select all that apply" (the previous
// behavior); the effective value is clamped to the current option count.
const [maxSelections, setMaxSelections] = useState(10);
// Results visibility: disclosed (live results, default) vs undisclosed (hidden
// until the poll is ended).
const [disclosed, setDisclosed] = useState(true);
@@ -78,15 +82,16 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
try {
// Text fallback for clients that don't understand polls: the question + a
// numbered list of the options.
const fallbackBody = [
trimmedQuestion,
...filledOptions.map((o, i) => `${i + 1}. ${o}`),
].join('\n');
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
'\n',
);
await mx.sendEvent(roomId, 'm.poll.start' as any, {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
max_selections: isMultiple ? filledOptions.length : 1,
max_selections: isMultiple
? Math.min(Math.max(2, maxSelections), filledOptions.length)
: 1,
kind: disclosed ? 'm.poll.disclosed' : 'm.poll.undisclosed',
},
body: fallbackBody,
@@ -223,6 +228,31 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
);
})}
</Box>
{isMultiple && (
<Box alignItems="Center" gap="200" style={{ marginTop: config.space.S200 }}>
<Text as="label" htmlFor="poll-max-select" size="T200" priority="400">
Voters can pick up to
</Text>
<Input
id="poll-max-select"
variant="Background"
size="300"
type="number"
min={2}
max={options.length}
value={Math.min(maxSelections, options.length)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxSelections(
Math.min(options.length, Math.max(2, parseInt(e.target.value, 10) || 2)),
)
}
style={{ width: '4rem' }}
/>
<Text size="T200" priority="300">
of {options.length} options
</Text>
</Box>
)}
</Box>
{/* Results visibility */}
+10 -4
View File
@@ -157,7 +157,10 @@ interface RoomInputProps {
editableName?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' }, ref) => {
(
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
ref,
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -518,9 +521,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
// sends literally rather than being parsed as a command.
const commandName = threadRootId ? undefined : getBeginCommand(editor);
// Slash commands work in threads too: content-transform commands (/me,
// /notice, /shrug, /tableflip, /unflip) flow into the normal send below,
// which routes to the thread via `threadRootId`; the rest (/invite, /kick,
// …) are room-level actions. This also matches the command autocomplete,
// which is already shown in the thread composer.
const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
+11 -21
View File
@@ -21,7 +21,13 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { scheduleMessage } from '../../utils/scheduledMessages';
import { useModalStyle } from '../../hooks/useModalStyle';
import { toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle } from '../../utils/datetimeInput';
import {
toLocalDate,
toLocalTime,
parseLocalDateTime,
pickerInputStyle,
formatFriendlyDateTime,
} from '../../utils/datetimeInput';
interface ScheduleMessageModalProps {
roomId: string;
@@ -47,25 +53,6 @@ function formatRelativeTime(ms: number): string {
return 'in less than a minute';
}
function formatSendAt(sendAt: Date): string {
const now = new Date();
const isToday =
sendAt.getFullYear() === now.getFullYear() &&
sendAt.getMonth() === now.getMonth() &&
sendAt.getDate() === now.getDate();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow =
sendAt.getFullYear() === tomorrow.getFullYear() &&
sendAt.getMonth() === tomorrow.getMonth() &&
sendAt.getDate() === tomorrow.getDate();
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${timeStr}`;
if (isTomorrow) return `Tomorrow at ${timeStr}`;
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
}
export function ScheduleMessageModal({
roomId,
initialBody,
@@ -112,7 +99,10 @@ export function ScheduleMessageModal({
setPreview(null);
return;
}
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
setPreview({
label: formatFriendlyDateTime(sendAt.getTime()),
relative: formatRelativeTime(diffMs),
});
}, [getSendAt]);
useEffect(() => {
+113 -109
View File
@@ -242,119 +242,123 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
background: color.SurfaceVariant.Container,
}}
>
{/* Tray header */}
<Button
variant="Secondary"
fill="None"
radii="0"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
before={<Icon src={Icons.Clock} size="50" />}
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
style={{
padding: `${config.space.S100} ${config.space.S300}`,
justifyContent: 'flex-start',
}}
>
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
</Text>
</Button>
{/* Tray header */}
<Button
variant="Secondary"
fill="None"
radii="0"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
before={<Icon src={Icons.Clock} size="50" />}
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
style={{
padding: `${config.space.S100} ${config.space.S300}`,
justifyContent: 'flex-start',
}}
>
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
</Text>
</Button>
{/* Tray items */}
{expanded && (
<Box direction="Column">
{messages.map((msg) => {
const bodyPreview =
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
return (
<Box
key={msg.delayId}
direction="Column"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Box alignItems="Center" gap="200">
<Text
size="T200"
priority="400"
{/* Tray items */}
{expanded && (
<Box direction="Column">
{messages.map((msg) => {
const bodyPreview =
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
return (
<Box
key={msg.delayId}
direction="Column"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
{bodyPreview}
</Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Send scheduled message now: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleSendNow(msg);
}}
>
<Icon src={Icons.Send} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Edit scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
setEditing(msg);
}}
>
<Icon src={Icons.Pencil} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Cancel scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleCancel(msg);
}}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
</Box>
{cancelErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not cancel this message. Try again.
</Text>
)}
{sendErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not send now. Try again.
</Text>
)}
</Box>
);
})}
</Box>
)}
<Box alignItems="Center" gap="200">
<Text
size="T200"
priority="400"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{bodyPreview}
</Text>
<Text
size="T200"
priority="300"
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Send scheduled message now: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleSendNow(msg);
}}
>
<Icon src={Icons.Send} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Edit scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
setEditing(msg);
}}
>
<Icon src={Icons.Pencil} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Cancel scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleCancel(msg);
}}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
</Box>
{cancelErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not cancel this message. Try again.
</Text>
)}
{sendErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not send now. Try again.
</Text>
)}
</Box>
);
})}
</Box>
)}
</Box>
</>
);
@@ -116,7 +116,9 @@ function ForwardPreview({
const mx = useMatrixClient();
const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined;
const senderId = mEvent.getSender() ?? '';
const senderName = room ? getMemberName(room, senderId) : senderId.split(':')[0]?.slice(1) || senderId;
const senderName = room
? getMemberName(room, senderId)
: senderId.split(':')[0]?.slice(1) || senderId;
const senderMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
const senderAvatarUrl = senderMxc
? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
@@ -297,9 +299,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
() =>
recents
.map((id) => mx.getRoom(id))
.filter(
(r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom(),
),
.filter((r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom()),
[recents, mx],
);
@@ -141,8 +141,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
delete orig['m.relates_to'];
delete orig['m.new_content'];
const filename =
typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
const filename = typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText);
const mediaContent: IContent = { ...orig };
@@ -70,10 +70,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
// Reminders already set on this message (soonest first) — so the user can see
// and cancel them instead of silently stacking duplicates.
const existing = useMemo(
() =>
reminders
.filter((r) => r.eventId === eventId)
.sort((a, b) => a.timestamp - b.timestamp),
() => reminders.filter((r) => r.eventId === eventId).sort((a, b) => a.timestamp - b.timestamp),
[reminders, eventId],
);
@@ -2,18 +2,7 @@ import React, { useEffect, useMemo, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Scroll,
Text,
config,
} from 'folds';
import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
import * as css from './ThreadsListPanel.css';
@@ -130,9 +119,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
const rootSender = rootEvent?.getSender() ?? '';
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
const bodyRaw =
typeof rootEvent?.getContent().body === 'string'
? (rootEvent.getContent().body as string)
: '';
typeof rootEvent?.getContent().body === 'string' ? (rootEvent.getContent().body as string) : '';
const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)';
const count = thread.length;
const lastTs = thread.replyToEvent?.getTs() ?? rootEvent?.getTs();
@@ -360,7 +347,9 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
</Box>
) : (
<Box className={css.ThreadsListContent} direction="Column">
<div style={{ position: 'relative', height: virtualizer.getTotalSize(), width: '100%' }}>
<div
style={{ position: 'relative', height: virtualizer.getTotalSize(), width: '100%' }}
>
{virtualizer.getVirtualItems().map((vItem) => {
const snap = visible[vItem.index];
const thread = threadById.get(snap.id);
@@ -36,9 +36,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
content={
accountDataType ? getAccountData<object>(mx, accountDataType) : undefined
}
content={accountDataType ? getAccountData<object>(mx, accountDataType) : undefined}
submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
/>
@@ -160,9 +160,7 @@ function PauseNotifications() {
return (
<SettingTile
title="Pause Notifications"
description={
<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>
}
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
after={
active ? (
<Button
+1 -3
View File
@@ -6,9 +6,7 @@ import { getAccountData } from '../utils/room';
export function useAccountData(eventType: string): MatrixEvent | undefined {
const mx = useMatrixClient();
const [event, setEvent] = useState<MatrixEvent | undefined>(() =>
getAccountData(mx, eventType),
);
const [event, setEvent] = useState<MatrixEvent | undefined>(() => getAccountData(mx, eventType));
useAccountDataCallback(
mx,
+4 -1
View File
@@ -46,7 +46,10 @@ export function useRoomThreads(room: Room): Thread[] {
// Force the first push, then pull the full server-known thread list.
sigRef.current = '';
refresh();
room.fetchRoomThreads().then(refresh).catch(() => undefined);
room
.fetchRoomThreads()
.then(refresh)
.catch(() => undefined);
room.on(ThreadEvent.New, refresh);
room.on(ThreadEvent.NewReply, refresh);
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// The module evaluates atomWithStorage(..., { getOnInit: true }), which reads
// localStorage at load time. node has none, so install a no-op mock, then import
// dynamically (a static import would hoist above the mock and evaluate too early).
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
const { addRecentSticker } = await import('./recentStickers');
const st = (url: string, shortcode = url, body = url) => ({ url, shortcode, body });
test('addRecentSticker prepends a new sticker', () => {
const out = addRecentSticker([st('a'), st('b')], st('c'));
assert.deepEqual(
out.map((s) => s.url),
['c', 'a', 'b'],
);
});
test('addRecentSticker de-dupes by url, moving the existing one to the front', () => {
const out = addRecentSticker([st('a'), st('b'), st('c')], st('c'));
assert.deepEqual(
out.map((s) => s.url),
['c', 'a', 'b'],
);
assert.equal(out.length, 3);
});
test('addRecentSticker caps the list at max (newest kept)', () => {
const start = [st('a'), st('b'), st('c')];
const out = addRecentSticker(start, st('d'), 3);
assert.deepEqual(
out.map((s) => s.url),
['d', 'a', 'b'],
);
});
test('addRecentSticker ignores an empty url', () => {
const start = [st('a')];
const out = addRecentSticker(start, st(''));
assert.equal(out, start); // returns the same array unchanged
});
+50
View File
@@ -0,0 +1,50 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
export type RecentSticker = {
/** mxc:// url of the sticker image. */
url: string;
shortcode: string;
body?: string;
};
const STORAGE_KEY = 'cinny_recent_stickers_v1';
const MAX_RECENT_STICKERS = 16;
// getOnInit reads localStorage synchronously so the Recent group is present on the
// first render of the sticker picker (no flash of the empty default).
const internalAtom = atomWithStorage<RecentSticker[]>(
STORAGE_KEY,
[],
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
/**
* Global atom: the most recently sent stickers, newest first, deduped by url,
* capped at MAX_RECENT_STICKERS. Backed by localStorage (device-local
* convenience), mirroring `recentGifsAtom`.
*/
export const recentStickersAtom = atom(
(get): RecentSticker[] => get(internalAtom),
(_get, set, updater: RecentSticker[] | ((prev: RecentSticker[]) => RecentSticker[])) => {
set(internalAtom, (prev) => {
const prevList = Array.isArray(prev) ? prev : [];
return typeof updater === 'function' ? updater(prevList) : updater;
});
},
);
/**
* Prepend a sticker: ignores an empty url, de-dupes by url (moving an existing
* entry to the front), and caps the list at `max`. Pure returns a new array.
*/
export const addRecentSticker = (
prev: RecentSticker[],
sticker: RecentSticker,
max = MAX_RECENT_STICKERS,
): RecentSticker[] => {
if (!sticker.url) return prev;
const withoutDupe = prev.filter((s) => s.url !== sticker.url);
return [sticker, ...withoutDupe].slice(0, max);
};
+3 -1
View File
@@ -46,7 +46,9 @@ test('myMainReceiptPresent: true for a thread_id "main" receipt', () => {
});
test('myMainReceiptPresent: false for a thread-scoped receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: '$root:server' } } } });
const event = receiptEvent({
$abc: { 'm.read': { [ME]: { ts: 1, thread_id: '$root:server' } } },
});
assert.equal(myMainReceiptPresent(event, ME), false);
});
+1 -6
View File
@@ -64,12 +64,7 @@ test('sortBookmarks does not mutate its input', () => {
});
test('groupBookmarksByRoom buckets by room, newest-first within a group', () => {
const input = [
bk('a', 'r1', 100),
bk('b', 'r2', 500),
bk('c', 'r1', 300),
bk('d', 'r2', 200),
];
const input = [bk('a', 'r1', 100), bk('b', 'r2', 500), bk('c', 'r1', 300), bk('d', 'r2', 200)];
const groups = groupBookmarksByRoom(input);
assert.equal(groups.length, 2);
const r1 = groups.find((g) => g.roomId === 'r1')!;
+2 -1
View File
@@ -31,7 +31,8 @@ export function sortBookmarks(bookmarks: Bookmark[], sort: BookmarkSort): Bookma
if (sort === 'oldest') {
// Oldest-first is the reverse ordering; keep the same eventId tie-break shape.
return copy.sort(
(a, b) => a.savedAt - b.savedAt || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0),
(a, b) =>
a.savedAt - b.savedAt || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0),
);
}
return copy.sort(byNewest);
+3 -1
View File
@@ -126,7 +126,9 @@ test('marked-unread + already fully read: clears the flag even though no receipt
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
assert.ok(accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false));
assert.ok(
accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false),
);
});
test('not marked-unread: markAsRead does not touch account data', async () => {
+1 -1
View File
@@ -201,7 +201,7 @@ test('parseResponseAnswerIds reads stable m.selections and unstable nested answe
assert.deepEqual(parseResponseAnswerIds({ 'm.selections': ['0', '1'] }), ['0', '1']);
assert.deepEqual(
parseResponseAnswerIds({ 'org.matrix.msc3381.poll.response': { answers: ['a'] } }),
['a']
['a'],
);
assert.deepEqual(parseResponseAnswerIds({}), []);
});
+1 -1
View File
@@ -77,7 +77,7 @@ export function parseResponseAnswerIds(content: Record<string, any>): string[] {
export function validateSelections(
rawIds: string[],
validIds: Set<string>,
maxSelections: number
maxSelections: number,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
+5 -1
View File
@@ -2,7 +2,11 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { upsertPreset, normalizeLabel, StatusPreset } from './statusPresets';
const p = (id: string, label: string, clearAfter = '0'): StatusPreset => ({ id, label, clearAfter });
const p = (id: string, label: string, clearAfter = '0'): StatusPreset => ({
id,
label,
clearAfter,
});
test('normalizeLabel trims and lowercases', () => {
assert.equal(normalizeLabel(' 🎮 Gaming '), '🎮 gaming');
+1 -5
View File
@@ -47,11 +47,7 @@ export function makePresetId(): string {
* re-saving the same status moves it to the front instead of duplicating, and
* capped at `max`. Pure returns a new array and never mutates the input.
*/
export function upsertPreset(
list: StatusPreset[],
preset: StatusPreset,
max = 20,
): StatusPreset[] {
export function upsertPreset(list: StatusPreset[], preset: StatusPreset, max = 20): StatusPreset[] {
const key = normalizeLabel(preset.label);
const withoutDup = list.filter((p) => normalizeLabel(p.label) !== key);
return [preset, ...withoutDup].slice(0, max);
+6 -6
View File
@@ -8,12 +8,12 @@ import {
ThreadSnapshot,
} from './threadList';
const t = (
id: string,
latestTs: number,
unread = 0,
participated = false,
): ThreadSnapshot => ({ id, latestTs, unread, participated });
const t = (id: string, latestTs: number, unread = 0, participated = false): ThreadSnapshot => ({
id,
latestTs,
unread,
participated,
});
test('filterThreads all returns every thread', () => {
const input = [t('a', 1, 0, false), t('b', 2, 3, true)];
+46 -12
View File
@@ -50,7 +50,9 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
id: '123456789',
hash: 'abc123',
});
assert.ok(parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=abc123'));
assert.ok(
parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=abc123'),
);
// channel / group / album share a trailing numeric video id
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
@@ -61,7 +63,9 @@ test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
assert.equal(extractEmbedHeight({ type: 'MEASURE', details: { height: 640 } }), 640);
assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit
assert.equal(
extractEmbedHeight({ 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }] }),
extractEmbedHeight({
'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }],
}),
500,
);
assert.equal(extractEmbedHeight({ height: 300 }), 300); // generic fallback
@@ -74,7 +78,10 @@ test('mobile Shorts (m.youtube.com) → portrait', () => {
});
test('TikTok: canonical /video/<id> only', () => {
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789');
assert.equal(
getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'),
'7234567890123456789',
);
assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link → oEmbed
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
});
@@ -89,10 +96,16 @@ test('isTikTokLink: canonical + short + vm/vt', () => {
});
test('tiktokIdFromOembed + player url', () => {
assert.equal(tiktokIdFromOembed({ embed_product_id: '7659555276823006478' }), '7659555276823006478');
assert.equal(
tiktokIdFromOembed({ embed_product_id: '7659555276823006478' }),
'7659555276823006478',
);
assert.equal(tiktokIdFromOembed({ html: '<blockquote data-video-id="123456">' }), '123456');
assert.equal(tiktokIdFromOembed({}), null);
assert.equal(tiktokPlayerEmbedUrl('999'), 'https://www.tiktok.com/player/v1/999?autoplay=1&rel=0');
assert.equal(
tiktokPlayerEmbedUrl('999'),
'https://www.tiktok.com/player/v1/999?autoplay=1&rel=0',
);
});
test('Dailymotion + Streamable', () => {
@@ -203,16 +216,28 @@ test('Tidal: track (audio) vs video (landscape)', () => {
embedUrl: 'https://embed.tidal.com/tracks/12345',
height: 120,
});
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.embedUrl, 'https://embed.tidal.com/albums/999?layout=gridify');
assert.equal(
getTidalEmbed('https://listen.tidal.com/album/999')?.embedUrl,
'https://embed.tidal.com/albums/999?layout=gridify',
);
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.height, 275);
assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape');
assert.equal(getTidalEmbed('https://tidal.com/browse'), null);
});
test('Instagram: p / reel / tv → embed path', () => {
assert.equal(getInstagramEmbed('https://www.instagram.com/p/AbC123_-/'), 'https://www.instagram.com/p/AbC123_-/embed/');
assert.equal(getInstagramEmbed('https://instagram.com/reel/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
assert.equal(getInstagramEmbed('https://www.instagram.com/reels/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
assert.equal(
getInstagramEmbed('https://www.instagram.com/p/AbC123_-/'),
'https://www.instagram.com/p/AbC123_-/embed/',
);
assert.equal(
getInstagramEmbed('https://instagram.com/reel/XyZ/'),
'https://www.instagram.com/reel/XyZ/embed/',
);
assert.equal(
getInstagramEmbed('https://www.instagram.com/reels/XyZ/'),
'https://www.instagram.com/reel/XyZ/embed/',
);
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
});
@@ -221,7 +246,10 @@ test('Reddit post embed → embed.reddit.com', () => {
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
);
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
assert.equal(
getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'),
'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark',
);
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
// redd.it short link / i.redd.it media host can't build the /r/<sub>/comments/<id>
// path embed.reddit.com requires (a bare /comments/<id> 404s) → null so the caller's
@@ -232,7 +260,10 @@ test('Reddit post embed → embed.reddit.com', () => {
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
assert.equal(parseMediaEmbed('https://www.instagram.com/p/abc/', 'h')?.kind, 'rich');
assert.equal(parseMediaEmbed('https://www.reddit.com/r/x/comments/y/z/', 'h')?.provider, 'reddit');
assert.equal(
parseMediaEmbed('https://www.reddit.com/r/x/comments/y/z/', 'h')?.provider,
'reddit',
);
assert.equal(parseMediaEmbed('https://tidal.com/browse/track/1', 'h')?.provider, 'tidal');
});
@@ -242,7 +273,10 @@ test('Bluesky / Loom / Kick', () => {
'https://embed.bsky.app/embed/alice.bsky.social/app.bsky.feed.post/3kabc',
);
assert.equal(getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social'), null);
assert.equal(parseMediaEmbed('https://bsky.app/profile/a.bsky.social/post/3k', 'h')?.kind, 'rich');
assert.equal(
parseMediaEmbed('https://bsky.app/profile/a.bsky.social/post/3k', 'h')?.kind,
'rich',
);
assert.equal(getLoomId('https://www.loom.com/share/abc123DEF'), 'abc123DEF');
assert.equal(getLoomId('https://www.loom.com/embed/abc123DEF'), 'abc123DEF');
+30 -7
View File
@@ -122,7 +122,11 @@ export function tiktokIdFromOembed(data: {
const pid = String(data.embed_product_id ?? '').match(/\d+/)?.[0];
if (pid) return pid;
if (typeof data.html === 'string') {
return data.html.match(/data-video-id="(\d+)"/)?.[1] ?? data.html.match(/\/video\/(\d+)/)?.[1] ?? null;
return (
data.html.match(/data-video-id="(\d+)"/)?.[1] ??
data.html.match(/\/video\/(\d+)/)?.[1] ??
null
);
}
return null;
}
@@ -252,7 +256,10 @@ export function isSoundCloudTrack(url: string): boolean {
// round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
const parts = pathname
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length >= 2;
} catch {
return false;
@@ -304,7 +311,8 @@ export function getTidalEmbed(
if (h !== 'tidal.com') return null;
const p = u.pathname.replace(/^\/browse/, '');
let m = p.match(/^\/track\/(\d+)/);
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
if (m)
return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
m = p.match(/^\/album\/(\d+)/);
if (m)
// layout=gridify → full-width grid that fills the container (fixes the
@@ -387,7 +395,10 @@ export function getKickChannel(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'kick.com') return null;
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
const parts = pathname
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
} catch {
return null;
@@ -412,7 +423,11 @@ export function getBlueskyEmbed(url: string): string | null {
const enc = encodeURIComponent;
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string, hash?: string): string {
export function buildVideoEmbedUrl(
provider: 'youtube' | 'vimeo',
id: string,
hash?: string,
): string {
if (provider === 'vimeo') {
// dnt=1 = Do Not Track (no non-essential cookies); h={hash} required for unlisted.
return `https://player.vimeo.com/video/${enc(id)}?autoplay=1&dnt=1${
@@ -435,11 +450,19 @@ export function spotifyEmbedHeight(type: SpotifyType): number {
export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
const shortsId = getYoutubeShortsId(url);
if (shortsId)
return { provider: 'youtube', kind: 'portrait', embedUrl: buildVideoEmbedUrl('youtube', shortsId) };
return {
provider: 'youtube',
kind: 'portrait',
embedUrl: buildVideoEmbedUrl('youtube', shortsId),
};
const ytId = getYouTubeVideoId(url);
if (ytId)
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
return {
provider: 'youtube',
kind: 'landscape',
embedUrl: buildVideoEmbedUrl('youtube', ytId),
};
const vimeo = getVimeoParts(url);
if (vimeo)