diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index e2086c95..da430cf4 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp title="GIF" aria-label="GIF picker" > - - + {/* Outlined badge, not a filled block: the solid rectangle read as + a plain square rather than a GIF picker. Letters reuse the + original glyph paths, scaled and centred inside the outline. */} + )} diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 1b0c7a6f..168a048c 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -5,6 +5,7 @@ import { useInstanceStore } from '../../../stores/instanceStore'; import { useSpaceStore } from '../../../stores/spaceStore'; import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; +import { GifPicker } from '../../chat/GifPicker'; import { DeleteAccountModal } from '../DeleteAccountModal'; import { api } from '../../../api/client'; import { useTransferStore } from '../../../stores/transferStore'; @@ -13,6 +14,16 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; import type { FederationOpResult } from '../../../utils/federationOps'; +/** + * Banner/avatar previews hold either a `blob:` object URL (local upload) or a + * remote `https:` URL (GIF picker). Only the former owns memory that must be + * released — calling revokeObjectURL on a remote URL is a silent no-op that + * would quietly hide a mistake here. + */ +function releasePreview(url: string | null): void { + if (url && url.startsWith('blob:')) URL.revokeObjectURL(url); +} + export function AccountPanel() { const user = useAuthStore((s) => s.user); const updateProfile = useAuthStore((s) => s.updateProfile); @@ -37,6 +48,7 @@ export function AccountPanel() { const [bannerFilename, setBannerFilename] = useState(null); const [uploadingBanner, setUploadingBanner] = useState(false); const [bannerCropSrc, setBannerCropSrc] = useState(null); + const [showBannerGif, setShowBannerGif] = useState(false); const bannerInputRef = useRef(null); const addToast = useUIStore((s) => s.addToast); @@ -54,7 +66,7 @@ export function AccountPanel() { setCustomHex(user.accentColor ?? ''); // Reset upload state if (avatarPreview) URL.revokeObjectURL(avatarPreview); - if (bannerPreview) URL.revokeObjectURL(bannerPreview); + releasePreview(bannerPreview); setAvatarPreview(null); setAvatarFilename(null); setBannerPreview(null); @@ -202,7 +214,7 @@ export function AccountPanel() { }; const handleBannerCropComplete = async (blob: Blob) => { - if (bannerPreview) URL.revokeObjectURL(bannerPreview); + releasePreview(bannerPreview); const previewUrl = URL.createObjectURL(blob); setBannerPreview(previewUrl); setBannerCropSrc(null); @@ -227,8 +239,21 @@ export function AccountPanel() { setAvatarFilename(''); }; + /** + * Banners accept absolute URLs end to end: the server's isValidAssetUrl + * allows http(s), and the profile render already branches on + * `banner.startsWith('http')`. So a picked GIF needs no upload — the remote + * URL is stored directly. + */ + const handleBannerGifSelect = (url: string) => { + releasePreview(bannerPreview); + setBannerPreview(url); + setBannerFilename(url); + setShowBannerGif(false); + }; + const handleRemoveBanner = () => { - if (bannerPreview) URL.revokeObjectURL(bannerPreview); + releasePreview(bannerPreview); setBannerPreview(null); setBannerFilename(''); }; @@ -300,7 +325,7 @@ export function AccountPanel() { setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); if (avatarPreview) URL.revokeObjectURL(avatarPreview); - if (bannerPreview) URL.revokeObjectURL(bannerPreview); + releasePreview(bannerPreview); setAvatarPreview(null); setAvatarFilename(null); setBannerPreview(null); @@ -482,7 +507,7 @@ export function AccountPanel() { )} -
+
+ + {showBannerGif && ( + <> + {/* Click-away layer, below the panel but above the page */} +
setShowBannerGif(false)} /> +
+ +
+ + )} {(displayBannerSrc || user.banner) && bannerFilename !== '' && (