Compare commits
6 Commits
0cbc5d9a1c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47979718eb | |||
| 862307ee7d | |||
| b16263b481 | |||
| c34cfaf33f | |||
| 87c9f59826 | |||
| d59945eb4c |
@@ -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
22
public/update/4.11.177.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
|
||||||
|
|
||||||

|
 
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
BIN
public/update/images/discordcosmetics1.png
Normal file
BIN
public/update/images/discordcosmetics1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/update/images/discordcosmetics2.png
Normal file
BIN
public/update/images/discordcosmetics2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
3
src/ext.d.ts
vendored
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user