4 Commits

Author SHA1 Message Date
47979718eb Fix responsive message images
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Constrain image attachments to their parent while preserving their aspect ratio.
2026-08-25 00:02:45 +10:00
862307ee7d Add TikTok player iframe
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use TikTok's supported player endpoint rather than injecting its oEmbed script.
2026-08-24 23:58:31 +10:00
b16263b481 Enable native HTTP for Capacitor.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Route mobile fetch requests through Capacitor's native network bridge to support redirected collectible catalog downloads.
2026-08-24 20:06:27 +10:00
c34cfaf33f Enable collectibles on mobile clients.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Fetch and cache the published catalog in the renderer when Electron is unavailable, then download selected CDN assets directly for Matrix upload.
2026-08-24 19:52:50 +10:00
7 changed files with 174 additions and 60 deletions

View File

@@ -7,6 +7,9 @@
"LocalNotifications": {
"smallIcon": "ic_stat_paarrot",
"iconColor": "#FF8A00"
},
"CapacitorHttp": {
"enabled": true
}
}
}

View File

@@ -262,7 +262,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
<AttachmentBox
style={{
width: toRem(width),
height: toRem(height),
aspectRatio: `${width} / ${height}`,
['--media-h' as string]: `${height}px`,
}}
data-paarrot-media-height={height}

View File

@@ -8,7 +8,7 @@ export const Attachment = recipe({
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
overflow: 'hidden',
maxWidth: toRem(400),
maxWidth: `min(100%, ${toRem(400)})`,
},
variants: {
outlined: {
@@ -33,7 +33,7 @@ export const AttachmentHeader = style({
export const AttachmentBox = style([
DefaultReset,
{
maxWidth: toRem(400),
maxWidth: `min(100%, ${toRem(400)})`,
maxHeight: toRem(400),
overflow: 'hidden',
},

View File

@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
border: 'none',
display: 'block',
},
]);

View File

@@ -42,6 +42,12 @@ export function isTikTokUrl(url: string): boolean {
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
}
function extractTikTokVideoId(url: string, embedHtml = ''): string | null {
const urlMatch = url.match(/\/video\/(\d+)/i) ?? url.match(/\/v\/(\d+)/i);
const embedMatch = embedHtml.match(/data-video-id=["'](\d+)["']/i);
return urlMatch?.[1] ?? embedMatch?.[1] ?? null;
}
/**
* Fetches TikTok video metadata using oEmbed API
* @param url The TikTok video URL
@@ -53,29 +59,39 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: string;
thumbnailUrl: string;
embedHtml: string;
videoId: string | null;
} | null> {
try {
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl);
if (!response.ok) return null;
const data = await response.json();
return {
title: data.title || 'TikTok Video',
authorName: data.author_name || 'Unknown',
authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '',
videoId: extractTikTokVideoId(url, data.html || ''),
};
} catch {
return null;
}
}
type TikTokEmbedState =
type TikTokEmbedState =
| { status: 'loading' }
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string }
| {
status: 'loaded';
title: string;
authorName: string;
authorUrl: string;
thumbnailUrl: string;
embedHtml: string;
videoId: string | null;
}
| { status: 'error' };
type TikTokEmbedProps = {
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
*/
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
const [showEmbed, setShowEmbed] = useState(false);
const [showPlayer, setShowPlayer] = useState(false);
// Fetch video info on mount
useEffect(() => {
@@ -96,7 +112,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
fetchTikTokOEmbed(url).then((info) => {
if (cancelled) return;
if (info) {
setState({
status: 'loaded',
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
authorUrl: info.authorUrl,
thumbnailUrl: info.thumbnailUrl,
embedHtml: info.embedHtml,
videoId: info.videoId,
});
} else {
setState({ status: 'error' });
@@ -134,9 +151,9 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
TikTok - Loading...
</Text>
</div>
<Box
className={css.TikTokThumbnailContainer}
alignItems="Center"
<Box
className={css.TikTokThumbnailContainer}
alignItems="Center"
justifyContent="Center"
style={{ background: '#000', display: 'flex' }}
>
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
}
// Loaded state - show thumbnail or embed
if (!showEmbed) {
if (!showPlayer) {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
<button
type="button"
className={css.TikTokThumbnailButton}
onClick={() => setShowEmbed(true)}
aria-label="Load TikTok video"
onClick={() => setShowPlayer(true)}
aria-label="Play TikTok video"
>
{state.thumbnailUrl && (
<img
src={state.thumbnailUrl}
{state.thumbnailUrl ? (
<img
src={state.thumbnailUrl}
alt={state.title}
className={css.TikTokThumbnail}
crossOrigin="anonymous"
/>
)}
) : null}
</button>
</Box>
);
}
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML)
const playerUrl = state.videoId
? `https://www.tiktok.com/player/v1/${state.videoId}?autoplay=1`
: null;
// Use TikTok's documented player instead of injecting the oEmbed script.
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
{state.title}
</Text>
</div>
<div
className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/>
{playerUrl ? (
<iframe
className={css.TikTokEmbedContainer}
src={playerUrl}
title="TikTok video player"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<div
className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/>
)}
</Box>
);
});

View File

@@ -14,8 +14,8 @@ import {
DiscordCollectibleItem,
collectibleKindLabel,
defaultPreviewAspectRatio,
fetchPublishedCollectiblesCatalog,
groupCollectibleItems,
isElectronCollectiblesAvailable,
uploadCollectibleToMatrix,
variantItemLabel,
} from '../../../utils/discordCollectibles';
@@ -347,24 +347,26 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
const [error, setError] = useState<string>();
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
const desktopAvailable = isElectronCollectiblesAvailable();
const loadCatalog = useCallback(async (force = false) => {
if (!desktopAvailable) return;
setCatalogLoading(true);
setCatalogError(undefined);
try {
const result = await window.electron!.discordCollectibles!.fetchCatalog(force);
if (!result?.success || !result.data?.items) {
throw new Error(result?.error || 'Failed to load Discord catalog.');
const desktopCatalog = window.electron?.discordCollectibles;
if (desktopCatalog) {
const result = await desktopCatalog.fetchCatalog(force);
if (!result?.success || !result.data?.items) {
throw new Error(result?.error || 'Failed to load collectibles catalog.');
}
setItems(result.data.items as DiscordCollectibleItem[]);
} else {
setItems(await fetchPublishedCollectiblesCatalog(force));
}
setItems(result.data.items as DiscordCollectibleItem[]);
} catch (e) {
setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.');
setItems([]);
}
setCatalogLoading(false);
}, [desktopAvailable]);
}, []);
useEffect(() => {
loadCatalog();
@@ -431,17 +433,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
kind === 'avatar_decoration' && css.CollectibleGroupGridDecorations
);
if (!desktopAvailable) {
return (
<Box direction="Column" gap="200">
<Text size="T200" style={{ opacity: 0.8 }}>
Discord profile overlays are available in the Paarrot desktop app. Assets are fetched live from
Discord and uploaded to your Matrix profile when you pick one.
</Text>
</Box>
);
}
return (
<Box direction="Column" gap="300">
<Text size="T200" style={{ opacity: 0.8 }}>

View File

@@ -37,9 +37,87 @@ export type DownloadedCollectibleAsset = DiscordCollectibleAsset & {
};
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
const PUBLISHED_CATALOG_URLS = [
'https://github.com/litruv/discord-collectibles/releases/download/latest/profileeffects.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/nameplate.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/avatardecorations.json',
];
const CATALOG_CACHE_KEY = 'paarrot.discordCollectiblesCatalog';
const CATALOG_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
export function isElectronCollectiblesAvailable(): boolean {
return Boolean(window.electron?.discordCollectibles);
type PublishedCatalog = {
schema_version: number;
fetchedAt: string;
items: DiscordCollectibleItem[];
};
function isPublishedCatalog(value: unknown): value is { schema_version: number; items: DiscordCollectibleItem[] } {
if (!value || typeof value !== 'object') return false;
const catalog = value as Record<string, unknown>;
return (
catalog.schema_version === 1 &&
Array.isArray(catalog.items) &&
catalog.items.every(
(item) =>
item &&
typeof item === 'object' &&
typeof (item as DiscordCollectibleItem).id === 'string' &&
Array.isArray((item as DiscordCollectibleItem).assets)
)
);
}
function loadCachedCatalog(): PublishedCatalog | undefined {
try {
const raw = localStorage.getItem(CATALOG_CACHE_KEY);
if (!raw) return undefined;
const cached = JSON.parse(raw) as PublishedCatalog;
if (
!isPublishedCatalog(cached) ||
typeof cached.fetchedAt !== 'string' ||
Date.now() - Date.parse(cached.fetchedAt) >= CATALOG_CACHE_TTL_MS
) {
return undefined;
}
return cached;
} catch {
return undefined;
}
}
function saveCachedCatalog(catalog: PublishedCatalog): void {
try {
localStorage.setItem(CATALOG_CACHE_KEY, JSON.stringify(catalog));
} catch {
// A full or unavailable storage backend should not prevent catalog use.
}
}
export async function fetchPublishedCollectiblesCatalog(
force = false
): Promise<DiscordCollectibleItem[]> {
const cached = loadCachedCatalog();
if (!force && cached) return cached.items;
try {
const documents = await Promise.all(
PUBLISHED_CATALOG_URLS.map(async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`Catalog request failed with HTTP ${response.status}.`);
const catalog = await response.json();
if (!isPublishedCatalog(catalog)) throw new Error('Catalog has an unsupported schema.');
return catalog;
})
);
const items = documents.flatMap((catalog) => catalog.items);
if (items.length === 0) throw new Error('Catalog is empty.');
saveCachedCatalog({ schema_version: 1, fetchedAt: new Date().toISOString(), items });
return items;
} catch (error) {
if (cached) return cached.items;
throw error;
}
}
function isCdnUrl(value: unknown): value is string {
@@ -73,22 +151,31 @@ export async function downloadCollectibleAssets(
assets: DiscordCollectibleAsset[]
): Promise<DownloadedCollectibleAsset[]> {
const api = window.electron?.discordCollectibles;
if (!api) {
throw new Error('Discord collectibles are only available in the Paarrot desktop app.');
if (api) {
const result = await api.downloadAssets(assets);
if (!result?.success || !result.data) {
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
}
return result.data.map((asset) => ({
role: asset.role,
url: asset.url,
filename: asset.filename,
mimeType: asset.mimeType,
data: asset.data instanceof Uint8Array ? asset.data : new Uint8Array(asset.data),
}));
}
const result = await api.downloadAssets(assets);
if (!result?.success || !result.data) {
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
}
return result.data.map((asset) => ({
role: asset.role,
url: asset.url,
filename: asset.filename,
mimeType: asset.mimeType,
data: asset.data instanceof Uint8Array ? asset.data : new Uint8Array(asset.data),
}));
return Promise.all(
assets.map(async (asset) => {
if (!isCdnUrl(asset.url)) throw new Error('Collectible asset URL is not a Discord CDN URL.');
const response = await fetch(asset.url);
if (!response.ok) throw new Error(`Failed to download ${asset.filename}: HTTP ${response.status}.`);
return {
...asset,
data: new Uint8Array(await response.arrayBuffer()),
};
})
);
}
export async function uploadCollectibleToMatrix(