Compare commits
6
Commits
360e72f73c
...
a8c99f2a45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c99f2a45 | ||
|
|
a4660a8163 | ||
|
|
85ac8de5d9 | ||
|
|
d727e7a7ab | ||
|
|
3a1c626bc8 | ||
|
|
fb8e0c6e14 |
+21
-9
@@ -679,6 +679,17 @@ The indicator is hidden once the server confirms the event (when the internal st
|
||||
- Picker UI is styled with TDS variables when the TDS theme is active
|
||||
- Located at `src/app/components/GifPicker.tsx`
|
||||
|
||||
### Sticker Picker — Recently used
|
||||
|
||||
The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar
|
||||
`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent
|
||||
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
|
||||
least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`),
|
||||
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker`
|
||||
(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s
|
||||
(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack
|
||||
stickers. Recorded on select for both the grouped and search paths (shared delegated click).
|
||||
|
||||
### Message Forwarding
|
||||
|
||||
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
|
||||
@@ -799,12 +810,12 @@ resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
|
||||
URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
|
||||
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:
|
||||
|
||||
| kind | shape | providers |
|
||||
| ----------- | ------------------------ | ----------------------------------------------------------------- |
|
||||
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
|
||||
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
|
||||
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
|
||||
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
|
||||
| kind | shape | providers |
|
||||
| ----------- | ------------------------- | ------------------------------------------------------------------ |
|
||||
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
|
||||
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
|
||||
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
|
||||
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
|
||||
|
||||
**Privacy-friendly facade.** The tile first shows the homeserver's cached
|
||||
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
|
||||
@@ -855,6 +866,7 @@ player.kick).
|
||||
|
||||
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
|
||||
- Supports 2 to 10 answer options; single-choice or multiple-choice
|
||||
- **Max selections** — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets `max_selections` (2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N")
|
||||
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
|
||||
- Accessible via the `Icons.OrderList` button in the composer toolbar
|
||||
|
||||
@@ -900,9 +912,9 @@ Root messages in the main timeline show a **"N replies · time"** chip (server-a
|
||||
|
||||
### Thread Composer
|
||||
|
||||
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
|
||||
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). **Slash commands work in threads** — content-transform commands (`/me`, `/notice`, `/shrug`, `/tableflip`, `/unflip`) route into the thread via the normal send path, and room-level commands (`/invite`, `/kick`, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).
|
||||
|
||||
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply *in that thread* — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
|
||||
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply _in that thread_ — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
|
||||
|
||||
### Notifications (Slack-style, P4-1)
|
||||
|
||||
@@ -1015,7 +1027,7 @@ Hook: `src/app/hooks/useUserNotes.ts`
|
||||
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
|
||||
|
||||
- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
|
||||
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room *before* the forwarded message (sequenced per room; a room counts as failed if either send fails).
|
||||
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room _before_ the forwarded message (sequenced per room; a room counts as failed if either send fails).
|
||||
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.
|
||||
|
||||
### Live Bookmark Previews (P6-3)
|
||||
|
||||
+1
-1
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Box, config, Icons, Scroll } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
@@ -29,6 +29,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useThrottle } from '../../hooks/useThrottle';
|
||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||
@@ -102,6 +103,7 @@ const useGroups = (
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const recentStickers = useAtomValue(recentStickersAtom);
|
||||
const labels = useEmojiGroupLabels();
|
||||
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||
|
||||
@@ -143,6 +145,16 @@ const useGroups = (
|
||||
const g: StickerGroupItem[] = [];
|
||||
if (tab !== EmojiBoardTab.Sticker) return g;
|
||||
|
||||
if (recentStickers.length > 0) {
|
||||
g.push({
|
||||
id: RECENT_GROUP_ID,
|
||||
name: 'Recent',
|
||||
// StickerItem only reads url/shortcode/body, so a minimal PackImageReader
|
||||
// reconstructed from the stored data renders and re-sends correctly.
|
||||
items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
|
||||
});
|
||||
}
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
@@ -157,7 +169,7 @@ const useGroups = (
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, imagePacks, tab]);
|
||||
}, [mx, imagePacks, tab, recentStickers]);
|
||||
|
||||
return [emojiGroupItems, stickerGroupItems];
|
||||
};
|
||||
@@ -289,6 +301,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||
const recentStickers = useAtomValue(recentStickersAtom);
|
||||
const usage = ImageUsage.Sticker;
|
||||
|
||||
const packLabels = useMemo(() => {
|
||||
@@ -308,6 +321,17 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{recentStickers.length > 0 && (
|
||||
<SidebarStack>
|
||||
<GroupIcon
|
||||
active={activeGroupId === RECENT_GROUP_ID}
|
||||
id={RECENT_GROUP_ID}
|
||||
label="Recent"
|
||||
icon={Icons.RecentClock}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
</SidebarStack>
|
||||
)}
|
||||
<SidebarStack>
|
||||
{packs.map((pack) => {
|
||||
const label = packLabels.get(pack.id);
|
||||
@@ -435,6 +459,7 @@ export function EmojiBoard({
|
||||
);
|
||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
const setRecentStickers = useSetAtom(recentStickersAtom);
|
||||
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||
@@ -494,6 +519,13 @@ export function EmojiBoard({
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
setRecentStickers((prev) =>
|
||||
addRecentSticker(prev, {
|
||||
url: emojiInfo.data,
|
||||
shortcode: emojiInfo.shortcode,
|
||||
body: emojiInfo.label,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
};
|
||||
|
||||
@@ -46,7 +46,13 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||
const { address } = imagePack;
|
||||
if (!address) return;
|
||||
|
||||
await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
address.roomId,
|
||||
StateEvent.PoniesRoomEmotes,
|
||||
packContent,
|
||||
address.stateKey,
|
||||
);
|
||||
},
|
||||
[mx, imagePack],
|
||||
);
|
||||
|
||||
@@ -432,9 +432,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
|
||||
}
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Audio';
|
||||
const waveform = (
|
||||
content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
|
||||
)['org.matrix.msc1767.audio']?.waveform;
|
||||
const waveform = (content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } })[
|
||||
'org.matrix.msc1767.audio'
|
||||
]?.waveform;
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,10 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState<string[]>(['', '']);
|
||||
const [isMultiple, setIsMultiple] = useState(false);
|
||||
// For multiple-choice polls: the most options a voter may pick. Defaults high
|
||||
// so an untouched multiple poll means "select all that apply" (the previous
|
||||
// behavior); the effective value is clamped to the current option count.
|
||||
const [maxSelections, setMaxSelections] = useState(10);
|
||||
// Results visibility: disclosed (live results, default) vs undisclosed (hidden
|
||||
// until the poll is ended).
|
||||
const [disclosed, setDisclosed] = useState(true);
|
||||
@@ -78,15 +82,16 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
try {
|
||||
// Text fallback for clients that don't understand polls: the question + a
|
||||
// numbered list of the options.
|
||||
const fallbackBody = [
|
||||
trimmedQuestion,
|
||||
...filledOptions.map((o, i) => `${i + 1}. ${o}`),
|
||||
].join('\n');
|
||||
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
|
||||
'\n',
|
||||
);
|
||||
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||
'm.poll': {
|
||||
question: { 'm.text': trimmedQuestion },
|
||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||
max_selections: isMultiple ? filledOptions.length : 1,
|
||||
max_selections: isMultiple
|
||||
? Math.min(Math.max(2, maxSelections), filledOptions.length)
|
||||
: 1,
|
||||
kind: disclosed ? 'm.poll.disclosed' : 'm.poll.undisclosed',
|
||||
},
|
||||
body: fallbackBody,
|
||||
@@ -223,6 +228,31 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{isMultiple && (
|
||||
<Box alignItems="Center" gap="200" style={{ marginTop: config.space.S200 }}>
|
||||
<Text as="label" htmlFor="poll-max-select" size="T200" priority="400">
|
||||
Voters can pick up to
|
||||
</Text>
|
||||
<Input
|
||||
id="poll-max-select"
|
||||
variant="Background"
|
||||
size="300"
|
||||
type="number"
|
||||
min={2}
|
||||
max={options.length}
|
||||
value={Math.min(maxSelections, options.length)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMaxSelections(
|
||||
Math.min(options.length, Math.max(2, parseInt(e.target.value, 10) || 2)),
|
||||
)
|
||||
}
|
||||
style={{ width: '4rem' }}
|
||||
/>
|
||||
<Text size="T200" priority="300">
|
||||
of {options.length} options
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Results visibility */}
|
||||
|
||||
@@ -157,7 +157,10 @@ interface RoomInputProps {
|
||||
editableName?: string;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
({ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' }, ref) => {
|
||||
(
|
||||
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
|
||||
ref,
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
@@ -518,9 +521,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const submit = useCallback(() => {
|
||||
uploadBoardHandlers.current?.handleSend();
|
||||
|
||||
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
|
||||
// sends literally rather than being parsed as a command.
|
||||
const commandName = threadRootId ? undefined : getBeginCommand(editor);
|
||||
// Slash commands work in threads too: content-transform commands (/me,
|
||||
// /notice, /shrug, /tableflip, /unflip) flow into the normal send below,
|
||||
// which routes to the thread via `threadRootId`; the rest (/invite, /kick,
|
||||
// …) are room-level actions. This also matches the command autocomplete,
|
||||
// which is already shown in the thread composer.
|
||||
const commandName = getBeginCommand(editor);
|
||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
|
||||
@@ -21,7 +21,13 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle } from '../../utils/datetimeInput';
|
||||
import {
|
||||
toLocalDate,
|
||||
toLocalTime,
|
||||
parseLocalDateTime,
|
||||
pickerInputStyle,
|
||||
formatFriendlyDateTime,
|
||||
} from '../../utils/datetimeInput';
|
||||
|
||||
interface ScheduleMessageModalProps {
|
||||
roomId: string;
|
||||
@@ -47,25 +53,6 @@ function formatRelativeTime(ms: number): string {
|
||||
return 'in less than a minute';
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: Date): string {
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
sendAt.getFullYear() === now.getFullYear() &&
|
||||
sendAt.getMonth() === now.getMonth() &&
|
||||
sendAt.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
sendAt.getFullYear() === tomorrow.getFullYear() &&
|
||||
sendAt.getMonth() === tomorrow.getMonth() &&
|
||||
sendAt.getDate() === tomorrow.getDate();
|
||||
|
||||
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today at ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow at ${timeStr}`;
|
||||
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
|
||||
}
|
||||
|
||||
export function ScheduleMessageModal({
|
||||
roomId,
|
||||
initialBody,
|
||||
@@ -112,7 +99,10 @@ export function ScheduleMessageModal({
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
|
||||
setPreview({
|
||||
label: formatFriendlyDateTime(sendAt.getTime()),
|
||||
relative: formatRelativeTime(diffMs),
|
||||
});
|
||||
}, [getSendAt]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// The module evaluates atomWithStorage(..., { getOnInit: true }), which reads
|
||||
// localStorage at load time. node has none, so install a no-op mock, then import
|
||||
// dynamically (a static import would hoist above the mock and evaluate too early).
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
|
||||
const { addRecentSticker } = await import('./recentStickers');
|
||||
|
||||
const st = (url: string, shortcode = url, body = url) => ({ url, shortcode, body });
|
||||
|
||||
test('addRecentSticker prepends a new sticker', () => {
|
||||
const out = addRecentSticker([st('a'), st('b')], st('c'));
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.url),
|
||||
['c', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('addRecentSticker de-dupes by url, moving the existing one to the front', () => {
|
||||
const out = addRecentSticker([st('a'), st('b'), st('c')], st('c'));
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.url),
|
||||
['c', 'a', 'b'],
|
||||
);
|
||||
assert.equal(out.length, 3);
|
||||
});
|
||||
|
||||
test('addRecentSticker caps the list at max (newest kept)', () => {
|
||||
const start = [st('a'), st('b'), st('c')];
|
||||
const out = addRecentSticker(start, st('d'), 3);
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.url),
|
||||
['d', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('addRecentSticker ignores an empty url', () => {
|
||||
const start = [st('a')];
|
||||
const out = addRecentSticker(start, st(''));
|
||||
assert.equal(out, start); // returns the same array unchanged
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
|
||||
export type RecentSticker = {
|
||||
/** mxc:// url of the sticker image. */
|
||||
url: string;
|
||||
shortcode: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'cinny_recent_stickers_v1';
|
||||
const MAX_RECENT_STICKERS = 16;
|
||||
|
||||
// getOnInit reads localStorage synchronously so the Recent group is present on the
|
||||
// first render of the sticker picker (no flash of the empty default).
|
||||
const internalAtom = atomWithStorage<RecentSticker[]>(
|
||||
STORAGE_KEY,
|
||||
[],
|
||||
createJSONStorage(() => localStorage),
|
||||
{ getOnInit: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Global atom: the most recently sent stickers, newest first, deduped by url,
|
||||
* capped at MAX_RECENT_STICKERS. Backed by localStorage (device-local
|
||||
* convenience), mirroring `recentGifsAtom`.
|
||||
*/
|
||||
export const recentStickersAtom = atom(
|
||||
(get): RecentSticker[] => get(internalAtom),
|
||||
(_get, set, updater: RecentSticker[] | ((prev: RecentSticker[]) => RecentSticker[])) => {
|
||||
set(internalAtom, (prev) => {
|
||||
const prevList = Array.isArray(prev) ? prev : [];
|
||||
return typeof updater === 'function' ? updater(prevList) : updater;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Prepend a sticker: ignores an empty url, de-dupes by url (moving an existing
|
||||
* entry to the front), and caps the list at `max`. Pure — returns a new array.
|
||||
*/
|
||||
export const addRecentSticker = (
|
||||
prev: RecentSticker[],
|
||||
sticker: RecentSticker,
|
||||
max = MAX_RECENT_STICKERS,
|
||||
): RecentSticker[] => {
|
||||
if (!sticker.url) return prev;
|
||||
const withoutDupe = prev.filter((s) => s.url !== sticker.url);
|
||||
return [sticker, ...withoutDupe].slice(0, max);
|
||||
};
|
||||
@@ -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