6 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
87c9f59826 Use public Discord collectibles catalog.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Remove local token setup in favor of the cached published catalog and document the collectible profile experience in the latest update notes.
2026-08-24 19:29:48 +10:00
d59945eb4c Add configurable sidebar nameplate background.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Render the equipped nameplate behind the larger settings avatar with blur-aware sidebar controls, adjustable opacity, and immediate collectible cache invalidation.
2026-08-24 18:48:48 +10:00
18 changed files with 398 additions and 160 deletions

View File

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

22
public/update/4.11.177.md Normal file
View File

@@ -0,0 +1,22 @@
---
version: 4.11.177
title: Profile colors & banners
date: 2026-08-23
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
---
## Profile colors (MSC4522)
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
- Per-room username overrides are read from member profile data when available.
![Paarrot](./images/usernamecolors.png)
## Profile banners (MSC4133)
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata.
- Upload or remove a banner independently from your avatar.
- Other clients that support MSC4133 can read your banner directly.

View File

@@ -1,22 +1,22 @@
--- ---
version: 4.11.177 version: 4.11.185
title: Profile colors & banners title: Discord collectibles
date: 2026-08-23 date: 2026-08-24
summary: Username colors now follow MSC4522 with separate dark and light theme pickers. summary: Add Discord-style profile effects, nameplates, and avatar decorations to Paarrot profiles.
--- ---
## Profile colors (MSC4522) ## Discord collectibles
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny - Browse and apply profile effects, animated nameplates, and avatar decorations in Settings → Account → Profile.
- Collectible assets are uploaded to your Matrix profile, so other Paarrot users can see them without Discord.
- The catalog now comes from a public git, and no Discord token is required in Paarrot for the cosmetics.
- Profile-effect previews play their intro and loop animations, with a mini profile preview for each effect.
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
- Per-room username overrides are read from member profile data when available.
![Paarrot](./images/usernamecolors.png) ![Discord Cosmetics 1](./images/discordcosmetics1.png) ![Discord Cosmetics 2](./images/discordcosmetics2.png)
## Profile banners (MSC4133) ## Profile presentation
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata. - Avatar decorations render in messages, profile cards, and the bottom sidebar avatar.
- Nameplates to the right of messages, beneath timestamps and can be displayed behind the bottom sidebar avatar with opacity settings.
- Upload or remove a banner independently from your avatar. - Customize the sidebar nameplate opacity in Settings → General → Collectibles.
- Other clients that support MSC4133 can read your banner directly.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ export const Sidebar = style([
width: toRem(66), width: toRem(66),
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
position: 'relative',
isolation: 'isolate',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
}); });
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>; export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
/** A vertical rendition of the user's nameplate behind the bottom settings avatar. */
export const SidebarAvatarNameplate = style({
position: 'absolute',
// The 50px avatar is centered in the 66px sidebar. Pin the rotated plate's
// bottom-right corner 8px left of it so the 66px cross-axis
// spans the whole sidebar and grows upward from its bottom edge.
right: toRem(58),
bottom: toRem(-16),
width: toRem(220),
height: toRem(66),
transformOrigin: 'right bottom',
transform: 'rotate(90deg)',
objectFit: 'cover',
objectPosition: 'right center',
pointerEvents: 'none',
zIndex: -1,
});
export const SidebarAvatarLarge = style({
width: toRem(50),
height: toRem(50),
borderRadius: '50%',
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarSearchAvatar = style({
position: 'relative',
zIndex: 1,
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarAvatarForeground = style({
position: 'relative',
zIndex: 1,
});
export const SidebarFolder = recipe({ export const SidebarFolder = recipe({
base: [ base: [
ContainerColor({ variant: 'Background' }), ContainerColor({ variant: 'Background' }),

View File

@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#000', 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)); 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 * Fetches TikTok video metadata using oEmbed API
* @param url The TikTok video URL * @param url The TikTok video URL
@@ -53,6 +59,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: string; authorUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
embedHtml: string; embedHtml: string;
videoId: string | null;
} | null> { } | null> {
try { try {
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`; const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
@@ -67,6 +74,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: data.author_url || url, authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '', thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '', embedHtml: data.html || '',
videoId: extractTikTokVideoId(url, data.html || ''),
}; };
} catch { } catch {
return null; return null;
@@ -75,7 +83,15 @@ async function fetchTikTokOEmbed(url: string): Promise<{
type TikTokEmbedState = type TikTokEmbedState =
| { status: 'loading' } | { 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' }; | { status: 'error' };
type TikTokEmbedProps = { type TikTokEmbedProps = {
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
*/ */
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => { export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' }); const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
const [showEmbed, setShowEmbed] = useState(false); const [showPlayer, setShowPlayer] = useState(false);
// Fetch video info on mount // Fetch video info on mount
useEffect(() => { useEffect(() => {
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
authorUrl: info.authorUrl, authorUrl: info.authorUrl,
thumbnailUrl: info.thumbnailUrl, thumbnailUrl: info.thumbnailUrl,
embedHtml: info.embedHtml, embedHtml: info.embedHtml,
videoId: info.videoId,
}); });
} else { } else {
setState({ status: 'error' }); setState({ status: 'error' });
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
} }
// Loaded state - show thumbnail or embed // Loaded state - show thumbnail or embed
if (!showEmbed) { if (!showPlayer) {
return ( return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}> <Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}> <div className={css.TikTokEmbedHeader}>
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
<button <button
type="button" type="button"
className={css.TikTokThumbnailButton} className={css.TikTokThumbnailButton}
onClick={() => setShowEmbed(true)} onClick={() => setShowPlayer(true)}
aria-label="Load TikTok video" aria-label="Play TikTok video"
> >
{state.thumbnailUrl && ( {state.thumbnailUrl ? (
<img <img
src={state.thumbnailUrl} src={state.thumbnailUrl}
alt={state.title} alt={state.title}
className={css.TikTokThumbnail} className={css.TikTokThumbnail}
crossOrigin="anonymous" crossOrigin="anonymous"
/> />
)} ) : null}
</button> </button>
</Box> </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 ( return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}> <Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}> <div className={css.TikTokEmbedHeader}>
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
{state.title} {state.title}
</Text> </Text>
</div> </div>
{playerUrl ? (
<iframe
className={css.TikTokEmbedContainer}
src={playerUrl}
title="TikTok video player"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<div <div
className={css.TikTokEmbedContainer} className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }} dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/> />
)}
</Box> </Box>
); );
}); });

View File

@@ -14,8 +14,8 @@ import {
DiscordCollectibleItem, DiscordCollectibleItem,
collectibleKindLabel, collectibleKindLabel,
defaultPreviewAspectRatio, defaultPreviewAspectRatio,
fetchPublishedCollectiblesCatalog,
groupCollectibleItems, groupCollectibleItems,
isElectronCollectiblesAvailable,
uploadCollectibleToMatrix, uploadCollectibleToMatrix,
variantItemLabel, variantItemLabel,
} from '../../../utils/discordCollectibles'; } from '../../../utils/discordCollectibles';
@@ -338,9 +338,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
); );
const previewDisplayName = profile.displayName; const previewDisplayName = profile.displayName;
const [kind, setKind] = useState<CollectibleKind>('profile_effect'); const [kind, setKind] = useState<CollectibleKind>('profile_effect');
const [hasToken, setHasToken] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [tokenSaving, setTokenSaving] = useState(false);
const [catalogLoading, setCatalogLoading] = useState(false); const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogError, setCatalogError] = useState<string>(); const [catalogError, setCatalogError] = useState<string>();
const [items, setItems] = useState<DiscordCollectibleItem[]>([]); const [items, setItems] = useState<DiscordCollectibleItem[]>([]);
@@ -350,40 +347,30 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({}); const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
const desktopAvailable = isElectronCollectiblesAvailable();
const refreshTokenState = useCallback(async () => {
if (!desktopAvailable) return;
const result = await window.electron!.discordCollectibles!.hasToken();
setHasToken(Boolean(result?.data));
}, [desktopAvailable]);
const loadCatalog = useCallback(async (force = false) => { const loadCatalog = useCallback(async (force = false) => {
if (!desktopAvailable) return;
setCatalogLoading(true); setCatalogLoading(true);
setCatalogError(undefined); setCatalogError(undefined);
try { try {
const result = await window.electron!.discordCollectibles!.fetchCatalog(force); const desktopCatalog = window.electron?.discordCollectibles;
if (desktopCatalog) {
const result = await desktopCatalog.fetchCatalog(force);
if (!result?.success || !result.data?.items) { if (!result?.success || !result.data?.items) {
throw new Error(result?.error || 'Failed to load Discord catalog.'); throw new Error(result?.error || 'Failed to load collectibles catalog.');
} }
setItems(result.data.items as DiscordCollectibleItem[]); setItems(result.data.items as DiscordCollectibleItem[]);
} else {
setItems(await fetchPublishedCollectiblesCatalog(force));
}
} catch (e) { } catch (e) {
setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.'); setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.');
setItems([]); setItems([]);
} }
setCatalogLoading(false); setCatalogLoading(false);
}, [desktopAvailable]); }, []);
useEffect(() => { useEffect(() => {
refreshTokenState();
}, [refreshTokenState]);
useEffect(() => {
if (hasToken) {
loadCatalog(); loadCatalog();
} }, [loadCatalog]);
}, [hasToken, loadCatalog]);
const current = collectibles[KIND_FIELD[kind]]; const current = collectibles[KIND_FIELD[kind]];
@@ -416,27 +403,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
}); });
}, [filteredGroups]); }, [filteredGroups]);
const handleSaveToken = async () => {
if (!desktopAvailable) return;
setTokenSaving(true);
setCatalogError(undefined);
try {
await window.electron!.discordCollectibles!.setToken(tokenInput);
setTokenInput('');
await refreshTokenState();
} catch (e) {
setCatalogError(e instanceof Error ? e.message : 'Failed to save token.');
}
setTokenSaving(false);
};
const handleClearToken = async () => {
if (!desktopAvailable) return;
await window.electron!.discordCollectibles!.clearToken();
setItems([]);
await refreshTokenState();
};
const handleApply = async (item: DiscordCollectibleItem) => { const handleApply = async (item: DiscordCollectibleItem) => {
setApplyingId(item.id); setApplyingId(item.id);
setError(undefined); setError(undefined);
@@ -467,61 +433,26 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
kind === 'avatar_decoration' && css.CollectibleGroupGridDecorations 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 ( return (
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<Text size="T200" style={{ opacity: 0.8 }}> <Text size="T200" style={{ opacity: 0.8 }}>
Browse Discord shop collectibles live, then download and upload only what you pick to Matrix. Browse a community-published Discord collectibles catalog, then download and upload only what
Your Discord token stays on this device and is only used to list the shop catalog. you pick to Matrix.
</Text> </Text>
{!hasToken ? ( <Box gap="200" alignItems="Center" wrap="Wrap">
<Box direction="Column" gap="200">
<Text size="T300">Discord token (required to list the shop)</Text>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
type="password"
placeholder="User token or Bot xxxxx"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
/>
<Button <Button
size="300" size="300"
variant="Primary" variant="Secondary"
fill="Solid" fill="Soft"
radii="300" radii="300"
onClick={handleSaveToken} onClick={() => loadCatalog(true)}
disabled={tokenSaving || !tokenInput.trim()} disabled={catalogLoading}
> >
<Text size="B300">{tokenSaving ? 'Saving…' : 'Save token'}</Text>
</Button>
{catalogError && <Text size="T200" style={{ color: color.Critical.Main }}>{catalogError}</Text>}
</Box>
) : (
<Box gap="200" alignItems="Center" wrap="Wrap">
<Text size="T200" style={{ opacity: 0.8 }}>Discord token saved on this device.</Text>
<Button size="300" variant="Secondary" fill="Soft" radii="300" onClick={() => loadCatalog(true)} disabled={catalogLoading}>
<Text size="B300">{catalogLoading ? 'Refreshing…' : 'Refresh catalog'}</Text> <Text size="B300">{catalogLoading ? 'Refreshing…' : 'Refresh catalog'}</Text>
</Button> </Button>
<Button size="300" variant="Critical" fill="Soft" radii="300" onClick={handleClearToken}>
<Text size="B300">Remove token</Text>
</Button>
</Box> </Box>
)}
{hasToken && (
<> <>
<Box gap="200" wrap="Wrap"> <Box gap="200" wrap="Wrap">
{KIND_TABS.map((tab) => ( {KIND_TABS.map((tab) => (
@@ -559,7 +490,7 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
{catalogLoading && ( {catalogLoading && (
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
<Spinner size="100" variant="Secondary" /> <Spinner size="100" variant="Secondary" />
<Text size="T200">Loading catalog from Discord</Text> <Text size="T200">Loading collectibles catalog</Text>
</Box> </Box>
)} )}
@@ -607,7 +538,6 @@ export function CollectiblesSection({ collectibles, onApply }: CollectiblesSecti
<Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text> <Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text>
)} )}
</> </>
)}
</Box> </Box>
); );
} }

View File

@@ -9,6 +9,7 @@ import React, {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds'; import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { Range } from 'react-range';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
[EmojiStyle.Twemoji]: 'Twemoji', [EmojiStyle.Twemoji]: 'Twemoji',
}; };
function SidebarNameplate() {
const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting(
settingsAtom,
'sidebarNameplateOpacity'
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Collectibles</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sidebar Nameplate Opacity"
description="Set to 0% to hide your equipped nameplate behind the bottom sidebar avatar."
after={
<Box gap="200" alignItems="Center" style={{ width: toRem(180) }}>
<Range
step={1}
min={0}
max={100}
values={[sidebarNameplateOpacity]}
onChange={(values) => setSidebarNameplateOpacity(values[0])}
renderTrack={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: '100%',
height: toRem(8),
borderRadius: toRem(4),
background: `linear-gradient(to right, ${color.Primary.Main} ${sidebarNameplateOpacity}%, ${color.Surface.ContainerLine} ${sidebarNameplateOpacity}%)`,
}}
>
{params.children}
</div>
)}
renderThumb={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: toRem(16),
height: toRem(16),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
border: `${toRem(2)} solid ${color.Surface.Container}`,
}}
/>
)}
/>
<Text size="T300" style={{ minWidth: toRem(32), textAlign: 'right' }}>
{sidebarNameplateOpacity}%
</Text>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
type EmojiStyleSelectorProps = { type EmojiStyleSelectorProps = {
selected: EmojiStyle; selected: EmojiStyle;
onSelect: (style: EmojiStyle) => void; onSelect: (style: EmojiStyle) => void;
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent> <PageContent>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Appearance /> <Appearance />
<SidebarNameplate />
<DateAndTime /> <DateAndTime />
<Editor /> <Editor />
<Spaces /> <Spaces />

View File

@@ -52,15 +52,18 @@ export function useUserCollectibles(): [
async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => { async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => {
if (kind === 'profile_effect') { if (kind === 'profile_effect') {
await saveProfileEffect(mx, collectible); await saveProfileEffect(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, profile_effect: collectible })); setCollectibles((prev) => ({ ...prev, profile_effect: collectible }));
return; return;
} }
if (kind === 'nameplate') { if (kind === 'nameplate') {
await saveNameplate(mx, collectible); await saveNameplate(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, nameplate: collectible })); setCollectibles((prev) => ({ ...prev, nameplate: collectible }));
return; return;
} }
await saveAvatarDecoration(mx, collectible); await saveAvatarDecoration(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible })); setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible }));
}, },
[mx] [mx]
@@ -70,8 +73,15 @@ export function useUserCollectibles(): [
} }
const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>(); const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>();
const collectibleCacheListeners = new Set<(userId: string) => void>();
const CACHE_TTL_MS = 5 * 60 * 1000; const CACHE_TTL_MS = 5 * 60 * 1000;
export function clearUserCollectiblesCache(userId?: string): void {
if (!userId) return;
collectibleCache.delete(userId);
collectibleCacheListeners.forEach((listener) => listener(userId));
}
async function fetchMxcBlobUrl( async function fetchMxcBlobUrl(
mx: ReturnType<typeof useMatrixClient>, mx: ReturnType<typeof useMatrixClient>,
mxc: string, mxc: string,
@@ -100,6 +110,17 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({}); const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({}); const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({});
const [cacheVersion, setCacheVersion] = useState(0);
useEffect(() => {
const handleCacheClear = (clearedUserId: string) => {
if (clearedUserId === userId) {
setCacheVersion((version) => version + 1);
}
};
collectibleCacheListeners.add(handleCacheClear);
return () => collectibleCacheListeners.delete(handleCacheClear);
}, [userId]);
useEffect(() => { useEffect(() => {
if (!userId) { if (!userId) {
@@ -152,7 +173,7 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel
cancelled = true; cancelled = true;
blobUrls.forEach((url) => URL.revokeObjectURL(url)); blobUrls.forEach((url) => URL.revokeObjectURL(url));
}; };
}, [mx, useAuthentication, userId]); }, [cacheVersion, mx, useAuthentication, userId]);
return { ...collectibles, assetUrls }; return { ...collectibles, assetUrls };
} }

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal'; import { searchModalAtom } from '../../../state/searchModal';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
export function SearchTab() { export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom); const [opened, setOpen] = useAtom(searchModalAtom);
@@ -14,7 +15,13 @@ export function SearchTab() {
<SidebarItem active={opened}> <SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search"> <SidebarItemTooltip tooltip="Search">
{(triggerRef) => ( {(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}> <SidebarAvatar
as="button"
ref={triggerRef}
className={sidebarCss.SidebarSearchAvatar}
outlined
onClick={open}
>
<Icon src={Icons.Search} filled={opened} /> <Icon src={Icons.Search} filled={opened} />
</SidebarAvatar> </SidebarAvatar>
)} )}

View File

@@ -12,8 +12,11 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings'; import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles'; import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css'; import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
import { Modal500 } from '../../../components/Modal500'; import { Modal500 } from '../../../components/Modal500';
import { AccountSwitcher } from '../../../components/account-switcher'; import { AccountSwitcher } from '../../../components/account-switcher';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
@@ -39,6 +42,11 @@ export function SettingsTab() {
: undefined; : undefined;
const { assetUrls } = useOtherUserCollectibles(userId ?? ''); const { assetUrls } = useOtherUserCollectibles(userId ?? '');
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls); const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
const nameplateUrl = pickNameplateUrl(assetUrls);
const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity');
const nameplateIsVideo = Boolean(
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
);
const openSettings = () => setSettings(true); const openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false); const closeSettings = () => setSettings(false);
@@ -53,12 +61,36 @@ export function SettingsTab() {
<SidebarItem active={settings}> <SidebarItem active={settings}>
<SidebarAvatar <SidebarAvatar
as="button" as="button"
className={sidebarCss.SidebarAvatarLarge}
onClick={openSettings} onClick={openSettings}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ overflow: 'visible' }} style={{ overflow: 'visible' }}
> >
<div className={avatarDecorationCss.SidebarAvatarStack}> <div className={avatarDecorationCss.SidebarAvatarStack}>
{sidebarNameplateOpacity > 0 &&
nameplateUrl &&
(nameplateIsVideo ? (
<video
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
autoPlay
loop
muted
playsInline
aria-hidden="true"
/>
) : (
<img
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
alt=""
draggable={false}
/>
))}
<UserAvatar <UserAvatar
className={sidebarCss.SidebarAvatarForeground}
userId={userId ?? ''} userId={userId ?? ''}
src={avatarUrl} src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>} renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}

View File

@@ -50,6 +50,7 @@ export interface Settings {
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
sidebarNameplateOpacity: number;
showNotifications: boolean; showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
encUrlPreview: false, encUrlPreview: false,
showHiddenEvents: false, showHiddenEvents: false,
legacyUsernameColor: false, legacyUsernameColor: false,
sidebarNameplateOpacity: 60,
showNotifications: true, showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,

View File

@@ -37,9 +37,87 @@ export type DownloadedCollectibleAsset = DiscordCollectibleAsset & {
}; };
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i; 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 { type PublishedCatalog = {
return Boolean(window.electron?.discordCollectibles); 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 { function isCdnUrl(value: unknown): value is string {
@@ -73,15 +151,11 @@ export async function downloadCollectibleAssets(
assets: DiscordCollectibleAsset[] assets: DiscordCollectibleAsset[]
): Promise<DownloadedCollectibleAsset[]> { ): Promise<DownloadedCollectibleAsset[]> {
const api = window.electron?.discordCollectibles; const api = window.electron?.discordCollectibles;
if (!api) { if (api) {
throw new Error('Discord collectibles are only available in the Paarrot desktop app.');
}
const result = await api.downloadAssets(assets); const result = await api.downloadAssets(assets);
if (!result?.success || !result.data) { if (!result?.success || !result.data) {
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.'); throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
} }
return result.data.map((asset) => ({ return result.data.map((asset) => ({
role: asset.role, role: asset.role,
url: asset.url, url: asset.url,
@@ -89,6 +163,19 @@ export async function downloadCollectibleAssets(
mimeType: asset.mimeType, mimeType: asset.mimeType,
data: asset.data instanceof Uint8Array ? asset.data : new Uint8Array(asset.data), 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( export async function uploadCollectibleToMatrix(

3
src/ext.d.ts vendored
View File

@@ -114,9 +114,6 @@ interface ElectronAPI {
}) => void) => void; }) => void) => void;
}; };
discordCollectibles?: { discordCollectibles?: {
hasToken: () => Promise<{ success: boolean; data?: boolean; error?: string }>;
setToken: (token: string) => Promise<{ success: boolean; error?: string }>;
clearToken: () => Promise<{ success: boolean; error?: string }>;
fetchCatalog: (force?: boolean) => Promise<{ fetchCatalog: (force?: boolean) => Promise<{
success: boolean; success: boolean;
data?: { items: unknown[]; fetchedAt: string }; data?: { items: unknown[]; fetchedAt: string };