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
+3 -3
View File
@@ -811,7 +811,7 @@ URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s: `TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:
| kind | shape | providers | | kind | shape | providers |
| ----------- | ------------------------ | ----------------------------------------------------------------- | | ----------- | ------------------------- | ------------------------------------------------------------------ |
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) | | `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait` | 9:16 video player | YouTube Shorts, TikTok | | `portrait` | 9:16 video player | YouTube Shorts, TikTok |
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal | | `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
@@ -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). 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) ### 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`. 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. - **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. - **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) ### 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] 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. - **[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). - **[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. // Loose Slate node builders for the test.
const txt = (text: string, marks: Record<string, unknown> = {}) => const txt = (text: string, marks: Record<string, unknown> = {}) =>
({ text, ...marks }) as unknown as Descendant; ({ text, ...marks }) as unknown as Descendant;
const el = (type: BlockType, children: unknown[]) => const el = (type: BlockType, children: unknown[]) => ({ type, children }) as unknown as Descendant;
({ type, children }) as unknown as Descendant;
const OPTS = { const OPTS = {
allowTextFormatting: true, allowTextFormatting: true,
+1 -1
View File
@@ -42,7 +42,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
.map((seg) => .map((seg) =>
seg.type === 'text' seg.type === 'text'
? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false }) ? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false })
: mathToCustomHtml(seg.value, seg.type === 'block') : mathToCustomHtml(seg.value, seg.type === 'block'),
) )
.join(''); .join('');
} }
@@ -151,9 +151,7 @@ const useGroups = (
name: 'Recent', name: 'Recent',
// StickerItem only reads url/shortcode/body, so a minimal PackImageReader // StickerItem only reads url/shortcode/body, so a minimal PackImageReader
// reconstructed from the stored data renders and re-sends correctly. // reconstructed from the stored data renders and re-sends correctly.
items: recentStickers.map( items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
(s) => new PackImageReader(s.shortcode, s.url, { body: s.body })
),
}); });
} }
@@ -526,7 +524,7 @@ export function EmojiBoard({
url: emojiInfo.data, url: emojiInfo.data,
shortcode: emojiInfo.shortcode, shortcode: emojiInfo.shortcode,
body: emojiInfo.label, body: emojiInfo.label,
}) }),
); );
} }
if (!evt.altKey && !evt.shiftKey) requestClose(); if (!evt.altKey && !evt.shiftKey) requestClose();
@@ -46,7 +46,13 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const { address } = imagePack; const { address } = imagePack;
if (!address) return; 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], [mx, imagePack],
); );
@@ -432,9 +432,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
} }
const filename = content.filename ?? content.body ?? 'Audio'; const filename = content.filename ?? content.body ?? 'Audio';
const waveform = ( const waveform = (content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } })[
content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } } 'org.matrix.msc1767.audio'
)['org.matrix.msc1767.audio']?.waveform; ]?.waveform;
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
@@ -36,7 +36,7 @@ function computePollState(
mx: ReturnType<typeof useMatrixClient>, mx: ReturnType<typeof useMatrixClient>,
room: Room, room: Room,
eventId: string, eventId: string,
parsed: ParsedPoll parsed: ParsedPoll,
): PollState { ): PollState {
const relations = room.getUnfilteredTimelineSet().relations; const relations = room.getUnfilteredTimelineSet().relations;
const myUserId = mx.getSafeUserId(); const myUserId = mx.getSafeUserId();
@@ -80,7 +80,7 @@ function computePollState(
const answerIds = validateSelections( const answerIds = validateSelections(
parseResponseAnswerIds(ev.getContent()), parseResponseAnswerIds(ev.getContent()),
validIds, validIds,
parsed.maxSelections parsed.maxSelections,
); );
responses.push({ sender, ts, answerIds }); responses.push({ sender, ts, answerIds });
} }
@@ -131,10 +131,14 @@ export function PollContent({
relations.getChildEventsForEvent( relations.getChildEventsForEvent(
eventId, eventId,
'm.reference', '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', '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) => { relObjs.forEach((r) => {
r?.on(RelationsEvent.Add, refresh); r?.on(RelationsEvent.Add, refresh);
@@ -252,7 +256,7 @@ export function PollContent({
if (!nav.includes(evt.key)) return; if (!nav.includes(evt.key)) return;
evt.preventDefault(); evt.preventDefault();
const buttons = Array.from( const buttons = Array.from(
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]') evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]'),
); );
if (buttons.length === 0) return; if (buttons.length === 0) return;
const current = buttons.findIndex((b) => b === document.activeElement); const current = buttons.findIndex((b) => b === document.activeElement);
@@ -324,11 +328,7 @@ export function PollContent({
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0; const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id); const isWinner = winners.has(id);
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable. // Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
? 0
: selected || (myVotes.size === 0 && i === 0)
? 0
: -1;
return ( return (
<React.Fragment key={id}> <React.Fragment key={id}>
<button <button
@@ -1,7 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk'; 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 { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -1360,9 +1372,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
onClick={start} onClick={start}
disabled={resolving} disabled={resolving}
aria-busy={resolving} aria-busy={resolving}
aria-label={ aria-label={resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`}
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
}
> >
{facadeInner} {facadeInner}
</button> </button>
@@ -1,11 +1,4 @@
import React, { import React, { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
ChangeEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils'; 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 → // Sanitize the mimetype the way MAudio does (e.g. application/ogg →
// audio/ogg) so the decrypted blob actually plays. // audio/ogg) so the decrypted blob actually plays.
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg'); 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 = ( const waveform = (
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } } c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
)['org.matrix.msc1767.audio']?.waveform; )['org.matrix.msc1767.audio']?.waveform;
+3 -4
View File
@@ -78,10 +78,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
try { try {
// Text fallback for clients that don't understand polls: the question + a // Text fallback for clients that don't understand polls: the question + a
// numbered list of the options. // numbered list of the options.
const fallbackBody = [ const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
trimmedQuestion, '\n',
...filledOptions.map((o, i) => `${i + 1}. ${o}`), );
].join('\n');
await mx.sendEvent(roomId, 'm.poll.start' as any, { await mx.sendEvent(roomId, 'm.poll.start' as any, {
'm.poll': { 'm.poll': {
question: { 'm.text': trimmedQuestion }, question: { 'm.text': trimmedQuestion },
+4 -1
View File
@@ -157,7 +157,10 @@ interface RoomInputProps {
editableName?: string; editableName?: string;
} }
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( 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 mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -291,7 +291,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
> >
{bodyPreview} {bodyPreview}
</Text> </Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}> <Text
size="T200"
priority="300"
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
>
{formatSendAt(msg.sendAt)} {formatSendAt(msg.sendAt)}
</Text> </Text>
<IconButton <IconButton
@@ -116,7 +116,9 @@ function ForwardPreview({
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined; const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined;
const senderId = mEvent.getSender() ?? ''; 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 senderMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
const senderAvatarUrl = senderMxc const senderAvatarUrl = senderMxc
? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined) ? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
@@ -297,9 +299,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
() => () =>
recents recents
.map((id) => mx.getRoom(id)) .map((id) => mx.getRoom(id))
.filter( .filter((r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom()),
(r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom(),
),
[recents, mx], [recents, mx],
); );
@@ -141,8 +141,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
delete orig['m.relates_to']; delete orig['m.relates_to'];
delete orig['m.new_content']; delete orig['m.new_content'];
const filename = const filename = typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText); const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText);
const mediaContent: IContent = { ...orig }; 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 // Reminders already set on this message (soonest first) — so the user can see
// and cancel them instead of silently stacking duplicates. // and cancel them instead of silently stacking duplicates.
const existing = useMemo( 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], [reminders, eventId],
); );
@@ -2,18 +2,7 @@ import React, { useEffect, useMemo, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils'; import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk'; import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
import { import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Scroll,
Text,
config,
} from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import * as css from './ThreadsListPanel.css'; import * as css from './ThreadsListPanel.css';
@@ -130,9 +119,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
const rootSender = rootEvent?.getSender() ?? ''; const rootSender = rootEvent?.getSender() ?? '';
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender); const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
const bodyRaw = const bodyRaw =
typeof rootEvent?.getContent().body === 'string' typeof rootEvent?.getContent().body === 'string' ? (rootEvent.getContent().body as string) : '';
? (rootEvent.getContent().body as string)
: '';
const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)'; const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)';
const count = thread.length; const count = thread.length;
const lastTs = thread.replyToEvent?.getTs() ?? rootEvent?.getTs(); const lastTs = thread.replyToEvent?.getTs() ?? rootEvent?.getTs();
@@ -360,7 +347,9 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
</Box> </Box>
) : ( ) : (
<Box className={css.ThreadsListContent} direction="Column"> <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) => { {virtualizer.getVirtualItems().map((vItem) => {
const snap = visible[vItem.index]; const snap = visible[vItem.index];
const thread = threadById.get(snap.id); const thread = threadById.get(snap.id);
@@ -36,9 +36,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
return ( return (
<AccountDataEditor <AccountDataEditor
type={accountDataType ?? undefined} type={accountDataType ?? undefined}
content={ content={accountDataType ? getAccountData<object>(mx, accountDataType) : undefined}
accountDataType ? getAccountData<object>(mx, accountDataType) : undefined
}
submitChange={submitAccountData} submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)} requestClose={() => setAccountDataType(undefined)}
/> />
@@ -160,9 +160,7 @@ function PauseNotifications() {
return ( return (
<SettingTile <SettingTile
title="Pause Notifications" title="Pause Notifications"
description={ description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>
}
after={ after={
active ? ( active ? (
<Button <Button
+1 -3
View File
@@ -6,9 +6,7 @@ import { getAccountData } from '../utils/room';
export function useAccountData(eventType: string): MatrixEvent | undefined { export function useAccountData(eventType: string): MatrixEvent | undefined {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [event, setEvent] = useState<MatrixEvent | undefined>(() => const [event, setEvent] = useState<MatrixEvent | undefined>(() => getAccountData(mx, eventType));
getAccountData(mx, eventType),
);
useAccountDataCallback( useAccountDataCallback(
mx, 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. // Force the first push, then pull the full server-known thread list.
sigRef.current = ''; sigRef.current = '';
refresh(); refresh();
room.fetchRoomThreads().then(refresh).catch(() => undefined); room
.fetchRoomThreads()
.then(refresh)
.catch(() => undefined);
room.on(ThreadEvent.New, refresh); room.on(ThreadEvent.New, refresh);
room.on(ThreadEvent.NewReply, 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')); const out = addRecentSticker([st('a'), st('b')], st('c'));
assert.deepEqual( assert.deepEqual(
out.map((s) => s.url), 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')); const out = addRecentSticker([st('a'), st('b'), st('c')], st('c'));
assert.deepEqual( assert.deepEqual(
out.map((s) => s.url), out.map((s) => s.url),
['c', 'a', 'b'] ['c', 'a', 'b'],
); );
assert.equal(out.length, 3); 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); const out = addRecentSticker(start, st('d'), 3);
assert.deepEqual( assert.deepEqual(
out.map((s) => s.url), 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 : []; const prevList = Array.isArray(prev) ? prev : [];
return typeof updater === 'function' ? updater(prevList) : updater; return typeof updater === 'function' ? updater(prevList) : updater;
}); });
} },
); );
/** /**
@@ -42,7 +42,7 @@ export const recentStickersAtom = atom(
export const addRecentSticker = ( export const addRecentSticker = (
prev: RecentSticker[], prev: RecentSticker[],
sticker: RecentSticker, sticker: RecentSticker,
max = MAX_RECENT_STICKERS max = MAX_RECENT_STICKERS,
): RecentSticker[] => { ): RecentSticker[] => {
if (!sticker.url) return prev; if (!sticker.url) return prev;
const withoutDupe = prev.filter((s) => s.url !== sticker.url); 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', () => { 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); 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', () => { test('groupBookmarksByRoom buckets by room, newest-first within a group', () => {
const input = [ const input = [bk('a', 'r1', 100), bk('b', 'r2', 500), bk('c', 'r1', 300), bk('d', 'r2', 200)];
bk('a', 'r1', 100),
bk('b', 'r2', 500),
bk('c', 'r1', 300),
bk('d', 'r2', 200),
];
const groups = groupBookmarksByRoom(input); const groups = groupBookmarksByRoom(input);
assert.equal(groups.length, 2); assert.equal(groups.length, 2);
const r1 = groups.find((g) => g.roomId === 'r1')!; 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') { if (sort === 'oldest') {
// Oldest-first is the reverse ordering; keep the same eventId tie-break shape. // Oldest-first is the reverse ordering; keep the same eventId tie-break shape.
return copy.sort( 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); 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); await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0); // no receipt (the stuck-dot case) assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
// ...but the marked-unread flag is cleared directly (both keys, unread:false) // ...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 () => { 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({ 'm.selections': ['0', '1'] }), ['0', '1']);
assert.deepEqual( assert.deepEqual(
parseResponseAnswerIds({ 'org.matrix.msc3381.poll.response': { answers: ['a'] } }), parseResponseAnswerIds({ 'org.matrix.msc3381.poll.response': { answers: ['a'] } }),
['a'] ['a'],
); );
assert.deepEqual(parseResponseAnswerIds({}), []); assert.deepEqual(parseResponseAnswerIds({}), []);
}); });
+1 -1
View File
@@ -77,7 +77,7 @@ export function parseResponseAnswerIds(content: Record<string, any>): string[] {
export function validateSelections( export function validateSelections(
rawIds: string[], rawIds: string[],
validIds: Set<string>, validIds: Set<string>,
maxSelections: number maxSelections: number,
): string[] { ): string[] {
const out: string[] = []; const out: string[] = [];
const seen = new Set<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 assert from 'node:assert/strict';
import { upsertPreset, normalizeLabel, StatusPreset } from './statusPresets'; 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', () => { test('normalizeLabel trims and lowercases', () => {
assert.equal(normalizeLabel(' 🎮 Gaming '), '🎮 gaming'); 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 * 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. * capped at `max`. Pure returns a new array and never mutates the input.
*/ */
export function upsertPreset( export function upsertPreset(list: StatusPreset[], preset: StatusPreset, max = 20): StatusPreset[] {
list: StatusPreset[],
preset: StatusPreset,
max = 20,
): StatusPreset[] {
const key = normalizeLabel(preset.label); const key = normalizeLabel(preset.label);
const withoutDup = list.filter((p) => normalizeLabel(p.label) !== key); const withoutDup = list.filter((p) => normalizeLabel(p.label) !== key);
return [preset, ...withoutDup].slice(0, max); return [preset, ...withoutDup].slice(0, max);
+6 -6
View File
@@ -8,12 +8,12 @@ import {
ThreadSnapshot, ThreadSnapshot,
} from './threadList'; } from './threadList';
const t = ( const t = (id: string, latestTs: number, unread = 0, participated = false): ThreadSnapshot => ({
id: string, id,
latestTs: number, latestTs,
unread = 0, unread,
participated = false, participated,
): ThreadSnapshot => ({ id, latestTs, unread, participated }); });
test('filterThreads all returns every thread', () => { test('filterThreads all returns every thread', () => {
const input = [t('a', 1, 0, false), t('b', 2, 3, true)]; 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', id: '123456789',
hash: 'abc123', 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 // 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/channels/staffpicks/76979871')?.id, '76979871');
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345'); 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: 'MEASURE', details: { height: 640 } }), 640);
assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit
assert.equal( assert.equal(
extractEmbedHeight({ 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }] }), extractEmbedHeight({
'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }],
}),
500, 500,
); );
assert.equal(extractEmbedHeight({ height: 300 }), 300); // generic fallback 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', () => { 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://vm.tiktok.com/ZMabc/'), null); // short link → oEmbed
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null); assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
}); });
@@ -89,10 +96,16 @@ test('isTikTokLink: canonical + short + vm/vt', () => {
}); });
test('tiktokIdFromOembed + player url', () => { 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({ html: '<blockquote data-video-id="123456">' }), '123456');
assert.equal(tiktokIdFromOembed({}), null); 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', () => { test('Dailymotion + Streamable', () => {
@@ -203,16 +216,28 @@ test('Tidal: track (audio) vs video (landscape)', () => {
embedUrl: 'https://embed.tidal.com/tracks/12345', embedUrl: 'https://embed.tidal.com/tracks/12345',
height: 120, 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://listen.tidal.com/album/999')?.height, 275);
assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape'); assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape');
assert.equal(getTidalEmbed('https://tidal.com/browse'), null); assert.equal(getTidalEmbed('https://tidal.com/browse'), null);
}); });
test('Instagram: p / reel / tv → embed path', () => { 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(
assert.equal(getInstagramEmbed('https://instagram.com/reel/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/'); getInstagramEmbed('https://www.instagram.com/p/AbC123_-/'),
assert.equal(getInstagramEmbed('https://www.instagram.com/reels/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/'); '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); 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/'), 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', '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 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> // 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 // 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', () => { test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
assert.equal(parseMediaEmbed('https://www.instagram.com/p/abc/', 'h')?.kind, 'rich'); 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'); 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', '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(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/share/abc123DEF'), 'abc123DEF');
assert.equal(getLoomId('https://www.loom.com/embed/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]; const pid = String(data.embed_product_id ?? '').match(/\d+/)?.[0];
if (pid) return pid; if (pid) return pid;
if (typeof data.html === 'string') { 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; 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. // round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false; if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile // /<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; return parts.length >= 2;
} catch { } catch {
return false; return false;
@@ -304,7 +311,8 @@ export function getTidalEmbed(
if (h !== 'tidal.com') return null; if (h !== 'tidal.com') return null;
const p = u.pathname.replace(/^\/browse/, ''); const p = u.pathname.replace(/^\/browse/, '');
let m = p.match(/^\/track\/(\d+)/); 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+)/); m = p.match(/^\/album\/(\d+)/);
if (m) if (m)
// layout=gridify → full-width grid that fills the container (fixes the // layout=gridify → full-width grid that fills the container (fixes the
@@ -387,7 +395,10 @@ export function getKickChannel(url: string): string | null {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'kick.com') return null; 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; return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
} catch { } catch {
return null; return null;
@@ -412,7 +423,11 @@ export function getBlueskyEmbed(url: string): string | null {
const enc = encodeURIComponent; 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') { if (provider === 'vimeo') {
// dnt=1 = Do Not Track (no non-essential cookies); h={hash} required for unlisted. // 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${ 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 { export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
const shortsId = getYoutubeShortsId(url); const shortsId = getYoutubeShortsId(url);
if (shortsId) if (shortsId)
return { provider: 'youtube', kind: 'portrait', embedUrl: buildVideoEmbedUrl('youtube', shortsId) }; return {
provider: 'youtube',
kind: 'portrait',
embedUrl: buildVideoEmbedUrl('youtube', shortsId),
};
const ytId = getYouTubeVideoId(url); const ytId = getYouTubeVideoId(url);
if (ytId) if (ytId)
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) }; return {
provider: 'youtube',
kind: 'landscape',
embedUrl: buildVideoEmbedUrl('youtube', ytId),
};
const vimeo = getVimeoParts(url); const vimeo = getVimeoParts(url);
if (vimeo) if (vimeo)