chore: bug fix cleanup (#4)

This commit is contained in:
hampus-fluxer
2026-01-03 06:44:40 +01:00
committed by GitHub
parent 275126d61b
commit c9c5dceb47
80 changed files with 4639 additions and 3709 deletions

View File

@@ -103,6 +103,7 @@
display: flex;
flex-direction: column;
min-height: 0;
background-color: var(--background-tertiary);
}
.listWrapper {
@@ -118,6 +119,33 @@
padding: var(--spacing-3) var(--spacing-2) 0;
}
.emptyState {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.emptyStateInner {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--text-primary-muted);
opacity: 0.7;
}
.emptyIcon {
font-size: 42px;
line-height: 1;
}
.emptyLabel {
font-size: 0.875rem;
}
.header {
display: flex;
align-items: center;

View File

@@ -18,6 +18,7 @@
*/
import {useLingui} from '@lingui/react/macro';
import {SmileySadIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as EmojiPickerActionCreators from '~/actions/EmojiPickerActionCreators';
@@ -37,7 +38,7 @@ import {useSearchInputAutofocus} from '~/hooks/useSearchInputAutofocus';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import UnicodeEmojis, {EMOJI_SPRITES} from '~/lib/UnicodeEmojis';
import ChannelStore from '~/stores/ChannelStore';
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
import EmojiStore, {type Emoji, normalizeEmojiSearchQuery} from '~/stores/EmojiStore';
import {checkEmojiAvailability, shouldShowEmojiPremiumUpsell} from '~/utils/ExpressionPermissionUtils';
import {shouldShowPremiumFeatures} from '~/utils/PremiumUtils';
@@ -53,14 +54,16 @@ export const EmojiPicker = observer(
const [searchTerm, setSearchTerm] = React.useState('');
const [hoveredEmoji, setHoveredEmoji] = React.useState<Emoji | null>(null);
const [renderedEmojis, setRenderedEmojis] = React.useState<Array<Emoji>>([]);
const [allEmojis, setAllEmojis] = React.useState<Array<Emoji>>([]);
const [selectedRow, setSelectedRow] = React.useState(-1);
const [selectedColumn, setSelectedColumn] = React.useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = React.useState(false);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const emojiRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
const normalizedSearchTerm = React.useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
const {i18n} = useLingui();
const {i18n, t} = useLingui();
const channel = channelId ? (ChannelStore.getChannel(channelId) ?? null) : null;
const categoryRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const forceUpdate = useForceUpdate();
@@ -81,7 +84,7 @@ export const EmojiPicker = observer(
}, []);
React.useEffect(() => {
const emojis = EmojiStore.search(channel, searchTerm).slice();
const emojis = EmojiStore.search(channel, normalizedSearchTerm).slice();
setRenderedEmojis(emojis);
if (emojis.length > 0) {
setSelectedRow(0);
@@ -91,7 +94,12 @@ export const EmojiPicker = observer(
setSelectedColumn(-1);
setHoveredEmoji(null);
}
}, [channel, searchTerm]);
}, [channel, normalizedSearchTerm]);
React.useEffect(() => {
const emojis = EmojiStore.search(channel, '').slice();
setAllEmojis(emojis);
}, [channel]);
React.useEffect(() => {
return ComponentDispatch.subscribe('EMOJI_PICKER_RERENDER', forceUpdate);
@@ -99,10 +107,13 @@ export const EmojiPicker = observer(
useSearchInputAutofocus(searchInputRef);
const {favoriteEmojis, frequentlyUsedEmojis, customEmojisByGuildId, unicodeEmojisByCategory} =
useEmojiCategories(renderedEmojis);
const {favoriteEmojis, frequentlyUsedEmojis, customEmojisByGuildId, unicodeEmojisByCategory} = useEmojiCategories(
allEmojis,
renderedEmojis,
);
const showFrequentlyUsedButton = frequentlyUsedEmojis.length > 0 && !normalizedSearchTerm;
const virtualRows = useVirtualRows(
searchTerm,
normalizedSearchTerm,
renderedEmojis,
favoriteEmojis,
frequentlyUsedEmojis,
@@ -110,7 +121,8 @@ export const EmojiPicker = observer(
unicodeEmojisByCategory,
);
const showPremiumUpsell = shouldShowPremiumFeatures() && shouldShowEmojiPremiumUpsell(channel);
const showPremiumUpsell =
shouldShowPremiumFeatures() && shouldShowEmojiPremiumUpsell(channel) && !normalizedSearchTerm;
const sections = React.useMemo(() => {
const result: Array<number> = [];
@@ -223,11 +235,12 @@ export const EmojiPicker = observer(
className={`${styles.list} ${styles.listWrapper}`}
fade={false}
key="emoji-picker-scroller"
reserveScrollbarTrack={false}
reserveScrollbarTrack={true}
>
{showPremiumUpsell && <PremiumUpsellBanner />}
{virtualRows.map((row, index) => {
const emojiRowIndex = virtualRows.slice(0, index).filter((r) => r.type === 'emoji-row').length;
const needsSpacingAfter = row.type === 'emoji-row' && virtualRows[index + 1]?.type === 'header';
return (
<div
@@ -241,6 +254,7 @@ export const EmojiPicker = observer(
}
: undefined
}
style={row.type === 'emoji-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined}
>
<VirtualizedRow
row={row}
@@ -260,6 +274,16 @@ export const EmojiPicker = observer(
);
})}
</Scroller>
{renderedEmojis.length === 0 && (
<div className={styles.emptyState}>
<div className={styles.emptyStateInner}>
<div className={styles.emptyIcon}>
<SmileySadIcon weight="duotone" />
</div>
<div className={styles.emptyLabel}>{t`No emojis match your search`}</div>
</div>
</div>
)}
</div>
</div>
<EmojiPickerInspector hoveredEmoji={hoveredEmoji} />
@@ -268,6 +292,7 @@ export const EmojiPicker = observer(
customEmojisByGuildId={customEmojisByGuildId}
unicodeEmojisByCategory={unicodeEmojisByCategory}
handleCategoryClick={handleCategoryClick}
showFrequentlyUsedButton={showFrequentlyUsedButton}
/>
</div>
);

View File

@@ -280,7 +280,12 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
if (data?.channelId && data.channelId !== channel.id) return;
scrollManager.handleScroll();
const heightDelta = data?.heightDelta;
const handledLayoutShift = typeof heightDelta === 'number' ? scrollManager.applyLayoutShift(heightDelta) : false;
if (!handledLayoutShift) {
scrollManager.handleScroll();
}
};
const onFocusBottommostMessage = (payload?: unknown) => {

View File

@@ -35,7 +35,7 @@ import {useForceUpdate} from '~/hooks/useForceUpdate';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import UnicodeEmojis, {EMOJI_SPRITES} from '~/lib/UnicodeEmojis';
import ChannelStore from '~/stores/ChannelStore';
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
import EmojiStore, {type Emoji, normalizeEmojiSearchQuery} from '~/stores/EmojiStore';
export const MobileEmojiPicker = observer(
({
@@ -57,6 +57,7 @@ export const MobileEmojiPicker = observer(
const [internalSearchTerm, setInternalSearchTerm] = React.useState('');
const [hoveredEmoji, setHoveredEmoji] = React.useState<Emoji | null>(null);
const [renderedEmojis, setRenderedEmojis] = React.useState<Array<Emoji>>([]);
const [allEmojis, setAllEmojis] = React.useState<Array<Emoji>>([]);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const emojiRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -67,6 +68,7 @@ export const MobileEmojiPicker = observer(
const searchTerm = externalSearchTerm ?? internalSearchTerm;
const setSearchTerm = externalSetSearchTerm ?? setInternalSearchTerm;
const normalizedSearchTerm = React.useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
const spriteSheetSizes = React.useMemo(() => {
const nonDiversitySize = [
@@ -83,18 +85,26 @@ export const MobileEmojiPicker = observer(
}, []);
React.useEffect(() => {
const emojis = EmojiStore.search(null, searchTerm).slice();
const emojis = EmojiStore.search(channel, normalizedSearchTerm).slice();
setRenderedEmojis(emojis);
}, [searchTerm]);
}, [channel, normalizedSearchTerm]);
React.useEffect(() => {
const emojis = EmojiStore.search(channel, '').slice();
setAllEmojis(emojis);
}, [channel]);
React.useEffect(() => {
return ComponentDispatch.subscribe('EMOJI_PICKER_RERENDER', forceUpdate);
});
const {customEmojisByGuildId, unicodeEmojisByCategory, favoriteEmojis, frequentlyUsedEmojis} =
useEmojiCategories(renderedEmojis);
const {customEmojisByGuildId, unicodeEmojisByCategory, favoriteEmojis, frequentlyUsedEmojis} = useEmojiCategories(
allEmojis,
renderedEmojis,
);
const showFrequentlyUsedButton = frequentlyUsedEmojis.length > 0 && !normalizedSearchTerm;
const virtualRows = useVirtualRows(
searchTerm,
normalizedSearchTerm,
renderedEmojis,
favoriteEmojis,
frequentlyUsedEmojis,
@@ -168,6 +178,7 @@ export const MobileEmojiPicker = observer(
unicodeEmojisByCategory={unicodeEmojisByCategory}
handleCategoryClick={handleCategoryClick}
horizontal={true}
showFrequentlyUsedButton={showFrequentlyUsedButton}
/>
</div>
</div>

View File

@@ -61,6 +61,9 @@ export const MobileStickersPicker = observer(
const [searchTerm, setSearchTerm] = React.useState('');
const [hoveredSticker, setHoveredSticker] = React.useState<GuildStickerRecord | null>(null);
const [renderedStickers, setRenderedStickers] = React.useState<ReadonlyArray<GuildStickerRecord>>([]);
const [allStickersForCategories, setAllStickersForCategories] = React.useState<ReadonlyArray<GuildStickerRecord>>(
[],
);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const stickerRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -74,13 +77,20 @@ export const MobileStickersPicker = observer(
setRenderedStickers(stickers);
}, [channel, searchTerm]);
React.useEffect(() => {
setAllStickersForCategories(StickerStore.getAllStickers());
}, []);
React.useEffect(() => {
return ComponentDispatch.subscribe('STICKER_PICKER_RERENDER', forceUpdate);
});
useSearchInputAutofocus(searchInputRef);
const {favoriteStickers, frequentlyUsedStickers, stickersByGuildId} = useStickerCategories(renderedStickers);
const {favoriteStickers, frequentlyUsedStickers, stickersByGuildId} = useStickerCategories(
allStickersForCategories,
renderedStickers,
);
const virtualRows = useVirtualRows(
searchTerm,
renderedStickers,

View File

@@ -55,6 +55,9 @@ export const StickersPicker = observer(
const [searchTerm, setSearchTerm] = React.useState('');
const [hoveredSticker, setHoveredSticker] = React.useState<GuildStickerRecord | null>(null);
const [renderedStickers, setRenderedStickers] = React.useState<ReadonlyArray<GuildStickerRecord>>([]);
const [allStickersForCategories, setAllStickersForCategories] = React.useState<ReadonlyArray<GuildStickerRecord>>(
[],
);
const [selectedRow, setSelectedRow] = React.useState(-1);
const [selectedColumn, setSelectedColumn] = React.useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = React.useState(false);
@@ -79,13 +82,20 @@ export const StickersPicker = observer(
}
}, [channel, searchTerm]);
React.useEffect(() => {
setAllStickersForCategories(StickerStore.getAllStickers());
}, []);
React.useEffect(() => {
return ComponentDispatch.subscribe('STICKER_PICKER_RERENDER', forceUpdate);
});
useSearchInputAutofocus(searchInputRef);
const {favoriteStickers, frequentlyUsedStickers, stickersByGuildId} = useStickerCategories(renderedStickers);
const {favoriteStickers, frequentlyUsedStickers, stickersByGuildId} = useStickerCategories(
allStickersForCategories,
renderedStickers,
);
const virtualRows = useVirtualRows(
searchTerm,
renderedStickers,
@@ -98,7 +108,8 @@ export const StickersPicker = observer(
const allStickers = React.useMemo(() => StickerStore.getAllStickers(), []);
const hasNoStickersAtAll = allStickers.length === 0;
const showPremiumUpsell = shouldShowPremiumFeatures() && shouldShowStickerPremiumUpsell(channel);
const isSearching = searchTerm.trim().length > 0;
const showPremiumUpsell = shouldShowPremiumFeatures() && shouldShowStickerPremiumUpsell(channel) && !isSearching;
const sections = React.useMemo(() => {
const result: Array<number> = [];
@@ -216,21 +227,6 @@ export const StickersPicker = observer(
/>
);
if (renderedStickers.length === 0 && searchTerm) {
return (
<div className={gifStyles.gifPickerContainer}>
<ExpressionPickerHeaderPortal>{renderSearchBar()}</ExpressionPickerHeaderPortal>
<div className={gifStyles.gifPickerMain}>
<PickerEmptyState
icon={SmileySadIcon}
title={t`No Stickers Found`}
description={t`Try a different search term`}
/>
</div>
</div>
);
}
return (
<div className={styles.container}>
<ExpressionPickerHeaderPortal>{renderSearchBar()}</ExpressionPickerHeaderPortal>
@@ -242,11 +238,12 @@ export const StickersPicker = observer(
className={`${styles.list} ${styles.listWrapper}`}
fade={false}
key="stickers-picker-scroller"
reserveScrollbarTrack={false}
reserveScrollbarTrack={true}
>
{showPremiumUpsell && <PremiumUpsellBanner />}
{virtualRows.map((row, index) => {
const stickerRowIndex = virtualRows.slice(0, index).filter((r) => r.type === 'sticker-row').length;
const needsSpacingAfter = row.type === 'sticker-row' && virtualRows[index + 1]?.type === 'header';
return (
<div
@@ -260,6 +257,7 @@ export const StickersPicker = observer(
}
: undefined
}
style={row.type === 'sticker-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined}
>
<VirtualRowWrapper
row={row}
@@ -278,6 +276,16 @@ export const StickersPicker = observer(
);
})}
</Scroller>
{renderedStickers.length === 0 && (
<div className={styles.emptyState}>
<div className={styles.emptyStateInner}>
<div className={styles.emptyIcon}>
<SmileySadIcon weight="duotone" />
</div>
<div className={styles.emptyLabel}>{t`No stickers match your search`}</div>
</div>
</div>
)}
</div>
</div>
<StickerPickerInspector hoveredSticker={hoveredSticker} />

View File

@@ -133,6 +133,27 @@ const getOptimizedMediaURL = (
});
};
const SuppressEmbedsConfirmModal: FC<{message: MessageRecord}> = ({message}) => {
const {t} = useLingui();
return (
<ConfirmModal
title={t`Suppress Embeds`}
description={
<Trans>
Are you sure you want to suppress all link embeds on this message? This action will hide all embeds from this
message.
</Trans>
}
primaryText={t`Suppress Embeds`}
primaryVariant="danger-primary"
onPrimary={async () => {
await MessageActionCreators.toggleSuppressEmbeds(message.channelId, message.id, message.flags);
}}
/>
);
};
const shouldRenderAsInlineThumbnail = (media?: EmbedMedia): boolean => {
if (!isValidMedia(media)) return false;
@@ -697,27 +718,7 @@ export const Embed: FC<EmbedProps> = observer(({embed, message, embedIndex, onDe
}, [message, channel]);
const handleSuppressEmbeds = useCallback(() => {
ModalActionCreators.push(
modal(() => {
const {t} = useLingui();
return (
<ConfirmModal
title={t`Suppress Embeds`}
description={
<Trans>
Are you sure you want to suppress all link embeds on this message? This action will hide all embeds from
this message.
</Trans>
}
primaryText={t`Suppress Embeds`}
primaryVariant="danger-primary"
onPrimary={async () => {
await MessageActionCreators.toggleSuppressEmbeds(message.channelId, message.id, message.flags);
}}
/>
);
}),
);
ModalActionCreators.push(modal(() => <SuppressEmbedsConfirmModal message={message} />));
}, [message]);
const showSuppressButton = !isMobile && canSuppressEmbeds() && AccessibilityStore.showSuppressEmbedsButton;

View File

@@ -18,6 +18,7 @@
*/
import {useLingui} from '@lingui/react/macro';
import {ClockIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import styles from '~/components/channel/EmojiPicker.module.css';
@@ -31,6 +32,7 @@ interface EmojiPickerCategoryListProps {
customEmojisByGuildId: Map<string, Array<Emoji>>;
unicodeEmojisByCategory: Map<string, Array<Emoji>>;
handleCategoryClick: (category: string) => void;
showFrequentlyUsedButton: boolean;
horizontal?: boolean;
}
@@ -39,12 +41,23 @@ export const EmojiPickerCategoryList = observer(
customEmojisByGuildId,
unicodeEmojisByCategory,
handleCategoryClick,
showFrequentlyUsedButton = false,
horizontal = false,
}: EmojiPickerCategoryListProps) => {
const {i18n} = useLingui();
if (horizontal) {
return (
<div className={styles.horizontalCategories}>
{showFrequentlyUsedButton && (
<button
type="button"
onClick={() => handleCategoryClick('frequently-used')}
className={clsx(styles.categoryListIcon, styles.textPrimaryMuted)}
aria-label={i18n._('Frequently Used')}
>
<ClockIcon className={styles.iconSize} />
</button>
)}
{Array.from(customEmojisByGuildId.keys()).map((guildId) => {
const guild = GuildStore.getGuild(guildId)!;
return (
@@ -81,6 +94,17 @@ export const EmojiPickerCategoryList = observer(
<div className={styles.categoryList}>
<div className={styles.categoryListScroll}>
<div className={styles.listItems}>
{showFrequentlyUsedButton && (
<Tooltip text={i18n._('Frequently Used')} position="left">
<button
type="button"
onClick={() => handleCategoryClick('frequently-used')}
className={clsx(styles.categoryListIcon, styles.textPrimaryMuted)}
>
<ClockIcon className={styles.iconSize} />
</button>
</Tooltip>
)}
{Array.from(customEmojisByGuildId.keys()).map((guildId) => {
const guild = GuildStore.getGuild(guildId)!;
return (

View File

@@ -23,15 +23,15 @@ import EmojiPickerStore from '~/stores/EmojiPickerStore';
import type {Emoji} from '~/stores/EmojiStore';
import GuildListStore from '~/stores/GuildListStore';
export const useEmojiCategories = (renderedEmojis: Array<Emoji>) => {
export const useEmojiCategories = (allEmojis: Array<Emoji>, _renderedEmojis: Array<Emoji>) => {
const guilds = GuildListStore.guilds;
const favoriteEmojis = EmojiPickerStore.getFavoriteEmojis(renderedEmojis);
const favoriteEmojis = EmojiPickerStore.getFavoriteEmojis(allEmojis);
const frequentlyUsedEmojis = EmojiPickerStore.getFrecentEmojis(renderedEmojis, 42);
const frequentlyUsedEmojis = EmojiPickerStore.getFrecentEmojis(allEmojis, 42);
const customEmojisByGuildId = React.useMemo(() => {
const guildEmojis = renderedEmojis.filter((emoji) => emoji.guildId != null);
const guildEmojis = allEmojis.filter((emoji) => emoji.guildId != null);
const guildEmojisByGuildId = new Map<string, Array<Emoji>>();
for (const guildEmoji of guildEmojis) {
@@ -50,10 +50,10 @@ export const useEmojiCategories = (renderedEmojis: Array<Emoji>) => {
}
return sortedGuildEmojisByGuildId;
}, [renderedEmojis, guilds]);
}, [allEmojis, guilds]);
const unicodeEmojisByCategory = React.useMemo(() => {
const unicodeEmojis = renderedEmojis.filter((emoji) => emoji.guildId == null);
const unicodeEmojis = allEmojis.filter((emoji) => emoji.guildId == null);
const unicodeEmojisByCategory = new Map<string, Array<Emoji>>();
for (const emoji of unicodeEmojis) {
@@ -76,7 +76,7 @@ export const useEmojiCategories = (renderedEmojis: Array<Emoji>) => {
}
return sortedUnicodeEmojisByCategory;
}, [renderedEmojis]);
}, [allEmojis]);
return {
favoriteEmojis,

View File

@@ -42,6 +42,7 @@ import {
} from '~/components/uikit/ContextMenu/ContextMenuIcons';
import type {MenuGroupType, MenuItemType} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
import type {MessageRecord} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import EmojiPickerStore from '~/stores/EmojiPickerStore';
import type {Emoji} from '~/stores/EmojiStore';
import EmojiStore from '~/stores/EmojiStore';
@@ -73,7 +74,8 @@ export const useMessageActionMenuData = (
const permissions = useMessagePermissions(message);
const handlers = React.useMemo(() => createMessageActionHandlers(message, {onClose}), [message, onClose]);
const isSaved = React.useMemo(() => SavedMessagesStore.isSaved(message.id), [message.id]);
const allEmojis = React.useMemo(() => EmojiStore.search(null, ''), []);
const channel = React.useMemo(() => ChannelStore.getChannel(message.channelId) ?? null, [message.channelId]);
const allEmojis = React.useMemo(() => EmojiStore.search(channel, ''), [channel]);
const quickReactionEmojis = React.useMemo(
() => EmojiPickerStore.getQuickReactionEmojis(allEmojis, quickReactionCount),
[allEmojis, quickReactionCount],

View File

@@ -33,8 +33,8 @@
.stickerGrid {
display: grid;
gap: 0.5rem;
padding: 0.5rem;
gap: var(--spacing-2);
padding: 0 0 0.5rem;
}
.stickerButton {

View File

@@ -22,22 +22,25 @@ import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
import GuildListStore from '~/stores/GuildListStore';
import StickerPickerStore from '~/stores/StickerPickerStore';
export const useStickerCategories = (renderedStickers: ReadonlyArray<GuildStickerRecord>) => {
export const useStickerCategories = (
allStickers: ReadonlyArray<GuildStickerRecord>,
_renderedStickers: ReadonlyArray<GuildStickerRecord>,
) => {
const guilds = GuildListStore.guilds;
const stickerPickerState = StickerPickerStore;
const favoriteStickers = React.useMemo(() => {
return StickerPickerStore.getFavoriteStickers(renderedStickers);
}, [renderedStickers, stickerPickerState.favoriteStickers]);
return StickerPickerStore.getFavoriteStickers(allStickers);
}, [allStickers, stickerPickerState.favoriteStickers]);
const frequentlyUsedStickers = React.useMemo(() => {
return StickerPickerStore.getFrecentStickers(renderedStickers, 42);
}, [renderedStickers, stickerPickerState.stickerUsage]);
return StickerPickerStore.getFrecentStickers(allStickers, 42);
}, [allStickers, stickerPickerState.stickerUsage]);
const stickersByGuildId = React.useMemo(() => {
const guildStickersMap = new Map<string, Array<GuildStickerRecord>>();
for (const sticker of renderedStickers) {
for (const sticker of allStickers) {
if (!guildStickersMap.has(sticker.guildId)) {
guildStickersMap.set(sticker.guildId, []);
}
@@ -53,7 +56,7 @@ export const useStickerCategories = (renderedStickers: ReadonlyArray<GuildSticke
}
return sortedGuildStickersMap;
}, [renderedStickers, guilds]);
}, [allStickers, guilds]);
return {
favoriteStickers,

View File

@@ -22,7 +22,6 @@ import React from 'react';
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
import {FAVORITES_GUILD_ID} from '~/Constants';
import {FavoritesWelcomeSection} from '~/components/favorites/FavoritesWelcomeSection';
import {DndContext} from '~/components/layout/DndContext';
import {FavoritesChannelListContent} from '~/components/layout/FavoritesChannelListContent';
import {FavoritesGuildHeader} from '~/components/layout/FavoritesGuildHeader';
import {GuildSidebar} from '~/components/layout/GuildSidebar';
@@ -66,45 +65,35 @@ export const FavoritesLayout = observer(({children}: {children?: React.ReactNode
if (shouldRenderWelcomeScreen) {
return (
<DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildLayoutContent}>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
<div className={styles.guildMainContent}>
<FavoritesWelcomeSection />
</div>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildLayoutContent}>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
<div className={styles.guildMainContent}>
<FavoritesWelcomeSection />
</div>
</div>
</DndContext>
</div>
);
}
if (mobileLayout.enabled) {
if (!channelId) {
return (
<DndContext>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
</DndContext>
);
return <GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />;
}
return (
<DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildMainContent}>{children}</div>
</div>
</DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildMainContent}>{children}</div>
</div>
);
}
return (
<DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildLayoutContent}>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
<div className={styles.guildMainContent}>{children}</div>
</div>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildLayoutContent}>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
<div className={styles.guildMainContent}>{children}</div>
</div>
</DndContext>
</div>
);
});

View File

@@ -27,7 +27,6 @@ import {modal} from '~/actions/ModalActionCreators';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
import {ChannelTypes, GuildFeatures, Permissions} from '~/Constants';
import {DndContext} from '~/components/layout/DndContext';
import {GuildNavbar} from '~/components/layout/GuildNavbar';
import {GuildNavbarSkeleton} from '~/components/layout/GuildNavbarSkeleton';
import {Nagbar} from '~/components/layout/Nagbar';
@@ -296,45 +295,39 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
if (guildUnavailable || guildNotFound) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
{guildUnavailable ? (
<GuildUnavailable
icon={NetworkSlashIcon}
title={t`Community temporarily unavailable`}
description={t`We fluxed up! Hang tight, we're working on it.`}
/>
) : (
<GuildUnavailable
icon={SmileySadIcon}
title={t`This is not the community you're looking for.`}
description={t`The community you're looking for may have been deleted or you may not have access to it.`}
/>
)}
</div>
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
{guildUnavailable ? (
<GuildUnavailable
icon={NetworkSlashIcon}
title={t`Community temporarily unavailable`}
description={t`We fluxed up! Hang tight, we're working on it.`}
/>
) : (
<GuildUnavailable
icon={SmileySadIcon}
title={t`This is not the community you're looking for.`}
description={t`The community you're looking for may have been deleted or you may not have access to it.`}
/>
)}
</div>
</DndContext>
</div>
</TopNagbarContext.Provider>
);
}
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<GuildNavbar guild={guild!} />
</DndContext>
<GuildNavbar guild={guild!} />
</TopNagbarContext.Provider>
);
}
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildMainContent}>{children}</div>
</div>
</DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildMainContent}>{children}</div>
</div>
</TopNagbarContext.Provider>
);
}
@@ -342,21 +335,19 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
if (guildUnavailable) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={NetworkSlashIcon}
title={t`Community temporarily unavailable`}
description={t`We fluxed up! Hang tight, we're working on it.`}
/>
</div>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={NetworkSlashIcon}
title={t`Community temporarily unavailable`}
description={t`We fluxed up! Hang tight, we're working on it.`}
/>
</div>
</div>
</DndContext>
</div>
</TopNagbarContext.Provider>
);
}
@@ -364,21 +355,19 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
if (guildNotFound) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={SmileySadIcon}
title={t`This is not the community you're looking for.`}
description={t`The community you're looking for may have been deleted or you may not have access to it.`}
/>
</div>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={SmileySadIcon}
title={t`This is not the community you're looking for.`}
description={t`The community you're looking for may have been deleted or you may not have access to it.`}
/>
</div>
</div>
</DndContext>
</div>
</TopNagbarContext.Provider>
);
}
@@ -386,36 +375,32 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
if (channelId && !ChannelStore.getChannel(channelId) && !firstAccessibleTextChannel) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbar guild={guild!} />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={SmileySadIcon}
title={t`No accessible channels`}
description={t`You don't have access to any channels in this community.`}
/>
</div>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbar guild={guild!} />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={SmileySadIcon}
title={t`No accessible channels`}
description={t`You don't have access to any channels in this community.`}
/>
</div>
</div>
</DndContext>
</div>
</TopNagbarContext.Provider>
);
}
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbar guild={guild!} />
<div className={styles.guildMainContent}>{children}</div>
</div>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbar guild={guild!} />
<div className={styles.guildMainContent}>{children}</div>
</div>
</DndContext>
</div>
</TopNagbarContext.Provider>
);
});

View File

@@ -117,6 +117,19 @@ function storePlaybackRate(rate: number): void {
} catch {}
}
const isAbortError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
const name = (error as {name?: unknown}).name;
if (name === 'AbortError') return true;
const message = (error as {message?: unknown}).message;
return typeof message === 'string' && message.toLowerCase().includes('interrupted');
};
const normalizeError = (error: unknown): Error => {
if (error instanceof Error) return error;
return new Error(typeof error === 'string' ? error : 'Unknown media error');
};
export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPlayerReturn {
const {
autoPlay = false,
@@ -324,9 +337,16 @@ export function useMediaPlayer(options: UseMediaPlayerOptions = {}): UseMediaPla
try {
await media.play();
setState((prev) => ({...prev, error: null}));
} catch (error) {
console.error('Play failed:', error);
throw error;
if (isAbortError(error)) {
console.debug('Play interrupted before it could start:', error);
return;
}
const normalizedError = normalizeError(error);
console.error('Play failed:', normalizedError);
setState((prev) => ({...prev, error: normalizedError}));
}
}, []);

View File

@@ -82,14 +82,12 @@ const ChannelInvitesTab: React.FC<{channelId: string}> = observer(({channelId})
</p>
</div>
{!(fetchStatus === 'success' && invites && invites.length === 0) && (
<div className={styles.buttonGroup}>
<Button small={true} disabled={!canInvite} onClick={handleCreateInvite}>
<Trans>Create Invite</Trans>
</Button>
{canManageGuild && channel?.guildId && <DisableInvitesButton guildId={channel.guildId} />}
</div>
)}
<div className={styles.buttonGroup}>
<Button small={true} disabled={!canInvite || fetchStatus === 'pending'} onClick={handleCreateInvite}>
<Trans>Create Invite</Trans>
</Button>
{canManageGuild && channel?.guildId && <DisableInvitesButton guildId={channel.guildId} />}
</div>
{fetchStatus === 'pending' && (
<div className={styles.spinnerContainer}>

View File

@@ -135,7 +135,7 @@ const ChannelWebhooksTab: React.FC<{channelId: string}> = observer(({channelId})
</div>
)}
{canManageWebhooks && !(fetchStatus === 'success' && (!webhooks || webhooks.length === 0)) && (
{canManageWebhooks && (
<div className={styles.buttonContainer}>
<Button onClick={handleCreateQuick} variant="primary" disabled={fetchStatus === 'pending'} small>
<Trans>Create Webhook</Trans>

View File

@@ -73,9 +73,7 @@ const GuildInvitesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
</p>
</div>
{canManageGuild && !(fetchStatus === 'success' && invites && invites.length === 0) && (
<DisableInvitesButton guildId={guildId} />
)}
{canManageGuild && <DisableInvitesButton guildId={guildId} />}
{fetchStatus === 'pending' && (
<div className={styles.spinnerContainer}>

View File

@@ -112,7 +112,7 @@ const GuildWebhooksTab: React.FC<{guildId: string}> = observer(({guildId}) => {
</div>
)}
{canManageWebhooks && !(fetchStatus === 'success' && sortedWebhooks.length === 0) && (
{canManageWebhooks && (
<div className={styles.infoBox}>
<Trans>
To create a webhook, open the channel's settings and use the <strong>Webhooks</strong> tab. You can still

View File

@@ -40,6 +40,7 @@
display: flex;
flex-direction: column;
gap: var(--spacing-3);
border-bottom: 1px solid var(--background-modifier-hover);
}
:global(.theme-light) .header {

View File

@@ -33,6 +33,7 @@ type GuildIconProps = {
icon: string | null;
className?: string;
sizePx?: number;
containerProps?: React.HTMLAttributes<HTMLElement> & {'data-jump-link-guild-icon'?: string};
};
type GuildIconStyleVars = React.CSSProperties & {
@@ -40,7 +41,14 @@ type GuildIconStyleVars = React.CSSProperties & {
'--guild-icon-image'?: string;
};
export const GuildIcon = observer(function GuildIcon({id, name, icon, className, sizePx}: GuildIconProps) {
export const GuildIcon = observer(function GuildIcon({
id,
name,
icon,
className,
sizePx,
containerProps,
}: GuildIconProps) {
const initials = React.useMemo(() => StringUtils.getInitialsFromName(name), [name]);
const initialsLength = React.useMemo(() => getInitialsLength(initials), [initials]);
const [hoverRef, isHovering] = useHover();
@@ -104,6 +112,7 @@ export const GuildIcon = observer(function GuildIcon({id, name, icon, className,
<div
ref={hoverRef}
className={clsx(styles.container, className, !icon && styles.containerNoIcon)}
{...containerProps}
data-initials-length={initialsLength}
style={styleVars}
>

View File

@@ -147,6 +147,13 @@ export const ManageRolesMenuItem: React.FC<ManageRolesMenuItemProps> = observer(
}));
}, [guild, canManageRole]);
const visibleRoles = React.useMemo(() => {
if (canManageRoles) return allRoles;
const memberRoles = currentMember?.roles;
if (!memberRoles) return [];
return allRoles.filter(({role}) => memberRoles.has(role.id));
}, [allRoles, canManageRoles, currentMember]);
const handleToggleRole = React.useCallback(
async (roleId: string, hasRole: boolean, canToggle: boolean) => {
if (!canToggle) return;
@@ -159,33 +166,42 @@ export const ManageRolesMenuItem: React.FC<ManageRolesMenuItemProps> = observer(
[guildId, member.user.id],
);
if (allRoles.length === 0) return null;
if (visibleRoles.length === 0) return null;
return (
<MenuItemSubmenu
label={t`Roles`}
icon={<UserListIcon className={itemStyles.icon} />}
selectionMode="multiple"
selectionMode={canManageRoles ? 'multiple' : 'none'}
render={() => (
<MenuGroup>
{allRoles.map(({role, canManage}) => {
const hasRole = currentMember?.roles.has(role.id) ?? false;
const canToggle = canManageRoles && canManage;
return (
<MenuItemCheckbox
key={role.id}
checked={hasRole}
disabled={!canToggle}
onChange={() => handleToggleRole(role.id, hasRole, canToggle)}
closeOnChange={false}
>
<div className={itemStyles.roleContainer}>
<div className={itemStyles.roleIcon} style={{backgroundColor: ColorUtils.int2rgb(role.color)}} />
<span className={!canToggle ? itemStyles.roleDisabled : undefined}>{role.name}</span>
</div>
</MenuItemCheckbox>
);
})}
{canManageRoles
? visibleRoles.map(({role, canManage}) => {
const hasRole = currentMember?.roles.has(role.id) ?? false;
const canToggle = canManageRoles && canManage;
return (
<MenuItemCheckbox
key={role.id}
checked={hasRole}
disabled={!canToggle}
onChange={() => handleToggleRole(role.id, hasRole, canToggle)}
closeOnChange={false}
>
<div className={itemStyles.roleContainer}>
<div className={itemStyles.roleIcon} style={{backgroundColor: ColorUtils.int2rgb(role.color)}} />
<span className={!canToggle ? itemStyles.roleDisabled : undefined}>{role.name}</span>
</div>
</MenuItemCheckbox>
);
})
: visibleRoles.map(({role}) => (
<MenuItem key={role.id} closeOnSelect={false}>
<div className={itemStyles.roleContainer}>
<div className={itemStyles.roleIcon} style={{backgroundColor: ColorUtils.int2rgb(role.color)}} />
<span className={itemStyles.roleName}>{role.name}</span>
</div>
</MenuItem>
))}
</MenuGroup>
)}
/>