initial commit
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.content {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.content.singleLine {
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.constrained {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.truncatedText {
|
||||
vertical-align: middle;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.clamped {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
max-height: calc(var(--max-lines, 2) * 1lh);
|
||||
max-block-size: calc(var(--max-lines, 2) * 1lh);
|
||||
}
|
||||
|
||||
@supports not (max-height: 1lh) {
|
||||
.clamped {
|
||||
max-height: calc(var(--max-lines, 2) * 1.25em);
|
||||
max-block-size: calc(var(--max-lines, 2) * 1.25em);
|
||||
}
|
||||
}
|
||||
|
||||
.statusEmoji {
|
||||
display: inline;
|
||||
height: 1.125em;
|
||||
width: 1.125em;
|
||||
object-fit: contain;
|
||||
margin-inline-end: 0.25em;
|
||||
vertical-align: -0.25em;
|
||||
}
|
||||
|
||||
.statusEmojiWrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
height: 1.125em;
|
||||
width: 1.125em;
|
||||
margin-inline-end: 0.25em;
|
||||
vertical-align: -0.25em;
|
||||
}
|
||||
|
||||
.statusEmojiWrapper .statusEmoji {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.statusEmojiWrapper .staticEmoji {
|
||||
opacity: calc(1 - var(--emoji-show-animated, 0));
|
||||
}
|
||||
|
||||
.statusEmojiWrapper .animatedEmoji {
|
||||
opacity: var(--emoji-show-animated, 0);
|
||||
}
|
||||
|
||||
img.statusEmoji {
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
.nativeEmoji {
|
||||
display: inline;
|
||||
font-size: 1.125em;
|
||||
line-height: 1;
|
||||
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
margin-inline-end: 0.25em;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.hiddenVisually {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.jumbo .statusEmoji {
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
margin-inline-end: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.jumbo .statusEmojiWrapper {
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
margin-inline-end: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.jumbo .nativeEmoji {
|
||||
font-size: 1.75rem;
|
||||
margin-inline-end: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.editableWrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
padding: 0;
|
||||
padding-bottom: 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editableWrapper .content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editableWrapper,
|
||||
.editableWrapper * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editableTextHover .truncatedText {
|
||||
text-decoration: none;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.editableTextHover:hover .truncatedText {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.editPencilIcon {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.editableEmojiOnly:hover .editPencilIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.emojiPressable {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emojiTooltipTrigger {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.emojiTooltipSubtext {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.emojiTooltipGuildRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.emojiTooltipGuildIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
--guild-icon-size: 1.25rem;
|
||||
}
|
||||
|
||||
.emojiTooltipGuildName {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emojiTooltipVerifiedIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
* 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 {FloatingPortal} from '@floating-ui/react';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {PencilIcon, SealCheckIcon, 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 {GuildFeatures} from '~/Constants';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
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 GuildListStore from '~/stores/GuildListStore';
|
||||
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';
|
||||
|
||||
const useTextOverflow = (
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
content: string | null,
|
||||
checkVertical = false,
|
||||
) => {
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !content) {
|
||||
setIsOverflowing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (el.scrollWidth > el.clientWidth || (checkVertical && el.scrollHeight > el.clientHeight)) {
|
||||
setIsOverflowing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const contentWidth = range.getBoundingClientRect().width;
|
||||
const containerWidth = el.getBoundingClientRect().width;
|
||||
setIsOverflowing(Math.ceil(contentWidth) > Math.ceil(containerWidth));
|
||||
};
|
||||
|
||||
const frameId = requestAnimationFrame(checkOverflow);
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkOverflow);
|
||||
resizeObserver.observe(el);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [containerRef, content, checkVertical]);
|
||||
|
||||
return isOverflowing;
|
||||
};
|
||||
|
||||
export interface EmojiPressData {
|
||||
id: string | null;
|
||||
name: string;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
interface CustomStatusDisplayProps {
|
||||
className?: string;
|
||||
emojiClassName?: string;
|
||||
customStatus?: CustomStatus | null;
|
||||
userId?: string;
|
||||
showText?: boolean;
|
||||
showTooltip?: boolean;
|
||||
allowJumboEmoji?: boolean;
|
||||
maxLines?: number;
|
||||
isEditable?: boolean;
|
||||
onEdit?: () => void;
|
||||
onEmojiPress?: (emoji: EmojiPressData) => void;
|
||||
constrained?: boolean;
|
||||
showPlaceholder?: boolean;
|
||||
animateOnParentHover?: boolean;
|
||||
alwaysAnimate?: boolean;
|
||||
}
|
||||
|
||||
interface ClampedStyle extends React.CSSProperties {
|
||||
'--max-lines'?: number;
|
||||
}
|
||||
|
||||
const sanitizeText = (text: string): string => {
|
||||
return text.replace(/[\r\n]+/g, ' ').trim();
|
||||
};
|
||||
|
||||
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`;
|
||||
}
|
||||
if (status.emojiName && !shouldUseNativeEmoji) {
|
||||
return getEmojiURL(status.emojiName);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const StatusEmojiTooltipSubtext = observer(({status}: {status: CustomStatus}) => {
|
||||
const {t} = useLingui();
|
||||
const isCustomEmoji = Boolean(status.emojiId);
|
||||
|
||||
if (!isCustomEmoji) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
|
||||
const guildId = emoji?.guildId;
|
||||
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
|
||||
|
||||
if (!isMember) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
||||
|
||||
if (!guild) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>This is a custom emoji from a community.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
|
||||
|
||||
return (
|
||||
<div className={styles.emojiTooltipSubtext}>
|
||||
<span>
|
||||
<Trans>This is a custom emoji from</Trans>
|
||||
</span>
|
||||
<div className={styles.emojiTooltipGuildRow}>
|
||||
<div className={styles.emojiTooltipGuildIcon}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
|
||||
</div>
|
||||
<span className={styles.emojiTooltipGuildName}>{guild.name}</span>
|
||||
{isVerified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.emojiTooltipVerifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface StatusEmojiWithTooltipProps {
|
||||
status: CustomStatus;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
isButton?: boolean;
|
||||
}
|
||||
|
||||
const StatusEmojiWithTooltip = observer(
|
||||
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
|
||||
const tooltipPortalRoot = useTooltipPortalRoot();
|
||||
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
|
||||
|
||||
const getEmojiDisplayName = (): string => {
|
||||
if (status.emojiId) {
|
||||
return `:${status.emojiName}:`;
|
||||
}
|
||||
if (status.emojiName) {
|
||||
return UnicodeEmojis.convertSurrogateToName(status.emojiName, true, status.emojiName);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const emojiName = getEmojiDisplayName();
|
||||
const tooltipEmojiUrl = getTooltipEmojiUrl(status);
|
||||
|
||||
const triggerRef = React.useRef<HTMLElement>(null);
|
||||
const mergedRef = useMergeRefs([targetRef, triggerRef]);
|
||||
|
||||
const TriggerComponent = isButton ? 'button' : 'span';
|
||||
const triggerProps = isButton
|
||||
? {type: 'button' as const, className: styles.emojiPressable, onClick}
|
||||
: {className: styles.emojiTooltipTrigger};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TriggerComponent
|
||||
ref={mergedRef as React.Ref<HTMLButtonElement & HTMLSpanElement>}
|
||||
{...triggerProps}
|
||||
{...handlers}
|
||||
>
|
||||
{children}
|
||||
</TriggerComponent>
|
||||
{state.isOpen && (
|
||||
<FloatingPortal root={tooltipPortalRoot}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={(node) => {
|
||||
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (node && targetRef.current) {
|
||||
updatePosition();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
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},
|
||||
}}
|
||||
{...tooltipHandlers}
|
||||
>
|
||||
<EmojiTooltipContent
|
||||
emojiUrl={tooltipEmojiUrl}
|
||||
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
|
||||
emojiAlt={status.emojiName ?? undefined}
|
||||
primaryContent={emojiName}
|
||||
subtext={<StatusEmojiTooltipSubtext status={status} />}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface EmojiRenderResult {
|
||||
node: React.ReactNode;
|
||||
altText: string;
|
||||
}
|
||||
|
||||
const renderStatusEmoji = (
|
||||
status: CustomStatus,
|
||||
emojiClassName?: string,
|
||||
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 animatedUrl = isAnimated
|
||||
? `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: true})}${sizeSuffix}`
|
||||
: null;
|
||||
|
||||
if (alwaysAnimate && animatedUrl) {
|
||||
return {
|
||||
node: (
|
||||
<img
|
||||
src={animatedUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, emojiClassName)}
|
||||
/>
|
||||
),
|
||||
altText,
|
||||
};
|
||||
}
|
||||
|
||||
if (animateOnParentHover && animatedUrl) {
|
||||
return {
|
||||
node: (
|
||||
<span className={styles.statusEmojiWrapper}>
|
||||
<img
|
||||
src={staticUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, styles.staticEmoji, emojiClassName)}
|
||||
/>
|
||||
<img
|
||||
src={animatedUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
altText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: (
|
||||
<img
|
||||
src={staticUrl}
|
||||
alt={status.emojiName ?? undefined}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, emojiClassName)}
|
||||
/>
|
||||
),
|
||||
altText,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.emojiName) {
|
||||
const altText = status.emojiName;
|
||||
|
||||
if (!shouldUseNativeEmoji) {
|
||||
const twemojiUrl = getEmojiURL(status.emojiName);
|
||||
if (twemojiUrl) {
|
||||
return {
|
||||
node: (
|
||||
<img
|
||||
src={twemojiUrl}
|
||||
alt={status.emojiName}
|
||||
draggable={false}
|
||||
className={clsx(styles.statusEmoji, emojiClassName)}
|
||||
/>
|
||||
),
|
||||
altText,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
node: <span className={clsx(styles.statusEmoji, styles.nativeEmoji, emojiClassName)}>{status.emojiName}</span>,
|
||||
altText,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const CustomStatusDisplay = observer(
|
||||
({
|
||||
className,
|
||||
emojiClassName,
|
||||
customStatus,
|
||||
userId,
|
||||
showText = true,
|
||||
showTooltip = true,
|
||||
allowJumboEmoji = false,
|
||||
maxLines = 1,
|
||||
isEditable = false,
|
||||
onEdit,
|
||||
onEmojiPress,
|
||||
constrained = false,
|
||||
showPlaceholder = false,
|
||||
animateOnParentHover = false,
|
||||
alwaysAnimate = false,
|
||||
}: CustomStatusDisplayProps) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const status = customStatus === undefined ? (userId ? PresenceStore.getCustomStatus(userId) : null) : customStatus;
|
||||
const normalized = normalizeCustomStatus(status);
|
||||
const displayText = normalized?.text ? sanitizeText(normalized.text) : null;
|
||||
const isOverflowing = useTextOverflow(containerRef, displayText, maxLines > 1);
|
||||
|
||||
if (!normalized) {
|
||||
if (showPlaceholder && isEditable && onEdit) {
|
||||
return (
|
||||
<FocusRing offset={-2}>
|
||||
<button type="button" className={styles.placeholder} onClick={onEdit}>
|
||||
<SmileyIcon size={14} weight="regular" className={styles.placeholderIcon} />
|
||||
<span className={styles.placeholderText}>
|
||||
<Trans>Set a custom status</Trans>
|
||||
</span>
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullText = getCustomStatusText(normalized);
|
||||
const hasEmoji = Boolean(normalized.emojiId || normalized.emojiName);
|
||||
const hasText = Boolean(normalized.text);
|
||||
|
||||
if (!hasEmoji && !hasText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiResult = hasEmoji
|
||||
? renderStatusEmoji(normalized, emojiClassName, animateOnParentHover, alwaysAnimate)
|
||||
: null;
|
||||
const isEmojiOnly = hasEmoji && !hasText;
|
||||
const isSingleLine = maxLines === 1 && !isEmojiOnly;
|
||||
const shouldClamp = maxLines > 1 && !isEmojiOnly;
|
||||
const clampedStyle: ClampedStyle | undefined = shouldClamp ? {'--max-lines': maxLines} : undefined;
|
||||
|
||||
if (isEditable && onEdit) {
|
||||
const isDesktop = !MobileLayoutStore.enabled;
|
||||
const shouldShowEmojiTooltip = showTooltip && isDesktop && hasEmoji;
|
||||
|
||||
const renderEditableEmoji = () => {
|
||||
if (!emojiResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldShowEmojiTooltip) {
|
||||
return (
|
||||
<StatusEmojiWithTooltip status={normalized}>
|
||||
{emojiResult.node}
|
||||
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
|
||||
</StatusEmojiWithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{emojiResult.node}
|
||||
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const editableContent = (
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.editableWrapper, {
|
||||
[styles.editableTextHover]: hasText,
|
||||
[styles.editableEmojiOnly]: isEmojiOnly,
|
||||
})}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.content, className, {
|
||||
[styles.jumbo]: allowJumboEmoji && isEmojiOnly,
|
||||
[styles.singleLine]: isSingleLine,
|
||||
[styles.clamped]: shouldClamp,
|
||||
})}
|
||||
style={clampedStyle}
|
||||
>
|
||||
{renderEditableEmoji()}
|
||||
{showText && displayText && <span className={styles.truncatedText}>{displayText}</span>}
|
||||
</div>
|
||||
{isEmojiOnly && <PencilIcon size={12} weight="bold" className={styles.editPencilIcon} />}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
|
||||
if (showTooltip && fullText && isOverflowing) {
|
||||
return <Tooltip text={fullText}>{editableContent}</Tooltip>;
|
||||
}
|
||||
|
||||
return editableContent;
|
||||
}
|
||||
|
||||
const handleEmojiPress = () => {
|
||||
if (!onEmojiPress || !normalized) {
|
||||
return;
|
||||
}
|
||||
const emoji = EmojiStore.getEmojiById(normalized.emojiId ?? '');
|
||||
const shouldAnimate = emoji?.animated ?? normalized.emojiAnimated ?? false;
|
||||
onEmojiPress({
|
||||
id: normalized.emojiId,
|
||||
name: normalized.emojiName ?? '',
|
||||
animated: shouldAnimate,
|
||||
});
|
||||
};
|
||||
|
||||
const renderEmojiNode = () => {
|
||||
if (!emojiResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDesktop = !MobileLayoutStore.enabled;
|
||||
const shouldShowEmojiTooltip = showTooltip && isDesktop && hasEmoji;
|
||||
|
||||
if (onEmojiPress && hasEmoji) {
|
||||
if (shouldShowEmojiTooltip) {
|
||||
return (
|
||||
<StatusEmojiWithTooltip status={normalized} onClick={handleEmojiPress} isButton>
|
||||
{emojiResult.node}
|
||||
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
|
||||
</StatusEmojiWithTooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={styles.emojiPressable} onClick={handleEmojiPress}>
|
||||
{emojiResult.node}
|
||||
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowEmojiTooltip) {
|
||||
return (
|
||||
<StatusEmojiWithTooltip status={normalized}>
|
||||
{emojiResult.node}
|
||||
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
|
||||
</StatusEmojiWithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.emojiTooltipTrigger}>
|
||||
{emojiResult.node}
|
||||
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.content, className, {
|
||||
[styles.jumbo]: allowJumboEmoji && isEmojiOnly,
|
||||
[styles.singleLine]: isSingleLine,
|
||||
[styles.clamped]: shouldClamp,
|
||||
[styles.constrained]: constrained,
|
||||
})}
|
||||
style={clampedStyle}
|
||||
>
|
||||
{renderEmojiNode()}
|
||||
{showText && displayText && <span className={styles.truncatedText}>{displayText}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (showTooltip && fullText && isOverflowing) {
|
||||
return <Tooltip text={fullText}>{content}</Tooltip>;
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
);
|
||||
|
||||
CustomStatusDisplay.displayName = 'CustomStatusDisplay';
|
||||
51
fluxer_app/src/components/common/ExpiryFootnote.module.css
Normal file
51
fluxer_app/src/components/common/ExpiryFootnote.module.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.footnote {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.footnote:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.inlineFootnote {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.inlineFootnote:hover {
|
||||
color: var(--text-tertiary) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
59
fluxer_app/src/components/common/ExpiryFootnote.tsx
Normal file
59
fluxer_app/src/components/common/ExpiryFootnote.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
export interface ExpiryFootnoteProps {
|
||||
expiresAt: Date | null;
|
||||
isExpired: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const ExpiryFootnote: FC<ExpiryFootnoteProps> = ({expiresAt, isExpired, label, className, inline = false}) => {
|
||||
const {t} = useLingui();
|
||||
const helpUrl = HelpCenterUtils.getURL('1447193503661555712');
|
||||
|
||||
let resolved = label;
|
||||
if (!resolved) {
|
||||
if (expiresAt) {
|
||||
const date = getFormattedShortDate(expiresAt);
|
||||
resolved = isExpired ? t`Expired on ${date}` : t`Expires on ${date}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={clsx(inline ? styles.inlineFootnote : styles.footnote, className)}
|
||||
href={helpUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{resolved}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
28
fluxer_app/src/components/common/ExternalLink.module.css
Normal file
28
fluxer_app/src/components/common/ExternalLink.module.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.externalLink {
|
||||
border-radius: 2px;
|
||||
transition: text-decoration 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.externalLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
57
fluxer_app/src/components/common/ExternalLink.tsx
Normal file
57
fluxer_app/src/components/common/ExternalLink.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ExternalLink: FC<ExternalLinkProps> = observer(({href, children, className, ...props}) => {
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleClick: MouseEventHandler<HTMLAnchorElement> = async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await openExternalUrl(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusRing ringTarget={linkRef}>
|
||||
<a
|
||||
ref={linkRef}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(styles.externalLink, className)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</FocusRing>
|
||||
);
|
||||
});
|
||||
212
fluxer_app/src/components/common/FriendSelector.module.css
Normal file
212
fluxer_app/src/components/common/FriendSelector.module.css
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchField {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.searchFieldInner {
|
||||
display: flex;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selectedPill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.removeButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-primary-muted);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.searchIconFocused {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
--scrollbar-track-bg: var(--background-secondary);
|
||||
margin-top: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scrollerNoSearch {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emptyStateText {
|
||||
text-align: center;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.groupsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.groupContainer {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.groupLetter {
|
||||
margin-bottom: 8px;
|
||||
padding: 0 16px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.friendsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.friendButton {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.friendButton::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 var(--spacing-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.friendButton:hover::before {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.friendButtonSelected::before {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.friendButtonDisabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.friendButtonDisabled:hover::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.friendInfo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.removeIcon {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
292
fluxer_app/src/components/common/FriendSelector.tsx
Normal file
292
fluxer_app/src/components/common/FriendSelector.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface FriendSelectorProps {
|
||||
selectedUserIds: Array<string>;
|
||||
onToggle: (userId: string) => void;
|
||||
maxSelections?: number;
|
||||
excludeUserIds?: Array<string>;
|
||||
searchQuery?: string;
|
||||
onSearchQueryChange?: (value: string) => void;
|
||||
showSearchInput?: boolean;
|
||||
stickyUserIds?: Array<string>;
|
||||
}
|
||||
|
||||
interface FriendGroup {
|
||||
letter: string;
|
||||
friendIds: Array<string>;
|
||||
}
|
||||
|
||||
export const FriendSelector: React.FC<FriendSelectorProps> = observer(
|
||||
({
|
||||
selectedUserIds,
|
||||
onToggle,
|
||||
maxSelections,
|
||||
excludeUserIds = [],
|
||||
searchQuery: externalSearchQuery,
|
||||
onSearchQueryChange,
|
||||
showSearchInput = true,
|
||||
stickyUserIds = [],
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const [internalSearchQuery, setInternalSearchQuery] = React.useState('');
|
||||
const searchQuery = externalSearchQuery ?? internalSearchQuery;
|
||||
const [inputFocused, setInputFocused] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
if (onSearchQueryChange) {
|
||||
onSearchQueryChange(value);
|
||||
} else {
|
||||
setInternalSearchQuery(value);
|
||||
}
|
||||
};
|
||||
|
||||
const relationships = RelationshipStore.getRelationships();
|
||||
const friendUsers = React.useMemo(() => {
|
||||
const friends = relationships.filter(
|
||||
(relationship) => relationship.type === RelationshipTypes.FRIEND && !excludeUserIds.includes(relationship.id),
|
||||
);
|
||||
|
||||
return friends
|
||||
.map((relationship) => UserStore.getUser(relationship.id))
|
||||
.filter((user): user is UserRecord => Boolean(user))
|
||||
.sort((a, b) => NicknameUtils.getNickname(a).localeCompare(NicknameUtils.getNickname(b)));
|
||||
}, [relationships, excludeUserIds]);
|
||||
|
||||
const activeStickyUserIds = React.useMemo(() => {
|
||||
return stickyUserIds.filter((id) => selectedUserIds.includes(id));
|
||||
}, [stickyUserIds, selectedUserIds]);
|
||||
|
||||
const groupedFriends = React.useMemo(() => {
|
||||
const filtered = friendUsers.filter((user) => {
|
||||
if (!searchQuery) return true;
|
||||
return NicknameUtils.getNickname(user).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
const stickySet = new Set(activeStickyUserIds);
|
||||
const groups: Record<string, Array<UserRecord>> = {};
|
||||
|
||||
filtered.forEach((user) => {
|
||||
if (stickySet.has(user.id)) return;
|
||||
const firstLetter = NicknameUtils.getNickname(user)[0].toUpperCase();
|
||||
if (!groups[firstLetter]) {
|
||||
groups[firstLetter] = [];
|
||||
}
|
||||
groups[firstLetter].push(user);
|
||||
});
|
||||
|
||||
const groupArray: Array<FriendGroup> = Object.keys(groups)
|
||||
.sort()
|
||||
.map((letter) => ({
|
||||
letter,
|
||||
friendIds: groups[letter].map((user) => user.id),
|
||||
}));
|
||||
|
||||
return groupArray;
|
||||
}, [friendUsers, searchQuery, activeStickyUserIds]);
|
||||
|
||||
const handleRemovePill = (userId: string) => {
|
||||
onToggle(userId);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Backspace' && searchQuery === '' && selectedUserIds.length > 0) {
|
||||
onToggle(selectedUserIds[selectedUserIds.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (userId: string) => {
|
||||
handleSearchChange('');
|
||||
onToggle(userId);
|
||||
};
|
||||
|
||||
const isMaxed = maxSelections !== undefined && selectedUserIds.length >= maxSelections;
|
||||
|
||||
const isMutableRefObject = (
|
||||
ref: React.Ref<HTMLInputElement> | undefined,
|
||||
): ref is React.MutableRefObject<HTMLInputElement | null> =>
|
||||
typeof ref === 'object' && ref !== null && 'current' in ref;
|
||||
|
||||
const renderSearchInput = ({inputProps, inputClassName, ref: forwardedRef}: RenderInputArgs) => {
|
||||
const handleRef = (node: HTMLInputElement | null) => {
|
||||
inputRef.current = node;
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(node);
|
||||
} else if (isMutableRefObject(forwardedRef)) {
|
||||
forwardedRef.current = node;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(inputClassName, styles.searchField)}>
|
||||
{selectedUserIds.map((userId) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div key={userId} className={styles.selectedPill}>
|
||||
<Avatar user={user} size={16} />
|
||||
<span>{NicknameUtils.getNickname(user)}</span>
|
||||
<FocusRing offset={-2}>
|
||||
<button type="button" onClick={() => handleRemovePill(userId)} className={styles.removeButton}>
|
||||
<XIcon className={styles.removeIcon} weight="bold" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={styles.searchFieldInner}>
|
||||
<MagnifyingGlassIcon
|
||||
className={clsx(styles.searchIcon, inputFocused && styles.searchIconFocused)}
|
||||
weight="bold"
|
||||
/>
|
||||
<input {...inputProps} ref={handleRef} className={styles.searchInput} spellCheck={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{showSearchInput && (
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setInputFocused(true)}
|
||||
onBlur={() => setInputFocused(false)}
|
||||
placeholder={selectedUserIds.length > 0 ? '' : t`Search friends`}
|
||||
renderInput={({inputProps, inputClassName, ref, defaultInput}) =>
|
||||
renderSearchInput({inputProps, inputClassName, ref, defaultInput})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Scroller
|
||||
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}>
|
||||
<p className={styles.emptyStateText}>{searchQuery ? t`No friends found` : t`You have no friends yet`}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.groupsContainer}>
|
||||
{activeStickyUserIds.length > 0 && (
|
||||
<div className={styles.friendsList}>
|
||||
{activeStickyUserIds.map((userId) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) return null;
|
||||
|
||||
const isSelected = selectedUserIds.includes(userId);
|
||||
const canSelect = !isMaxed || isSelected;
|
||||
|
||||
return (
|
||||
<FocusRing key={userId} offset={-2} enabled={canSelect}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canSelect && handleToggle(userId)}
|
||||
disabled={!canSelect}
|
||||
className={clsx(
|
||||
styles.friendButton,
|
||||
isSelected && styles.friendButtonSelected,
|
||||
!canSelect && styles.friendButtonDisabled,
|
||||
)}
|
||||
>
|
||||
<div className={styles.friendInfo}>
|
||||
<StatusAwareAvatar user={user} size={32} />
|
||||
<span className={styles.friendName}>{NicknameUtils.getNickname(user)}</span>
|
||||
</div>
|
||||
<div className={styles.checkboxContainer}>
|
||||
<Checkbox checked={isSelected} readOnly aria-hidden={true} />
|
||||
</div>
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{groupedFriends.map((group) => (
|
||||
<div key={group.letter}>
|
||||
<div className={styles.groupLetter}>{group.letter}</div>
|
||||
<div className={styles.friendsList}>
|
||||
{group.friendIds.map((userId) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) return null;
|
||||
|
||||
const isSelected = selectedUserIds.includes(userId);
|
||||
const canSelect = !isMaxed || isSelected;
|
||||
|
||||
return (
|
||||
<FocusRing key={userId} offset={-2} enabled={canSelect}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canSelect && handleToggle(userId)}
|
||||
disabled={!canSelect}
|
||||
className={clsx(
|
||||
styles.friendButton,
|
||||
isSelected && styles.friendButtonSelected,
|
||||
!canSelect && styles.friendButtonDisabled,
|
||||
)}
|
||||
>
|
||||
<div className={styles.friendInfo}>
|
||||
<StatusAwareAvatar user={user} size={32} />
|
||||
<span className={styles.friendName}>{NicknameUtils.getNickname(user)}</span>
|
||||
</div>
|
||||
<div className={styles.checkboxContainer}>
|
||||
<Checkbox checked={isSelected} readOnly aria-hidden={true} />
|
||||
</div>
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Scroller>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
66
fluxer_app/src/components/common/GroupDMAvatar.module.css
Normal file
66
fluxer_app/src/components/common/GroupDMAvatar.module.css
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.iconImageContainer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-secondary);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.defaultIconContainer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.defaultIcon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.avatarPosition {
|
||||
position: absolute;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.avatarWrapper {
|
||||
position: absolute;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.multiAvatarContainer {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
384
fluxer_app/src/components/common/GroupDMAvatar.tsx
Normal file
384
fluxer_app/src/components/common/GroupDMAvatar.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* 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 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';
|
||||
|
||||
function computeGroupStatus(channel: ChannelRecord): string | null {
|
||||
const memberIds = new Set<string>(channel.recipientIds);
|
||||
if (memberIds.size === 0) return null;
|
||||
|
||||
for (const id of memberIds) {
|
||||
if (PresenceStore.getStatus(id) === StatusTypes.ONLINE) {
|
||||
return StatusTypes.ONLINE;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderGroupStatusDot(status: string | null, size: number, isTyping?: boolean, i18nInstance: I18n = i18n) {
|
||||
const layout = getAvatarStatusLayout(size);
|
||||
|
||||
if (!layout.supportsStatus) return null;
|
||||
if (!status && !isTyping) return null;
|
||||
|
||||
const baseStatus = status || StatusTypes.ONLINE;
|
||||
const renderableStatus = baseStatus === StatusTypes.INVISIBLE ? StatusTypes.OFFLINE : baseStatus;
|
||||
|
||||
const statusColor = `var(--status-${renderableStatus})`;
|
||||
const statusLabel = getStatusTypeLabel(i18nInstance, renderableStatus);
|
||||
|
||||
const typingMode = Boolean(isTyping);
|
||||
|
||||
const bubbleWidth = typingMode ? layout.innerTypingWidth : layout.innerStatusWidth;
|
||||
const bubbleHeight = typingMode ? layout.innerTypingHeight : layout.innerStatusHeight;
|
||||
const bubbleRight = typingMode ? layout.innerTypingRight : layout.innerStatusRight;
|
||||
const bubbleBottom = typingMode ? layout.innerTypingBottom : layout.innerStatusBottom;
|
||||
|
||||
return (
|
||||
<Tooltip text={statusLabel}>
|
||||
<div
|
||||
className={styles.statusDot}
|
||||
style={{
|
||||
right: bubbleRight,
|
||||
bottom: bubbleBottom,
|
||||
width: bubbleWidth,
|
||||
height: bubbleHeight,
|
||||
borderRadius: bubbleHeight / 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
role="img"
|
||||
aria-label={typingMode ? `${statusLabel} typing indicator` : `${statusLabel} status`}
|
||||
>
|
||||
{typingMode ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: statusColor,
|
||||
borderRadius: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className={baseAvatarStyles.typingDots}>
|
||||
{[0, 0.25, 0.5].map((delay, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={baseAvatarStyles.typingDot}
|
||||
style={{
|
||||
width: Math.min(layout.innerStatusWidth, layout.innerStatusHeight) * 0.25,
|
||||
height: Math.min(layout.innerStatusWidth, layout.innerStatusHeight) * 0.25,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
y={0}
|
||||
width={1}
|
||||
height={1}
|
||||
fill={statusColor}
|
||||
mask={`url(#svg-mask-status-${renderableStatus})`}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTypingCutouts(layout: AvatarStatusLayout): Array<React.ReactNode> {
|
||||
const extendW = Math.max(0, layout.innerTypingWidth - layout.innerStatusWidth);
|
||||
|
||||
const cx = layout.cutoutCx;
|
||||
const cy = layout.cutoutCy;
|
||||
const r = layout.cutoutRadius;
|
||||
|
||||
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" />,
|
||||
];
|
||||
}
|
||||
|
||||
interface GroupDMAvatarProps {
|
||||
channel: ChannelRecord;
|
||||
size: number;
|
||||
isTyping?: boolean;
|
||||
disableStatusIndicator?: boolean;
|
||||
statusOverride?: StatusType | null;
|
||||
}
|
||||
|
||||
interface AvatarPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
avatarSize: number;
|
||||
}
|
||||
|
||||
function getAvatarPosition(count: number, index: number, size: number): AvatarPosition {
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let avatarSize = size;
|
||||
|
||||
if (count === 2) {
|
||||
const ratio = 0.7;
|
||||
avatarSize = size * ratio;
|
||||
const verticalInset = Math.min(size * 0.06, avatarSize * 0.18);
|
||||
|
||||
if (index === 0) {
|
||||
top = verticalInset;
|
||||
left = 0;
|
||||
} else {
|
||||
top = size - avatarSize - verticalInset;
|
||||
left = size - avatarSize;
|
||||
}
|
||||
|
||||
return {top, left, avatarSize};
|
||||
}
|
||||
|
||||
if (count === 3) {
|
||||
const ratio = 0.68;
|
||||
avatarSize = size * ratio;
|
||||
|
||||
const verticalInset = Math.min(size * 0.04, avatarSize * 0.12);
|
||||
const topRowTop = verticalInset;
|
||||
const bottomRowTop = size - avatarSize - verticalInset;
|
||||
const topLeft = 0;
|
||||
const topRight = size - avatarSize;
|
||||
const bottomCenter = (size - avatarSize) / 2;
|
||||
|
||||
if (index === 0) {
|
||||
top = topRowTop;
|
||||
left = topLeft;
|
||||
} else if (index === 1) {
|
||||
top = topRowTop;
|
||||
left = topRight;
|
||||
} else {
|
||||
top = bottomRowTop;
|
||||
left = bottomCenter;
|
||||
}
|
||||
|
||||
return {top, left, avatarSize};
|
||||
}
|
||||
|
||||
return {top: 0, left: 0, avatarSize: size};
|
||||
}
|
||||
|
||||
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 shouldShowStatusIndicator = !disableStatusIndicator;
|
||||
const status = shouldShowStatusIndicator ? (statusOverride ?? computeGroupStatus(channel)) : null;
|
||||
const statusForIndicator = status === StatusTypes.ONLINE ? status : null;
|
||||
const groupMaskId = React.useId();
|
||||
|
||||
if (iconUrl) {
|
||||
const layout = getAvatarStatusLayout(size);
|
||||
const shouldRenderStatusDot = shouldShowStatusIndicator && (statusForIndicator || isTyping);
|
||||
const hasCutout = layout.supportsStatus && shouldRenderStatusDot;
|
||||
const statusDot = shouldRenderStatusDot ? renderGroupStatusDot(statusForIndicator, size, isTyping) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.container} style={{width: size, height: size}}>
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className={styles.iconImageContainer} aria-hidden role="presentation">
|
||||
<defs>
|
||||
<mask id={groupMaskId} maskUnits="userSpaceOnUse" x={0} y={0} width={size} height={size}>
|
||||
<circle cx={size / 2} cy={size / 2} r={size / 2} fill="white" />
|
||||
{hasCutout &&
|
||||
(isTyping ? (
|
||||
renderTypingCutouts(layout)
|
||||
) : (
|
||||
<circle cx={layout.cutoutCx} cy={layout.cutoutCy} r={layout.cutoutRadius} fill="black" />
|
||||
))}
|
||||
</mask>
|
||||
</defs>
|
||||
<image
|
||||
href={iconUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
mask={`url(#${groupMaskId})`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
/>
|
||||
</svg>
|
||||
{statusDot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.recipientIds.length === 0) {
|
||||
const shouldRenderStatusDot = shouldShowStatusIndicator && (statusForIndicator || isTyping);
|
||||
const statusDot = shouldRenderStatusDot ? renderGroupStatusDot(statusForIndicator, size, isTyping) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.defaultIconContainer}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: accentColor,
|
||||
}}
|
||||
>
|
||||
<UsersIcon weight="fill" className={styles.defaultIcon} style={{width: size * 0.5, height: size * 0.5}} />
|
||||
{statusDot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayRecipientIds =
|
||||
channel.recipientIds.length === 1 && currentUser
|
||||
? [channel.recipientIds[0], currentUser.id]
|
||||
: channel.recipientIds.slice(0, 3);
|
||||
|
||||
const count = displayRecipientIds.length;
|
||||
|
||||
const clusterSize = count === 3 ? Math.min(size, 32) : size;
|
||||
|
||||
const layout = getAvatarStatusLayout(clusterSize);
|
||||
const shouldRenderStatusDot = shouldShowStatusIndicator && (statusForIndicator || isTyping);
|
||||
const statusDot = shouldRenderStatusDot ? renderGroupStatusDot(statusForIndicator, clusterSize, isTyping) : null;
|
||||
const avatarBorderSize = 2;
|
||||
|
||||
return (
|
||||
<div className={styles.multiAvatarContainer} style={{width: clusterSize, height: clusterSize}}>
|
||||
<svg
|
||||
viewBox={`0 0 ${clusterSize} ${clusterSize}`}
|
||||
style={{position: 'absolute', inset: 0, overflow: 'visible'}}
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
>
|
||||
<defs>
|
||||
{displayRecipientIds.map((userId, index) => {
|
||||
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
|
||||
const avatarMaskId = `${groupMaskId}-avatar-${index}`;
|
||||
const cx = left + avatarSize / 2;
|
||||
const cy = top + avatarSize / 2;
|
||||
const r = avatarSize / 2;
|
||||
|
||||
const cutouts: Array<React.ReactNode> = [];
|
||||
|
||||
for (let j = index + 1; j < displayRecipientIds.length; j++) {
|
||||
const otherPos = getAvatarPosition(count, j, clusterSize);
|
||||
const otherCx = otherPos.left + otherPos.avatarSize / 2;
|
||||
const otherCy = otherPos.top + otherPos.avatarSize / 2;
|
||||
const otherR = otherPos.avatarSize / 2 + avatarBorderSize;
|
||||
cutouts.push(<circle key={`cutout-${j}`} cx={otherCx} cy={otherCy} r={otherR} fill="black" />);
|
||||
}
|
||||
|
||||
const isBottomRight = (count === 2 && index === 1) || (count === 3 && index === 2);
|
||||
|
||||
if (shouldRenderStatusDot && isBottomRight && layout.supportsStatus) {
|
||||
if (isTyping) {
|
||||
cutouts.push(...renderTypingCutouts(layout));
|
||||
} else if (layout.cutoutRadius > 0) {
|
||||
cutouts.push(
|
||||
<circle
|
||||
key="status-cutout"
|
||||
cx={layout.cutoutCx}
|
||||
cy={layout.cutoutCy}
|
||||
r={layout.cutoutRadius}
|
||||
fill="black"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<mask
|
||||
key={userId}
|
||||
id={avatarMaskId}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x={0}
|
||||
y={0}
|
||||
width={clusterSize}
|
||||
height={clusterSize}
|
||||
>
|
||||
<circle cx={cx} cy={cy} r={r} fill="white" />
|
||||
{cutouts}
|
||||
</mask>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
{displayRecipientIds.map((userId, index) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
const {top, left, avatarSize} = getAvatarPosition(count, index, clusterSize);
|
||||
const avatarMaskId = `${groupMaskId}-avatar-${index}`;
|
||||
|
||||
let avatarUrl: string;
|
||||
if (user) {
|
||||
avatarUrl = AvatarUtils.getUserAvatarURL({id: user.id, avatar: user.avatar});
|
||||
} else {
|
||||
const avatarIndex = index % 6;
|
||||
avatarUrl = cdnUrl(`avatars/${avatarIndex}.png`);
|
||||
}
|
||||
|
||||
return (
|
||||
<image
|
||||
key={userId}
|
||||
href={avatarUrl}
|
||||
x={left}
|
||||
y={top}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
mask={`url(#${avatarMaskId})`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
clipPath={`circle(${avatarSize / 2}px at ${avatarSize / 2}px ${avatarSize / 2}px)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{statusDot}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
85
fluxer_app/src/components/common/SpoilerOverlay.module.css
Normal file
85
fluxer_app/src/components/common/SpoilerOverlay.module.css
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hidden .content {
|
||||
filter: blur(10px);
|
||||
opacity: 0.2;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--transition-normal),
|
||||
filter var(--transition-normal);
|
||||
}
|
||||
|
||||
.overlayButton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--spoiler-overlay-color);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
z-index: 2;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
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);
|
||||
}
|
||||
|
||||
.overlayLabel {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: color-mix(in srgb, var(--background-secondary) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
50
fluxer_app/src/components/common/SpoilerOverlay.tsx
Normal file
50
fluxer_app/src/components/common/SpoilerOverlay.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
onReveal: () => void;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
|
||||
const {t} = useLingui();
|
||||
const ariaLabel = label ?? t`Reveal spoiler`;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}>
|
||||
<div className={styles.content} aria-hidden={hidden}>
|
||||
{children}
|
||||
</div>
|
||||
{hidden && (
|
||||
<button type="button" className={styles.overlayButton} onClick={onReveal} aria-label={ariaLabel}>
|
||||
<span className={styles.overlayLabel}>{label ?? t`Spoiler`}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user