diff --git a/fluxer_app/src/features/invite/components/modals/InviteAcceptModal.tsx b/fluxer_app/src/features/invite/components/modals/InviteAcceptModal.tsx index 898339dfc..57a98d243 100644 --- a/fluxer_app/src/features/invite/components/modals/InviteAcceptModal.tsx +++ b/fluxer_app/src/features/invite/components/modals/InviteAcceptModal.tsx @@ -131,13 +131,10 @@ export const InviteAcceptModal = observer(function InviteAcceptModal({code}: Inv if (!guild.id || !guild.splash) { return null; } - return AvatarUtils.getGuildSplashURL( - { - id: guild.id, - splash: guild.splash, - }, - 4096, - ); + return AvatarUtils.getGuildSplashURL({ + id: guild.id, + splash: guild.splash, + }); }, [invite]); const isJoinDisabled = isGuildInviteActionDisabled(guildActionState); const primaryActionType = getGuildInvitePrimaryAction(guildActionState); diff --git a/fluxer_app/src/features/invite/components/modals/InvitePagePreviewModal.tsx b/fluxer_app/src/features/invite/components/modals/InvitePagePreviewModal.tsx index 872089c14..d1e85821a 100644 --- a/fluxer_app/src/features/invite/components/modals/InvitePagePreviewModal.tsx +++ b/fluxer_app/src/features/invite/components/modals/InvitePagePreviewModal.tsx @@ -57,7 +57,7 @@ export const InvitePagePreviewModal: React.FC = obs const splashUrl = useMemo(() => { if (previewSplashUrl) return previewSplashUrl; if (guild?.splash) { - return AvatarUtils.getGuildSplashURL({id: guild.id, splash: guild.splash}, 4096); + return AvatarUtils.getGuildSplashURL({id: guild.id, splash: guild.splash}); } return null; }, [previewSplashUrl, guild?.id, guild?.splash]); diff --git a/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx b/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx index 111703599..1565eeb9d 100644 --- a/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx +++ b/fluxer_app/src/features/invite/components/pages/InviteLoginPage.tsx @@ -170,7 +170,7 @@ const InviteLoginPageContainer = observer(() => { } const guild = guildInvite.guild; if (guild?.splash && guild.id) { - const splashUrl = getGuildSplashURL({id: guild.id, splash: guild.splash}, 4096) ?? null; + const splashUrl = getGuildSplashURL({id: guild.id, splash: guild.splash}) ?? null; setSplashUrl(splashUrl); } }, [guildInvite?.guild?.splash, guildInvite?.guild?.id, setSplashUrl]); diff --git a/fluxer_app/src/features/invite/components/pages/InviteRegisterPage.tsx b/fluxer_app/src/features/invite/components/pages/InviteRegisterPage.tsx index 284d7a239..fe32caf92 100644 --- a/fluxer_app/src/features/invite/components/pages/InviteRegisterPage.tsx +++ b/fluxer_app/src/features/invite/components/pages/InviteRegisterPage.tsx @@ -66,13 +66,10 @@ const InviteRegisterPage = observer(function InviteRegisterPage() { } const guild = guildInvite.guild; if (guild?.splash) { - const splashUrl = AvatarUtils.getGuildSplashURL( - { - id: guild.id, - splash: guild.splash, - }, - 4096, - ); + const splashUrl = AvatarUtils.getGuildSplashURL({ + id: guild.id, + splash: guild.splash, + }); setSplashUrl(splashUrl); } }, [guildInvite?.guild?.splash, guildInvite?.guild?.id, setSplashUrl]); diff --git a/fluxer_app/src/features/user/components/modals/UserProfileMobileSheet.tsx b/fluxer_app/src/features/user/components/modals/UserProfileMobileSheet.tsx index a5fe0ef93..4bc81cd76 100644 --- a/fluxer_app/src/features/user/components/modals/UserProfileMobileSheet.tsx +++ b/fluxer_app/src/features/user/components/modals/UserProfileMobileSheet.tsx @@ -79,7 +79,7 @@ import {hasActiveDirectCallWithUser} from '@app/features/voice/utils/PrivateCall import {ME} from '@fluxer/constants/src/AppConstants'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import { - MEDIA_PROXY_AVATAR_SIZE_PROFILE, + MEDIA_PROXY_AVATAR_SIZE_DEFAULT, MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL, } from '@fluxer/constants/src/MediaProxyAssetSizes'; import {PublicUserFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants'; @@ -282,7 +282,7 @@ const UserProfileMobileSheetContent: React.FC ProfileDisplayUtils.getProfileAvatarUrls(profileContext, undefined, MEDIA_PROXY_AVATAR_SIZE_PROFILE), + () => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, undefined, MEDIA_PROXY_AVATAR_SIZE_DEFAULT), [profileContext], ); const {bannerUrl: staticBannerUrl, hoverBannerUrl} = useMemo( diff --git a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/InterfaceTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/InterfaceTab.tsx index 8a29b8c34..86423f108 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/InterfaceTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/InterfaceTab.tsx @@ -6,13 +6,13 @@ import {SettingsTabSection} from '@app/features/app/components/dialogs/shared/Se import {ChannelItemCore} from '@app/features/app/components/layout/ChannelItem'; import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css'; import {Typing} from '@app/features/channel/components/ChannelTyping'; -import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils'; import {AvatarStack} from '@app/features/ui/avatars/AvatarStack'; import {MockAvatar} from '@app/features/ui/components/MockAvatar'; import type {RadioOption} from '@app/features/ui/radio_group/RadioGroup'; import {RadioGroup} from '@app/features/ui/radio_group/RadioGroup'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import styles from '@app/features/user/components/modals/tabs/appearance_tab/InterfaceTab.module.css'; +import {getDefaultAvatarURLForIndex} from '@app/features/user/utils/AvatarUtils'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; @@ -92,7 +92,7 @@ const ChannelListPreview = observer(({mode}: {mode: ChannelTypingIndicatorMode}) key={index} size={12} userTag={`User ${index}`} - avatarUrl={cdnUrl(`avatars/${index}.png`)} + avatarUrl={getDefaultAvatarURLForIndex(index)} data-flx="user.appearance-tab.interface-tab.channel-list-preview.mock-avatar" /> ))} diff --git a/fluxer_app/src/features/user/utils/AvatarSourceUtils.ts b/fluxer_app/src/features/user/utils/AvatarSourceUtils.ts index 5897bbd9e..99c23175b 100644 --- a/fluxer_app/src/features/user/utils/AvatarSourceUtils.ts +++ b/fluxer_app/src/features/user/utils/AvatarSourceUtils.ts @@ -1,482 +1,3 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; -import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; -import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils'; -import {cdnUrl, mediaUrl, setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; -import type {User} from '@app/features/user/models/User'; -import { - getDefaultAvatarIndex, - getDefaultAvatarPrimaryColor as getSharedDefaultAvatarPrimaryColor, - normalizeEndpoint, - parseAvatarHash, -} from '@app/features/user/utils/AvatarMediaUtils'; -import { - MEDIA_PROXY_AVATAR_SIZE_DEFAULT, - MEDIA_PROXY_ICON_SIZE_DEFAULT, -} from '@fluxer/constants/src/MediaProxyAssetSizes'; -import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes'; -import {SOUNDBOARD_SOUND_PATH_PREFIX} from '@fluxer/constants/src/SoundboardConstants'; - -const getDefaultAvatar = (index: number): string => cdnUrl(`avatars/${index}.png`); - -export function getDefaultAvatarPrimaryColor(id: string) { - return getSharedDefaultAvatarPrimaryColor(id); -} - -export function getDefaultAvatarURL(id: string) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); -} - -type AvatarOptions = Pick; -type BannerOptions = Pick; - -interface IconOptions { - id: string; - icon: string | null; -} - -type MediaURLParams = { - path: string; - id: string; - hash: string; - size?: MediaProxyImageSize; - format: string; - animated?: boolean; - endpoint?: string; -}; - -const parseMediaHashForRequest = (value: string, animated = false) => { - const {animated: isAnimated} = parseAvatarHash(value); - return { - hash: value, - animated: isAnimated ? animated : undefined, - }; -}; -const getMediaURL = ({path, id, hash, size, format, animated, endpoint}: MediaURLParams) => { - if (DeveloperOptions.forceRenderPlaceholders) { - return ''; - } - const baseEndpoint = endpoint === undefined ? RuntimeConfig.mediaEndpoint : endpoint; - if (!baseEndpoint) { - return ''; - } - const basePath = `${path}/${id}/${hash}.${format}`; - const url = size ? setPathQueryParams(basePath, {size}) : basePath; - const proxyOptions = animated === undefined ? undefined : {animated}; - return buildMediaProxyURL(`${normalizeEndpoint(baseEndpoint)}/${url}`, proxyOptions); -}; - -type GuildMemberMediaURLParams = { - path: string; - guildId: string; - userId: string; - hash: string; - size?: MediaProxyImageSize; - format: string; - animated?: boolean; -}; - -const getGuildMemberMediaURL = ({path, guildId, userId, hash, size, format, animated}: GuildMemberMediaURLParams) => { - if (DeveloperOptions.forceRenderPlaceholders) { - return ''; - } - const baseEndpoint = RuntimeConfig.mediaEndpoint; - if (!baseEndpoint) { - return ''; - } - const basePath = `guilds/${guildId}/users/${userId}/${path}/${hash}.${format}`; - const url = size ? setPathQueryParams(basePath, {size}) : basePath; - const proxyOptions = animated === undefined ? undefined : {animated}; - return buildMediaProxyURL(`${normalizeEndpoint(baseEndpoint)}/${url}`, proxyOptions); -}; -const buildWebpMediaUrl = (params: Omit) => getMediaURL({...params, format: 'webp'}); -const buildPngMediaUrl = (params: Omit) => getMediaURL({...params, format: 'png'}); -const buildGuildMemberWebpUrl = (params: Omit) => - getGuildMemberMediaURL({...params, format: 'webp'}); - -export function getUserAvatarURL( - {id, avatar}: AvatarOptions, - animated = false, - size: MediaProxyImageSize = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, -) { - if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); - return buildWebpMediaUrl({ - path: 'avatars', - id, - hash, - size, - animated: shouldAnimate, - }); -} - -export function getUserNotificationAvatarURL( - {id, avatar}: AvatarOptions, - size: MediaProxyImageSize = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, -) { - if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); - } - const {hash, animated} = parseMediaHashForRequest(avatar, false); - return buildPngMediaUrl({ - path: 'avatars', - id, - hash, - size, - animated, - }); -} - -export function getUserBannerURL({id, banner}: BannerOptions, animated = false, size: MediaProxyImageSize = 1024) { - if (!banner) { - return ''; - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(banner, animated); - return buildWebpMediaUrl({ - path: 'banners', - id, - hash, - size, - animated: shouldAnimate, - }); -} - -const SOUNDBOARD_SOUND_URL_CACHE = new Map(); -const SOUNDBOARD_SOUND_URL_CACHE_LIMIT = 4096; - -export function getSoundboardSoundURL(soundId: string): string { - if (DeveloperOptions.forceRenderPlaceholders) { - return ''; - } - const key = `${RuntimeConfig.mediaEndpoint}:${soundId}`; - const cached = SOUNDBOARD_SOUND_URL_CACHE.get(key); - if (cached !== undefined) return cached; - const result = mediaUrl(`${SOUNDBOARD_SOUND_PATH_PREFIX}/${soundId}`); - if (SOUNDBOARD_SOUND_URL_CACHE.size >= SOUNDBOARD_SOUND_URL_CACHE_LIMIT) SOUNDBOARD_SOUND_URL_CACHE.clear(); - SOUNDBOARD_SOUND_URL_CACHE.set(key, result); - return result; -} - -export function getGuildIconURL({id, icon}: IconOptions, animated = false) { - if (!icon) { - return ''; - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(icon, animated); - return buildWebpMediaUrl({ - path: 'icons', - id, - hash, - size: MEDIA_PROXY_ICON_SIZE_DEFAULT, - animated: shouldAnimate, - }); -} - -export function getGuildIconURLWithProxy({id, icon}: IconOptions, endpoint: string, animated = false) { - if (!endpoint || !icon) { - return ''; - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(icon, animated); - return buildWebpMediaUrl({ - path: 'icons', - id, - hash, - size: MEDIA_PROXY_ICON_SIZE_DEFAULT, - animated: shouldAnimate, - endpoint, - }); -} - -export function getGuildSplashURL( - { - id, - splash, - }: { - id: string; - splash: string | null; - }, - size: MediaProxyImageSize = 1024, -) { - if (!splash) { - return ''; - } - return buildWebpMediaUrl({ - path: 'splashes', - id, - hash: splash, - size, - }); -} - -export function getGuildDiscoverySplashURL({id, splash}: {id: string; splash: string | null}) { - if (!splash) { - return ''; - } - return buildWebpMediaUrl({ - path: 'discovery-splashes', - id, - hash: splash, - size: 1024, - }); -} - -export function getGuildBannerURL( - { - id, - banner, - }: { - id: string; - banner: string | null; - }, - animated = false, -) { - if (!banner) { - return ''; - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(banner, animated); - return buildWebpMediaUrl({ - path: 'banners', - id, - hash, - size: 1024, - animated: shouldAnimate, - }); -} - -export function getGuildMemberAvatarURL({ - guildId, - userId, - avatar, - memberAvatar, - animated = false, - size = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, -}: { - guildId: string; - userId: string; - avatar: string | null; - memberAvatar?: string | null; - animated?: boolean; - size?: MediaProxyImageSize; -}) { - if (memberAvatar) { - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(memberAvatar, animated); - return buildGuildMemberWebpUrl({ - path: 'avatars', - guildId, - userId, - hash, - size, - animated: shouldAnimate, - }); - } - if (avatar) { - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); - return buildWebpMediaUrl({ - path: 'avatars', - id: userId, - hash, - size, - animated: shouldAnimate, - }); - } - return getDefaultAvatar(getDefaultAvatarIndex(userId)); -} - -export function getGuildMemberDisplayAvatarURL({ - guildId, - user, - memberAvatar, - avatarUnset = false, - animated = false, - size = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, -}: { - guildId: string; - user: AvatarOptions; - memberAvatar?: string | null; - avatarUnset?: boolean; - animated?: boolean; - size?: MediaProxyImageSize; -}) { - if (avatarUnset) { - return getUserAvatarURL({id: user.id, avatar: null}, animated, size); - } - return getGuildMemberAvatarURL({ - guildId, - userId: user.id, - avatar: user.avatar, - memberAvatar, - animated, - size, - }); -} - -export function getGuildMemberBannerURL({ - guildId, - userId, - banner, - memberBanner, - animated = false, - size = 1024, -}: { - guildId: string; - userId: string; - banner: string | null; - memberBanner?: string | null; - animated?: boolean; - size?: MediaProxyImageSize; -}) { - if (memberBanner) { - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(memberBanner, animated); - return buildGuildMemberWebpUrl({ - path: 'banners', - guildId, - userId, - hash, - size, - animated: shouldAnimate, - }); - } - if (banner) { - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(banner, animated); - return buildWebpMediaUrl({ - path: 'banners', - id: userId, - hash, - size, - animated: shouldAnimate, - }); - } - return ''; -} - -export function getUserAvatarURLWithProxy( - options: AvatarOptions, - endpoint: string, - animated = false, - size: MediaProxyImageSize = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, -) { - const {id, avatar} = options; - if (!endpoint || !avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); - return buildWebpMediaUrl({ - path: 'avatars', - id, - hash, - size, - animated: shouldAnimate, - endpoint, - }); -} - -export function getGuildEmbedSplashURL( - { - id, - embedSplash, - }: { - id: string; - embedSplash: string | null; - }, - size: MediaProxyImageSize = 1024, -) { - if (!embedSplash) { - return ''; - } - return buildWebpMediaUrl({ - path: 'embed-splashes', - id, - hash: embedSplash, - size, - }); -} - -export function getChannelIconURL( - {id, icon}: IconOptions, - size: MediaProxyImageSize = MEDIA_PROXY_ICON_SIZE_DEFAULT, - animated = false, -) { - if (!icon) { - return ''; - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(icon, animated); - return buildWebpMediaUrl({ - path: 'icons', - id, - hash, - size, - animated: shouldAnimate, - }); -} - -export function getChannelIconURLWithProxy( - {id, icon}: IconOptions, - endpoint: string, - size: MediaProxyImageSize = MEDIA_PROXY_ICON_SIZE_DEFAULT, - animated = false, -) { - if (!endpoint || !icon) { - return ''; - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(icon, animated); - return buildWebpMediaUrl({ - path: 'icons', - id, - hash, - size, - animated: shouldAnimate, - endpoint, - }); -} - -export function getWebhookAvatarURL({id, avatar}: AvatarOptions, animated = false) { - if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); - } - const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); - return buildWebpMediaUrl({ - path: 'avatars', - id, - hash, - size: MEDIA_PROXY_AVATAR_SIZE_DEFAULT, - animated: shouldAnimate, - }); -} - -const EMOJI_URL_CACHE = new Map(); -const EMOJI_URL_CACHE_LIMIT = 4096; - -export function getEmojiURL({id, animated}: {id: string; animated?: boolean}) { - if (DeveloperOptions.forceRenderPlaceholders) { - return ''; - } - const animatedFlag = Boolean(animated); - const key = `${RuntimeConfig.mediaEndpoint}:${animatedFlag ? 'a' : 's'}:${id}`; - const cached = EMOJI_URL_CACHE.get(key); - if (cached !== undefined) return cached; - const result = mediaUrl(setPathQueryParams(`emojis/${id}.webp`, {v: 5}), {animated: animatedFlag}); - if (EMOJI_URL_CACHE.size >= EMOJI_URL_CACHE_LIMIT) EMOJI_URL_CACHE.clear(); - EMOJI_URL_CACHE.set(key, result); - return result; -} - -type StickerSize = 160 | 320; - -export function getStickerURL({id, animated, size = 320}: {id: string; animated?: boolean; size?: StickerSize}) { - if (DeveloperOptions.forceRenderPlaceholders) { - return ''; - } - const safeSize: StickerSize = size === 320 ? 320 : 160; - return mediaUrl(setPathQueryParams(`stickers/${id}.webp`, {size: safeSize}), {animated: Boolean(animated)}); -} - -export function fileToBase64(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} +export * from '@app/features/user/utils/AvatarUtils'; diff --git a/fluxer_app/src/features/user/utils/AvatarUtils.ts b/fluxer_app/src/features/user/utils/AvatarUtils.ts index 124be990b..c7dd4a7ab 100644 --- a/fluxer_app/src/features/user/utils/AvatarUtils.ts +++ b/fluxer_app/src/features/user/utils/AvatarUtils.ts @@ -2,7 +2,15 @@ import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; -import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils'; +import { + buildMediaProxyURL, + LARGEST_MEDIA_PROXY_IMAGE_SIZE, + MEDIA_PROXY_IMAGE_SIZE_LADDER, + snapMediaProxyImageSize, +} from '@app/features/messaging/utils/MediaProxyUtils'; + +export {MEDIA_PROXY_IMAGE_SIZE_LADDER, snapMediaProxyImageSize}; + import {cdnUrl, mediaUrl, setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; import type {User} from '@app/features/user/models/User'; import { @@ -16,13 +24,68 @@ import { MEDIA_PROXY_ICON_SIZE_DEFAULT, } from '@fluxer/constants/src/MediaProxyAssetSizes'; import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes'; +import {SOUNDBOARD_SOUND_PATH_PREFIX} from '@fluxer/constants/src/SoundboardConstants'; -const getDefaultAvatar = (index: number): string => cdnUrl(`avatars/${index}.png`); +const GUILD_BANNER_CSS_WIDTH = 360; +const GUILD_EMBED_SPLASH_CSS_WIDTH = 360; +const GUILD_SPLASH_FALLBACK_CSS_WIDTH = 1024; + +const MEDIA_PROXY_WIDE_ASSET_SERVER_MIN_DIMENSION = 480; +const MEDIA_PROXY_WIDE_ASSET_SERVER_MAX_DIMENSION = 2400; +const MEDIA_PROXY_ICON_SERVER_MIN_DIMENSION = 128; +const MEDIA_PROXY_ICON_SERVER_MAX_DIMENSION = 1024; + +function snapImageSizeWithinServerRange( + cssPixels: number, + minDimension: number, + maxDimension: number, +): MediaProxyImageSize { + const floor = MEDIA_PROXY_IMAGE_SIZE_LADDER.find((rung) => rung >= minDimension) ?? LARGEST_MEDIA_PROXY_IMAGE_SIZE; + const ceiling = MEDIA_PROXY_IMAGE_SIZE_LADDER.reduce( + (largest, rung) => (rung <= maxDimension ? rung : largest), + MEDIA_PROXY_IMAGE_SIZE_LADDER[0], + ); + const snapped = snapMediaProxyImageSize(cssPixels); + if (snapped < floor) return floor; + if (snapped > ceiling) return ceiling; + return snapped; +} + +function snapWideAssetImageSize(cssPixels: number): MediaProxyImageSize { + return snapImageSizeWithinServerRange( + cssPixels, + MEDIA_PROXY_WIDE_ASSET_SERVER_MIN_DIMENSION, + MEDIA_PROXY_WIDE_ASSET_SERVER_MAX_DIMENSION, + ); +} + +function snapIconImageSize(cssPixels: number): MediaProxyImageSize { + return snapImageSizeWithinServerRange( + cssPixels, + MEDIA_PROXY_ICON_SERVER_MIN_DIMENSION, + MEDIA_PROXY_ICON_SERVER_MAX_DIMENSION, + ); +} + +const getViewportSplashSize = (): MediaProxyImageSize => { + const screenWidth = typeof window === 'undefined' ? Number.NaN : window.screen.width; + const cssWidth = Number.isFinite(screenWidth) && screenWidth > 0 ? screenWidth : GUILD_SPLASH_FALLBACK_CSS_WIDTH; + return snapWideAssetImageSize(cssWidth); +}; + +const DEFAULT_AVATAR_ASSET_VERSION = '1'; + +export const getDefaultAvatarURLForIndex = (index: number): string => + cdnUrl(`avatars/${index}.png?v=${DEFAULT_AVATAR_ASSET_VERSION}`); export function getDefaultAvatarPrimaryColor(id: string) { return getSharedDefaultAvatarPrimaryColor(id); } +export function getDefaultAvatarURL(id: string) { + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(id)); +} + type AvatarOptions = Pick; type BannerOptions = Pick; @@ -42,17 +105,18 @@ type MediaURLParams = { }; const parseMediaHashForRequest = (value: string, animated = false) => { - const {animated: isAnimated} = parseAvatarHash(value); + const {animated: isAnimated, hash} = parseAvatarHash(value); + const shouldAnimate = isAnimated && animated; return { - hash: value, - animated: isAnimated ? animated : undefined, + hash: shouldAnimate ? value : hash, + animated: shouldAnimate ? true : undefined, }; }; const getMediaURL = ({path, id, hash, size, format, animated, endpoint}: MediaURLParams) => { if (DeveloperOptions.forceRenderPlaceholders) { return ''; } - const baseEndpoint = endpoint ?? RuntimeConfig.mediaEndpoint; + const baseEndpoint = endpoint === undefined ? RuntimeConfig.mediaEndpoint : endpoint; if (!baseEndpoint) { return ''; } @@ -89,6 +153,8 @@ const buildWebpMediaUrl = (params: Omit) => getMediaUR const buildPngMediaUrl = (params: Omit) => getMediaURL({...params, format: 'png'}); const buildGuildMemberWebpUrl = (params: Omit) => getGuildMemberMediaURL({...params, format: 'webp'}); +const buildGuildMemberPngUrl = (params: Omit) => + getGuildMemberMediaURL({...params, format: 'png'}); export function getUserAvatarURL( {id, avatar}: AvatarOptions, @@ -96,14 +162,14 @@ export function getUserAvatarURL( size: MediaProxyImageSize = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, ) { if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(id)); } const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); return buildWebpMediaUrl({ path: 'avatars', id, hash, - size, + size: snapIconImageSize(size), animated: shouldAnimate, }); } @@ -113,18 +179,49 @@ export function getUserNotificationAvatarURL( size: MediaProxyImageSize = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, ) { if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(id)); } const {hash, animated} = parseMediaHashForRequest(avatar, false); return buildPngMediaUrl({ path: 'avatars', id, hash, - size, + size: snapIconImageSize(size), animated, }); } +export function getGuildMemberNotificationAvatarURL({ + guildId, + userId, + avatar, + memberAvatar, + avatarUnset = false, + size = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, +}: { + guildId: string; + userId: string; + avatar: string | null; + memberAvatar?: string | null; + avatarUnset?: boolean; + size?: MediaProxyImageSize; +}) { + if (avatarUnset) { + return getUserNotificationAvatarURL({id: userId, avatar: null}, size); + } + if (memberAvatar) { + const {hash} = parseMediaHashForRequest(memberAvatar, false); + return buildGuildMemberPngUrl({ + path: 'avatars', + guildId, + userId, + hash, + size: snapIconImageSize(size), + }); + } + return getUserNotificationAvatarURL({id: userId, avatar}, size); +} + export function getUserBannerURL({id, banner}: BannerOptions, animated = false, size: MediaProxyImageSize = 1024) { if (!banner) { return ''; @@ -134,11 +231,27 @@ export function getUserBannerURL({id, banner}: BannerOptions, animated = false, path: 'banners', id, hash, - size, + size: snapWideAssetImageSize(size), animated: shouldAnimate, }); } +const SOUNDBOARD_SOUND_URL_CACHE = new Map(); +const SOUNDBOARD_SOUND_URL_CACHE_LIMIT = 4096; + +export function getSoundboardSoundURL(soundId: string): string { + if (DeveloperOptions.forceRenderPlaceholders) { + return ''; + } + const key = `${RuntimeConfig.mediaEndpoint}:${soundId}`; + const cached = SOUNDBOARD_SOUND_URL_CACHE.get(key); + if (cached !== undefined) return cached; + const result = mediaUrl(`${SOUNDBOARD_SOUND_PATH_PREFIX}/${soundId}`); + if (SOUNDBOARD_SOUND_URL_CACHE.size >= SOUNDBOARD_SOUND_URL_CACHE_LIMIT) SOUNDBOARD_SOUND_URL_CACHE.clear(); + SOUNDBOARD_SOUND_URL_CACHE.set(key, result); + return result; +} + export function getGuildIconURL({id, icon}: IconOptions, animated = false) { if (!icon) { return ''; @@ -148,21 +261,12 @@ export function getGuildIconURL({id, icon}: IconOptions, animated = false) { path: 'icons', id, hash, - size: MEDIA_PROXY_ICON_SIZE_DEFAULT, + size: snapIconImageSize(MEDIA_PROXY_ICON_SIZE_DEFAULT), animated: shouldAnimate, }); } -export function getGuildSplashURL( - { - id, - splash, - }: { - id: string; - splash: string | null; - }, - size: MediaProxyImageSize = 1024, -) { +export function getGuildSplashURL({id, splash}: {id: string; splash: string | null}) { if (!splash) { return ''; } @@ -170,19 +274,7 @@ export function getGuildSplashURL( path: 'splashes', id, hash: splash, - size, - }); -} - -export function getGuildDiscoverySplashURL({id, splash}: {id: string; splash: string | null}) { - if (!splash) { - return ''; - } - return buildWebpMediaUrl({ - path: 'discovery-splashes', - id, - hash: splash, - size: 1024, + size: getViewportSplashSize(), }); } @@ -204,7 +296,7 @@ export function getGuildBannerURL( path: 'banners', id, hash, - size: 1024, + size: snapWideAssetImageSize(GUILD_BANNER_CSS_WIDTH), animated: shouldAnimate, }); } @@ -231,7 +323,7 @@ export function getGuildMemberAvatarURL({ guildId, userId, hash, - size, + size: snapIconImageSize(size), animated: shouldAnimate, }); } @@ -241,11 +333,11 @@ export function getGuildMemberAvatarURL({ path: 'avatars', id: userId, hash, - size, + size: snapIconImageSize(size), animated: shouldAnimate, }); } - return getDefaultAvatar(getDefaultAvatarIndex(userId)); + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(userId)); } export function getGuildMemberDisplayAvatarURL({ @@ -298,7 +390,7 @@ export function getGuildMemberBannerURL({ guildId, userId, hash, - size, + size: snapWideAssetImageSize(size), animated: shouldAnimate, }); } @@ -308,7 +400,7 @@ export function getGuildMemberBannerURL({ path: 'banners', id: userId, hash, - size, + size: snapWideAssetImageSize(size), animated: shouldAnimate, }); } @@ -326,29 +418,20 @@ export function getUserAvatarURLWithProxy( } const {id, avatar} = options; if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(id)); } const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); return buildWebpMediaUrl({ path: 'avatars', id, hash, - size, + size: snapIconImageSize(size), animated: shouldAnimate, endpoint, }); } -export function getGuildEmbedSplashURL( - { - id, - embedSplash, - }: { - id: string; - embedSplash: string | null; - }, - size: MediaProxyImageSize = 1024, -) { +export function getGuildEmbedSplashURL({id, embedSplash}: {id: string; embedSplash: string | null}) { if (!embedSplash) { return ''; } @@ -356,7 +439,7 @@ export function getGuildEmbedSplashURL( path: 'embed-splashes', id, hash: embedSplash, - size, + size: snapWideAssetImageSize(GUILD_EMBED_SPLASH_CSS_WIDTH), }); } @@ -373,21 +456,21 @@ export function getChannelIconURL( path: 'icons', id, hash, - size, + size: snapIconImageSize(size), animated: shouldAnimate, }); } export function getWebhookAvatarURL({id, avatar}: AvatarOptions, animated = false) { if (!avatar) { - return getDefaultAvatar(getDefaultAvatarIndex(id)); + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(id)); } const {hash, animated: shouldAnimate} = parseMediaHashForRequest(avatar, animated); return buildWebpMediaUrl({ path: 'avatars', id, hash, - size: MEDIA_PROXY_AVATAR_SIZE_DEFAULT, + size: snapIconImageSize(MEDIA_PROXY_AVATAR_SIZE_DEFAULT), animated: shouldAnimate, }); } @@ -395,15 +478,15 @@ export function getWebhookAvatarURL({id, avatar}: AvatarOptions, animated = fals const EMOJI_URL_CACHE = new Map(); const EMOJI_URL_CACHE_LIMIT = 4096; -export function getEmojiURL({id, animated}: {id: string; animated?: boolean}) { +export function getEmojiURL({id, animated, isAnimatable}: {id: string; animated?: boolean; isAnimatable?: boolean}) { if (DeveloperOptions.forceRenderPlaceholders) { return ''; } - const animatedFlag = Boolean(animated); - const key = animatedFlag ? `a:${id}` : `s:${id}`; + const animatedFlag = isAnimatable === false ? false : animated === true; + const key = `${RuntimeConfig.mediaEndpoint}:${animatedFlag ? 'a' : 's'}:${id}`; const cached = EMOJI_URL_CACHE.get(key); if (cached !== undefined) return cached; - const result = mediaUrl(setPathQueryParams(`emojis/${id}.webp`, {v: 5}), {animated: animatedFlag}); + const result = mediaUrl(`emojis/${id}.webp`, {animated: animatedFlag}); if (EMOJI_URL_CACHE.size >= EMOJI_URL_CACHE_LIMIT) EMOJI_URL_CACHE.clear(); EMOJI_URL_CACHE.set(key, result); return result; @@ -411,12 +494,32 @@ export function getEmojiURL({id, animated}: {id: string; animated?: boolean}) { type StickerSize = 160 | 320; -export function getStickerURL({id, animated, size = 320}: {id: string; animated?: boolean; size?: StickerSize}) { +const STICKER_URL_CACHE = new Map(); +const STICKER_URL_CACHE_LIMIT = 4096; + +export function getStickerURL({ + id, + animated, + isAnimatable, + size = 320, +}: { + id: string; + animated?: boolean; + isAnimatable?: boolean; + size?: StickerSize; +}) { if (DeveloperOptions.forceRenderPlaceholders) { return ''; } + const animatedFlag = isAnimatable === false ? false : animated === true; const safeSize: StickerSize = size === 320 ? 320 : 160; - return mediaUrl(setPathQueryParams(`stickers/${id}.webp`, {size: safeSize}), {animated: Boolean(animated)}); + const key = `${RuntimeConfig.mediaEndpoint}:${animatedFlag ? 'a' : 's'}:${safeSize}:${id}`; + const cached = STICKER_URL_CACHE.get(key); + if (cached !== undefined) return cached; + const result = mediaUrl(setPathQueryParams(`stickers/${id}.webp`, {size: safeSize}), {animated: animatedFlag}); + if (STICKER_URL_CACHE.size >= STICKER_URL_CACHE_LIMIT) STICKER_URL_CACHE.clear(); + STICKER_URL_CACHE.set(key, result); + return result; } export function fileToBase64(file: File) {