refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -17,14 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
import styles from '@app/components/common/ExpiryFootnote.module.css';
import {ExpiryFootnoteContextMenu} from '@app/components/common/ExpiryFootnoteContextMenu';
import {HelpCenterArticleSlug} from '@app/constants/HelpCenterConstants';
import {getFormattedShortDate} from '@app/utils/DateUtils';
import * as HelpCenterUtils from '@app/utils/HelpCenterUtils';
import {useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import type {FC} from 'react';
import {getFormattedShortDate} from '~/utils/DateUtils';
import * as HelpCenterUtils from '~/utils/HelpCenterUtils';
import styles from './ExpiryFootnote.module.css';
import {type FC, type MouseEvent, useCallback} from 'react';
export interface ExpiryFootnoteProps {
interface ExpiryFootnoteProps {
expiresAt: Date | null;
isExpired: boolean;
label?: string;
@@ -34,7 +37,11 @@ export interface ExpiryFootnoteProps {
export const ExpiryFootnote: FC<ExpiryFootnoteProps> = ({expiresAt, isExpired, label, className, inline = false}) => {
const {t} = useLingui();
const helpUrl = HelpCenterUtils.getURL('1447193503661555712');
const helpUrl = HelpCenterUtils.getURL(HelpCenterArticleSlug.AttachmentExpiry);
const handleContextMenu = useCallback((event: MouseEvent<HTMLAnchorElement>) => {
ContextMenuActionCreators.openFromEvent(event, () => <ExpiryFootnoteContextMenu />);
}, []);
let resolved = label;
if (!resolved) {
@@ -50,6 +57,7 @@ export const ExpiryFootnote: FC<ExpiryFootnoteProps> = ({expiresAt, isExpired, l
<a
className={clsx(inline ? styles.inlineFootnote : styles.footnote, className)}
href={helpUrl}
onContextMenu={handleContextMenu}
target="_blank"
rel="noreferrer"
>

View File

@@ -0,0 +1,47 @@
/*
* 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 AccessibilityActionCreators from '@app/actions/AccessibilityActionCreators';
import {MenuGroup} from '@app/components/uikit/context_menu/MenuGroup';
import {MenuItem} from '@app/components/uikit/context_menu/MenuItem';
import {HelpCenterArticleSlug} from '@app/constants/HelpCenterConstants';
import * as HelpCenterUtils from '@app/utils/HelpCenterUtils';
import {openExternalUrl} from '@app/utils/NativeUtils';
import {useLingui} from '@lingui/react/macro';
import {type FC, useCallback} from 'react';
export const ExpiryFootnoteContextMenu: FC = () => {
const {t} = useLingui();
const helpUrl = HelpCenterUtils.getURL(HelpCenterArticleSlug.AttachmentExpiry);
const handleHideFootnotes = useCallback(() => {
AccessibilityActionCreators.update({showAttachmentExpiryIndicator: false});
}, []);
const handleOpenHelpCenter = useCallback(() => {
void openExternalUrl(helpUrl);
}, [helpUrl]);
return (
<MenuGroup>
<MenuItem onClick={handleHideFootnotes}>{t`Hide expiry footnotes`}</MenuItem>
<MenuItem onClick={handleOpenHelpCenter}>{t`View help article`}</MenuItem>
</MenuGroup>
);
};

View File

@@ -17,13 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/common/ExternalLink.module.css';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {openExternalUrl} from '@app/utils/NativeUtils';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type {AnchorHTMLAttributes, FC, MouseEventHandler} from 'react';
import {useRef} from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {openExternalUrl} from '~/utils/NativeUtils';
import styles from './ExternalLink.module.css';
type ExternalLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;

View File

@@ -17,23 +17,24 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/common/FriendSelector.module.css';
import {Input, type RenderInputArgs} from '@app/components/form/Input';
import {Avatar} from '@app/components/uikit/Avatar';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Scroller} from '@app/components/uikit/Scroller';
import {StatusAwareAvatar} from '@app/components/uikit/StatusAwareAvatar';
import type {UserRecord} from '@app/records/UserRecord';
import RelationshipStore from '@app/stores/RelationshipStore';
import UserStore from '@app/stores/UserStore';
import * as NicknameUtils from '@app/utils/NicknameUtils';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon, XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {RelationshipTypes} from '~/Constants';
import {Input, type RenderInputArgs} from '~/components/form/Input';
import {Avatar} from '~/components/uikit/Avatar';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Scroller} from '~/components/uikit/Scroller';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import type {UserRecord} from '~/records/UserRecord';
import RelationshipStore from '~/stores/RelationshipStore';
import UserStore from '~/stores/UserStore';
import * as NicknameUtils from '~/utils/NicknameUtils';
import styles from './FriendSelector.module.css';
import type React from 'react';
import {useMemo, useRef, useState} from 'react';
interface FriendSelectorProps {
selectedUserIds: Array<string>;
@@ -63,10 +64,10 @@ export const FriendSelector: React.FC<FriendSelectorProps> = observer(
stickyUserIds = [],
}) => {
const {t} = useLingui();
const [internalSearchQuery, setInternalSearchQuery] = React.useState('');
const [internalSearchQuery, setInternalSearchQuery] = useState('');
const searchQuery = externalSearchQuery ?? internalSearchQuery;
const [inputFocused, setInputFocused] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [inputFocused, setInputFocused] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const handleSearchChange = (value: string) => {
if (onSearchQueryChange) {
@@ -77,7 +78,7 @@ export const FriendSelector: React.FC<FriendSelectorProps> = observer(
};
const relationships = RelationshipStore.getRelationships();
const friendUsers = React.useMemo(() => {
const friendUsers = useMemo(() => {
const friends = relationships.filter(
(relationship) => relationship.type === RelationshipTypes.FRIEND && !excludeUserIds.includes(relationship.id),
);
@@ -88,11 +89,11 @@ export const FriendSelector: React.FC<FriendSelectorProps> = observer(
.sort((a, b) => NicknameUtils.getNickname(a).localeCompare(NicknameUtils.getNickname(b)));
}, [relationships, excludeUserIds]);
const activeStickyUserIds = React.useMemo(() => {
const activeStickyUserIds = useMemo(() => {
return stickyUserIds.filter((id) => selectedUserIds.includes(id));
}, [stickyUserIds, selectedUserIds]);
const groupedFriends = React.useMemo(() => {
const groupedFriends = useMemo(() => {
const filtered = friendUsers.filter((user) => {
if (!searchQuery) return true;
return NicknameUtils.getNickname(user).toLowerCase().includes(searchQuery.toLowerCase());
@@ -204,7 +205,6 @@ export const FriendSelector: React.FC<FriendSelectorProps> = observer(
className={clsx(styles.scroller, !showSearchInput && styles.scrollerNoSearch)}
key="friend-selector-scroller"
fade={false}
reserveScrollbarTrack={false}
>
{groupedFriends.length === 0 && activeStickyUserIds.length === 0 ? (
<div className={styles.emptyState}>

View File

@@ -17,22 +17,28 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {getStatusTypeLabel} from '@app/AppConstants';
import styles from '@app/components/common/GroupDMAvatar.module.css';
import type {AvatarStatusLayout} from '@app/components/uikit/AvatarStatusLayout';
import {getAvatarStatusLayout} from '@app/components/uikit/AvatarStatusLayout';
import baseAvatarStyles from '@app/components/uikit/BaseAvatar.module.css';
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO} from '@app/components/uikit/TypingConstants';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import i18n from '@app/I18n';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import PresenceStore from '@app/stores/PresenceStore';
import UserStore from '@app/stores/UserStore';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {getGroupDMAccentColor} from '@app/utils/GroupDMColorUtils';
import {cdnUrl} from '@app/utils/UrlUtils';
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import type {I18n} from '@lingui/core';
import {UsersIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {getStatusTypeLabel, type StatusType, StatusTypes} from '~/Constants';
import {type AvatarStatusLayout, getAvatarStatusLayout} from '~/components/uikit/AvatarStatusLayout';
import baseAvatarStyles from '~/components/uikit/BaseAvatar.module.css';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import i18n from '~/i18n';
import type {ChannelRecord} from '~/records/ChannelRecord';
import PresenceStore from '~/stores/PresenceStore';
import UserStore from '~/stores/UserStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {getGroupDMAccentColor} from '~/utils/GroupDMColorUtils';
import {cdnUrl} from '~/utils/UrlUtils';
import styles from './GroupDMAvatar.module.css';
import type React from 'react';
import {useId, useMemo} from 'react';
function computeGroupStatus(channel: ChannelRecord): string | null {
const memberIds = new Set<string>(channel.recipientIds);
@@ -113,7 +119,6 @@ function renderGroupStatusDot(status: string | null, size: number, isTyping?: bo
</div>
</div>
) : (
// biome-ignore lint/a11y/noSvgWithoutTitle: decorative SVG, parent has aria-label
<svg width={layout.innerStatusWidth} height={layout.innerStatusHeight} viewBox="0 0 1 1" aria-hidden>
<rect
x={0}
@@ -137,21 +142,25 @@ function renderTypingCutouts(layout: AvatarStatusLayout): Array<React.ReactNode>
const cy = layout.cutoutCy;
const r = layout.cutoutRadius;
const typingBridgeShift = extendW * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
const bridgeX = cx - extendW + typingBridgeShift;
const typingRightCapX = cx + typingBridgeShift;
if (r <= 0) return [];
if (extendW <= 0) {
return [<circle key="status-cutout" cx={cx} cy={cy} r={r} fill="black" />];
}
return [
<circle key="typing-right-cap" cx={cx} cy={cy} r={r} fill="black" />,
<rect key="typing-bridge" x={cx - extendW} y={cy - r} width={extendW} height={r * 2} fill="black" />,
<circle key="typing-left-cap" cx={cx - extendW} cy={cy} r={r} fill="black" />,
<circle key="typing-right-cap" cx={typingRightCapX} cy={cy} r={r} fill="black" />,
<rect key="typing-bridge" x={bridgeX} y={cy - r} width={extendW} height={r * 2} fill="black" />,
<circle key="typing-left-cap" cx={bridgeX} cy={cy} r={r} fill="black" />,
];
}
interface GroupDMAvatarProps {
channel: ChannelRecord;
size: number;
size: MediaProxyImageSize;
isTyping?: boolean;
disableStatusIndicator?: boolean;
statusOverride?: StatusType | null;
@@ -215,12 +224,12 @@ function getAvatarPosition(count: number, index: number, size: number): AvatarPo
export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
({channel, size, isTyping = false, disableStatusIndicator = false, statusOverride}) => {
const currentUser = UserStore.currentUser;
const iconUrl = AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}, size * 2);
const accentColor = React.useMemo(() => getGroupDMAccentColor(channel.id), [channel.id]);
const iconUrl = AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon});
const accentColor = useMemo(() => getGroupDMAccentColor(channel.id), [channel.id]);
const shouldShowStatusIndicator = !disableStatusIndicator;
const status = shouldShowStatusIndicator ? (statusOverride ?? computeGroupStatus(channel)) : null;
const statusForIndicator = status === StatusTypes.ONLINE ? status : null;
const groupMaskId = React.useId();
const groupMaskId = useId();
if (iconUrl) {
const layout = getAvatarStatusLayout(size);

View File

@@ -0,0 +1,54 @@
/*
* 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/>.
*/
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 9999px;
background-color: var(--background-tertiary);
cursor: default;
transition: background-color var(--transition-fast);
}
.badge:hover {
background-color: var(--background-modifier-hover);
}
.small {
width: 16px;
height: 16px;
}
.medium {
width: 24px;
height: 24px;
}
.favicon {
width: 100%;
height: 100%;
border-radius: 9999px;
object-fit: cover;
}
.globeIcon {
color: var(--text-secondary);
}

View File

@@ -0,0 +1,112 @@
/*
* 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 styles from '@app/components/common/InstanceBadge.module.css';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {GlobeIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useState} from 'react';
interface InstanceBadgeProps {
instanceDomain: string;
size?: 'small' | 'medium';
showTooltip?: boolean;
className?: string;
}
type FaviconLoadState = 'loading' | 'loaded' | 'error';
const faviconCache = new Map<string, {state: FaviconLoadState; url: string | null}>();
function getFaviconUrl(instanceDomain: string): string {
return `https://${instanceDomain}/favicon.ico`;
}
export const InstanceBadge = observer(function InstanceBadge({
instanceDomain,
size = 'small',
showTooltip = true,
className,
}: InstanceBadgeProps) {
const [faviconState, setFaviconState] = useState<FaviconLoadState>(() => {
const cached = faviconCache.get(instanceDomain);
return cached?.state ?? 'loading';
});
const faviconUrl = useMemo(() => getFaviconUrl(instanceDomain), [instanceDomain]);
const handleImageLoad = useCallback(() => {
faviconCache.set(instanceDomain, {state: 'loaded', url: faviconUrl});
setFaviconState('loaded');
}, [instanceDomain, faviconUrl]);
const handleImageError = useCallback(() => {
faviconCache.set(instanceDomain, {state: 'error', url: null});
setFaviconState('error');
}, [instanceDomain]);
useEffect(() => {
const cached = faviconCache.get(instanceDomain);
if (cached) {
setFaviconState(cached.state);
return;
}
const img = new Image();
img.onload = handleImageLoad;
img.onerror = handleImageError;
img.src = faviconUrl;
return () => {
img.onload = null;
img.onerror = null;
};
}, [instanceDomain, faviconUrl, handleImageLoad, handleImageError]);
const iconSize = size === 'small' ? 12 : 18;
const badgeContent = useMemo(() => {
const containerClass = clsx(styles.badge, size === 'small' ? styles.small : styles.medium, className);
if (faviconState === 'loaded') {
return (
<span className={containerClass}>
<img src={faviconUrl} alt="" className={styles.favicon} aria-hidden="true" />
</span>
);
}
return (
<span className={containerClass}>
<GlobeIcon size={iconSize} weight="regular" className={styles.globeIcon} aria-hidden="true" />
</span>
);
}, [faviconState, faviconUrl, size, iconSize, className]);
if (!showTooltip) {
return badgeContent;
}
return (
<Tooltip text={instanceDomain} position="top">
{badgeContent}
</Tooltip>
);
});

View File

@@ -20,8 +20,13 @@
.container {
position: relative;
display: block;
width: auto;
width: fit-content;
max-width: 100%;
overflow: hidden;
}
.media {
height: fit-content;
}
.inline {
@@ -33,17 +38,14 @@
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.hidden .content {
filter: blur(10px);
opacity: 0.2;
visibility: hidden;
user-select: none;
-webkit-user-select: none;
pointer-events: none;
transition:
opacity var(--transition-normal),
filter var(--transition-normal);
}
.overlayButton {
@@ -69,11 +71,6 @@
color var(--transition-fast);
}
.overlayButton:focus-visible {
outline: 2px solid var(--text-link);
outline-offset: 2px;
}
.overlayButton:hover {
background-color: var(--spoiler-overlay-hover-color);
}

View File

@@ -17,10 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/common/SpoilerOverlay.module.css';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import type {FC, ReactNode} from 'react';
import styles from './SpoilerOverlay.module.css';
interface SpoilerOverlayProps {
hidden: boolean;
@@ -41,9 +42,11 @@ export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, child
{children}
</div>
{hidden && (
<button type="button" className={styles.overlayButton} onClick={onReveal} aria-label={ariaLabel}>
<span className={styles.overlayLabel}>{label ?? t`Spoiler`}</span>
</button>
<FocusRing offset={-2}>
<button type="button" className={styles.overlayButton} onClick={onReveal} aria-label={ariaLabel}>
<span className={styles.overlayLabel}>{label ?? t`Spoiler`}</span>
</button>
</FocusRing>
)}
</div>
);

View File

@@ -202,6 +202,10 @@ img.statusEmoji {
color: var(--text-tertiary);
}
.placeholder:hover .placeholderText {
text-decoration: underline;
}
.emojiPressable {
display: inline;
padding: 0;

View File

@@ -17,38 +17,41 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/common/custom_status_display/CustomStatusDisplay.module.css';
import {EmojiAttributionSubtext, getEmojiAttribution} from '@app/components/emojis/EmojiAttributionSubtext';
import {EmojiTooltipContent} from '@app/components/uikit/emoji_tooltip_content/EmojiTooltipContent';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Tooltip, useTooltipPortalRoot} from '@app/components/uikit/tooltip/Tooltip';
import {useMergeRefs} from '@app/hooks/useMergeRefs';
import {usePresenceCustomStatus} from '@app/hooks/usePresenceCustomStatus';
import {useReactionTooltip} from '@app/hooks/useReactionTooltip';
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '@app/lib/CustomStatus';
import UnicodeEmojis from '@app/lib/UnicodeEmojis';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import EmojiStore from '@app/stores/EmojiStore';
import GuildStore from '@app/stores/GuildStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {getEmojiURL, shouldUseNativeEmoji} from '@app/utils/EmojiUtils';
import {getReducedMotionProps, TOOLTIP_MOTION} from '@app/utils/ReducedMotionAnimation';
import {setUrlQueryParams} from '@app/utils/UrlUtils';
import {FloatingPortal} from '@floating-ui/react';
import {Trans} from '@lingui/react/macro';
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {useReactionTooltip} from '~/hooks/useReactionTooltip';
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus';
import UnicodeEmojis from '~/lib/UnicodeEmojis';
import EmojiStore from '~/stores/EmojiStore';
import GuildStore from '~/stores/GuildStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PresenceStore from '~/stores/PresenceStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {getEmojiURL, shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import styles from './CustomStatusDisplay.module.css';
import type React from 'react';
import {useLayoutEffect, useRef, useState} from 'react';
const useTextOverflow = (
containerRef: React.RefObject<HTMLElement | null>,
content: string | null,
checkVertical = false,
) => {
const [isOverflowing, setIsOverflowing] = React.useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
React.useLayoutEffect(() => {
useLayoutEffect(() => {
const el = containerRef.current;
if (!el || !content) {
setIsOverflowing(false);
@@ -118,7 +121,10 @@ const getTooltipEmojiUrl = (status: CustomStatus): string | null => {
if (status.emojiId) {
const emoji = EmojiStore.getEmojiById(status.emojiId);
const isAnimated = emoji?.animated ?? status.emojiAnimated ?? false;
return `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: isAnimated})}?size=96&quality=lossless`;
return setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: isAnimated}), {
size: 96,
quality: 'lossless',
});
}
if (status.emojiName && !shouldUseNativeEmoji) {
return getEmojiURL(status.emojiName);
@@ -136,6 +142,7 @@ interface StatusEmojiWithTooltipProps {
const StatusEmojiWithTooltip = observer(
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltipPortalRoot = useTooltipPortalRoot();
const tooltipMotion = getReducedMotionProps(TOOLTIP_MOTION, AccessibilityStore.useReducedMotion);
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
const attribution = getEmojiAttribution({
@@ -158,7 +165,7 @@ const StatusEmojiWithTooltip = observer(
const emojiName = getEmojiDisplayName();
const tooltipEmojiUrl = getTooltipEmojiUrl(status);
const triggerRef = React.useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLElement>(null);
const mergedRef = useMergeRefs([targetRef, triggerRef]);
const TriggerComponent = isButton ? 'button' : 'span';
@@ -179,7 +186,7 @@ const StatusEmojiWithTooltip = observer(
<FloatingPortal root={tooltipPortalRoot}>
<AnimatePresence>
<motion.div
ref={(node) => {
ref={(node: HTMLDivElement | null) => {
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (node && targetRef.current) {
updatePosition();
@@ -192,13 +199,7 @@ const StatusEmojiWithTooltip = observer(
zIndex: 'var(--z-index-tooltip)',
visibility: state.isReady ? 'visible' : 'hidden',
}}
initial={{opacity: 0, scale: 0.98}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0, scale: 0.98}}
transition={{
opacity: {duration: 0.1},
scale: {type: 'spring', damping: 25, stiffness: 500},
}}
{...tooltipMotion}
{...tooltipHandlers}
>
<EmojiTooltipContent
@@ -239,15 +240,19 @@ const renderStatusEmoji = (
animateOnParentHover?: boolean,
alwaysAnimate?: boolean,
): EmojiRenderResult | null => {
const sizeSuffix = '?size=96&quality=lossless';
if (status.emojiId) {
const emoji = EmojiStore.getEmojiById(status.emojiId);
const altText = `:${status.emojiName}:`;
const isAnimated = emoji?.animated ?? status.emojiAnimated ?? false;
const staticUrl = `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: false})}${sizeSuffix}`;
const staticUrl = setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: false}), {
size: 96,
quality: 'lossless',
});
const animatedUrl = isAnimated
? `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: true})}${sizeSuffix}`
? setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: true}), {
size: 96,
quality: 'lossless',
})
: null;
if (alwaysAnimate && animatedUrl) {
@@ -345,8 +350,13 @@ export const CustomStatusDisplay = observer(
animateOnParentHover = false,
alwaysAnimate = false,
}: CustomStatusDisplayProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const status = customStatus === undefined ? (userId ? PresenceStore.getCustomStatus(userId) : null) : customStatus;
const containerRef = useRef<HTMLDivElement>(null);
const shouldFetchFromPresence = customStatus === undefined && userId !== undefined;
const presenceStatus = usePresenceCustomStatus({
userId: userId ?? '',
enabled: shouldFetchFromPresence,
});
const status = shouldFetchFromPresence ? presenceStatus : (customStatus ?? null);
const normalized = normalizeCustomStatus(status);
const displayText = normalized?.text ? sanitizeText(normalized.text) : null;
const isOverflowing = useTextOverflow(containerRef, displayText, maxLines > 1);