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>
This commit is contained in:
+8
-8
@@ -810,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
|
||||
@@ -913,7 +913,7 @@ Root messages in the main timeline show a **"N replies · time"** chip (server-a
|
||||
|
||||
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)
|
||||
|
||||
@@ -1026,7 +1026,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
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -151,9 +151,7 @@ const useGroups = (
|
||||
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 })
|
||||
),
|
||||
items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -526,7 +524,7 @@ export function EmojiBoard({
|
||||
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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,10 +78,9 @@ 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 },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,7 @@ 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']
|
||||
['c', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ 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']
|
||||
['c', 'a', 'b'],
|
||||
);
|
||||
assert.equal(out.length, 3);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ test('addRecentSticker caps the list at max (newest kept)', () => {
|
||||
const out = addRecentSticker(start, st('d'), 3);
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.url),
|
||||
['d', 'a', 'b']
|
||||
['d', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const recentStickersAtom = atom(
|
||||
const prevList = Array.isArray(prev) ? prev : [];
|
||||
return typeof updater === 'function' ? updater(prevList) : updater;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export const recentStickersAtom = atom(
|
||||
export const addRecentSticker = (
|
||||
prev: RecentSticker[],
|
||||
sticker: RecentSticker,
|
||||
max = MAX_RECENT_STICKERS
|
||||
max = MAX_RECENT_STICKERS,
|
||||
): RecentSticker[] => {
|
||||
if (!sticker.url) return prev;
|
||||
const withoutDupe = prev.filter((s) => s.url !== sticker.url);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({}), []);
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user