247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<ReadonlyArray<GuildStickerWithUser>>([]);
|
|
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<ReadonlyArray<GuildStickerWithUser>>) => {
|
|
setStickers((prev) => {
|
|
const next =
|
|
typeof updater === 'function'
|
|
? (updater as (previous: ReadonlyArray<GuildStickerWithUser>) => ReadonlyArray<GuildStickerWithUser>)(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(() => <AddGuildStickerModal guildId={guildId} file={file} onSuccess={fetchStickers} />),
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (files: Array<File>) => {
|
|
const file = files[0];
|
|
if (file) {
|
|
ModalActionCreators.push(
|
|
modal(() => <AddGuildStickerModal guildId={guildId} file={file} onSuccess={fetchStickers} />),
|
|
);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div className={styles.container}>
|
|
<div className={styles.controls}>
|
|
<Input
|
|
type="text"
|
|
placeholder={t`Search stickers...`}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
leftIcon={<MagnifyingGlassIcon size={16} weight="bold" />}
|
|
className={styles.searchInput}
|
|
/>
|
|
|
|
<div className={styles.viewToggle} role="group" aria-label={t`Sticker density`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => layoutStore.setStickerViewMode('cozy')}
|
|
className={clsx(styles.viewToggleButton, viewMode === 'cozy' && styles.viewToggleButtonActive)}
|
|
aria-pressed={viewMode === 'cozy'}
|
|
>
|
|
<Trans>Cozy</Trans>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => layoutStore.setStickerViewMode('compact')}
|
|
className={clsx(styles.viewToggleButton, viewMode === 'compact' && styles.viewToggleButtonActive)}
|
|
aria-pressed={viewMode === 'compact'}
|
|
>
|
|
<Trans>Compact</Trans>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{canCreateExpressions && (
|
|
<>
|
|
<UploadSlotInfo
|
|
title={<Trans>Sticker Slots</Trans>}
|
|
currentCount={stickers.length}
|
|
maxCount={maxStickers}
|
|
uploadButtonText={<Trans>Upload Sticker</Trans>}
|
|
onUploadClick={handleAddSticker}
|
|
description={
|
|
<Trans>
|
|
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.
|
|
</Trans>
|
|
}
|
|
/>
|
|
<UploadDropZone
|
|
onDrop={handleDrop}
|
|
description={<Trans>Drag and drop a sticker file here (one at a time)</Trans>}
|
|
acceptMultiple={false}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{fetchStatus === 'pending' && (
|
|
<div className={styles.spinnerContainer}>
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
|
|
{searchQuery && filteredStickers.length === 0 && (
|
|
<StatusSlate
|
|
Icon={MagnifyingGlassIcon}
|
|
title={t`No Stickers Found`}
|
|
description={t`No stickers found matching your search.`}
|
|
fullHeight={true}
|
|
/>
|
|
)}
|
|
|
|
{fetchStatus === 'success' && filteredStickers.length > 0 && (
|
|
<div className={clsx(styles.stickerGrid, viewMode === 'compact' ? styles.compactGrid : styles.cozyGrid)}>
|
|
{filteredStickers.map((sticker) => (
|
|
<StickerGridItem
|
|
key={sticker.id}
|
|
guildId={guildId}
|
|
sticker={sticker}
|
|
canModify={canModifySticker(sticker)}
|
|
onUpdate={fetchStickers}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{fetchStatus === 'error' && (
|
|
<StatusSlate
|
|
Icon={WarningCircleIcon}
|
|
title={t`Failed to Load Stickers`}
|
|
description={t`There was an error loading the stickers. Please try again.`}
|
|
actions={[
|
|
{
|
|
text: t`Retry`,
|
|
onClick: fetchStickers,
|
|
variant: 'primary',
|
|
},
|
|
]}
|
|
fullHeight={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default GuildStickersTab;
|