/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import * as GuildStickerActionCreators from '@app/actions/GuildStickerActionCreators'; import * as ModalActionCreators from '@app/actions/ModalActionCreators'; import {modal} from '@app/actions/ModalActionCreators'; import {Input} from '@app/components/form/Input'; import {UploadDropZone} from '@app/components/guild/UploadDropZone'; import {UploadSlotInfo} from '@app/components/guild/UploadSlotInfo'; import {AddGuildStickerModal} from '@app/components/modals/AddGuildStickerModal'; import styles from '@app/components/modals/guild_tabs/GuildStickersTab.module.css'; import {StatusSlate} from '@app/components/modals/shared/StatusSlate'; import {StickerGridItem} from '@app/components/stickers/StickerGridItem'; import {Spinner} from '@app/components/uikit/Spinner'; import {Logger} from '@app/lib/Logger'; import EmojiStickerLayoutStore from '@app/stores/EmojiStickerLayoutStore'; import {seedGuildStickerCache, subscribeToGuildStickerUpdates} from '@app/stores/GuildExpressionTabCache'; import GuildStore from '@app/stores/GuildStore'; import PermissionStore from '@app/stores/PermissionStore'; import UserStore from '@app/stores/UserStore'; import {openFilePicker} from '@app/utils/FilePickerUtils'; import {GlobalLimits} from '@app/utils/limits/GlobalLimits'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import type {GuildStickerWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import {sortBySnowflakeDesc} from '@fluxer/snowflake/src/SnowflakeUtils'; import {Trans, useLingui} from '@lingui/react/macro'; import {MagnifyingGlassIcon, WarningCircleIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {matchSorter} from 'match-sorter'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react'; const logger = new Logger('GuildStickersTab'); const GuildStickersTab: React.FC<{guildId: string}> = observer(function GuildStickersTab({guildId}) { const {t} = useLingui(); const [stickers, setStickers] = useState>([]); const [fetchStatus, setFetchStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle'); const [searchQuery, setSearchQuery] = useState(''); const layoutStore = EmojiStickerLayoutStore; const viewMode = layoutStore.getStickerViewMode(); const guild = GuildStore.getGuild(guildId); const canCreateExpressions = PermissionStore.can(Permissions.CREATE_EXPRESSIONS, {guildId}); const canManageExpressions = PermissionStore.can(Permissions.MANAGE_EXPRESSIONS, {guildId}); const currentUserId = UserStore.currentUserId; const setStickersWithCache = useCallback( (updater: React.SetStateAction>) => { setStickers((prev) => { const next = typeof updater === 'function' ? (updater as (previous: ReadonlyArray) => ReadonlyArray)(prev) : updater; const frozen = Object.freeze(sortBySnowflakeDesc(next)); seedGuildStickerCache(guildId, frozen); return frozen; }); }, [guildId], ); const fetchStickers = useCallback(async () => { try { setFetchStatus('pending'); const stickerList = await GuildStickerActionCreators.list(guildId); setStickersWithCache(stickerList); setFetchStatus('success'); } catch (error) { logger.error('Failed to fetch stickers', error); setFetchStatus('error'); } }, [guildId, setStickersWithCache]); useEffect(() => { if (fetchStatus === 'idle') { void fetchStickers(); } }, [fetchStatus, fetchStickers]); useEffect(() => { return subscribeToGuildStickerUpdates(guildId, (updatedStickers) => { setStickersWithCache(updatedStickers); }); }, [guildId, setStickersWithCache]); const handleAddSticker = async () => { const [file] = await openFilePicker({ accept: '.png,.gif,.apng,.webp,.avif', }); if (file) { ModalActionCreators.push( modal(() => ), ); } }; const handleDrop = (files: Array) => { const file = files[0]; if (file) { ModalActionCreators.push( modal(() => ), ); } }; const filteredStickers = useMemo(() => { if (!searchQuery) return stickers; return matchSorter(stickers, searchQuery, { keys: [(sticker) => sticker.name], }); }, [stickers, searchQuery]); const canModifySticker = useCallback( (sticker: GuildStickerWithUser): boolean => { if (canManageExpressions) return true; if (canCreateExpressions && sticker.user?.id === currentUserId) return true; return false; }, [canManageExpressions, canCreateExpressions, currentUserId], ); const maxStickers = guild?.maxStickers ?? 50; return (
setSearchQuery(e.target.value)} leftIcon={} className={styles.searchInput} />
{canCreateExpressions && ( <> Sticker Slots} currentCount={stickers.length} maxCount={maxStickers} uploadButtonText={Upload Sticker} onUploadClick={handleAddSticker} description={ Stickers must be exactly 320x320 pixels and no larger than{' '} {Math.round(GlobalLimits.getStickerMaxSize() / 1024)} KB, but we automatically resize and compress images for you. Allowed file types: JPEG, PNG, WebP, GIF. } /> Drag and drop a sticker file here (one at a time)} acceptMultiple={false} /> )} {fetchStatus === 'pending' && (
)} {searchQuery && filteredStickers.length === 0 && ( )} {fetchStatus === 'success' && filteredStickers.length > 0 && (
{filteredStickers.map((sticker) => ( ))}
)} {fetchStatus === 'error' && ( )}
); }); export default GuildStickersTab;