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:
2026-07-11 13:52:36 -04:00
co-authored by Claude Opus 4.8
parent d727e7a7ab
commit 85ac8de5d9
35 changed files with 395 additions and 342 deletions
+8 -8
View File
@@ -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
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('');
}
@@ -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>
+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;
+3 -4
View File
@@ -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 },
+4 -1
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');
+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);
+3 -3
View File
@@ -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'],
);
});
+2 -2
View File
@@ -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);
+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)