/*
* 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 (
{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' && (