initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
/*
* 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/>.
*/
.accordion {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-top: var(--spacing-6);
padding-bottom: var(--spacing-6);
border-top: 1px solid var(--background-modifier-accent);
}
.accordion:first-child {
padding-top: 0;
border-top: none;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
gap: 12px;
}
.headerContent {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.caret {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform 0.2s ease;
margin-top: 4px;
}
.caretExpanded {
transform: rotate(180deg);
}
.contentWrapper {
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,142 @@
/*
* 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 {CaretDownIcon} 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 sectionStyles from '~/components/modals/shared/SettingsSection.module.css';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import AccessibilityStore from '~/stores/AccessibilityStore';
import styles from './Accordion.module.css';
export interface AccordionProps {
id: string;
title: React.ReactNode;
description?: React.ReactNode;
defaultExpanded?: boolean;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
children: React.ReactNode;
className?: string;
}
export const Accordion: React.FC<AccordionProps> = observer(
({
id,
title,
description,
defaultExpanded = false,
expanded: controlledExpanded,
onExpandedChange,
children,
className,
}) => {
const [internalExpanded, setInternalExpanded] = React.useState(defaultExpanded);
const headerRef = React.useRef<HTMLButtonElement>(null);
const contentId = `${id}-content`;
const isControlled = controlledExpanded !== undefined;
const expanded = isControlled ? controlledExpanded : internalExpanded;
const reduceMotion = AccessibilityStore.useReducedMotion;
const handleToggle = React.useCallback(() => {
const scrollContainer = headerRef.current?.closest('[data-settings-scroll-container]') as HTMLElement | null;
const headerRect = headerRef.current?.getBoundingClientRect();
const containerRect = scrollContainer?.getBoundingClientRect();
const offsetFromContainer = headerRect && containerRect ? headerRect.top - containerRect.top : null;
const wasAtBottom =
scrollContainer != null &&
Math.abs(scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight) < 20;
const newExpanded = !expanded;
if (isControlled) {
onExpandedChange?.(newExpanded);
} else {
setInternalExpanded(newExpanded);
onExpandedChange?.(newExpanded);
}
if (scrollContainer) {
if (newExpanded && wasAtBottom) {
setTimeout(() => {
scrollContainer.scrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight;
}, 250);
} else if (offsetFromContainer !== null && !newExpanded) {
requestAnimationFrame(() => {
const newHeaderRect = headerRef.current?.getBoundingClientRect();
const newContainerRect = scrollContainer.getBoundingClientRect();
if (newHeaderRect && newContainerRect) {
const newOffset = newHeaderRect.top - newContainerRect.top;
const delta = newOffset - offsetFromContainer;
if (Math.abs(delta) > 1) {
scrollContainer.scrollTop += delta;
}
}
});
}
}
}, [expanded, isControlled, onExpandedChange]);
const animationProps = reduceMotion
? {}
: {
initial: {height: 0, opacity: 0},
animate: {height: 'auto', opacity: 1},
exit: {height: 0, opacity: 0},
transition: {duration: 0.2, ease: [0.4, 0, 0.2, 1] as const},
};
return (
<div className={clsx(styles.accordion, className)} id={id}>
<FocusRing offset={-2}>
<button
ref={headerRef}
type="button"
className={styles.header}
onClick={handleToggle}
aria-expanded={expanded}
aria-controls={contentId}
>
<div className={styles.headerContent}>
<span className={sectionStyles.sectionTitle}>{title}</span>
{description ? <span className={sectionStyles.sectionDescription}>{description}</span> : null}
</div>
<CaretDownIcon className={clsx(styles.caret, expanded && styles.caretExpanded)} size={20} weight="bold" />
</button>
</FocusRing>
<AnimatePresence initial={false}>
{expanded ? (
<motion.div
id={contentId}
className={styles.contentWrapper}
{...animationProps}
style={{overflow: 'hidden'}}
>
<div className={styles.content}>{children}</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
},
);

View File

@@ -0,0 +1,147 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React, {type CSSProperties} from 'react';
import {getStatusTypeLabel} from '~/Constants';
import {BaseAvatar} from '~/components/uikit/BaseAvatar';
import {useHover} from '~/hooks/useHover';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import type {UserRecord} from '~/records/UserRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as ImageCacheUtils from '~/utils/ImageCacheUtils';
interface AvatarProps {
user: UserRecord;
size: number;
status?: string | null;
isMobileStatus?: boolean;
forceAnimate?: boolean;
isTyping?: boolean;
showOffline?: boolean;
className?: string;
style?: CSSProperties;
isClickable?: boolean;
disableStatusTooltip?: boolean;
avatarUrl?: string | null;
hoverAvatarUrl?: string | null;
guildId?: string | null;
}
const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
(
{
user,
size,
status,
isMobileStatus = false,
forceAnimate = false,
isTyping = false,
showOffline = true,
className,
isClickable = false,
disableStatusTooltip = false,
avatarUrl: customAvatarUrl,
hoverAvatarUrl: customHoverAvatarUrl,
guildId,
...props
},
ref,
) => {
const {i18n} = useLingui();
const guildMember = GuildMemberStore.getMember(guildId || '', user.id);
const avatarUrl = React.useMemo(() => {
if (customAvatarUrl !== undefined) return customAvatarUrl;
if (guildId && guildMember?.avatar) {
return AvatarUtils.getGuildMemberAvatarURL({
guildId,
userId: user.id,
avatar: guildMember.avatar,
animated: false,
});
}
return AvatarUtils.getUserAvatarURL(user, false);
}, [user, customAvatarUrl, guildId, guildMember]);
const hoverAvatarUrl = React.useMemo(() => {
if (customHoverAvatarUrl !== undefined) return customHoverAvatarUrl;
if (guildId && guildMember?.avatar) {
return AvatarUtils.getGuildMemberAvatarURL({
guildId,
userId: user.id,
avatar: guildMember.avatar,
animated: true,
});
}
return AvatarUtils.getUserAvatarURL(user, true);
}, [user, customHoverAvatarUrl, guildId, guildMember]);
const statusLabel = status != null ? getStatusTypeLabel(i18n, status) : null;
const [hoverRef, isHovering] = useHover();
const [isStaticLoaded, setIsStaticLoaded] = React.useState(ImageCacheUtils.hasImage(avatarUrl));
const [isAnimatedLoaded, setIsAnimatedLoaded] = React.useState(ImageCacheUtils.hasImage(hoverAvatarUrl));
const [shouldPlayAnimated, setShouldPlayAnimated] = React.useState(false);
React.useEffect(() => {
ImageCacheUtils.loadImage(avatarUrl, () => setIsStaticLoaded(true));
if (isHovering || forceAnimate) {
ImageCacheUtils.loadImage(hoverAvatarUrl, () => setIsAnimatedLoaded(true));
}
}, [avatarUrl, hoverAvatarUrl, isHovering, forceAnimate]);
React.useEffect(() => {
setShouldPlayAnimated((isHovering || forceAnimate) && isAnimatedLoaded);
}, [isHovering, forceAnimate, isAnimatedLoaded]);
const safeAvatarUrl = avatarUrl || AvatarUtils.getUserAvatarURL({id: user.id, avatar: null}, false);
const safeHoverAvatarUrl = hoverAvatarUrl || undefined;
return (
<BaseAvatar
ref={useMergeRefs([ref, hoverRef])}
size={size}
avatarUrl={safeAvatarUrl}
hoverAvatarUrl={safeHoverAvatarUrl}
status={status}
isMobileStatus={isMobileStatus}
shouldPlayAnimated={shouldPlayAnimated && isStaticLoaded}
isTyping={isTyping}
showOffline={showOffline}
className={className}
isClickable={isClickable}
userTag={user.tag}
statusLabel={statusLabel}
disableStatusTooltip={disableStatusTooltip}
{...props}
/>
);
},
);
AvatarComponent.displayName = 'Avatar';
export const Avatar = observer(AvatarComponent);

View File

@@ -0,0 +1,119 @@
/*
* 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 {getStatusGeometry} from '~/components/uikit/AvatarStatusGeometry';
const TYPING_WIDTH_MULTIPLIER = 1.8;
export interface AvatarStatusLayout {
supportsStatus: boolean;
statusSize: number;
borderSize: number;
statusWidth: number;
statusHeight: number;
typingWidth: number;
typingHeight: number;
innerStatusWidth: number;
innerStatusHeight: number;
innerTypingWidth: number;
innerTypingHeight: number;
statusRight: number;
statusBottom: number;
typingRight: number;
innerStatusRight: number;
innerStatusBottom: number;
innerTypingRight: number;
innerTypingBottom: number;
cutoutCx: number;
cutoutCy: number;
cutoutRadius: number;
typingCutoutCx: number;
typingCutoutCy: number;
typingCutoutWidth: number;
typingCutoutHeight: number;
typingCutoutRx: number;
}
export function getAvatarStatusLayout(size: number, isMobile: boolean = false): AvatarStatusLayout {
const supportsStatus = size > 16;
const geom = getStatusGeometry(size, isMobile);
const statusSize = geom.size;
const borderSize = geom.borderWidth;
const statusWidth = statusSize;
const statusHeight = isMobile && geom.phoneHeight ? geom.phoneHeight : statusSize;
const typingWidth = Math.round(statusSize * TYPING_WIDTH_MULTIPLIER);
const typingHeight = statusSize;
const innerStatusWidth = statusSize;
const innerStatusHeight = isMobile && geom.phoneHeight ? geom.phoneHeight : statusSize;
const innerTypingWidth = typingWidth;
const innerTypingHeight = typingHeight;
const cutoutCx = geom.cx;
const cutoutCy = geom.cy;
const cutoutRadius = geom.radius;
const typingCutoutWidth = typingWidth;
const typingCutoutHeight = typingHeight;
const typingCutoutCx = cutoutCx + statusSize / 2 - typingWidth / 2;
const typingCutoutCy = cutoutCy;
const typingCutoutRx = geom.radius;
const innerStatusRight = size - cutoutCx - statusSize / 2;
const innerStatusBottom = size - cutoutCy - statusHeight / 2;
const innerTypingRight = innerStatusRight;
const innerTypingBottom = size - cutoutCy - typingHeight / 2;
const statusRight = innerStatusRight;
const statusBottom = innerStatusBottom;
const typingRight = innerTypingRight;
return {
supportsStatus,
statusSize,
borderSize,
statusWidth,
statusHeight,
typingWidth,
typingHeight,
innerStatusWidth,
innerStatusHeight,
innerTypingWidth,
innerTypingHeight,
statusRight,
statusBottom,
typingRight,
innerStatusRight,
innerStatusBottom,
innerTypingRight,
innerTypingBottom,
cutoutCx,
cutoutCy,
cutoutRadius,
typingCutoutCx,
typingCutoutCy,
typingCutoutWidth,
typingCutoutHeight,
typingCutoutRx,
};
}

View File

@@ -0,0 +1,79 @@
/*
* 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;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
opacity: 0.9;
}
.overlay {
position: absolute;
inset: 0;
}
.hoverOverlay {
position: absolute;
inset: 0;
border-radius: inherit;
background-color: hsl(0, 0%, 0%);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.clickable:hover .hoverOverlay,
.clickable:has(:focus-visible) .hoverOverlay {
opacity: 0.4;
}
.statusContainer {
pointer-events: auto;
position: absolute;
}
.typingDots {
display: flex;
gap: 2px;
align-items: center;
justify-content: center;
}
.typingDot {
background-color: white;
border-radius: 50%;
animation: 1s blink infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
:global(html:not(.window-focused)) .typingDot {
animation-play-state: paused;
opacity: 1;
}

View File

@@ -0,0 +1,313 @@
/*
* 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 {motion} from 'framer-motion';
import React from 'react';
import type {StatusType} from '~/Constants';
import {getStatusTypeLabel, normalizeStatus, StatusTypes} from '~/Constants';
import {getStatusGeometry} from '~/components/uikit/AvatarStatusGeometry';
import {getAvatarStatusLayout} from '~/components/uikit/AvatarStatusLayout';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import FocusManager from '~/lib/FocusManager';
import typingStyles from '~/styles/Typing.module.css';
import styles from './BaseAvatar.module.css';
interface BaseAvatarProps {
size: number;
avatarUrl: string;
hoverAvatarUrl?: string;
status?: StatusType | string | null;
shouldPlayAnimated?: boolean;
isTyping?: boolean;
showOffline?: boolean;
className?: string;
isClickable?: boolean;
userTag?: string;
statusLabel?: string | null;
disableStatusTooltip?: boolean;
isMobileStatus?: boolean;
}
export const BaseAvatar = React.forwardRef<HTMLDivElement, BaseAvatarProps>(
(
{
size,
avatarUrl,
hoverAvatarUrl,
status,
shouldPlayAnimated = false,
isTyping = false,
showOffline = true,
className,
isClickable = false,
userTag,
statusLabel,
disableStatusTooltip = false,
isMobileStatus = false,
...props
},
ref,
) => {
const {t, i18n} = useLingui();
const [isFocused, setIsFocused] = React.useState(FocusManager.isFocused());
React.useEffect(() => {
const unsubscribe = FocusManager.subscribe(setIsFocused);
return unsubscribe;
}, []);
const normalizedStatus = status == null ? null : normalizeStatus(status);
const renderableStatus = resolveRenderableStatus(normalizedStatus);
const layout = getAvatarStatusLayout(size, isMobileStatus);
const SNAPPY_TRANSITION = {type: 'tween', duration: 0.16, ease: 'easeOut'} as const;
const rawId = React.useId();
const safeId = rawId.replace(/:/g, '');
const dynamicAvatarMaskId = `svg-mask-avatar-dynamic-${safeId}`;
const isMobileOnline = isMobileStatus && renderableStatus === StatusTypes.ONLINE && !isTyping;
const shouldShowStatus =
layout.supportsStatus &&
(isTyping || (normalizedStatus != null && (showOffline || renderableStatus !== StatusTypes.OFFLINE)));
const shouldUseDynamicAvatarMask = shouldShowStatus && !isMobileOnline;
const statusGeom = shouldUseDynamicAvatarMask ? getStatusGeometry(size, false) : null;
const cutoutR = statusGeom?.radius ?? 0;
const cutoutCx = statusGeom?.cx ?? 0;
const cutoutCy = statusGeom?.cy ?? 0;
const typingDeltaW = layout.innerTypingWidth - layout.innerStatusWidth;
const extendW = Math.max(0, typingDeltaW);
const baseBridgeRect = {
x: cutoutCx,
y: cutoutCy - cutoutR,
width: 0,
height: cutoutR * 2,
};
const typingBridgeRect = {
x: cutoutCx - extendW,
y: cutoutCy - cutoutR,
width: extendW,
height: cutoutR * 2,
};
const baseLeftCap = {cx: cutoutCx};
const typingLeftCap = {cx: cutoutCx - extendW};
const displayUrl = shouldPlayAnimated && hoverAvatarUrl && isFocused ? hoverAvatarUrl : avatarUrl;
const avatarMaskId = shouldUseDynamicAvatarMask
? dynamicAvatarMaskId
: resolveAvatarMaskId({shouldShowStatus, isTyping, isMobileOnline, size});
const statusMaskId = isMobileOnline
? `svg-mask-status-online-mobile-${size}`
: `svg-mask-status-${renderableStatus}`;
const statusColor = `var(--status-${renderableStatus})`;
const baseR = layout.innerStatusHeight / 2;
const typingR = layout.innerTypingHeight / 2;
const typingAnimation = {
width: layout.innerTypingWidth,
height: layout.innerTypingHeight,
right: layout.innerStatusRight,
bottom: layout.innerTypingBottom,
borderRadius: typingR,
};
const statusAnimation = {
width: layout.innerStatusWidth,
height: layout.innerStatusHeight,
right: layout.innerStatusRight,
bottom: layout.innerStatusBottom,
borderRadius: isMobileOnline ? 0 : baseR,
};
const dotDelays = [0, 250, 500] as const;
const ariaLabel = statusLabel && userTag ? `${userTag}, ${statusLabel}` : userTag || t`Avatar`;
const effectiveStatusLabel = statusLabel || (normalizedStatus ? getStatusTypeLabel(i18n, normalizedStatus) : '');
return (
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label is supported by both button and img roles
<div
ref={ref}
className={`${styles.container} ${isClickable ? styles.clickable : ''} ${className || ''}`.trim()}
role={isClickable ? 'button' : 'img'}
aria-label={ariaLabel}
style={{width: size, height: size}}
aria-hidden={false}
tabIndex={isClickable ? 0 : undefined}
{...props}
>
<svg
viewBox={`0 0 ${size} ${size}`}
className={styles.overlay}
style={{borderRadius: '50%'}}
aria-hidden
role="presentation"
>
{shouldUseDynamicAvatarMask && statusGeom && (
<defs>
<mask id={dynamicAvatarMaskId} maskUnits="userSpaceOnUse" x={0} y={0} width={size} height={size}>
<circle fill="white" cx={size / 2} cy={size / 2} r={size / 2} />
<circle fill="black" cx={cutoutCx} cy={cutoutCy} r={cutoutR} />
<motion.rect
fill="black"
rx={0}
ry={0}
initial={false}
animate={isTyping ? typingBridgeRect : baseBridgeRect}
transition={SNAPPY_TRANSITION}
/>
<motion.circle
fill="black"
cy={cutoutCy}
r={cutoutR}
initial={false}
animate={isTyping ? typingLeftCap : baseLeftCap}
transition={SNAPPY_TRANSITION}
/>
</mask>
</defs>
)}
<image
href={displayUrl}
width={size}
height={size}
mask={`url(#${avatarMaskId})`}
preserveAspectRatio="xMidYMid slice"
/>
</svg>
<div className={styles.hoverOverlay} style={{borderRadius: '50%'}} />
{shouldShowStatus && (
<Tooltip text={effectiveStatusLabel}>
<motion.div
className={styles.statusContainer}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: isTyping || disableStatusTooltip ? 'none' : 'auto',
overflow: 'hidden',
}}
initial={false}
animate={isTyping ? typingAnimation : statusAnimation}
transition={SNAPPY_TRANSITION}
role="img"
aria-label={isTyping ? t`Typing indicator` : `${effectiveStatusLabel} status`}
>
{isTyping ? (
<div
style={{
width: '100%',
height: '100%',
backgroundColor: statusColor,
borderRadius: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: Math.round(layout.innerTypingHeight * 0.12),
}}
>
{dotDelays.map((delay) => (
<div
key={delay}
className={typingStyles.dot}
style={{
width: Math.round(layout.innerTypingHeight * 0.25),
height: Math.round(layout.innerTypingHeight * 0.25),
borderRadius: '50%',
backgroundColor: 'white',
animationDelay: `${delay}ms`,
}}
/>
))}
</div>
</div>
) : (
<StatusIndicatorSvg
width={layout.innerStatusWidth}
height={isMobileOnline ? layout.innerStatusHeight : layout.innerStatusWidth}
statusColor={statusColor}
statusMaskId={statusMaskId}
/>
)}
</motion.div>
</Tooltip>
)}
</div>
);
},
);
BaseAvatar.displayName = 'BaseAvatar';
const resolveRenderableStatus = (status: StatusType | null | undefined): StatusType => {
if (status == null) return StatusTypes.OFFLINE;
if (status === StatusTypes.INVISIBLE) return StatusTypes.OFFLINE;
return status;
};
const resolveAvatarMaskId = ({
shouldShowStatus,
isTyping,
isMobileOnline,
size,
}: {
shouldShowStatus: boolean;
isTyping: boolean;
isMobileOnline: boolean;
size: number;
}): string => {
if (!shouldShowStatus) return 'svg-mask-avatar-default';
if (isTyping) return `svg-mask-avatar-status-typing-${size}`;
if (isMobileOnline) return `svg-mask-avatar-status-mobile-${size}`;
return `svg-mask-avatar-status-round-${size}`;
};
interface StatusIndicatorSvgProps {
width: number;
height: number;
statusColor: string;
statusMaskId: string;
}
const StatusIndicatorSvg = ({width, height, statusColor, statusMaskId}: StatusIndicatorSvgProps) => (
// biome-ignore lint/a11y/noSvgWithoutTitle: decorative SVG, parent has aria-label
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>
<rect x={0} y={0} width={width} height={height} fill={statusColor} mask={`url(#${statusMaskId})`} />
</svg>
);

View File

@@ -0,0 +1,109 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import * as Sheet from '~/components/uikit/Sheet/Sheet';
interface BottomSheetProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
initialSnap?: number;
snapPoints?: Array<number>;
disablePadding?: boolean;
disableDefaultHeader?: boolean;
zIndex?: number;
showHandle?: boolean;
showCloseButton?: boolean;
surface?: 'primary' | 'secondary' | 'tertiary';
headerSlot?: React.ReactNode;
leadingAction?: React.ReactNode;
trailingAction?: React.ReactNode;
containerClassName?: string;
contentClassName?: string;
}
export const BottomSheet: React.FC<BottomSheetProps> = observer(
({
isOpen,
onClose,
children,
title,
initialSnap = 1,
snapPoints = [0, 0.5, 0.8, 1],
disablePadding = false,
disableDefaultHeader = false,
zIndex,
showHandle = true,
showCloseButton = true,
surface = 'secondary',
headerSlot,
leadingAction,
trailingAction,
containerClassName,
contentClassName,
}) => {
const shouldRenderDefaultHeader =
!disableDefaultHeader && (!!title || !!leadingAction || !!trailingAction || showCloseButton);
const renderTrailingContent = () => {
if (!shouldRenderDefaultHeader) return undefined;
if (trailingAction && showCloseButton) {
return (
<>
{trailingAction}
<Sheet.CloseButton onClick={onClose} />
</>
);
}
if (showCloseButton) {
return <Sheet.CloseButton onClick={onClose} />;
}
return trailingAction;
};
return (
<Sheet.Root
isOpen={isOpen}
onClose={onClose}
snapPoints={snapPoints}
initialSnap={initialSnap}
surface={surface}
zIndex={zIndex}
className={containerClassName}
>
{showHandle && <Sheet.Handle />}
{shouldRenderDefaultHeader && (
<Sheet.Header
leading={leadingAction}
trailing={renderTrailingContent()}
safeAreaTop={!showHandle}
after={headerSlot}
>
{title && <Sheet.Title>{title}</Sheet.Title>}
</Sheet.Header>
)}
{!shouldRenderDefaultHeader && headerSlot}
{disablePadding ? children : <Sheet.Content className={contentClassName}>{children}</Sheet.Content>}
</Sheet.Root>
);
},
);

View File

@@ -0,0 +1,314 @@
/*
* 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/>.
*/
.button {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
line-height: 20px;
height: 44px;
min-height: 44px;
min-width: 96px;
border-radius: 8px;
border: none;
cursor: pointer;
appearance: none;
text-decoration: none;
position: relative;
overflow: hidden;
transition:
background-color 0.15s ease,
color 0.15s ease,
transform 0.1s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.button.matchSkeletonHeight {
height: 36px;
min-height: 36px;
width: 100%;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.button:active:not(:disabled) {
transform: translateY(1px);
}
.button.small {
height: 40px;
min-height: 40px;
min-width: 60px;
padding: 8px 12px;
}
.button.compact {
height: 32px;
min-height: 32px;
min-width: 60px;
padding: 6px 12px;
}
.button.superCompact {
height: 24px;
min-height: 24px;
min-width: 0;
padding: 4px;
font-size: 12px;
line-height: 16px;
border-radius: 6px;
}
.button.fitContent {
min-width: 0;
padding: 10px 16px;
}
.button.superCompact.fitContent {
padding: 4px 8px;
}
.button.fitContainer {
width: 100%;
}
.button.square {
width: 44px;
min-width: 44px;
padding: 10px;
}
.button.square.small {
width: 40px;
min-width: 40px;
padding: 8px;
}
.button.square.compact {
width: 32px;
min-width: 32px;
padding: 6px;
}
.button.primary {
background-color: var(--brand-primary);
color: var(--brand-primary-fill);
}
.button.primary:hover:not(:disabled),
.button.primary:focus-visible:not(:disabled) {
background-color: var(--brand-secondary);
}
.button.secondary {
background-color: var(--background-tertiary);
color: var(--button-secondary-text);
}
:global(.theme-light) .button.secondary {
background-color: var(--background-modifier-hover);
color: var(--button-ghost-text);
}
.button.secondary:hover:not(:disabled),
.button.secondary:focus-visible:not(:disabled) {
background-color: var(--button-secondary-active-fill);
color: var(--button-secondary-active-text);
}
:global(.theme-light) .button.secondary:hover:not(:disabled),
:global(.theme-light) .button.secondary:focus-visible:not(:disabled) {
background-color: var(--background-modifier-hover);
color: var(--button-ghost-text);
}
.button.dangerPrimary {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.button.dangerPrimary:hover:not(:disabled),
.button.dangerPrimary:focus-visible:not(:disabled) {
background-color: var(--button-danger-active-fill);
}
.button.dangerSecondary {
background-color: color-mix(in srgb, var(--button-danger-fill) 12%, transparent);
color: var(--button-danger-outline-text);
}
.button.dangerSecondary:hover:not(:disabled),
.button.dangerSecondary:focus-visible:not(:disabled) {
background-color: color-mix(in srgb, var(--button-danger-fill) 20%, transparent);
}
.button.dangerSecondary:active:not(:disabled) {
background-color: color-mix(in srgb, var(--button-danger-fill) 26%, transparent);
}
.button.inverted {
background-color: var(--button-inverted-fill);
color: var(--button-inverted-text);
}
.button.invertedOutline {
background-color: transparent;
color: white;
border: 1px solid white;
padding: 4px 8px;
}
.button.invertedOutline.small {
padding: 6px 10px;
}
.button.invertedOutline.superCompact {
padding: 2px 6px;
}
.button.invertedOutline:hover:not(:disabled),
.button.invertedOutline:focus-visible:not(:disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
.button.recording {
background-color: var(--accent-success, #16a34a);
color: var(--brand-primary-fill);
border: 1px solid color-mix(in srgb, var(--accent-success, #16a34a) 55%, transparent);
animation: buttonRecordingPulse 1.1s infinite;
}
.button.recording:hover:not(:disabled),
.button.recording:focus-visible:not(:disabled) {
background-color: color-mix(in srgb, var(--accent-success, #16a34a) 90%, #000 0%);
}
.spinner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.spinnerInner {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 28px;
height: auto;
}
.spinnerItem {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 2px;
background-color: hsl(0, 0%, 100%);
border-radius: 4px;
opacity: 0.3;
animation: spinnerPulsingEllipsis 1.4s ease-in-out infinite;
}
:global(.theme-light) .button.secondary .spinnerItem {
background-color: #000;
}
.spinnerItemInverted {
background-color: #000;
}
.spinnerItem:nth-of-type(2) {
animation-delay: 0.2s;
}
.spinnerItem:nth-of-type(3) {
animation-delay: 0.4s;
}
.iconWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.spinnerWrapper {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.hidden {
opacity: 0;
pointer-events: none;
}
.grid {
display: grid;
width: 100%;
height: 100%;
}
@keyframes spinnerPulsingEllipsis {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes buttonRecordingPulse {
0% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--accent-success, #16a34a) 18%, transparent),
0 0 0 0 color-mix(in srgb, var(--accent-success, #16a34a) 0%, transparent);
}
50% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--accent-success, #16a34a) 28%, transparent),
0 0 0 6px color-mix(in srgb, var(--accent-success, #16a34a) 12%, transparent);
}
100% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--accent-success, #16a34a) 18%, transparent),
0 0 0 0 color-mix(in srgb, var(--accent-success, #16a34a) 0%, transparent);
}
}

View File

@@ -0,0 +1,180 @@
/*
* 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 React from 'react';
import styles from '~/components/uikit/Button/Button.module.css';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
interface BaseButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'type' | 'disabled' | 'className'> {
className?: string;
contentClassName?: string;
disabled?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
onClick?:
| ((event: React.MouseEvent<HTMLButtonElement>) => void)
| ((event: React.KeyboardEvent<HTMLButtonElement>) => void);
small?: boolean;
compact?: boolean;
superCompact?: boolean;
submitting?: boolean;
type?: 'button' | 'submit';
variant?: 'primary' | 'secondary' | 'danger-primary' | 'danger-secondary' | 'inverted' | 'inverted-outline';
fitContainer?: boolean;
fitContent?: boolean;
recording?: boolean;
matchSkeletonHeight?: boolean;
}
export interface SquareButtonProps extends BaseButtonProps {
square: true;
children?: never;
icon: React.ReactNode;
}
export interface RegularButtonProps extends BaseButtonProps {
square?: false;
children?: React.ReactNode;
}
export type ButtonProps = SquareButtonProps | RegularButtonProps;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const {
children,
className,
contentClassName,
disabled,
leftIcon,
rightIcon,
onClick,
small,
compact,
superCompact,
square,
submitting,
type = 'button',
variant = 'primary',
fitContainer = false,
fitContent = false,
recording = false,
matchSkeletonHeight = false,
onKeyDown: userOnKeyDown,
...buttonProps
} = props;
const icon = square ? (props as SquareButtonProps).icon : undefined;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (submitting) {
event.preventDefault();
return;
}
(onClick as ((e: React.MouseEvent<HTMLButtonElement>) => void) | undefined)?.(event);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
userOnKeyDown?.(event);
if (submitting) {
event.preventDefault();
return;
}
const isSpaceKey = event.key === ' ' || event.key === 'Spacebar';
if (isSpaceKey) {
event.preventDefault();
(onClick as ((e: React.KeyboardEvent<HTMLButtonElement>) => void) | undefined)?.(event);
}
};
const spinnerItemClass = clsx(styles.spinnerItem, {
[styles.spinnerItemInverted]: variant === 'inverted',
});
const variantClass =
variant === 'inverted-outline'
? 'invertedOutline'
: variant === 'danger-primary'
? 'dangerPrimary'
: variant === 'danger-secondary'
? 'dangerSecondary'
: variant;
return (
<FocusRing offset={-2}>
<button
ref={ref}
className={clsx(
styles.button,
styles[variantClass],
{
[styles.small]: small,
[styles.compact]: compact,
[styles.superCompact]: superCompact,
[styles.square]: square,
[styles.fitContainer]: fitContainer,
[styles.fitContent]: fitContent,
[styles.recording]: recording,
[styles.matchSkeletonHeight]: matchSkeletonHeight,
},
className,
)}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
type={type}
tabIndex={disabled ? -1 : 0}
{...buttonProps}
>
<div className={clsx(contentClassName)}>
<div className={styles.grid}>
<div className={clsx(styles.iconWrapper, {[styles.hidden]: submitting})}>
{square ? (
icon
) : (
<>
{leftIcon}
{children}
{rightIcon}
</>
)}
</div>
<div className={clsx(styles.spinnerWrapper, {[styles.hidden]: !submitting})}>
<span className={styles.spinner}>
<span className={styles.spinnerInner}>
<span className={spinnerItemClass} />
<span className={spinnerItemClass} />
<span className={spinnerItemClass} />
</span>
</span>
</div>
</div>
</div>
</button>
</FocusRing>
);
});
Button.displayName = 'Button';

View File

@@ -0,0 +1,70 @@
/*
* 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/>.
*/
.controls {
display: flex;
align-items: center;
gap: 4px;
padding: 6px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--background-modifier-accent) 70%, transparent);
background: color-mix(in srgb, var(--background-secondary) 85%, transparent);
backdrop-filter: blur(12px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
}
.controlsDisabled {
opacity: 0.5;
cursor: not-allowed;
}
.button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-primary-muted);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.button:hover {
background: var(--background-modifier-hover);
color: var(--text-primary);
}
.buttonActive {
background: var(--background-modifier-accent);
color: var(--text-primary);
}
.buttonActive:hover {
background: var(--background-modifier-accent);
}
.buttonDisabled {
cursor: not-allowed;
pointer-events: none;
}

View File

@@ -0,0 +1,113 @@
/*
* 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 {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type {IconProps} from '@phosphor-icons/react';
import {TextAlignCenterIcon, TextAlignLeftIcon, TextAlignRightIcon} from '@phosphor-icons/react';
import clsx from 'clsx';
import {useMemo} from 'react';
import type {GuildSplashCardAlignmentValue} from '~/Constants';
import {GuildSplashCardAlignment} from '~/Constants';
import type {TooltipPosition} from '~/components/uikit/Tooltip';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import styles from './CardAlignmentControls.module.css';
interface CardAlignmentControlOption {
value: GuildSplashCardAlignmentValue;
label: MessageDescriptor;
icon?: React.ComponentType<IconProps>;
}
interface CardAlignmentControlsProps {
value: GuildSplashCardAlignmentValue;
onChange: (alignment: GuildSplashCardAlignmentValue) => void;
options?: ReadonlyArray<CardAlignmentControlOption>;
disabled?: boolean;
disabledTooltipText?: string;
tooltipPosition?: TooltipPosition;
className?: string;
}
const DEFAULT_ALIGNMENT_OPTIONS: ReadonlyArray<CardAlignmentControlOption> = [
{value: GuildSplashCardAlignment.LEFT, label: msg`Left`, icon: TextAlignLeftIcon},
{value: GuildSplashCardAlignment.CENTER, label: msg`Center`, icon: TextAlignCenterIcon},
{value: GuildSplashCardAlignment.RIGHT, label: msg`Right`, icon: TextAlignRightIcon},
];
export const CardAlignmentControls: React.FC<CardAlignmentControlsProps> = ({
value,
onChange,
options = DEFAULT_ALIGNMENT_OPTIONS,
disabled = false,
disabledTooltipText,
tooltipPosition = 'top',
className,
}) => {
const {t} = useLingui();
const translatedOptions = useMemo(() => options.map((option) => ({...option, label: t(option.label)})), [options, t]);
const controls = (
<div
className={clsx(styles.controls, disabled && styles.controlsDisabled, className)}
role="group"
aria-label={t`Card alignment controls`}
>
{translatedOptions.map((option) => {
const isActive = value === option.value;
const Icon = option.icon;
const handleClick = () => {
if (disabled) return;
onChange(option.value);
};
const button = (
<button
type="button"
className={clsx(styles.button, isActive && styles.buttonActive, disabled && styles.buttonDisabled)}
onClick={handleClick}
disabled={disabled}
aria-pressed={isActive}
aria-label={option.label}
title={option.label}
>
{Icon ? <Icon size={18} weight={isActive ? 'bold' : 'regular'} /> : option.label}
</button>
);
return (
<Tooltip key={option.value} text={option.label} position="top">
{button}
</Tooltip>
);
})}
</div>
);
if (disabled && disabledTooltipText) {
return (
<Tooltip text={disabledTooltipText} position={tooltipPosition}>
{controls}
</Tooltip>
);
}
return controls;
};

View File

@@ -0,0 +1,49 @@
/*
* 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/>.
*/
.counter {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
line-height: 1rem;
}
.counterButton {
composes: counter;
cursor: pointer;
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.counterButton:hover {
opacity: 0.8;
}
.counterSpan {
composes: counter;
cursor: default;
}
.textDanger {
color: var(--status-danger);
}
.textTertiary {
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,89 @@
/*
* 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 {t} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {shouldShowPremiumFeatures} from '~/utils/PremiumUtils';
import styles from './CharacterCounter.module.css';
interface CharacterCounterProps {
currentLength: number;
maxLength: number;
isPremium: boolean;
premiumMaxLength: number;
onUpgradeClick: () => void;
className?: string;
}
export const CharacterCounter = observer(
({currentLength, maxLength, isPremium, premiumMaxLength, onUpgradeClick, className}: CharacterCounterProps) => {
const {i18n} = useLingui();
const remaining = maxLength - currentLength;
const isOverLimit = remaining < 0;
const isNearingLimit = remaining < 50;
const showPremiumFeatures = shouldShowPremiumFeatures();
const needsPremium = !isPremium && showPremiumFeatures && (isNearingLimit || isOverLimit);
const tooltipText = needsPremium
? t(i18n)`${remaining} characters left. Get Plutonium to write up to ${premiumMaxLength} characters.`
: isPremium && isOverLimit
? t(i18n)`Message is too long`
: t(i18n)`${remaining} characters left`;
if (needsPremium) {
return (
<Tooltip text={tooltipText}>
<FocusRing offset={-2}>
<button
type="button"
onClick={onUpgradeClick}
className={clsx(
styles.counterButton,
isOverLimit || isNearingLimit ? styles.textDanger : styles.textTertiary,
className,
)}
>
{remaining}
</button>
</FocusRing>
</Tooltip>
);
}
return (
<Tooltip text={tooltipText}>
<span
className={clsx(
styles.counterSpan,
isOverLimit || isNearingLimit ? styles.textDanger : styles.textTertiary,
className,
)}
>
{remaining}
</span>
</Tooltip>
);
},
);

View File

@@ -0,0 +1,194 @@
/*
* 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/>.
*/
.checkboxWrapper {
display: flex;
align-items: center;
position: relative;
height: 24px;
user-select: none;
-webkit-user-select: none;
flex: 0 0 auto;
cursor: pointer;
}
.disabled {
opacity: 0.6;
}
.menuDisabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--background-header-secondary);
box-sizing: border-box;
cursor: pointer;
}
.box {
border-radius: 3px;
}
.round {
border-radius: 50%;
}
.checked {
background-color: var(--brand-primary);
border: 1px solid var(--brand-primary);
}
.checkedInverted {
background-color: var(--text-on-brand-primary);
border-color: var(--brand-primary);
}
.inverted {
border-color: white;
}
.focused {
box-shadow: none;
}
.checkIcon {
color: white;
}
.invertedIcon {
color: var(--brand-primary);
}
.label {
padding-left: 8px;
color: var(--text-primary);
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin-top: -2px;
}
.labelInteractive {
cursor: pointer;
}
.labelText {
line-height: 1.25rem;
}
.keyboardShortcutHints {
margin-left: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: flex-start;
font-size: 0.75rem;
color: var(--text-tertiary);
}
.keyboardShortcut {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 0;
}
.keyboardShortcutKey {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary-alt);
color: var(--text-primary);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.2),
0 1px 0 rgba(255, 255, 255, 0.1) inset,
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.keyboardShortcutLabel {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.keyboardShortcutHint {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.keyboardShortcutPortal {
position: absolute;
left: 0;
top: calc(100% + 4px);
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: flex-start;
font-size: 0.75rem;
color: var(--text-tertiary);
line-height: 1;
}
.labelFocusRing {
border-radius: var(--radius-sm);
}
.noOutline {
outline: none !important;
}
.menuChecked {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
}
.menuChecked:hover {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
}
.checkboxIndicator {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: inherit;
}

View File

@@ -0,0 +1,324 @@
/*
* 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 {CheckIcon} from '@phosphor-icons/react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import styles from './Checkbox.module.css';
const LINK_SELECTOR = 'a[href], [role="link"]';
const isLinkTarget = (target: EventTarget | null): target is Element =>
target instanceof Element && Boolean(target.closest(LINK_SELECTOR));
const CheckboxTypes = {
BOX: 'box',
ROUND: 'round',
} as const;
type CheckboxType = (typeof CheckboxTypes)[keyof typeof CheckboxTypes];
interface CheckboxLinkShortcut {
key: string;
label: string;
hint: string;
action: () => void;
}
interface CheckboxBaseProps {
checked?: boolean;
disabled?: boolean;
readOnly?: boolean;
inverted?: boolean;
type?: CheckboxType;
className?: string;
noFocus?: boolean;
size?: number | 'small';
variant?: 'default' | 'menu';
linkShortcuts?: ReadonlyArray<CheckboxLinkShortcut>;
onChange?: (checked: boolean) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
'aria-label'?: string;
'aria-describedby'?: string;
'aria-hidden'?: boolean;
}
type CheckboxWithLabelProps = CheckboxBaseProps & {
children: React.ReactNode;
};
type CheckboxAccessibleProps = CheckboxBaseProps & {
children?: undefined;
'aria-label': string;
};
type CheckboxHiddenProps = CheckboxBaseProps & {
children?: undefined;
'aria-hidden': true;
};
type CheckboxProps = CheckboxWithLabelProps | CheckboxAccessibleProps | CheckboxHiddenProps;
export const Checkbox: React.FC<CheckboxProps> = observer(
({
checked = false,
disabled = false,
readOnly = false,
inverted = false,
type = CheckboxTypes.BOX,
className,
children,
noFocus = false,
size = 24,
variant = 'default',
onChange,
onFocus,
onBlur,
linkShortcuts,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
'aria-hidden': ariaHidden,
}) => {
const rootRef = React.useRef<React.ElementRef<typeof CheckboxPrimitive.Root>>(null);
const checkboxRef = React.useRef<HTMLLabelElement>(null);
const labelRef = React.useRef<HTMLDivElement>(null);
const actualSize = size === 'small' ? 18 : size;
const checkIconSize = Math.floor(actualSize * 0.75);
const baseId = React.useId();
const checkboxId = `${baseId}-checkbox-input`;
const handleChange = React.useCallback(
(isSelected: boolean) => {
if (!disabled && !readOnly) {
onChange?.(isSelected);
}
},
[disabled, onChange, readOnly],
);
const handleFocus = React.useCallback(
(event: React.FocusEvent<HTMLButtonElement>) => {
setIsCheckboxFocused(true);
if (!disabled && !readOnly && !noFocus) {
onFocus?.(event);
}
},
[disabled, noFocus, onFocus, readOnly],
);
const handleBlur = React.useCallback(
(event: React.FocusEvent<HTMLButtonElement>) => {
setIsCheckboxFocused(false);
onBlur?.(event);
},
[onBlur],
);
const labelInteractive = !disabled && !readOnly && Boolean(children);
const [labelContainsLink, setLabelContainsLink] = React.useState(false);
React.useLayoutEffect(() => {
const container = labelRef.current;
const hasLink = Boolean(container?.querySelector(LINK_SELECTOR));
setLabelContainsLink((previous) => (previous === hasLink ? previous : hasLink));
}, [children]);
const labelHandlesFocus = labelInteractive && !labelContainsLink;
const labelFocusEnabled = labelHandlesFocus && !noFocus;
const labelTabIndex = labelHandlesFocus ? 0 : -1;
const [isCheckboxFocused, setIsCheckboxFocused] = React.useState(false);
const keyboardModeEnabled = KeyboardModeStore.keyboardModeEnabled;
const showLinkShortcuts =
keyboardModeEnabled && isCheckboxFocused && Array.isArray(linkShortcuts) && linkShortcuts.length > 0;
const shortcutHintId = showLinkShortcuts ? `${checkboxId}-link-shortcuts` : undefined;
const describedByIds = [ariaDescribedBy, shortcutHintId].filter(Boolean).join(' ') || undefined;
const handleLabelClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!labelInteractive) return;
if (isLinkTarget(event.target)) {
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
handleChange(!checked);
rootRef.current?.focus();
},
[checked, handleChange, labelInteractive],
);
const handleLabelKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!labelInteractive || isLinkTarget(event.target)) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleChange(!checked);
rootRef.current?.focus();
}
},
[checked, handleChange, labelInteractive],
);
const handleShortcutKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!showLinkShortcuts) return;
const targetKey = event.key.toLowerCase();
const shortcut = linkShortcuts?.find((entry) => entry.key.toLowerCase() === targetKey);
if (!shortcut) return;
event.preventDefault();
event.stopPropagation();
shortcut.action();
},
[linkShortcuts, showLinkShortcuts],
);
const focusRingEnabled = !noFocus && !ariaHidden;
if (ariaHidden) {
return (
<span
className={clsx(
styles.checkboxWrapper,
disabled && (variant === 'menu' ? styles.menuDisabled : styles.disabled),
className,
)}
style={{height: actualSize}}
aria-hidden={true}
>
<span
className={clsx(
styles.checkbox,
type === CheckboxTypes.ROUND ? styles.round : styles.box,
checked && styles.checked,
inverted && !checked && styles.inverted,
checked && inverted && styles.checkedInverted,
variant === 'menu' && checked && styles.menuChecked,
)}
style={{width: actualSize, height: actualSize}}
>
<span className={styles.checkboxIndicator}>
{checked && (
<CheckIcon
size={checkIconSize}
weight="bold"
color={inverted ? 'var(--brand-primary)' : '#ffffff'}
className={clsx(styles.checkIcon, inverted && styles.invertedIcon)}
/>
)}
</span>
</span>
</span>
);
}
return (
<FocusRing focusTarget={rootRef} ringTarget={checkboxRef} offset={4} enabled={focusRingEnabled}>
<label
ref={checkboxRef}
className={clsx(
styles.checkboxWrapper,
disabled && (variant === 'menu' ? styles.menuDisabled : styles.disabled),
className,
)}
htmlFor={checkboxId}
style={{height: actualSize}}
>
<CheckboxPrimitive.Root
ref={rootRef}
checked={checked}
disabled={disabled}
onCheckedChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleShortcutKeyDown}
className={clsx(
styles.checkbox,
type === CheckboxTypes.ROUND ? styles.round : styles.box,
checked && styles.checked,
inverted && !checked && styles.inverted,
checked && inverted && styles.checkedInverted,
variant === 'menu' && checked && styles.menuChecked,
)}
style={{width: actualSize, height: actualSize}}
aria-label={ariaLabel}
aria-describedby={describedByIds}
tabIndex={0}
id={checkboxId}
>
<CheckboxPrimitive.Indicator className={styles.checkboxIndicator}>
{checked && (
<CheckIcon
size={checkIconSize}
weight="bold"
color={inverted ? 'var(--brand-primary)' : '#ffffff'}
className={clsx(styles.checkIcon, inverted && styles.invertedIcon)}
/>
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
{children && (
<FocusRing
focusTarget={labelRef}
ringTarget={labelRef}
offset={-2}
ringClassName={styles.labelFocusRing}
enabled={labelFocusEnabled}
>
<div
ref={labelRef}
className={clsx(styles.label, labelInteractive && styles.labelInteractive)}
tabIndex={labelTabIndex}
onClick={handleLabelClick}
onKeyDown={labelHandlesFocus ? handleLabelKeyDown : undefined}
role="checkbox"
aria-checked={checked}
aria-disabled={disabled || readOnly}
>
<div className={styles.labelText}>{children}</div>
</div>
</FocusRing>
)}
{showLinkShortcuts && shortcutHintId && (
<div id={shortcutHintId} className={styles.keyboardShortcutPortal} role="status" aria-live="polite">
{linkShortcuts!.map((shortcut) => (
<span key={shortcut.key} className={styles.keyboardShortcut}>
<span className={styles.keyboardShortcutKey} aria-hidden="true">
{shortcut.key.toUpperCase()}
</span>
<span className={styles.keyboardShortcutLabel} aria-hidden="true">
{shortcut.label}
</span>
<span className={styles.keyboardShortcutHint}>{shortcut.hint}</span>
</span>
))}
</div>
)}
</label>
</FocusRing>
);
},
);

View File

@@ -0,0 +1,62 @@
/*
* 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 {Trans} from '@lingui/react/macro';
import {EyeIcon, ProhibitIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {GuildBan} from '~/actions/GuildActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {BanDetailsModal} from '~/components/modals/BanDetailsModal';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface BannedUserContextMenuProps {
ban: GuildBan;
onClose: () => void;
onRevoke: () => void;
}
export const BannedUserContextMenu: React.FC<BannedUserContextMenuProps> = observer(({ban, onClose, onRevoke}) => {
const handleViewDetails = () => {
onClose();
ModalActionCreators.push(modal(() => <BanDetailsModal ban={ban} onRevoke={onRevoke} />));
};
const handleRevokeBan = () => {
onClose();
onRevoke();
};
return (
<>
<MenuGroup>
<MenuItem icon={<EyeIcon weight="bold" />} onClick={handleViewDetails}>
<Trans>View Details</Trans>
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem icon={<ProhibitIcon weight="bold" />} danger onClick={handleRevokeBan}>
<Trans>Revoke Ban</Trans>
</MenuItem>
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,80 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import {Permissions} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import PermissionStore from '~/stores/PermissionStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import {
CategoryNotificationSettingsMenuItem,
CollapseAllCategoriesMenuItem,
CollapseCategoryMenuItem,
CopyCategoryIdMenuItem,
DeleteCategoryMenuItem,
EditCategoryMenuItem,
MarkCategoryAsReadMenuItem,
MuteCategoryMenuItem,
} from './items/CategoryMenuItems';
import {DebugChannelMenuItem} from './items/DebugMenuItems';
import {MenuGroup} from './MenuGroup';
interface CategoryContextMenuProps {
category: ChannelRecord;
onClose: () => void;
}
export const CategoryContextMenu: React.FC<CategoryContextMenuProps> = observer(({category, onClose}) => {
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
channelId: category.id,
guildId: category.guildId,
});
const developerMode = UserSettingsStore.developerMode;
return (
<>
<MenuGroup>
<MarkCategoryAsReadMenuItem category={category} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<CollapseCategoryMenuItem category={category} onClose={onClose} />
<CollapseAllCategoriesMenuItem category={category} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<MuteCategoryMenuItem category={category} onClose={onClose} />
<CategoryNotificationSettingsMenuItem category={category} onClose={onClose} />
</MenuGroup>
{canManageChannels && (
<MenuGroup>
<EditCategoryMenuItem category={category} onClose={onClose} />
<DeleteCategoryMenuItem category={category} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyCategoryIdMenuItem category={category} onClose={onClose} />
{developerMode && <DebugChannelMenuItem channel={category} onClose={onClose} />}
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,90 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import {Permissions} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import PermissionStore from '~/stores/PermissionStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import {
ChannelNotificationSettingsMenuItem,
CopyChannelIdMenuItem,
CopyChannelLinkMenuItem,
DeleteChannelMenuItem,
EditChannelMenuItem,
FavoriteChannelMenuItem,
InvitePeopleToChannelMenuItem,
MarkChannelAsReadMenuItem,
MuteChannelMenuItem,
} from './items/ChannelMenuItems';
import {DebugChannelMenuItem} from './items/DebugMenuItems';
import {MenuGroup} from './MenuGroup';
interface ChannelContextMenuProps {
channel: ChannelRecord;
onClose: () => void;
}
export const ChannelContextMenu: React.FC<ChannelContextMenuProps> = observer(({channel, onClose}) => {
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
channelId: channel.id,
guildId: channel.guildId,
});
const developerMode = UserSettingsStore.developerMode;
return (
<>
<MenuGroup>
<MarkChannelAsReadMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<FavoriteChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<InvitePeopleToChannelMenuItem channel={channel} onClose={onClose} />
<CopyChannelLinkMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<MuteChannelMenuItem channel={channel} onClose={onClose} />
<ChannelNotificationSettingsMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
{canManageChannels && (
<MenuGroup>
<EditChannelMenuItem channel={channel} onClose={onClose} />
<DeleteChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
{developerMode && (
<MenuGroup>
<DebugChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyChannelIdMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,102 @@
/*
* 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 {FolderPlusIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {Permissions} from '~/Constants';
import {CategoryCreateModal} from '~/components/modals/CategoryCreateModal';
import {ChannelCreateModal} from '~/components/modals/ChannelCreateModal';
import {InviteModal} from '~/components/modals/InviteModal';
import type {GuildRecord} from '~/records/GuildRecord';
import PermissionStore from '~/stores/PermissionStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import * as InviteUtils from '~/utils/InviteUtils';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
import {MenuItemCheckbox} from './MenuItemCheckbox';
interface ChannelListContextMenuProps {
guild: GuildRecord;
onClose: () => void;
}
export const ChannelListContextMenu: React.FC<ChannelListContextMenuProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
guildId: guild.id,
});
const invitableChannelId = InviteUtils.getInvitableChannelId(guild.id);
const canInvite = InviteUtils.canInviteToChannel(invitableChannelId, guild.id);
const hideMutedChannels = UserGuildSettingsStore.getSettings(guild.id)?.hide_muted_channels ?? false;
const handleToggleHideMutedChannels = React.useCallback(() => {
UserGuildSettingsActionCreators.toggleHideMutedChannels(guild.id);
}, [guild.id]);
const handleCreateChannel = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <ChannelCreateModal guildId={guild.id} />));
}, [guild.id, onClose]);
const handleCreateCategory = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <CategoryCreateModal guildId={guild.id} />));
}, [guild.id, onClose]);
const handleInvitePeople = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <InviteModal channelId={invitableChannelId ?? ''} />));
}, [invitableChannelId, onClose]);
return (
<>
<MenuGroup>
<MenuItemCheckbox checked={hideMutedChannels} onChange={handleToggleHideMutedChannels}>
{t`Hide Muted Channels`}
</MenuItemCheckbox>
</MenuGroup>
{canManageChannels && (
<MenuGroup>
<MenuItem icon={<PlusIcon style={{width: 16, height: 16}} />} onClick={handleCreateChannel}>
{t`Create Channel`}
</MenuItem>
<MenuItem icon={<FolderPlusIcon style={{width: 16, height: 16}} />} onClick={handleCreateCategory}>
{t`Create Category`}
</MenuItem>
</MenuGroup>
)}
{canInvite && (
<MenuGroup>
<MenuItem icon={<UserPlusIcon style={{width: 16, height: 16}} />} onClick={handleInvitePeople}>
{t`Invite People`}
</MenuItem>
</MenuGroup>
)}
</>
);
});

View File

@@ -0,0 +1,391 @@
/*
* 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/>.
*/
.contextMenuOverlay {
position: fixed;
inset: 0;
z-index: var(--z-index-contextmenu);
background: transparent;
pointer-events: none;
}
.backdrop {
position: absolute;
inset: 0;
background: transparent;
pointer-events: auto;
z-index: -1;
}
.contextMenu {
pointer-events: auto;
min-width: 220px;
max-width: 360px;
width: max-content;
padding: 8px;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
outline: none;
box-sizing: border-box;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--background-modifier-accent) transparent;
-webkit-app-region: no-drag;
}
.contextMenu::-webkit-scrollbar {
width: 8px;
}
.contextMenu::-webkit-scrollbar-track {
background: transparent;
}
.contextMenu::-webkit-scrollbar-thumb {
background-color: var(--background-modifier-accent);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.contextMenu::-webkit-scrollbar-thumb:hover {
background-color: var(--background-modifier-hover);
}
.item {
all: unset;
display: grid;
grid-template-columns: 18px 1fr auto;
align-items: center;
gap: 12px;
padding: 6px 8px;
margin: 1px 0;
border-radius: 3px;
font-size: 14px;
font-weight: 500;
line-height: 18px;
color: var(--text-secondary);
cursor: pointer;
box-sizing: border-box;
min-height: 32px;
width: 100%;
}
.item:has(.itemShortcut) {
padding-right: 0;
}
.item:hover:not(.disabled) {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.item:focus-visible:not(.disabled) {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.item.disabled {
color: var(--interactive-muted);
cursor: not-allowed;
opacity: 0.5;
}
.item.danger {
color: var(--status-danger);
}
.item.danger:hover:not(.disabled) {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.item.danger:focus-visible:not(.disabled) {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.item.danger:is([data-highlighted], [data-hovered], [data-focused], [data-focus-visible], [data-selected]):not(
.disabled
):not([data-disabled]) {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.itemIcon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
grid-column: 1;
}
.itemIcon > svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
.itemLabel {
grid-column: 2;
display: flex;
align-items: center;
min-height: 18px;
min-width: 0;
overflow: hidden;
}
.itemShortcut {
grid-column: 3;
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-mono);
white-space: nowrap;
margin-left: auto;
padding-left: 24px;
}
.item.danger:is(
:hover,
:focus-visible,
[data-highlighted],
[data-hovered],
[data-focused],
[data-focus-visible],
[data-selected],
[data-open]
):not(.disabled):not([data-disabled])
.itemShortcut {
color: var(--button-danger-text);
}
.itemLabelContainer {
grid-column: 2;
display: flex;
flex-direction: column;
min-width: 0;
}
.itemLabelText {
display: flex;
align-items: center;
min-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemHint {
color: var(--text-tertiary-muted);
font-size: 12px;
line-height: 16px;
margin-top: 2px;
white-space: nowrap;
}
.submenuCaret {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-secondary);
grid-column: 3;
display: flex;
align-items: center;
justify-content: center;
}
.item[data-open] {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.item.danger[data-open] {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.item:not(:has(.itemIcon)) {
grid-template-columns: 18px 1fr auto;
}
.item:not(:has(.itemIcon)) .itemLabel {
grid-column: 1 / 3;
}
.item:not(:has(.itemIcon)) .submenuCaret {
grid-column: 3;
}
.separator {
height: 1px;
margin: 6px 0;
background-color: var(--background-modifier-accent);
opacity: 0.3;
}
.separator:last-child {
display: none;
}
.checkboxItem {
display: grid !important;
grid-template-columns: 18px 1fr auto !important;
align-items: center;
gap: 12px;
}
.checkboxItem .itemLabel {
grid-column: 2;
}
.menuItemCheckboxLabel {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
overflow: hidden;
}
.menuItemCheckboxLabelPrimary {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menuItemCheckboxDescription {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.checkboxIndicator {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
grid-column: 3;
}
.checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--background-header-secondary);
border-radius: 3px;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.checkboxChecked {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
}
.checkboxChecked::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2.5px 2.5px 0;
transform: translate(-50%, -60%) rotate(45deg);
}
.item.danger:hover .checkbox {
border-color: #ffffff;
background-color: transparent;
}
.item.danger:hover .checkboxChecked {
background-color: #ffffff;
border-color: #ffffff;
}
.item.danger:hover .checkboxChecked::after {
border-color: var(--status-danger);
}
.group {
display: flex;
flex-direction: column;
}
.groupLabel {
padding: 6px 8px 2px;
margin-top: 2px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.ariaMenu {
outline: none;
}
.submenuPopover {
pointer-events: auto !important;
z-index: 2147483647 !important;
min-width: 220px;
max-width: 360px;
width: max-content;
padding: 8px;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
outline: none;
box-sizing: border-box;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--background-modifier-accent) transparent;
-webkit-app-region: no-drag;
}
.submenuPopover::-webkit-scrollbar {
width: 8px;
}
.submenuPopover::-webkit-scrollbar-track {
background: transparent;
}
.submenuPopover::-webkit-scrollbar-thumb {
background-color: var(--background-modifier-accent);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.submenuPopover::-webkit-scrollbar-thumb:hover {
background-color: var(--background-modifier-hover);
}

View File

@@ -0,0 +1,482 @@
/*
* 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 React from 'react';
import type {PressEvent} from 'react-aria-components';
import {
Menu as AriaMenu,
MenuItem as AriaMenuItem,
Popover as AriaPopover,
MenuSection as AriaSection,
Separator as AriaSeparator,
SubmenuTrigger as AriaSubmenuTrigger,
} from 'react-aria-components';
import {createPortal} from 'react-dom';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import type {ContextMenu as ContextMenuType} from '~/stores/ContextMenuStore';
import ContextMenuStore from '~/stores/ContextMenuStore';
import LayerManager from '~/stores/LayerManager';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {isScrollbarDragActive} from '~/utils/ScrollbarDragState';
import styles from './ContextMenu.module.css';
const ContextMenuCloseContext = React.createContext<() => void>(() => {});
export const useContextMenuClose = () => React.useContext(ContextMenuCloseContext);
export const ContextMenuCloseProvider = ContextMenuCloseContext.Provider;
interface RootContextMenuProps {
contextMenu: ContextMenuType;
}
const RootContextMenuInner: React.FC<RootContextMenuProps> = observer(({contextMenu}) => {
const [isOpen, setIsOpen] = React.useState(true);
const [isPositioned, setIsPositioned] = React.useState(false);
const [position, setPosition] = React.useState({x: 0, y: 0});
const menuRef = React.useRef<HTMLDivElement>(null);
const menuContentRef = React.useRef<HTMLDivElement>(null);
const rafIdRef = React.useRef<number | null>(null);
const focusFirstMenuItem = React.useCallback(() => {
const menuElement = menuContentRef.current;
if (!menuElement) {
return;
}
const firstInteractable = menuElement.querySelector<HTMLElement>(
[
'[role="menuitem"]:not([aria-disabled="true"])',
'[role="menuitemcheckbox"]:not([aria-disabled="true"])',
'[role="menuitemradio"]:not([aria-disabled="true"])',
].join(', '),
);
(firstInteractable ?? menuElement).focus({preventScroll: true});
}, []);
const close = React.useCallback(() => {
setIsOpen(false);
ContextMenuActionCreators.close();
}, []);
React.useLayoutEffect(() => {
const {x, y} = contextMenu.target;
const align = contextMenu.config?.align ?? 'top-left';
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cursorOffset = 4;
const edgePadding = 12;
let finalX: number;
let finalY = y + cursorOffset;
if (align === 'top-right') {
finalX = x - rect.width;
if (finalX < edgePadding) {
finalX = x + cursorOffset;
if (finalX + rect.width > viewportWidth - edgePadding) {
finalX = Math.max(edgePadding, viewportWidth - rect.width - edgePadding);
}
}
} else {
finalX = x + cursorOffset;
if (finalX + rect.width > viewportWidth - edgePadding) {
finalX = x - rect.width - cursorOffset;
if (finalX < edgePadding) {
finalX = Math.max(edgePadding, viewportWidth - rect.width - edgePadding);
}
}
}
if (finalY + rect.height > viewportHeight - edgePadding) {
finalY = y - rect.height - cursorOffset;
if (finalY < edgePadding) {
finalY = Math.max(edgePadding, viewportHeight - rect.height - edgePadding);
}
}
finalX = Math.max(edgePadding, finalX);
finalY = Math.max(edgePadding, finalY);
setPosition({x: finalX, y: finalY});
}
setIsPositioned(true);
rafIdRef.current = null;
});
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [contextMenu.target, contextMenu.config?.align]);
React.useEffect(() => {
if (isOpen) {
LayerManager.addLayer('contextmenu', contextMenu.id, close);
return () => {
LayerManager.removeLayer('contextmenu', contextMenu.id);
};
}
return;
}, [isOpen, contextMenu.id, close]);
const handleBackdropClick = React.useCallback(() => {
if (isScrollbarDragActive()) {
return;
}
close();
}, [close]);
React.useLayoutEffect(() => {
if (!isOpen || !isPositioned) {
return;
}
focusFirstMenuItem();
}, [isOpen, isPositioned, contextMenu.id, focusFirstMenuItem]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
close();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const menuElement = menuContentRef.current;
if (!menuElement) return;
const menuItems = menuElement.querySelectorAll<HTMLElement>('[role="menuitem"]');
const pressedKey = e.key.toLowerCase();
for (const item of menuItems) {
const shortcutElement =
item.querySelector(`.${styles.itemShortcut}`) || item.querySelector('[class*="shortcut"]');
if (shortcutElement) {
const shortcutText = shortcutElement.textContent?.toLowerCase().trim();
if (shortcutText === pressedKey) {
e.preventDefault();
e.stopPropagation();
item.click();
return;
}
}
}
}
};
const handleClickOutside = (e: MouseEvent) => {
if (isScrollbarDragActive()) {
return;
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [close]);
if (!isOpen) {
return null;
}
const {x, y} = contextMenu.target;
return (
<div className={styles.contextMenuOverlay} data-overlay-pass-through="true">
<div className={styles.backdrop} onClick={handleBackdropClick} aria-hidden="true" />
<div
ref={menuRef}
className={styles.contextMenu}
style={{
position: 'fixed',
left: `${isPositioned ? position.x : x}px`,
top: `${isPositioned ? position.y : y}px`,
opacity: isPositioned ? 1 : 0,
visibility: isPositioned ? 'visible' : 'hidden',
pointerEvents: isPositioned ? 'auto' : 'none',
zIndex: 'var(--z-index-contextmenu)',
}}
>
<ContextMenuCloseContext.Provider value={close}>
<AriaMenu ref={menuContentRef} className={styles.ariaMenu} aria-label="Context menu">
{contextMenu.render({onClose: close})}
</AriaMenu>
</ContextMenuCloseContext.Provider>
</div>
</div>
);
});
export const RootContextMenu: React.FC<RootContextMenuProps> = observer(({contextMenu}) => {
return <RootContextMenuInner contextMenu={contextMenu} />;
});
interface MenuItemProps {
label: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onSelect?: (event: PressEvent) => void;
icon?: React.ReactNode;
danger?: boolean;
color?: string;
className?: string;
children?: React.ReactNode;
closeOnSelect?: boolean;
shortcut?: string;
}
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
({label, disabled, onSelect, icon, danger, className, children, closeOnSelect = true, shortcut}, forwardedRef) => {
const closeMenu = React.useContext(ContextMenuCloseContext);
const handlePress = React.useCallback(
(event: PressEvent) => {
if (disabled) return;
onSelect?.(event);
if (closeOnSelect) {
closeMenu();
}
},
[closeMenu, closeOnSelect, disabled, onSelect],
);
return (
<AriaMenuItem
ref={forwardedRef}
onPress={handlePress}
isDisabled={disabled}
className={clsx(styles.item, className, {
[styles.danger]: danger,
[styles.disabled]: disabled,
})}
textValue={label || (typeof children === 'string' ? children : '')}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div className={styles.itemLabel}>{children || label}</div>
{shortcut && <div className={styles.itemShortcut}>{shortcut}</div>}
</AriaMenuItem>
);
},
);
MenuItem.displayName = 'MenuItem';
interface SubMenuProps {
label: string;
icon?: React.ReactNode;
disabled?: boolean;
hint?: string;
children?: React.ReactNode;
onTriggerSelect?: () => void;
selectionMode?: 'none' | 'single' | 'multiple';
}
export const SubMenu = React.forwardRef<HTMLDivElement, SubMenuProps>(
({label, icon, disabled, hint, children, onTriggerSelect, selectionMode}, forwardedRef) => {
const handleTriggerPress = React.useCallback(() => {
if (disabled) return;
onTriggerSelect?.();
}, [disabled, onTriggerSelect]);
const handleLabelClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (disabled || !onTriggerSelect) return;
const target = event.target as HTMLElement;
if (target.closest('[data-submenu-caret="true"]')) {
return;
}
event.preventDefault();
event.stopPropagation();
onTriggerSelect();
},
[disabled, onTriggerSelect],
);
const handleLabelKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled || !onTriggerSelect) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onTriggerSelect();
}
},
[disabled, onTriggerSelect],
);
return (
<AriaSubmenuTrigger>
<AriaMenuItem
ref={forwardedRef}
isDisabled={disabled}
className={clsx(styles.item, {
[styles.disabled]: disabled,
})}
textValue={label}
onAction={onTriggerSelect ? handleTriggerPress : undefined}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div
className={clsx(styles.itemLabelContainer)}
onClick={handleLabelClick}
onKeyDown={handleLabelKeyDown}
role="button"
tabIndex={-1}
>
<div className={styles.itemLabelText}>{label}</div>
{hint && <div className={styles.itemHint}>{hint}</div>}
</div>
<svg
className={styles.submenuCaret}
width="16"
height="16"
viewBox="0 0 256 256"
aria-hidden="true"
data-submenu-caret="true"
>
<path
fill="currentColor"
d="M184.49 136.49l-80 80a12 12 0 0 1-17-17L159 128L87.51 56.49a12 12 0 1 1 17-17l80 80a12 12 0 0 1-.02 17"
/>
</svg>
</AriaMenuItem>
<AriaPopover placement="right top" offset={4} className={styles.submenuPopover}>
<AriaMenu className={styles.ariaMenu} aria-label="Submenu" autoFocus="first" selectionMode={selectionMode}>
{children}
</AriaMenu>
</AriaPopover>
</AriaSubmenuTrigger>
);
},
);
SubMenu.displayName = 'SubMenu';
export const MenuSeparator: React.FC = observer(() => {
return <AriaSeparator className={styles.separator} />;
});
interface CheckboxItemProps {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
icon?: React.ReactNode;
children?: React.ReactNode;
danger?: boolean;
closeOnChange?: boolean;
}
export const CheckboxItem = React.forwardRef<HTMLDivElement, CheckboxItemProps>(
(
{label, checked, onCheckedChange, disabled, icon, children, danger = false, closeOnChange = false},
forwardedRef,
) => {
const closeMenu = React.useContext(ContextMenuCloseContext);
const handleAction = React.useCallback(
(_e: PressEvent) => {
if (disabled) return;
onCheckedChange(!checked);
if (closeOnChange) {
closeMenu();
}
},
[checked, closeMenu, closeOnChange, disabled, onCheckedChange],
);
return (
<AriaMenuItem
ref={forwardedRef}
onPress={handleAction}
isDisabled={disabled}
className={clsx(styles.item, styles.checkboxItem, {
[styles.disabled]: disabled,
[styles.danger]: danger,
})}
textValue={label || (typeof children === 'string' ? children : '')}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div className={styles.itemLabel}>{children || label}</div>
<div className={styles.checkboxIndicator}>
<div
className={clsx(styles.checkbox, {
[styles.checkboxChecked]: checked,
})}
/>
</div>
</AriaMenuItem>
);
},
);
CheckboxItem.displayName = 'CheckboxItem';
interface MenuGroupProps {
label?: string;
children?: React.ReactNode;
}
export const MenuGroup: React.FC<MenuGroupProps> = observer(({children}) => {
const validChildren = React.Children.toArray(children).filter((child): child is React.ReactElement => {
if (!React.isValidElement(child)) return false;
if (child.type === React.Fragment && !(child.props as {children?: React.ReactNode}).children) return false;
return true;
});
if (validChildren.length === 0) {
return null;
}
return <AriaSection className={styles.group}>{validChildren}</AriaSection>;
});
export const ContextMenu: React.FC = observer(() => {
if (isMobileExperienceEnabled()) {
return null;
}
const contextMenu = ContextMenuStore.contextMenu;
if (!contextMenu) return null;
return createPortal(<RootContextMenu key={contextMenu.id} contextMenu={contextMenu} />, document.body);
});

View File

@@ -0,0 +1,176 @@
/*
* 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 {
ArrowBendUpLeftIcon,
ArrowBendUpRightIcon,
ArrowSquareOutIcon,
BellIcon,
BellSlashIcon,
BookmarkSimpleIcon,
BugBeetleIcon,
ChatCircleDotsIcon,
CheckCircleIcon,
CheckIcon,
ClipboardTextIcon,
ClockCounterClockwiseIcon,
CopyIcon as CopyIconPhosphor,
DownloadSimpleIcon,
FlagIcon,
FolderPlusIcon,
GearIcon,
GlobeIcon,
ImageSquareIcon,
LinkIcon,
NotePencilIcon,
PencilIcon,
PhoneIcon,
PlusCircleIcon,
ProhibitIcon,
PushPinIcon,
ShieldIcon,
SignOutIcon,
SmileyIcon,
SmileyXEyesIcon,
SnowflakeIcon,
SpeakerHighIcon,
StarIcon,
TrashIcon,
UserCircleIcon,
UserMinusIcon,
UserPlusIcon,
VideoCameraIcon,
WrenchIcon,
XIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
interface IconProps {
size?: number;
}
export const ReplyIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ArrowBendUpLeftIcon size={size} weight="fill" />
));
export const ForwardIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ArrowBendUpRightIcon size={size} weight="fill" />
));
export const EditIcon: React.FC<IconProps> = observer(({size = 16}) => <PencilIcon size={size} weight="fill" />);
export const DeleteIcon: React.FC<IconProps> = observer(({size = 16}) => <TrashIcon size={size} weight="fill" />);
export const AddReactionIcon: React.FC<IconProps> = observer(({size = 16}) => <SmileyIcon size={size} weight="fill" />);
export const RemoveAllReactionsIcon: React.FC<IconProps> = observer(({size = 16}) => (
<SmileyXEyesIcon size={size} weight="fill" />
));
export const PinIcon: React.FC<IconProps> = observer(({size = 16}) => <PushPinIcon size={size} weight="fill" />);
export const BookmarkIcon: React.FC<IconProps & {filled?: boolean}> = observer(({size = 16, filled = false}) => (
<BookmarkSimpleIcon size={size} weight={filled ? 'fill' : 'regular'} />
));
export const FavoriteIcon: React.FC<IconProps & {filled?: boolean}> = observer(({size = 16, filled = false}) => (
<StarIcon size={size} weight={filled ? 'fill' : 'regular'} />
));
export const CopyTextIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ClipboardTextIcon size={size} weight="fill" />
));
export const CopyLinkIcon: React.FC<IconProps> = observer(({size = 16}) => <LinkIcon size={size} weight="bold" />);
export const CopyIdIcon: React.FC<IconProps> = observer(({size = 16}) => (
<SnowflakeIcon size={size} weight="regular" />
));
export const CopyIcon: React.FC<IconProps> = observer(({size = 16}) => <CopyIconPhosphor size={size} weight="fill" />);
export const CopyUserIdIcon: React.FC<IconProps> = observer(({size = 16}) => (
<SnowflakeIcon size={size} weight="regular" />
));
export const CopyFluxerTagIcon: React.FC<IconProps> = observer(({size = 16}) => (
<CopyIconPhosphor size={size} weight="fill" />
));
export const OpenLinkIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ArrowSquareOutIcon size={size} weight="regular" />
));
export const SaveIcon: React.FC<IconProps> = observer(({size = 16}) => (
<DownloadSimpleIcon size={size} weight="fill" />
));
export const SuppressEmbedsIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ImageSquareIcon size={size} weight="fill" />
));
export const MarkAsReadIcon: React.FC<IconProps> = observer(({size = 16}) => (
<CheckIcon size={size} weight="regular" />
));
export const MarkAsUnreadIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ChatCircleDotsIcon size={size} weight="fill" />
));
export const MuteIcon: React.FC<IconProps> = observer(({size = 16}) => <BellSlashIcon size={size} weight="fill" />);
export const NotificationSettingsIcon: React.FC<IconProps> = observer(({size = 16}) => (
<BellIcon size={size} weight="fill" />
));
export const InviteIcon: React.FC<IconProps> = observer(({size = 16}) => <UserPlusIcon size={size} weight="fill" />);
export const CreateChannelIcon: React.FC<IconProps> = observer(({size = 16}) => (
<PlusCircleIcon size={size} weight="fill" />
));
export const CreateCategoryIcon: React.FC<IconProps> = observer(({size = 16}) => (
<FolderPlusIcon size={size} weight="fill" />
));
export const SettingsIcon: React.FC<IconProps> = observer(({size = 16}) => <GearIcon size={size} weight="fill" />);
export const PrivacySettingsIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ShieldIcon size={size} weight="fill" />
));
export const LeaveIcon: React.FC<IconProps> = observer(({size = 16}) => <SignOutIcon size={size} weight="fill" />);
export const EditProfileIcon: React.FC<IconProps> = observer(({size = 16}) => (
<UserCircleIcon size={size} weight="fill" />
));
export const VoiceCallIcon: React.FC<IconProps> = observer(({size = 16}) => <PhoneIcon size={size} weight="fill" />);
export const VideoCallIcon: React.FC<IconProps> = observer(({size = 16}) => (
<VideoCameraIcon size={size} weight="fill" />
));
export const DebugIcon: React.FC<IconProps> = observer(({size = 16}) => <BugBeetleIcon size={size} weight="fill" />);
export const AddNoteIcon: React.FC<IconProps> = observer(({size = 16}) => <NotePencilIcon size={size} weight="fill" />);
export const SendFriendRequestIcon: React.FC<IconProps> = observer(({size = 16}) => (
<UserPlusIcon size={size} weight="fill" />
));
export const AcceptFriendRequestIcon: React.FC<IconProps> = observer(({size = 16}) => (
<CheckCircleIcon size={size} weight="fill" />
));
export const RemoveFriendIcon: React.FC<IconProps> = observer(({size = 16}) => (
<UserMinusIcon size={size} weight="fill" />
));
export const IgnoreFriendRequestIcon: React.FC<IconProps> = observer(({size = 16}) => (
<XIcon size={size} weight="fill" />
));
export const CancelFriendRequestIcon: React.FC<IconProps> = observer(({size = 16}) => (
<ClockCounterClockwiseIcon size={size} weight="fill" />
));
export const BlockUserIcon: React.FC<IconProps> = observer(({size = 16}) => <ProhibitIcon size={size} weight="fill" />);
export const ReportUserIcon: React.FC<IconProps> = observer(({size = 16}) => <FlagIcon size={size} weight="fill" />);
export const ViewGlobalProfileIcon: React.FC<IconProps> = observer(({size = 16}) => (
<GlobeIcon size={size} weight="fill" />
));
export const WrenchToolIcon: React.FC<IconProps> = observer(({size = 16}) => <WrenchIcon size={size} weight="fill" />);
export const SpeakIcon: React.FC<IconProps> = observer(({size = 16}) => <SpeakerHighIcon size={size} weight="fill" />);

View File

@@ -0,0 +1,203 @@
/*
* 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 {t} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {PushPinIcon, XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes, ME, RelationshipTypes} from '~/Constants';
import {DMCloseFailedModal} from '~/components/alerts/DMCloseFailedModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {UserRecord} from '~/records/UserRecord';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import * as RouterUtils from '~/utils/RouterUtils';
import {StartVoiceCallMenuItem} from './items/CallMenuItems';
import {CopyChannelIdMenuItem, FavoriteChannelMenuItem} from './items/ChannelMenuItems';
import {CopyUserIdMenuItem} from './items/CopyMenuItems';
import {DebugChannelMenuItem, DebugUserMenuItem} from './items/DebugMenuItems';
import {MarkDMAsReadMenuItem, MuteDMMenuItem} from './items/DMMenuItems';
import {InviteToCommunityMenuItem} from './items/InviteMenuItems';
import {
BlockUserMenuItem,
ChangeFriendNicknameMenuItem,
RelationshipActionMenuItem,
UnblockUserMenuItem,
} from './items/RelationshipMenuItems';
import {AddNoteMenuItem} from './items/UserNoteMenuItems';
import {UserProfileMenuItem} from './items/UserProfileMenuItem';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface DMContextMenuProps {
channel: ChannelRecord;
recipient?: UserRecord | null;
onClose: () => void;
}
export const DMContextMenu: React.FC<DMContextMenuProps> = observer(({channel, recipient, onClose}) => {
const {i18n} = useLingui();
const isGroupDM = channel.type === ChannelTypes.GROUP_DM;
const developerMode = UserSettingsStore.developerMode;
const isRecipientBot = recipient?.bot;
const relationshipType = recipient ? RelationshipStore.getRelationship(recipient.id)?.type : undefined;
const handleCloseDM = React.useCallback(() => {
onClose();
const username = recipient?.username ?? '';
const description = isGroupDM
? t(i18n)`Are you sure you want to close this group DM? You can always reopen it later.`
: t(i18n)`Are you sure you want to close your DM with ${username}? You can always reopen it later.`;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t(i18n)`Close DM`}
description={description}
primaryText={t(i18n)`Close DM`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await ChannelActionCreators.remove(channel.id);
const selectedChannel = SelectedChannelStore.selectedChannelIds.get(ME);
if (selectedChannel === channel.id) {
RouterUtils.transitionTo(Routes.ME);
}
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`DM closed`,
});
} catch (error) {
console.error('Failed to close DM:', error);
ModalActionCreators.push(modal(() => <DMCloseFailedModal />));
}
}}
/>
)),
);
}, [channel.id, i18n, isGroupDM, onClose, recipient?.username]);
const handlePinDM = React.useCallback(async () => {
onClose();
try {
await PrivateChannelActionCreators.pinDmChannel(channel.id);
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`Pinned DM`,
});
} catch (error) {
console.error('Failed to pin DM:', error);
ToastActionCreators.createToast({
type: 'error',
children: t(i18n)`Failed to pin DM`,
});
}
}, [channel.id, i18n, onClose]);
const handleUnpinDM = React.useCallback(async () => {
onClose();
try {
await PrivateChannelActionCreators.unpinDmChannel(channel.id);
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`Unpinned DM`,
});
} catch (error) {
console.error('Failed to unpin DM:', error);
ToastActionCreators.createToast({
type: 'error',
children: t(i18n)`Failed to unpin DM`,
});
}
}, [channel.id, i18n, onClose]);
return (
<>
<MenuGroup>
<MarkDMAsReadMenuItem channel={channel} onClose={onClose} />
{channel.isPinned ? (
<MenuItem icon={<PushPinIcon />} onClick={handleUnpinDM}>
{t(i18n)`Unpin DM`}
</MenuItem>
) : (
<MenuItem icon={<PushPinIcon />} onClick={handlePinDM}>
{t(i18n)`Pin DM`}
</MenuItem>
)}
</MenuGroup>
<MenuGroup>
<FavoriteChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
{recipient && (
<MenuGroup>
<UserProfileMenuItem user={recipient} onClose={onClose} />
{!isRecipientBot && <StartVoiceCallMenuItem user={recipient} onClose={onClose} />}
<AddNoteMenuItem user={recipient} onClose={onClose} />
<ChangeFriendNicknameMenuItem user={recipient} onClose={onClose} />
<MenuItem icon={<XIcon weight="bold" />} onClick={handleCloseDM}>
{t(i18n)`Close DM`}
</MenuItem>
</MenuGroup>
)}
{recipient && (
<MenuGroup>
{!isRecipientBot && <InviteToCommunityMenuItem user={recipient} onClose={onClose} />}
{!isRecipientBot && <RelationshipActionMenuItem user={recipient} onClose={onClose} />}
{relationshipType === RelationshipTypes.BLOCKED ? (
<UnblockUserMenuItem user={recipient} onClose={onClose} />
) : (
<BlockUserMenuItem user={recipient} onClose={onClose} />
)}
</MenuGroup>
)}
<MenuGroup>
<MuteDMMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
{developerMode && (
<MenuGroup>
{recipient && <DebugUserMenuItem user={recipient} onClose={onClose} />}
<DebugChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
{recipient && <CopyUserIdMenuItem user={recipient} onClose={onClose} />}
<CopyChannelIdMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,96 @@
/*
* 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 {CaretDownIcon, CaretUpIcon, PencilSimpleIcon, PlusCircleIcon, TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {RenameChannelModal} from '~/components/modals/RenameChannelModal';
import FavoritesStore from '~/stores/FavoritesStore';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface FavoritesCategoryContextMenuProps {
category: {id: string; name: string};
onClose: () => void;
onAddChannel: () => void;
}
export const FavoritesCategoryContextMenu: React.FC<FavoritesCategoryContextMenuProps> = observer(
({category, onClose, onAddChannel}) => {
const {t} = useLingui();
const isCollapsed = FavoritesStore.isCategoryCollapsed(category.id);
const handleRename = () => {
onClose();
ModalActionCreators.push(
modal(() => (
<RenameChannelModal
currentName={category.name}
onSave={(name) => {
FavoritesStore.renameCategory(category.id, name);
}}
/>
)),
);
};
const handleToggleCollapse = () => {
FavoritesStore.toggleCategoryCollapsed(category.id);
onClose();
};
const handleRemove = () => {
FavoritesStore.removeCategory(category.id);
onClose();
};
const handleAddChannelClick = () => {
onClose();
onAddChannel();
};
return (
<>
<MenuGroup>
<MenuItem icon={<PlusCircleIcon />} onClick={handleAddChannelClick}>
{t`Add Channel`}
</MenuItem>
<MenuItem icon={<PencilSimpleIcon />} onClick={handleRename}>
{t`Rename Category`}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem icon={isCollapsed ? <CaretDownIcon /> : <CaretUpIcon />} onClick={handleToggleCollapse}>
{isCollapsed ? t`Expand Category` : t`Collapse Category`}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem icon={<TrashIcon />} onClick={handleRemove} danger>
{t`Delete Category`}
</MenuItem>
</MenuGroup>
</>
);
},
);

View File

@@ -0,0 +1,170 @@
/*
* 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 {ArrowSquareOutIcon, ArrowsOutCardinalIcon, PencilSimpleIcon, StarIcon, TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Permissions} from '~/Constants';
import {RenameChannelModal} from '~/components/modals/RenameChannelModal';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import FavoritesStore, {type FavoriteChannel} from '~/stores/FavoritesStore';
import PermissionStore from '~/stores/PermissionStore';
import * as RouterUtils from '~/utils/RouterUtils';
import {
ChannelNotificationSettingsMenuItem,
EditChannelMenuItem,
InvitePeopleToChannelMenuItem,
MarkChannelAsReadMenuItem,
MuteChannelMenuItem,
} from './items/ChannelMenuItems';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
import {MenuItemSubmenu} from './MenuItemSubmenu';
interface FavoritesChannelContextMenuProps {
favoriteChannel: FavoriteChannel;
channel: ChannelRecord | null;
guild: GuildRecord | null;
onClose: () => void;
}
export const FavoritesChannelContextMenu: React.FC<FavoritesChannelContextMenuProps> = observer(
({favoriteChannel, channel, guild: _guild, onClose}) => {
const {t} = useLingui();
const handleSetNickname = () => {
onClose();
ModalActionCreators.push(
modal(() => (
<RenameChannelModal
currentName={favoriteChannel.nickname || channel?.name || ''}
onSave={(nickname) => {
FavoritesStore.setChannelNickname(favoriteChannel.channelId, nickname || null);
}}
/>
)),
);
};
const handleRemoveFromFavorites = () => {
FavoritesStore.removeChannel(favoriteChannel.channelId);
ToastActionCreators.createToast({type: 'success', children: t`Channel removed from favorites`});
onClose();
};
const handleMoveTo = (categoryId: string | null) => {
const currentChannel = FavoritesStore.getChannel(favoriteChannel.channelId);
if (!currentChannel) return;
const channelsInTarget = FavoritesStore.getChannelsInCategory(categoryId);
const newPosition = channelsInTarget.length;
FavoritesStore.moveChannel(favoriteChannel.channelId, categoryId, newPosition);
onClose();
};
const handleOpenInGuild = () => {
if (!channel?.guildId) return;
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId, channel.id));
onClose();
};
if (!channel) {
return (
<MenuGroup>
<MenuItem icon={<TrashIcon />} onClick={handleRemoveFromFavorites} danger>
{t`Remove from Favorites`}
</MenuItem>
</MenuGroup>
);
}
const canManageChannel =
channel.guildId &&
PermissionStore.can(Permissions.MANAGE_CHANNELS, {channelId: channel.id, guildId: channel.guildId});
return (
<>
{channel.guildId && (
<MenuGroup>
<MarkChannelAsReadMenuItem channel={channel} onClose={onClose} />
<InvitePeopleToChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<MenuItem icon={<PencilSimpleIcon />} onClick={handleSetNickname}>
{t`Change Nickname`}
</MenuItem>
{channel.guildId && (
<MenuItem icon={<ArrowSquareOutIcon />} onClick={handleOpenInGuild}>
{t`Open in Community`}
</MenuItem>
)}
{(favoriteChannel.parentId !== null ||
FavoritesStore.sortedCategories.some((category) => category.id !== favoriteChannel.parentId)) && (
<MenuItemSubmenu
label={t`Move to`}
icon={<ArrowsOutCardinalIcon />}
render={() => (
<MenuGroup>
{favoriteChannel.parentId !== null && (
<MenuItem onClick={() => handleMoveTo(null)}>{t`Uncategorized`}</MenuItem>
)}
{FavoritesStore.sortedCategories
.filter((category) => category.id !== favoriteChannel.parentId)
.map((category) => (
<MenuItem key={category.id} onClick={() => handleMoveTo(category.id)}>
{category.name}
</MenuItem>
))}
</MenuGroup>
)}
/>
)}
</MenuGroup>
{channel.guildId && (
<MenuGroup>
<MuteChannelMenuItem channel={channel} onClose={onClose} />
<ChannelNotificationSettingsMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
{canManageChannel && (
<MenuGroup>
<EditChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<MenuItem icon={<StarIcon weight="fill" />} onClick={handleRemoveFromFavorites} danger>
{t`Remove from Favorites`}
</MenuItem>
</MenuGroup>
</>
);
},
);

View File

@@ -0,0 +1,73 @@
/*
* 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 {FolderPlusIcon, PlusCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {AddFavoriteChannelModal} from '~/components/modals/AddFavoriteChannelModal';
import {CreateFavoriteCategoryModal} from '~/components/modals/CreateFavoriteCategoryModal';
import FavoritesStore from '~/stores/FavoritesStore';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
import {MenuItemCheckbox} from './MenuItemCheckbox';
interface FavoritesChannelListContextMenuProps {
onClose: () => void;
}
export const FavoritesChannelListContextMenu: React.FC<FavoritesChannelListContextMenuProps> = observer(({onClose}) => {
const {t} = useLingui();
const hideMutedChannels = FavoritesStore.hideMutedChannels;
const handleToggleHideMutedChannels = React.useCallback((checked: boolean) => {
FavoritesStore.setHideMutedChannels(checked);
}, []);
const handleAddChannel = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <AddFavoriteChannelModal />));
}, [onClose]);
const handleCreateCategory = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <CreateFavoriteCategoryModal />));
}, [onClose]);
return (
<>
<MenuGroup>
<MenuItemCheckbox checked={hideMutedChannels} onChange={handleToggleHideMutedChannels}>
{t`Hide Muted Channels`}
</MenuItemCheckbox>
</MenuGroup>
<MenuGroup>
<MenuItem icon={<PlusCircleIcon style={{width: 16, height: 16}} />} onClick={handleAddChannel}>
{t`Add Channel`}
</MenuItem>
<MenuItem icon={<FolderPlusIcon style={{width: 16, height: 16}} />} onClick={handleCreateCategory}>
{t`Create Category`}
</MenuItem>
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,65 @@
/*
* 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 {EyeSlashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as FavoritesActionCreators from '~/actions/FavoritesActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {FAVORITES_GUILD_ID} from '~/Constants';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import {MuteIcon} from './ContextMenuIcons';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface FavoritesGuildContextMenuProps {
onClose: () => void;
}
export const FavoritesGuildContextMenu: React.FC<FavoritesGuildContextMenuProps> = observer(({onClose}) => {
const {t, i18n} = useLingui();
const settings = UserGuildSettingsStore.getSettings(FAVORITES_GUILD_ID);
const isMuted = settings?.muted ?? false;
const handleToggleMute = () => {
UserGuildSettingsActionCreators.updateGuildSettings(FAVORITES_GUILD_ID, {muted: !isMuted});
onClose();
};
const handleHideFavorites = () => {
onClose();
FavoritesActionCreators.confirmHideFavorites(undefined, i18n);
};
return (
<>
<MenuGroup>
<MenuItem icon={<MuteIcon />} onClick={handleToggleMute}>
{isMuted ? t`Unmute Community` : t`Mute Community`}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem icon={<EyeSlashIcon />} onClick={handleHideFavorites} danger>
{t`Hide Favorites`}
</MenuItem>
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,334 @@
/*
* 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 {CrownIcon, PencilIcon, PushPinIcon, SignOutIcon, TicketIcon, UserMinusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes, RelationshipTypes} from '~/Constants';
import {GroupOwnershipTransferFailedModal} from '~/components/alerts/GroupOwnershipTransferFailedModal';
import {GroupRemoveUserFailedModal} from '~/components/alerts/GroupRemoveUserFailedModal';
import {ChangeGroupDMNicknameModal} from '~/components/modals/ChangeGroupDMNicknameModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {EditGroupModal} from '~/components/modals/EditGroupModal';
import {GroupInvitesModal} from '~/components/modals/GroupInvitesModal';
import {useLeaveGroup} from '~/hooks/useLeaveGroup';
import type {ChannelRecord} from '~/records/ChannelRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import UserStore from '~/stores/UserStore';
import {StartVoiceCallMenuItem} from './items/CallMenuItems';
import {CopyChannelIdMenuItem, FavoriteChannelMenuItem} from './items/ChannelMenuItems';
import {CopyUserIdMenuItem} from './items/CopyMenuItems';
import {DebugChannelMenuItem, DebugUserMenuItem} from './items/DebugMenuItems';
import {MarkDMAsReadMenuItem, MuteDMMenuItem} from './items/DMMenuItems';
import {InviteToCommunityMenuItem} from './items/InviteMenuItems';
import {MentionUserMenuItem} from './items/MentionUserMenuItem';
import {MessageUserMenuItem} from './items/MessageUserMenuItem';
import {
BlockUserMenuItem,
ChangeFriendNicknameMenuItem,
RelationshipActionMenuItem,
UnblockUserMenuItem,
} from './items/RelationshipMenuItems';
import {AddNoteMenuItem} from './items/UserNoteMenuItems';
import {UserProfileMenuItem} from './items/UserProfileMenuItem';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface GroupDMContextMenuProps {
channel: ChannelRecord;
onClose: () => void;
}
export const GroupDMContextMenu: React.FC<GroupDMContextMenuProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const currentUserId = AuthenticationStore.currentUserId;
const isOwner = channel.ownerId === currentUserId;
const developerMode = UserSettingsStore.developerMode;
const leaveGroup = useLeaveGroup();
const handleEditGroup = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <EditGroupModal channelId={channel.id} />));
}, [channel.id, onClose]);
const handleShowInvites = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <GroupInvitesModal channelId={channel.id} />));
}, [channel.id, onClose]);
const handleLeaveGroup = React.useCallback(() => {
onClose();
leaveGroup(channel.id);
}, [channel.id, onClose, leaveGroup]);
const handlePinDM = React.useCallback(async () => {
onClose();
try {
await PrivateChannelActionCreators.pinDmChannel(channel.id);
ToastActionCreators.createToast({
type: 'success',
children: t`Pinned group`,
});
} catch (error) {
console.error('Failed to pin group:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to pin group`,
});
}
}, [channel.id, onClose, t]);
const handleUnpinDM = React.useCallback(async () => {
onClose();
try {
await PrivateChannelActionCreators.unpinDmChannel(channel.id);
ToastActionCreators.createToast({
type: 'success',
children: t`Unpinned group`,
});
} catch (error) {
console.error('Failed to unpin group:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to unpin group`,
});
}
}, [channel.id, onClose, t]);
return (
<>
<MenuGroup>
<MarkDMAsReadMenuItem channel={channel} onClose={onClose} />
{channel.isPinned ? (
<MenuItem icon={<PushPinIcon />} onClick={handleUnpinDM}>
{t`Unpin Group DM`}
</MenuItem>
) : (
<MenuItem icon={<PushPinIcon />} onClick={handlePinDM}>
{t`Pin Group DM`}
</MenuItem>
)}
</MenuGroup>
<MenuGroup>
<FavoriteChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
{isOwner && (
<MenuItem icon={<TicketIcon />} onClick={handleShowInvites}>
{t`Invites`}
</MenuItem>
)}
<MenuItem icon={<PencilIcon />} onClick={handleEditGroup}>
{t`Edit Group`}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MuteDMMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<MenuItem icon={<SignOutIcon />} onClick={handleLeaveGroup} danger>
{t`Leave Group`}
</MenuItem>
</MenuGroup>
{developerMode && (
<MenuGroup>
<DebugChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyChannelIdMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
</>
);
});
interface GroupDMMemberContextMenuProps {
userId: string;
channelId: string;
onClose: () => void;
}
export const GroupDMMemberContextMenu: React.FC<GroupDMMemberContextMenuProps> = observer(
({userId, channelId, onClose}) => {
const {t} = useLingui();
const currentUserId = AuthenticationStore.currentUserId;
const channel = ChannelStore.getChannel(channelId);
const user = UserStore.getUser(userId);
const developerMode = UserSettingsStore.developerMode;
const handleChangeNickname = React.useCallback(() => {
if (!user) return;
onClose();
ModalActionCreators.push(modal(() => <ChangeGroupDMNicknameModal channelId={channelId} user={user} />));
}, [channelId, onClose, user]);
const handleRemoveFromGroup = React.useCallback(() => {
if (!user) return;
onClose();
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Remove from Group`}
description={t`Are you sure you want to remove ${user.username} from the group?`}
primaryText={t`Remove`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await PrivateChannelActionCreators.removeRecipient(channelId, userId);
ToastActionCreators.createToast({
type: 'success',
children: t`Removed from group`,
});
} catch (error) {
console.error('Failed to remove from group:', error);
ModalActionCreators.push(modal(() => <GroupRemoveUserFailedModal username={user.username} />));
}
}}
/>
)),
);
}, [channelId, onClose, t, user, userId]);
const handleMakeGroupOwner = React.useCallback(() => {
if (!user) return;
onClose();
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Transfer Ownership`}
description={t`Are you sure you want to make ${user.username} the group owner? You will lose owner privileges.`}
primaryText={t`Transfer Ownership`}
onPrimary={async () => {
try {
await ChannelActionCreators.update(channelId, {owner_id: userId});
ToastActionCreators.createToast({
type: 'success',
children: t`Ownership transferred`,
});
} catch (error) {
console.error('Failed to transfer ownership:', error);
ModalActionCreators.push(modal(() => <GroupOwnershipTransferFailedModal username={user.username} />));
}
}}
/>
)),
);
}, [channelId, onClose, t, user, userId]);
if (!user || !channel) {
return null;
}
const isGroupDM = channel.type === ChannelTypes.GROUP_DM;
if (!isGroupDM) {
return null;
}
const isSelf = userId === currentUserId;
const isOwner = channel.ownerId === currentUserId;
const relationship = RelationshipStore.getRelationship(userId);
const relationshipType = relationship?.type;
if (isSelf) {
return (
<>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={undefined} onClose={onClose} />
<MentionUserMenuItem user={user} onClose={onClose} />
<MenuItem icon={<PencilIcon />} onClick={handleChangeNickname}>
{t`Change My Group Nickname`}
</MenuItem>
</MenuGroup>
{developerMode && (
<MenuGroup>
<DebugUserMenuItem user={user} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
}
return (
<>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={undefined} onClose={onClose} />
<MentionUserMenuItem user={user} onClose={onClose} />
<MessageUserMenuItem user={user} onClose={onClose} />
<StartVoiceCallMenuItem user={user} onClose={onClose} />
<AddNoteMenuItem user={user} onClose={onClose} />
<ChangeFriendNicknameMenuItem user={user} onClose={onClose} />
</MenuGroup>
{isOwner && (
<MenuGroup>
<MenuItem icon={<UserMinusIcon />} onClick={handleRemoveFromGroup} danger>
{t`Remove from Group`}
</MenuItem>
<MenuItem icon={<CrownIcon />} onClick={handleMakeGroupOwner} danger>
{t`Make Group Owner`}
</MenuItem>
<MenuItem icon={<PencilIcon />} onClick={handleChangeNickname}>
{t`Change Group Nickname`}
</MenuItem>
</MenuGroup>
)}
<MenuGroup>
<InviteToCommunityMenuItem user={user} onClose={onClose} />
<RelationshipActionMenuItem user={user} onClose={onClose} />
{relationshipType === RelationshipTypes.BLOCKED ? (
<UnblockUserMenuItem user={user} onClose={onClose} />
) : (
<BlockUserMenuItem user={user} onClose={onClose} />
)}
</MenuGroup>
{developerMode && (
<MenuGroup>
<DebugUserMenuItem user={user} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
},
);

View File

@@ -0,0 +1,106 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import {Permissions} from '~/Constants';
import type {GuildRecord} from '~/records/GuildRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import PermissionStore from '~/stores/PermissionStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import * as InviteUtils from '~/utils/InviteUtils';
import {DebugGuildMenuItem} from './items/DebugMenuItems';
import {
CommunitySettingsMenuItem,
CopyGuildIdMenuItem,
CreateCategoryMenuItem,
CreateChannelMenuItem,
EditCommunityProfileMenuItem,
HideMutedChannelsMenuItem,
InvitePeopleMenuItem,
LeaveCommunityMenuItem,
MarkAsReadMenuItem,
MuteCommunityMenuItem,
NotificationSettingsMenuItem,
PrivacySettingsMenuItem,
} from './items/GuildMenuItems';
import {ReportGuildMenuItem} from './items/ReportGuildMenuItem';
import {MenuGroup} from './MenuGroup';
interface GuildContextMenuProps {
guild: GuildRecord;
onClose: () => void;
}
export const GuildContextMenu: React.FC<GuildContextMenuProps> = observer(({guild, onClose}) => {
const invitableChannelId = InviteUtils.getInvitableChannelId(guild.id);
const canInvite = InviteUtils.canInviteToChannel(invitableChannelId, guild.id);
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id});
const developerMode = UserSettingsStore.developerMode;
return (
<>
<MenuGroup>
<MarkAsReadMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
{canInvite && (
<MenuGroup>
<InvitePeopleMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<MuteCommunityMenuItem guild={guild} onClose={onClose} />
<NotificationSettingsMenuItem guild={guild} onClose={onClose} />
<HideMutedChannelsMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<CommunitySettingsMenuItem guild={guild} onClose={onClose} />
<PrivacySettingsMenuItem guild={guild} onClose={onClose} />
<EditCommunityProfileMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
{canManageChannels && (
<MenuGroup>
<CreateChannelMenuItem guild={guild} onClose={onClose} />
<CreateCategoryMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
)}
{!guild.isOwner(AuthenticationStore.currentUserId) && (
<MenuGroup>
<LeaveCommunityMenuItem guild={guild} onClose={onClose} />
<ReportGuildMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
)}
{developerMode && (
<MenuGroup>
<DebugGuildMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyGuildIdMenuItem guild={guild} onClose={onClose} />
</MenuGroup>
</>
);
});

View File

@@ -0,0 +1,153 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import {Permissions, RelationshipTypes} from '~/Constants';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import * as PermissionUtils from '~/utils/PermissionUtils';
import {StartVoiceCallMenuItem} from './items/CallMenuItems';
import {CopyUserIdMenuItem} from './items/CopyMenuItems';
import {DebugGuildMemberMenuItem, DebugUserMenuItem} from './items/DebugMenuItems';
import {
BanMemberMenuItem,
ChangeNicknameMenuItem,
KickMemberMenuItem,
ManageRolesMenuItem,
TimeoutMemberMenuItem,
TransferOwnershipMenuItem,
} from './items/GuildMemberMenuItems';
import {InviteToCommunityMenuItem} from './items/InviteMenuItems';
import {MentionUserMenuItem} from './items/MentionUserMenuItem';
import {MessageUserMenuItem} from './items/MessageUserMenuItem';
import {
BlockUserMenuItem,
ChangeFriendNicknameMenuItem,
RelationshipActionMenuItem,
UnblockUserMenuItem,
} from './items/RelationshipMenuItems';
import {AddNoteMenuItem} from './items/UserNoteMenuItems';
import {UserProfileMenuItem} from './items/UserProfileMenuItem';
import {MenuGroup} from './MenuGroup';
interface GuildMemberContextMenuProps {
user: UserRecord;
onClose: () => void;
guildId: string;
channelId?: string;
}
export const GuildMemberContextMenu: React.FC<GuildMemberContextMenuProps> = observer(
({user, onClose, guildId, channelId}) => {
const channel = channelId ? ChannelStore.getChannel(channelId) : null;
const canSendMessages = channel ? PermissionStore.can(Permissions.SEND_MESSAGES, {channelId, guildId}) : true;
const canMention = channel !== null && canSendMessages;
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
const guild = GuildStore.getGuild(guildId);
const member = GuildMemberStore.getMember(guildId, user.id);
const currentUserId = AuthenticationStore.currentUserId;
const isBot = user.bot;
const canKickMembers = PermissionStore.can(Permissions.KICK_MEMBERS, {guildId});
const canBanMembers = PermissionStore.can(Permissions.BAN_MEMBERS, {guildId});
const canModerateMembers = PermissionStore.can(Permissions.MODERATE_MEMBERS, {guildId});
const isOwner = guild?.ownerId === currentUserId;
const canKick = !isCurrentUser && canKickMembers;
const canBan = !isCurrentUser && canBanMembers;
const guildSnapshot = guild?.toJSON();
const targetHasModerateMembersPermission =
guildSnapshot !== undefined && PermissionUtils.can(Permissions.MODERATE_MEMBERS, user.id, guildSnapshot);
const canTimeout = !isCurrentUser && canModerateMembers && !targetHasModerateMembersPermission;
const canTransfer = !isCurrentUser && isOwner;
const developerMode = UserSettingsStore.developerMode;
const hasManageNicknamesPermission = PermissionStore.can(Permissions.MANAGE_NICKNAMES, {guildId});
const canManageNicknames = member && (isCurrentUser || hasManageNicknamesPermission);
const hasRoles = guild && Object.values(guild.roles).some((r) => !r.isEveryone);
return (
<>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={guildId} onClose={onClose} />
{canMention && <MentionUserMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && <MessageUserMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && !isBot && <StartVoiceCallMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && <AddNoteMenuItem user={user} onClose={onClose} />}
<ChangeFriendNicknameMenuItem user={user} onClose={onClose} />
</MenuGroup>
<MenuGroup>
{canManageNicknames && member && (
<ChangeNicknameMenuItem guildId={guildId} user={user} member={member} onClose={onClose} />
)}
{!isCurrentUser && !isBot && <InviteToCommunityMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && !isBot && <RelationshipActionMenuItem user={user} onClose={onClose} />}
{!isCurrentUser &&
(relationshipType === RelationshipTypes.BLOCKED ? (
<UnblockUserMenuItem user={user} onClose={onClose} />
) : (
<BlockUserMenuItem user={user} onClose={onClose} />
))}
</MenuGroup>
{canTransfer && member && (
<MenuGroup>
<TransferOwnershipMenuItem guildId={guildId} user={user} member={member} onClose={onClose} />
</MenuGroup>
)}
{(canTimeout || canKick || canBan) && member && (
<MenuGroup>
{canTimeout && <TimeoutMemberMenuItem guildId={guildId} user={user} member={member} onClose={onClose} />}
{canKick && <KickMemberMenuItem guildId={guildId} user={user} onClose={onClose} />}
{canBan && <BanMemberMenuItem guildId={guildId} user={user} onClose={onClose} />}
</MenuGroup>
)}
{member && hasRoles && (
<MenuGroup>
<ManageRolesMenuItem guildId={guildId} member={member} />
</MenuGroup>
)}
{developerMode && (
<MenuGroup>
<DebugUserMenuItem user={user} onClose={onClose} />
{member && <DebugGuildMemberMenuItem member={member} onClose={onClose} />}
</MenuGroup>
)}
<MenuGroup>
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
},
);

View File

@@ -0,0 +1,379 @@
/*
* 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 {t} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useSyncExternalStore} from 'react';
import {clearAllAttachmentMocks, setAttachmentMock} from '~/actions/DeveloperOptionsActionCreators';
import * as FavoriteMemeActionCreators from '~/actions/FavoriteMemeActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {AddFavoriteMemeModal} from '~/components/modals/AddFavoriteMemeModal';
import type {MessageRecord} from '~/records/MessageRecord';
import DeveloperModeStore from '~/stores/DeveloperModeStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import FavoriteMemeStore from '~/stores/FavoriteMemeStore';
import * as FavoriteMemeUtils from '~/utils/FavoriteMemeUtils';
import {createSaveHandler} from '~/utils/FileDownloadUtils';
import {buildMediaProxyURL, stripMediaProxyParams} from '~/utils/MediaProxyUtils';
import {openExternalUrl} from '~/utils/NativeUtils';
import {
CopyIcon,
CopyIdIcon,
CopyLinkIcon,
FavoriteIcon,
OpenLinkIcon,
SaveIcon,
WrenchToolIcon,
} from './ContextMenuIcons';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
import {MenuItemSubmenu} from './MenuItemSubmenu';
import {MessageContextMenu} from './MessageContextMenu';
type MediaType = 'image' | 'gif' | 'gifv' | 'video' | 'audio' | 'file';
interface MediaContextMenuProps {
message: MessageRecord;
originalSrc: string;
proxyURL?: string;
type: MediaType;
contentHash?: string | null;
attachmentId?: string;
embedIndex?: number;
defaultName?: string;
defaultAltText?: string;
onClose: () => void;
onDelete: (bypassConfirm?: boolean) => void;
}
export const MediaContextMenu: React.FC<MediaContextMenuProps> = observer(
({
message,
originalSrc,
proxyURL,
type,
contentHash,
attachmentId,
embedIndex,
defaultName,
defaultAltText,
onClose,
onDelete,
}) => {
const {i18n} = useLingui();
const memes = useSyncExternalStore(
(listener) => {
const dispose = autorun(listener);
return () => dispose();
},
() => FavoriteMemeStore.memes,
);
const isFavorited = contentHash ? memes.some((meme) => meme.contentHash === contentHash) : false;
const handleAddToFavorites = useCallback(() => {
ModalActionCreators.push(
modal(() => (
<AddFavoriteMemeModal
channelId={message.channelId}
messageId={message.id}
attachmentId={attachmentId}
embedIndex={embedIndex}
defaultName={
defaultName ||
FavoriteMemeUtils.deriveDefaultNameFromEmbedMedia(i18n, {
url: originalSrc,
proxy_url: originalSrc,
flags: 0,
})
}
defaultAltText={defaultAltText}
/>
)),
);
onClose();
}, [message, attachmentId, embedIndex, defaultName, defaultAltText, originalSrc, onClose]);
const handleRemoveFromFavorites = useCallback(async () => {
if (!contentHash) return;
const meme = memes.find((m) => m.contentHash === contentHash);
if (!meme) return;
await FavoriteMemeActionCreators.deleteFavoriteMeme(i18n, meme.id);
onClose();
}, [contentHash, memes, onClose, i18n]);
const handleCopyMedia = useCallback(async () => {
if (!originalSrc) {
ToastActionCreators.createToast({
type: 'error',
children: t(i18n)`Attachment is expired or unavailable`,
});
onClose();
return;
}
if (type === 'video' || type === 'gifv' || type === 'gif' || type === 'audio' || type === 'file') {
await TextCopyActionCreators.copy(i18n, originalSrc, true);
ToastActionCreators.createToast({
type: 'success',
children: type === 'file' ? t(i18n)`Link copied to clipboard` : t(i18n)`URL copied to clipboard`,
});
onClose();
return;
}
const baseProxyURL = proxyURL ? stripMediaProxyParams(proxyURL) : null;
const pngUrl = baseProxyURL ? buildMediaProxyURL(baseProxyURL, {format: 'png'}) : null;
const urlToFetch = pngUrl || originalSrc;
let toastId: string | null = null;
try {
toastId = ToastActionCreators.createToast({
type: 'info',
children: t(i18n)`Copying image...`,
timeout: 0,
});
const response = await fetch(urlToFetch);
const blob = await response.blob();
if (blob.type !== 'image/png') {
throw new Error('Image is not PNG format, falling back to URL copy');
}
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
if (toastId) ToastActionCreators.destroyToast(toastId);
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`Image copied to clipboard`,
});
onClose();
} catch (error) {
console.error('Failed to copy image to clipboard:', error);
if (toastId) ToastActionCreators.destroyToast(toastId);
await TextCopyActionCreators.copy(i18n, originalSrc, true);
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`URL copied to clipboard`,
});
onClose();
}
}, [originalSrc, proxyURL, type, onClose, i18n]);
const handleSaveMedia = useCallback(() => {
if (!originalSrc) {
ToastActionCreators.createToast({
type: 'error',
children: t(i18n)`Attachment is expired or unavailable`,
});
onClose();
return;
}
const mediaType: 'image' | 'video' | 'audio' | 'file' =
type === 'video' || type === 'gifv' ? 'video' : type === 'audio' ? 'audio' : type === 'file' ? 'file' : 'image';
const baseProxyURL = proxyURL ? stripMediaProxyParams(proxyURL) : null;
const urlToSave = baseProxyURL || originalSrc;
createSaveHandler(urlToSave, mediaType)();
onClose();
}, [originalSrc, proxyURL, type, onClose, i18n]);
const handleCopyLink = useCallback(async () => {
if (!originalSrc) {
ToastActionCreators.createToast({
type: 'error',
children: t(i18n)`Attachment is expired or unavailable`,
});
onClose();
return;
}
await TextCopyActionCreators.copy(i18n, originalSrc, true);
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`Link copied to clipboard`,
});
onClose();
}, [originalSrc, onClose, i18n]);
const handleOpenLink = useCallback(() => {
if (!originalSrc) {
ToastActionCreators.createToast({
type: 'error',
children: t(i18n)`Attachment is expired or unavailable`,
});
onClose();
return;
}
void openExternalUrl(originalSrc);
onClose();
}, [originalSrc, onClose, i18n]);
const handleCopyAttachmentId = useCallback(async () => {
if (!attachmentId) return;
await TextCopyActionCreators.copy(i18n, attachmentId, true);
ToastActionCreators.createToast({
type: 'success',
children: t(i18n)`Attachment ID copied to clipboard`,
});
onClose();
}, [attachmentId, onClose, i18n]);
const copyLabel = getCopyLabel(type, i18n);
const saveLabel = getSaveLabel(type, i18n);
const isDev = DeveloperModeStore.isDeveloper;
const currentMock = attachmentId ? DeveloperOptionsStore.mockAttachmentStates[attachmentId] : undefined;
const mockExpiresSoon = () =>
setAttachmentMock(attachmentId!, {
expired: false,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
});
const mockExpiresWeek = () =>
setAttachmentMock(attachmentId!, {
expired: false,
expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(),
});
const mockExpired = () =>
setAttachmentMock(attachmentId!, {
expired: true,
expiresAt: new Date(Date.now() - 3600000).toISOString(),
});
const clearMock = () => setAttachmentMock(attachmentId!, null);
return (
<>
<MenuGroup>
{isFavorited ? (
<MenuItem icon={<FavoriteIcon filled />} onClick={handleRemoveFromFavorites}>
{t(i18n)`Remove from Favorites`}
</MenuItem>
) : (
<MenuItem icon={<FavoriteIcon />} onClick={handleAddToFavorites}>
{t(i18n)`Add to Favorites`}
</MenuItem>
)}
</MenuGroup>
<MenuGroup>
<MenuItem icon={<CopyIcon />} onClick={handleCopyMedia}>
{copyLabel}
</MenuItem>
<MenuItem icon={<SaveIcon />} onClick={handleSaveMedia}>
{saveLabel}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem icon={<CopyLinkIcon />} onClick={handleCopyLink}>
{t(i18n)`Copy Link`}
</MenuItem>
<MenuItem icon={<OpenLinkIcon />} onClick={handleOpenLink}>
{t(i18n)`Open Link`}
</MenuItem>
</MenuGroup>
{attachmentId && (
<MenuGroup>
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyAttachmentId}>
{t(i18n)`Copy Attachment ID`}
</MenuItem>
</MenuGroup>
)}
{isDev && attachmentId && (
<MenuGroup>
<MenuItemSubmenu
label={t(i18n)`Attachment Mock`}
icon={<WrenchToolIcon />}
render={() => (
<>
<MenuItem onClick={mockExpiresSoon}>{t(i18n)`Mock expires in 1 day`}</MenuItem>
<MenuItem onClick={mockExpiresWeek}>{t(i18n)`Mock expires in 7 days`}</MenuItem>
<MenuItem onClick={mockExpired}>{t(i18n)`Mock expired`}</MenuItem>
{currentMock && <MenuItem onClick={clearMock}>{t(i18n)`Clear mock for this attachment`}</MenuItem>}
<MenuItem onClick={clearAllAttachmentMocks}>{t(i18n)`Clear all attachment mocks`}</MenuItem>
</>
)}
/>
</MenuGroup>
)}
<MessageContextMenu message={message} onClose={onClose} onDelete={onDelete} />
</>
);
},
);
function getCopyLabel(type: MediaType, i18n: I18n): string {
switch (type) {
case 'image':
return t(i18n)`Copy Image`;
case 'gif':
case 'gifv':
return t(i18n)`Copy GIF`;
case 'video':
return t(i18n)`Copy Video`;
case 'audio':
return t(i18n)`Copy Audio`;
case 'file':
return t(i18n)`Copy File Link`;
default:
return t(i18n)`Copy Media`;
}
}
function getSaveLabel(type: MediaType, i18n: I18n): string {
switch (type) {
case 'image':
return t(i18n)`Save Image`;
case 'gif':
case 'gifv':
return t(i18n)`Save GIF`;
case 'video':
return t(i18n)`Save Video`;
case 'audio':
return t(i18n)`Save Audio`;
case 'file':
return t(i18n)`Save File`;
default:
return t(i18n)`Save Media`;
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import {MenuGroup as MenuGroupPrimitive, MenuSeparator} from './ContextMenu';
interface MenuGroupProps {
children?: React.ReactNode;
}
export const MenuGroup: React.FC<MenuGroupProps> = observer(({children}) => {
const validChildren = React.Children.toArray(children).filter((child): child is React.ReactElement => {
if (!React.isValidElement(child)) return false;
if (child.type === React.Fragment && !(child.props as {children?: React.ReactNode}).children) return false;
return true;
});
if (validChildren.length === 0) {
return null;
}
return (
<>
<MenuGroupPrimitive>{validChildren}</MenuGroupPrimitive>
<MenuSeparator />
</>
);
});

View 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 {observer} from 'mobx-react-lite';
import React from 'react';
import {MenuSeparator} from './ContextMenu';
import {MenuGroup} from './MenuGroup';
interface MenuGroupsProps {
children?: React.ReactNode;
}
export const MenuGroups: React.FC<MenuGroupsProps> = observer(({children}) => {
const groups = React.Children.toArray(children).filter((child) => {
if (!child) return false;
if (!React.isValidElement(child)) return false;
return child.type === MenuGroup;
});
if (groups.length === 0) {
return null;
}
return (
<>
{groups.map((group, index) => (
<React.Fragment key={index}>
{group}
{index < groups.length - 1 && <MenuSeparator />}
</React.Fragment>
))}
</>
);
});

View File

@@ -0,0 +1,192 @@
/*
* 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/>.
*/
.menuItem {
display: grid;
grid-template-columns: 18px 1fr auto;
align-items: center;
gap: 12px;
padding: 6px 8px;
margin: 0;
border-radius: 3px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
line-height: 18px;
cursor: pointer;
outline: none;
transition: none;
box-sizing: border-box;
min-height: 32px;
}
.menuItem:has(.shortcut) {
padding-right: 0;
}
.menuItem[data-highlighted]:not([data-disabled]),
.menuItem:hover:not([data-disabled]):not(.disabled) {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.menuItem.danger {
color: var(--status-danger);
}
.menuItem.danger:is(
:hover,
:focus-visible,
[data-highlighted],
[data-hovered],
[data-focused],
[data-focus-visible],
[data-selected],
[data-open]
):not([data-disabled]):not(.disabled) {
background-color: var(--button-danger-fill);
color: var(--button-danger-text);
}
.menuItem[data-disabled],
.menuItem.disabled {
color: var(--interactive-muted);
cursor: not-allowed;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
grid-column: 1;
}
.icon > svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
.labelContainer {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
grid-column: 2;
display: flex;
align-items: center;
min-height: 18px;
}
.hint {
margin-left: 8px;
color: var(--text-tertiary-muted);
font-size: 12px;
font-weight: 400;
flex-shrink: 0;
}
.subtext {
color: var(--text-tertiary-muted);
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin-top: 2px;
}
.shortcut {
color: var(--text-tertiary-muted);
font-size: 12px;
font-weight: 500;
font-family: var(--font-mono);
justify-self: end;
white-space: nowrap;
margin-left: auto;
padding-left: 24px;
}
.menuItem.danger:is(
:hover,
:focus-visible,
[data-highlighted],
[data-hovered],
[data-focused],
[data-focus-visible],
[data-selected],
[data-open]
):not([data-disabled]):not(.disabled)
.shortcut {
color: var(--button-danger-text);
}
.sliderItem {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 8px 8px;
margin: 0;
border-radius: 3px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
line-height: 18px;
cursor: default;
outline: none;
box-sizing: border-box;
}
.sliderItem.disabled {
color: var(--interactive-muted);
cursor: not-allowed;
opacity: 0.5;
}
.sliderHeader {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.sliderLabel {
font-size: 14px;
font-weight: 500;
color: currentColor;
}
.sliderValue {
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary-muted);
}
.sliderContainer {
width: 100%;
}

View File

@@ -0,0 +1,78 @@
/*
* 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 React from 'react';
import {MenuItem as MenuItemPrimitive} from './ContextMenu';
import styles from './MenuItem.module.css';
type MenuItemPrimitiveProps = React.ComponentProps<typeof MenuItemPrimitive>;
type MenuItemSelectEvent = Parameters<NonNullable<MenuItemPrimitiveProps['onSelect']>>[0];
interface MenuItemProps {
children?: React.ReactNode;
icon?: React.ReactNode;
danger?: boolean;
disabled?: boolean;
onClick?: ((event: MenuItemSelectEvent) => void) | (() => void);
hint?: string;
shortcut?: string;
className?: string;
closeOnSelect?: boolean;
}
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
(
{children, icon, danger = false, disabled = false, onClick, hint, shortcut, className, closeOnSelect = true},
ref,
) => {
const handleSelect = React.useCallback(
(event: MenuItemSelectEvent) => {
if (!onClick) return;
if (onClick.length === 0) {
(onClick as () => void)();
return;
}
(onClick as (event: MenuItemSelectEvent) => void)(event);
},
[onClick],
);
const combinedClassName =
`${styles.menuItem} ${danger ? styles.danger : ''} ${disabled ? styles.disabled : ''} ${className ?? ''}`.trim();
return (
<MenuItemPrimitive
ref={ref}
label=""
className={combinedClassName}
disabled={disabled}
onSelect={handleSelect}
danger={danger}
icon={icon}
closeOnSelect={closeOnSelect}
>
<div className={styles.labelContainer}>
<span className={styles.label}>{children}</span>
{hint && <div className={styles.subtext}>{hint}</div>}
</div>
{shortcut && <span className={styles.shortcut}>{shortcut}</span>}
</MenuItemPrimitive>
);
},
);

View File

@@ -0,0 +1,62 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import {CheckboxItem} from './ContextMenu';
import styles from './ContextMenu.module.css';
interface MenuItemCheckboxProps {
children?: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
checked: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
danger?: boolean;
closeOnChange?: boolean;
}
export const MenuItemCheckbox: React.FC<MenuItemCheckboxProps> = observer(
({children, description, icon, checked, disabled = false, onChange, danger = false, closeOnChange = false}) => {
const handleCheckedChange = React.useCallback(
(newChecked: boolean) => {
onChange?.(newChecked);
},
[onChange],
);
return (
<CheckboxItem
label={children?.toString() || ''}
icon={icon}
checked={checked}
disabled={disabled}
danger={danger}
onCheckedChange={handleCheckedChange}
closeOnChange={closeOnChange}
>
<div className={styles.menuItemCheckboxLabel}>
<span className={styles.menuItemCheckboxLabelPrimary}>{children}</span>
{description && <span className={styles.menuItemCheckboxDescription}>{description}</span>}
</div>
</CheckboxItem>
);
},
);

View File

@@ -0,0 +1,46 @@
/*
* 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/>.
*/
.radioButton {
display: flex;
height: 20px;
width: 20px;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 2px solid;
cursor: pointer;
}
.radioButtonSelected {
border-color: var(--brand-primary);
background-color: var(--brand-primary);
}
.radioButtonUnselected {
border-color: var(--interactive-muted);
background-color: transparent;
}
.radioIndicator {
height: 8px;
width: 8px;
border-radius: 50%;
background-color: white;
}

View File

@@ -0,0 +1,69 @@
/*
* 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 React from 'react';
import {MenuItem as AriaMenuItem} from 'react-aria-components';
import {useContextMenuClose} from './ContextMenu';
import styles from './ContextMenu.module.css';
import radioStyles from './MenuItemRadio.module.css';
interface MenuItemRadioProps {
children?: React.ReactNode;
icon?: React.ReactNode;
selected: boolean;
disabled?: boolean;
onSelect?: () => void;
closeOnSelect?: boolean;
}
export const MenuItemRadio = React.forwardRef<HTMLDivElement, MenuItemRadioProps>(
({children, icon, selected, disabled = false, onSelect, closeOnSelect = false}, forwardedRef) => {
const closeMenu = useContextMenuClose();
const handleAction = React.useCallback(() => {
if (disabled) return;
onSelect?.();
if (closeOnSelect) {
closeMenu();
}
}, [closeMenu, closeOnSelect, disabled, onSelect]);
return (
<AriaMenuItem
ref={forwardedRef}
onAction={handleAction}
isDisabled={disabled}
className={`${styles.item} ${styles.checkboxItem} ${disabled ? styles.disabled : ''}`.trim()}
textValue={typeof children === 'string' ? children : ''}
>
{icon && <div className={styles.itemIcon}>{icon}</div>}
<div className={styles.itemLabel}>{children}</div>
<div className={styles.checkboxIndicator}>
<div
className={`${radioStyles.radioButton} ${selected ? radioStyles.radioButtonSelected : radioStyles.radioButtonUnselected}`}
>
{selected && <div className={radioStyles.radioIndicator} />}
</div>
</div>
</AriaMenuItem>
);
},
);
MenuItemRadio.displayName = 'MenuItemRadio';

View File

@@ -0,0 +1,108 @@
/*
* 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 React from 'react';
import {MenuItem as AriaMenuItem} from 'react-aria-components';
import {Slider} from '../Slider';
import styles from './MenuItem.module.css';
interface MenuItemSliderProps {
label: string;
value: number;
minValue?: number;
maxValue?: number;
disabled?: boolean;
onChange?: (value: number) => void;
onFormat?: (value: number) => string;
}
export const MenuItemSlider = React.forwardRef<HTMLDivElement, MenuItemSliderProps>(
({label, value, minValue = 0, maxValue = 100, disabled = false, onChange, onFormat}, forwardedRef) => {
const [localValue, setLocalValue] = React.useState(value);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
const formattedValue = onFormat ? onFormat(localValue) : `${Math.round(localValue)}%`;
const handleValueChange = React.useCallback(
(newValue: number) => {
setLocalValue(newValue);
onChange?.(newValue);
},
[onChange],
);
const handleValueCommit = React.useCallback(
(newValue: number) => {
onChange?.(newValue);
},
[onChange],
);
const handleSliderInteraction = React.useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
e.preventDefault();
}, []);
return (
<AriaMenuItem
ref={forwardedRef}
className={clsx(styles.sliderItem, {
[styles.disabled]: disabled,
})}
isDisabled={disabled}
textValue={label}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: interactive slider element */}
<div
onClick={handleSliderInteraction}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
}
}}
style={{width: '100%'}}
>
<div className={styles.sliderHeader}>
<span className={styles.sliderLabel}>{label}</span>
<span className={styles.sliderValue}>{formattedValue}</span>
</div>
<div className={styles.sliderContainer}>
<Slider
defaultValue={localValue}
factoryDefaultValue={100}
minValue={minValue}
maxValue={maxValue}
disabled={disabled}
onValueChange={handleValueCommit}
asValueChanges={handleValueChange}
mini={true}
value={localValue}
/>
</div>
</div>
</AriaMenuItem>
);
},
);
MenuItemSlider.displayName = 'MenuItemSlider';

View File

@@ -0,0 +1,49 @@
/*
* 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 {observer} from 'mobx-react-lite';
import type React from 'react';
import {SubMenu} from './ContextMenu';
interface MenuItemSubmenuProps {
label: string;
icon?: React.ReactNode;
disabled?: boolean;
hint?: string;
render: () => React.ReactNode;
onTriggerSelect?: () => void;
selectionMode?: 'none' | 'single' | 'multiple';
}
export const MenuItemSubmenu: React.FC<MenuItemSubmenuProps> = observer(
({label, icon, disabled = false, hint, render, onTriggerSelect, selectionMode}) => {
return (
<SubMenu
label={label}
icon={icon}
disabled={disabled}
hint={hint}
onTriggerSelect={onTriggerSelect}
selectionMode={selectionMode}
>
{render()}
</SubMenu>
);
},
);

View File

@@ -0,0 +1,282 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React, {useEffect, useMemo, useState} from 'react';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import {Permissions} from '~/Constants';
import {createMessageActionHandlers, useMessagePermissions} from '~/components/channel/messageActionUtils';
import type {MessageRecord} from '~/records/MessageRecord';
import PermissionStore from '~/stores/PermissionStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import {openExternalUrl} from '~/utils/NativeUtils';
import {CopyIcon, CopyLinkIcon, OpenLinkIcon} from './ContextMenuIcons';
import {
AddReactionMenuItem,
BookmarkMessageMenuItem,
CopyMessageIdMenuItem,
CopyMessageLinkMenuItem,
CopyMessageTextMenuItem,
DebugMessageMenuItem,
DeleteMessageMenuItem,
EditMessageMenuItem,
ForwardMessageMenuItem,
MarkAsUnreadMenuItem,
PinMessageMenuItem,
RemoveAllReactionsMenuItem,
ReplyMessageMenuItem,
SpeakMessageMenuItem,
SuppressEmbedsMenuItem,
} from './items/MessageMenuItems';
import {ReportMessageMenuItem} from './items/ReportMessageMenuItem';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface SelectionSnapshot {
text: string;
range: Range | null;
}
const getSelectionSnapshot = (): SelectionSnapshot => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return {text: '', range: null};
}
const text = selection.toString().trim();
if (!text) {
return {text: '', range: null};
}
try {
return {text, range: selection.getRangeAt(0).cloneRange()};
} catch {
return {text, range: null};
}
};
interface MessageContextMenuProps {
message: MessageRecord;
onClose: () => void;
onDelete: (bypassConfirm?: boolean) => void;
linkUrl?: string;
}
export const MessageContextMenu: React.FC<MessageContextMenuProps> = observer(
({message, onClose, onDelete, linkUrl}) => {
const {i18n} = useLingui();
const [{text: initialSelectionText, range: initialSelectionRange}] = useState(getSelectionSnapshot);
const [selectionText, setSelectionText] = useState(initialSelectionText);
const savedSelectionRangeRef = React.useRef<Range | null>(initialSelectionRange);
const restoringSelectionRef = React.useRef(false);
const restoreSelection = React.useCallback(() => {
const savedRange = savedSelectionRangeRef.current;
if (!savedRange) return;
const selection = window.getSelection();
if (!selection) return;
try {
const rangeForSelection = savedRange.cloneRange();
const rangeForStorage = rangeForSelection.cloneRange();
restoringSelectionRef.current = true;
selection.removeAllRanges();
selection.addRange(rangeForSelection);
savedSelectionRangeRef.current = rangeForStorage;
setSelectionText(rangeForStorage.toString().trim());
} catch {
savedSelectionRangeRef.current = null;
return;
} finally {
window.requestAnimationFrame(() => {
restoringSelectionRef.current = false;
});
}
}, []);
React.useLayoutEffect(() => {
if (!savedSelectionRangeRef.current) return;
restoreSelection();
}, [restoreSelection]);
useEffect(() => {
const handleSelectionChange = () => {
if (restoringSelectionRef.current) return;
const {text, range} = getSelectionSnapshot();
if (text) {
savedSelectionRangeRef.current = range;
setSelectionText(text);
return;
}
if (savedSelectionRangeRef.current) {
restoreSelection();
return;
}
setSelectionText('');
};
document.addEventListener('selectionchange', handleSelectionChange);
return () => document.removeEventListener('selectionchange', handleSelectionChange);
}, [restoreSelection]);
const copyShortcut = useMemo(() => {
return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘C' : 'Ctrl+C';
}, []);
const handleCopySelection = React.useCallback(async () => {
if (!selectionText) return;
await TextCopyActionCreators.copy(i18n, selectionText, true);
onClose();
}, [selectionText, onClose, i18n]);
const handleCopyLink = React.useCallback(async () => {
if (!linkUrl) return;
await TextCopyActionCreators.copy(i18n, linkUrl, true);
onClose();
}, [linkUrl, onClose, i18n]);
const handleOpenLink = React.useCallback(() => {
if (!linkUrl) return;
void openExternalUrl(linkUrl);
onClose();
}, [linkUrl, onClose]);
const {
canSendMessages,
canAddReactions,
canEditMessage,
canDeleteMessage,
canPinMessage,
shouldRenderSuppressEmbeds,
isDM,
} = useMessagePermissions(message);
const canManageMessages = !isDM && PermissionStore.can(Permissions.MANAGE_MESSAGES, {channelId: message.channelId});
const handlers = createMessageActionHandlers(message);
const developerMode = UserSettingsStore.developerMode;
return (
<>
{linkUrl && (
<MenuGroup>
<MenuItem icon={<OpenLinkIcon />} onClick={handleOpenLink}>
Open Link
</MenuItem>
<MenuItem icon={<CopyLinkIcon />} onClick={handleCopyLink}>
Copy Link
</MenuItem>
</MenuGroup>
)}
{selectionText && (
<MenuGroup>
<MenuItem icon={<CopyIcon />} onClick={handleCopySelection} shortcut={copyShortcut}>
Copy
</MenuItem>
</MenuGroup>
)}
<MenuGroup>
{canAddReactions && <AddReactionMenuItem message={message} onClose={onClose} />}
<MarkAsUnreadMenuItem message={message} onMarkAsUnread={handlers.handleMarkAsUnread} onClose={onClose} />
{message.isUserMessage() && !message.messageSnapshots && canEditMessage && (
<EditMessageMenuItem message={message} onEdit={handlers.handleEditMessage} onClose={onClose} />
)}
{message.isUserMessage() && canSendMessages && (
<ReplyMessageMenuItem message={message} onReply={handlers.handleReply} onClose={onClose} />
)}
{message.isUserMessage() && (
<ForwardMessageMenuItem message={message} onForward={handlers.handleForward} onClose={onClose} />
)}
</MenuGroup>
{(message.isUserMessage() || message.content) && (
<MenuGroup>
{message.isUserMessage() && (
<BookmarkMessageMenuItem message={message} onSave={handlers.handleSaveMessage} onClose={onClose} />
)}
{message.isUserMessage() && canPinMessage && (
<PinMessageMenuItem message={message} onPin={handlers.handlePinMessage} onClose={onClose} />
)}
{shouldRenderSuppressEmbeds && (
<SuppressEmbedsMenuItem
message={message}
onToggleSuppressEmbeds={handlers.handleToggleSuppressEmbeds}
onClose={onClose}
/>
)}
{message.content && (
<CopyMessageTextMenuItem message={message} onCopyMessage={handlers.handleCopyMessage} onClose={onClose} />
)}
<SpeakMessageMenuItem message={message} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyMessageLinkMenuItem
message={message}
onCopyMessageLink={handlers.handleCopyMessageLink}
onClose={onClose}
/>
<CopyMessageIdMenuItem message={message} onCopyMessageId={handlers.handleCopyMessageId} onClose={onClose} />
{developerMode && <DebugMessageMenuItem message={message} onClose={onClose} />}
</MenuGroup>
{!message.isCurrentUserAuthor() && (
<MenuGroup>
<ReportMessageMenuItem message={message} onClose={onClose} />
</MenuGroup>
)}
{canDeleteMessage && (
<MenuGroup>
<DeleteMessageMenuItem message={message} onDelete={onDelete} onClose={onClose} />
</MenuGroup>
)}
{canManageMessages && message.reactions.length > 0 && (
<MenuGroup>
<RemoveAllReactionsMenuItem
message={message}
onRemoveAllReactions={handlers.handleRemoveAllReactions}
onClose={onClose}
/>
</MenuGroup>
)}
</>
);
},
);

View File

@@ -0,0 +1,122 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {
getSettingsTabs,
getSubtabsForTab,
type SettingsSubtab,
type SettingsTab,
} from '~/components/modals/utils/settingsConstants';
import DeveloperModeStore from '~/stores/DeveloperModeStore';
import FeatureFlagStore from '~/stores/FeatureFlagStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
import {MenuItemSubmenu} from './MenuItemSubmenu';
interface SettingsContextMenuProps {
onClose: () => void;
}
export const SettingsContextMenu: React.FC<SettingsContextMenuProps> = observer(({onClose}) => {
const {t} = useLingui();
const isDeveloper = DeveloperModeStore.isDeveloper;
const selectedGuildId = SelectedGuildStore.selectedGuildId;
const hasExpressionPackAccess = FeatureFlagStore.isExpressionPacksEnabled(selectedGuildId ?? undefined);
const handleOpenSettings = React.useCallback(
(tab: SettingsTab, subtab?: SettingsSubtab) => {
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab={tab.type} initialSubtab={subtab?.type} />));
onClose();
},
[onClose],
);
const renderSettingsMenuItem = React.useCallback(
(tab: SettingsTab) => {
const subtabs = getSubtabsForTab(tab.type, t);
if (subtabs.length === 0) {
const IconComponent = tab.icon;
return (
<MenuItem
key={tab.type}
icon={<IconComponent size={16} weight={tab.iconWeight ?? 'fill'} />}
onClick={() => handleOpenSettings(tab)}
>
{tab.label}
</MenuItem>
);
}
const IconComponent = tab.icon;
return (
<MenuItemSubmenu
key={tab.type}
label={tab.label}
icon={<IconComponent size={16} weight={tab.iconWeight ?? 'fill'} />}
onTriggerSelect={() => handleOpenSettings(tab)}
render={() => (
<>
{subtabs.map((subtab) => (
<MenuItem key={subtab.type} onClick={() => handleOpenSettings(tab, subtab)}>
{subtab.label}
</MenuItem>
))}
</>
)}
/>
);
},
[handleOpenSettings],
);
const accessibleTabs = React.useMemo(() => {
const allTabs = getSettingsTabs(t);
return allTabs.filter((tab) => {
if (!isDeveloper && tab.category === 'staff_only') {
return false;
}
if (!hasExpressionPackAccess && tab.type === 'expression_packs') {
return false;
}
return true;
});
}, [isDeveloper, hasExpressionPackAccess, t]);
const userSettingsTabs = accessibleTabs.filter((tab) => tab.category === 'user_settings');
const appSettingsTabs = accessibleTabs.filter((tab) => tab.category === 'app_settings');
const developerTabs = accessibleTabs.filter((tab) => tab.category === 'developer');
const staffOnlyTabs = accessibleTabs.filter((tab) => tab.category === 'staff_only');
return (
<>
{userSettingsTabs.length > 0 && <MenuGroup>{userSettingsTabs.map(renderSettingsMenuItem)}</MenuGroup>}
{appSettingsTabs.length > 0 && <MenuGroup>{appSettingsTabs.map(renderSettingsMenuItem)}</MenuGroup>}
{developerTabs.length > 0 && <MenuGroup>{developerTabs.map(renderSettingsMenuItem)}</MenuGroup>}
{staffOnlyTabs.length > 0 && <MenuGroup>{staffOnlyTabs.map(renderSettingsMenuItem)}</MenuGroup>}
</>
);
});

View File

@@ -0,0 +1,339 @@
/*
* 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 {CrownIcon, PencilSimpleIcon, UserMinusIcon, XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ME, Permissions, RelationshipTypes} from '~/Constants';
import {DMCloseFailedModal} from '~/components/alerts/DMCloseFailedModal';
import {ChangeGroupDMNicknameModal} from '~/components/modals/ChangeGroupDMNicknameModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {Routes} from '~/Routes';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import CallStateStore from '~/stores/CallStateStore';
import ChannelStore from '~/stores/ChannelStore';
import PermissionStore from '~/stores/PermissionStore';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import UserStore from '~/stores/UserStore';
import * as RouterUtils from '~/utils/RouterUtils';
import {RingUserMenuItem, StartVoiceCallMenuItem} from './items/CallMenuItems';
import {FavoriteChannelMenuItem} from './items/ChannelMenuItems';
import {CopyUserIdMenuItem} from './items/CopyMenuItems';
import {DebugUserMenuItem} from './items/DebugMenuItems';
import {MarkDMAsReadMenuItem} from './items/DMMenuItems';
import {InviteToCommunityMenuItem} from './items/InviteMenuItems';
import {MentionUserMenuItem} from './items/MentionUserMenuItem';
import {MessageUserMenuItem} from './items/MessageUserMenuItem';
import {
BlockUserMenuItem,
ChangeFriendNicknameMenuItem,
RelationshipActionMenuItem,
UnblockUserMenuItem,
} from './items/RelationshipMenuItems';
import {AddNoteMenuItem} from './items/UserNoteMenuItems';
import {UserProfileMenuItem} from './items/UserProfileMenuItem';
import {LocalMuteParticipantMenuItem, ParticipantVolumeSlider} from './items/VoiceParticipantMenuItems';
import {MenuGroup} from './MenuGroup';
import {MenuItem} from './MenuItem';
interface UserContextMenuProps {
user: UserRecord;
onClose: () => void;
guildId?: string;
channelId?: string;
isCallContext?: boolean;
}
export const UserContextMenu: React.FC<UserContextMenuProps> = observer(
({user, onClose, guildId, channelId, isCallContext = false}) => {
const {t} = useLingui();
const channel = channelId ? ChannelStore.getChannel(channelId) : null;
const canSendMessages = channel
? channel.isPrivate() || PermissionStore.can(Permissions.SEND_MESSAGES, {channelId, guildId})
: true;
const canMention = channel !== null && canSendMessages;
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
const developerMode = UserSettingsStore.developerMode;
const currentUserId = AuthenticationStore.currentUserId;
const dmPartnerId = channel?.isDM()
? (channel.recipientIds.find((id) => id !== currentUserId) ?? channel.recipientIds[0])
: null;
const dmPartner = dmPartnerId ? UserStore.getUser(dmPartnerId) : null;
const isGroupDM = channel?.isGroupDM();
const isOwner = channel?.ownerId === currentUserId;
const isRecipient = channel?.recipientIds.includes(user.id);
const isBot = user.bot;
const call = channelId ? CallStateStore.getCall(channelId) : null;
const showCallItems = isCallContext && call && !isCurrentUser;
const handleChangeGroupNickname = React.useCallback(() => {
if (!channel) return;
onClose();
ModalActionCreators.push(modal(() => <ChangeGroupDMNicknameModal channelId={channel.id} user={user} />));
}, [channel, onClose, user]);
const handleRemoveFromGroup = React.useCallback(() => {
if (!channel) return;
onClose();
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Remove from Group`}
description={t`Are you sure you want to remove ${user.username} from the group?`}
primaryText={t`Remove`}
primaryVariant="danger-primary"
onPrimary={() => PrivateChannelActionCreators.removeRecipient(channel.id, user.id)}
/>
)),
);
}, [channel, onClose, t, user.id, user.username]);
const handleMakeGroupOwner = React.useCallback(() => {
if (!channel) return;
onClose();
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Change Owner`}
description={t`Are you sure you want to transfer ownership of the group to ${user.username}?`}
primaryText={t`Transfer Ownership`}
onPrimary={() => {
ChannelActionCreators.update(channel.id, {owner_id: user.id});
}}
/>
)),
);
}, [channel, onClose, t, user.id, user.username]);
const handleCloseDM = React.useCallback(() => {
if (!channel || !channel.isDM()) return;
onClose();
const displayName = dmPartner?.username ?? user.username;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Close DM`}
description={t`Are you sure you want to close your DM with ${displayName}? You can always reopen it later.`}
primaryText={t`Close DM`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await ChannelActionCreators.remove(channel.id);
const selectedChannel = SelectedChannelStore.selectedChannelIds.get(ME);
if (selectedChannel === channel.id) {
RouterUtils.transitionTo(Routes.ME);
}
ToastActionCreators.createToast({
type: 'success',
children: t`DM closed`,
});
} catch (error) {
console.error('Failed to close DM:', error);
ModalActionCreators.push(modal(() => <DMCloseFailedModal />));
}
}}
/>
)),
);
}, [channel, dmPartner?.username, onClose, t, user.username]);
const renderDmSelfMenu = () => {
if (!channel) return renderDefaultMenu();
return (
<>
<MenuGroup>
<MarkDMAsReadMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<FavoriteChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={guildId} onClose={onClose} />
<MenuItem icon={<XIcon weight="bold" />} onClick={handleCloseDM}>
{t`Close DM`}
</MenuItem>
</MenuGroup>
<MenuGroup>{developerMode && <DebugUserMenuItem user={user} onClose={onClose} />}</MenuGroup>
<MenuGroup>
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
};
const renderDmOtherMenu = () => {
if (!channel) return renderDefaultMenu();
return (
<>
<MenuGroup>
<MarkDMAsReadMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<FavoriteChannelMenuItem channel={channel} onClose={onClose} />
</MenuGroup>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={guildId} onClose={onClose} />
{showCallItems && channelId && (
<RingUserMenuItem userId={user.id} channelId={channelId} onClose={onClose} />
)}
{!isBot && <StartVoiceCallMenuItem user={user} onClose={onClose} />}
<AddNoteMenuItem user={user} onClose={onClose} />
<ChangeFriendNicknameMenuItem user={user} onClose={onClose} />
<MenuItem icon={<XIcon weight="bold" />} onClick={handleCloseDM}>
{t`Close DM`}
</MenuItem>
</MenuGroup>
<MenuGroup>
{!isBot && <InviteToCommunityMenuItem user={user} onClose={onClose} />}
{!isBot && <RelationshipActionMenuItem user={user} onClose={onClose} />}
{relationshipType === RelationshipTypes.BLOCKED ? (
<UnblockUserMenuItem user={user} onClose={onClose} />
) : (
<BlockUserMenuItem user={user} onClose={onClose} />
)}
</MenuGroup>
{developerMode && (
<MenuGroup>
<DebugUserMenuItem user={user} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
};
const renderDefaultMenu = () => (
<>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={guildId} onClose={onClose} />
{isGroupDM && isCurrentUser && (
<MenuItem icon={<PencilSimpleIcon />} onClick={handleChangeGroupNickname}>
{t`Change Group Nickname`}
</MenuItem>
)}
{canMention && <MentionUserMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && <MessageUserMenuItem user={user} onClose={onClose} />}
{showCallItems && channelId && <RingUserMenuItem userId={user.id} channelId={channelId} onClose={onClose} />}
{!isCurrentUser && !isBot && !isCallContext && <StartVoiceCallMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && <AddNoteMenuItem user={user} onClose={onClose} />}
<ChangeFriendNicknameMenuItem user={user} onClose={onClose} />
</MenuGroup>
{showCallItems && (
<MenuGroup>
<ParticipantVolumeSlider userId={user.id} />
</MenuGroup>
)}
{isGroupDM && isOwner && isRecipient && !isCurrentUser && (
<MenuGroup>
<MenuItem icon={<UserMinusIcon />} onClick={handleRemoveFromGroup} danger>
{t`Remove from Group`}
</MenuItem>
<MenuItem icon={<CrownIcon />} onClick={handleMakeGroupOwner} danger>
{t`Make Group Owner`}
</MenuItem>
<MenuItem icon={<PencilSimpleIcon />} onClick={handleChangeGroupNickname}>
{t`Change Group Nickname`}
</MenuItem>
</MenuGroup>
)}
{showCallItems && (
<MenuGroup>
<LocalMuteParticipantMenuItem userId={user.id} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
{!isCurrentUser && !isBot && <InviteToCommunityMenuItem user={user} onClose={onClose} />}
{!isCurrentUser && !isBot && <RelationshipActionMenuItem user={user} onClose={onClose} />}
{!isCurrentUser &&
(relationshipType === RelationshipTypes.BLOCKED ? (
<UnblockUserMenuItem user={user} onClose={onClose} />
) : (
<BlockUserMenuItem user={user} onClose={onClose} />
))}
</MenuGroup>
{developerMode && (
<MenuGroup>
<DebugUserMenuItem user={user} onClose={onClose} />
</MenuGroup>
)}
<MenuGroup>
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
if (channel?.isDM()) {
return isCurrentUser ? renderDmSelfMenu() : renderDmOtherMenu();
}
return renderDefaultMenu();
},
);

View File

@@ -0,0 +1,302 @@
/*
* 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 {UsersIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as VoiceSettingsActionCreators from '~/actions/VoiceSettingsActionCreators';
import {Permissions, RelationshipTypes} from '~/Constants';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import PermissionStore from '~/stores/PermissionStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import VoiceSettingsStore from '~/stores/VoiceSettingsStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import type {VoiceState} from '~/stores/voice/VoiceStateManager';
import {CopyUserIdMenuItem} from './items/CopyMenuItems';
import {DebugUserMenuItem} from './items/DebugMenuItems';
import {
BanMemberMenuItem,
ChangeNicknameMenuItem,
KickMemberMenuItem,
ManageRolesMenuItem,
} from './items/GuildMemberMenuItems';
import {MessageUserMenuItem} from './items/MessageUserMenuItem';
import {MoveToChannelSubmenu} from './items/MoveToChannelSubmenu';
import {BlockUserMenuItem, RelationshipActionMenuItem, UnblockUserMenuItem} from './items/RelationshipMenuItems';
import {UserProfileMenuItem} from './items/UserProfileMenuItem';
import {
BulkCameraDevicesMenuItem,
BulkDeafenDevicesMenuItem,
BulkDisconnectDevicesMenuItem,
BulkMuteDevicesMenuItem,
CopyDeviceIdMenuItem,
DisconnectParticipantMenuItem,
FocusParticipantMenuItem,
GuildDeafenMenuItem,
GuildMuteMenuItem,
LocalDisableVideoMenuItem,
LocalMuteParticipantMenuItem,
ParticipantVolumeSlider,
SelfDeafenMenuItem,
SelfMuteMenuItem,
SelfTurnOffCameraMenuItem,
SelfTurnOffStreamMenuItem,
TurnOffDeviceCameraMenuItem,
TurnOffDeviceStreamMenuItem,
VoiceVideoSettingsMenuItem,
} from './items/VoiceParticipantMenuItems';
import {MenuGroup} from './MenuGroup';
import {MenuItemCheckbox} from './MenuItemCheckbox';
interface VoiceParticipantContextMenuProps {
user: UserRecord;
participantName: string;
onClose: () => void;
guildId?: string;
connectionId?: string;
isGroupedItem?: boolean;
isParentGroupedItem?: boolean;
}
export const VoiceParticipantContextMenu: React.FC<VoiceParticipantContextMenuProps> = observer(
({user, participantName, onClose, guildId, connectionId, isGroupedItem = false, isParentGroupedItem = false}) => {
const {t} = useLingui();
const member = GuildMemberStore.getMember(guildId ?? '', user.id);
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
const developerMode = UserSettingsStore.developerMode;
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
const canMuteMembers = guildId ? PermissionStore.can(Permissions.MUTE_MEMBERS, {guildId}) : false;
const canMoveMembers = guildId ? PermissionStore.can(Permissions.MOVE_MEMBERS, {guildId}) : false;
const canKickMembers = guildId ? PermissionStore.can(Permissions.KICK_MEMBERS, {guildId}) : false;
const canBanMembers = guildId ? PermissionStore.can(Permissions.BAN_MEMBERS, {guildId}) : false;
const userVoiceStates = React.useMemo(() => {
if (!guildId) return [] as Array<{connectionId: string; voiceState: VoiceState}>;
const allStates = MediaEngineStore.getAllVoiceStates();
const acc: Array<{connectionId: string; voiceState: VoiceState}> = [];
Object.entries(allStates).forEach(([g, guildData]) => {
if (g === guildId) {
Object.entries(guildData).forEach(([, channelData]) => {
Object.entries(channelData).forEach(([cid, vs]) => {
if (vs.user_id === user.id) acc.push({connectionId: cid, voiceState: vs});
});
});
}
});
return acc;
}, [guildId, user.id]);
const hasMultipleConnections = userVoiceStates.length > 1;
const connectionIds = React.useMemo(() => userVoiceStates.map((u) => u.connectionId), [userVoiceStates]);
return (
<>
<MenuGroup>
<UserProfileMenuItem user={user} guildId={guildId} onClose={onClose} />
{connectionId && guildId && (
<FocusParticipantMenuItem userId={user.id} connectionId={connectionId} onClose={onClose} />
)}
{!isCurrentUser && <MessageUserMenuItem user={user} onClose={onClose} />}
</MenuGroup>
{isCurrentUser ? (
<>
<MenuGroup>
{isGroupedItem && connectionId ? (
<>
<SelfMuteMenuItem
onClose={onClose}
connectionId={connectionId}
isDeviceSpecific={true}
label={t`Mute Device`}
/>
<SelfDeafenMenuItem
onClose={onClose}
connectionId={connectionId}
isDeviceSpecific={true}
label={t`Deafen Device`}
/>
<TurnOffDeviceCameraMenuItem onClose={onClose} connectionId={connectionId} />
<TurnOffDeviceStreamMenuItem onClose={onClose} connectionId={connectionId} />
<CopyDeviceIdMenuItem connectionId={connectionId} onClose={onClose} />
{guildId && (
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId}
connectionId={connectionId}
onClose={onClose}
label={t`Move Device To...`}
/>
)}
</>
) : (
<>
<SelfMuteMenuItem onClose={onClose} />
<SelfDeafenMenuItem onClose={onClose} />
<SelfTurnOffCameraMenuItem onClose={onClose} />
<SelfTurnOffStreamMenuItem onClose={onClose} />
<VoiceVideoSettingsMenuItem onClose={onClose} />
</>
)}
{guildId && (
<DisconnectParticipantMenuItem
userId={user.id}
guildId={guildId}
participantName={participantName}
connectionId={connectionId}
onClose={onClose}
label={isGroupedItem ? t`Disconnect Device` : undefined}
/>
)}
</MenuGroup>
<MenuGroup>
<MenuItemCheckbox
icon={<UsersIcon weight="fill" style={{width: 16, height: 16}} />}
checked={VoiceSettingsStore.showMyOwnCamera}
onChange={(checked) => VoiceSettingsActionCreators.update({showMyOwnCamera: checked})}
>
{t`Show My Own Camera`}
</MenuItemCheckbox>
<MenuItemCheckbox
icon={<UsersIcon weight="fill" style={{width: 16, height: 16}} />}
checked={VoiceSettingsStore.showNonVideoParticipants}
onChange={(checked) => VoiceSettingsActionCreators.update({showNonVideoParticipants: checked})}
>
{t`Show Non-Video Participants`}
</MenuItemCheckbox>
</MenuGroup>
</>
) : (
<MenuGroup>
<ParticipantVolumeSlider userId={user.id} />
<LocalMuteParticipantMenuItem userId={user.id} onClose={onClose} />
{connectionId && (
<>
<LocalDisableVideoMenuItem userId={user.id} connectionId={connectionId} onClose={onClose} />
<CopyDeviceIdMenuItem connectionId={connectionId} onClose={onClose} />
</>
)}
</MenuGroup>
)}
{isParentGroupedItem && hasMultipleConnections && (
<MenuGroup>
{isCurrentUser ? (
<>
<BulkMuteDevicesMenuItem userVoiceStates={userVoiceStates} onClose={onClose} />
<BulkDeafenDevicesMenuItem userVoiceStates={userVoiceStates} onClose={onClose} />
<BulkCameraDevicesMenuItem userVoiceStates={userVoiceStates} onClose={onClose} />
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId!}
connectionIds={connectionIds}
onClose={onClose}
label={t`Move All Devices To...`}
/>
<BulkDisconnectDevicesMenuItem userVoiceStates={userVoiceStates} onClose={onClose} />
</>
) : (
canMoveMembers && (
<>
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId!}
connectionIds={connectionIds}
onClose={onClose}
label={t`Move All Devices To...`}
/>
<BulkDisconnectDevicesMenuItem userVoiceStates={userVoiceStates} onClose={onClose} />
</>
)
)}
</MenuGroup>
)}
{guildId && (canMuteMembers || canMoveMembers) && (
<MenuGroup>
{canMuteMembers && (
<>
<GuildMuteMenuItem userId={user.id} guildId={guildId} onClose={onClose} />
<GuildDeafenMenuItem userId={user.id} guildId={guildId} onClose={onClose} />
</>
)}
{canMoveMembers && !isParentGroupedItem && !isCurrentUser && (
<>
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId!}
connectionId={connectionId}
onClose={onClose}
label={connectionId ? t`Move Device To...` : t`Move To...`}
/>
<DisconnectParticipantMenuItem
userId={user.id}
guildId={guildId!}
participantName={participantName}
connectionId={connectionId}
onClose={onClose}
label={connectionId ? t`Disconnect Device` : t`Disconnect`}
/>
</>
)}
</MenuGroup>
)}
{!isCurrentUser && (
<MenuGroup>
{guildId && member && (
<ChangeNicknameMenuItem guildId={guildId} user={user} member={member} onClose={onClose} />
)}
<RelationshipActionMenuItem user={user} onClose={onClose} />
{relationshipType === RelationshipTypes.BLOCKED ? (
<UnblockUserMenuItem user={user} onClose={onClose} />
) : (
<BlockUserMenuItem user={user} onClose={onClose} />
)}
</MenuGroup>
)}
{guildId && !isCurrentUser && (canKickMembers || canBanMembers) && (
<MenuGroup>
{canKickMembers && <KickMemberMenuItem guildId={guildId!} user={user} onClose={onClose} />}
{canBanMembers && <BanMemberMenuItem guildId={guildId!} user={user} onClose={onClose} />}
</MenuGroup>
)}
{guildId && member && (
<MenuGroup>
<ManageRolesMenuItem guildId={guildId} member={member} />
</MenuGroup>
)}
<MenuGroup>
{developerMode && <DebugUserMenuItem user={user} onClose={onClose} />}
<CopyUserIdMenuItem user={user} onClose={onClose} />
</MenuGroup>
</>
);
},
);

View File

@@ -0,0 +1,142 @@
/*
* 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 {PhoneIcon, PhoneXIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {PressEvent} from 'react-aria-components';
import * as CallActionCreators from '~/actions/CallActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import type {UserRecord} from '~/records/UserRecord';
import CallStateStore from '~/stores/CallStateStore';
import * as CallUtils from '~/utils/CallUtils';
import {VideoCallIcon, VoiceCallIcon} from '../ContextMenuIcons';
import {MenuItem} from '../MenuItem';
import styles from './MenuItems.module.css';
interface StartVoiceCallMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const StartVoiceCallMenuItem: React.FC<StartVoiceCallMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const handleStartVoiceCall = React.useCallback(
async (event: PressEvent) => {
onClose();
try {
const channelId = await PrivateChannelActionCreators.ensureDMChannel(user.id);
await CallUtils.checkAndStartCall(channelId, event.shiftKey);
} catch (error) {
console.error('Failed to start voice call:', error);
}
},
[user.id, onClose],
);
if (user.bot) {
return null;
}
return (
<MenuItem icon={<VoiceCallIcon />} onClick={handleStartVoiceCall}>
{t`Start Voice Call`}
</MenuItem>
);
});
interface StartVideoCallMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const StartVideoCallMenuItem: React.FC<StartVideoCallMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const handleStartVideoCall = React.useCallback(
async (event: PressEvent) => {
onClose();
try {
const channelId = await PrivateChannelActionCreators.ensureDMChannel(user.id);
await CallUtils.checkAndStartCall(channelId, event.shiftKey);
} catch (error) {
console.error('Failed to start video call:', error);
}
},
[user.id, onClose],
);
if (user.bot) {
return null;
}
return (
<MenuItem icon={<VideoCallIcon />} onClick={handleStartVideoCall}>
{t`Start Video Call`}
</MenuItem>
);
});
interface RingUserMenuItemProps {
userId: string;
channelId: string;
onClose: () => void;
}
export const RingUserMenuItem: React.FC<RingUserMenuItemProps> = observer(({userId, channelId, onClose}) => {
const {t} = useLingui();
const call = CallStateStore.getCall(channelId);
const participants = call ? CallStateStore.getParticipants(channelId) : [];
const isInCall = participants.includes(userId);
const isRinging = call?.ringing.includes(userId) ?? false;
const handleRing = React.useCallback(async () => {
onClose();
try {
await CallActionCreators.ringParticipants(channelId, [userId]);
} catch (error) {
console.error('Failed to ring user:', error);
}
}, [channelId, userId, onClose]);
const handleStopRinging = React.useCallback(async () => {
onClose();
try {
await CallActionCreators.stopRingingParticipants(channelId, [userId]);
} catch (error) {
console.error('Failed to stop ringing user:', error);
}
}, [channelId, userId, onClose]);
if (!call || isInCall) return null;
if (isRinging) {
return (
<MenuItem icon={<PhoneXIcon weight="fill" className={styles.icon} />} onClick={handleStopRinging}>
{t`Stop Ringing`}
</MenuItem>
);
}
return (
<MenuItem icon={<PhoneIcon weight="fill" className={styles.icon} />} onClick={handleRing}>
{t`Ring`}
</MenuItem>
);
});

View File

@@ -0,0 +1,381 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {ChannelTypes, MessageNotifications, Permissions} from '~/Constants';
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import type {ChannelRecord} from '~/records/ChannelRecord';
import ChannelStore from '~/stores/ChannelStore';
import PermissionStore from '~/stores/PermissionStore';
import ReadStateStore from '~/stores/ReadStateStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
import {
CopyIdIcon,
DeleteIcon,
MarkAsReadIcon,
MuteIcon,
NotificationSettingsIcon,
SettingsIcon,
} from '../ContextMenuIcons';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import menuItemStyles from '../MenuItem.module.css';
import {MenuItemCheckbox} from '../MenuItemCheckbox';
import {MenuItemRadio} from '../MenuItemRadio';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
import itemStyles from './MenuItems.module.css';
interface CategoryMenuItemProps {
category: ChannelRecord;
onClose: () => void;
}
export const MarkCategoryAsReadMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const channels = ChannelStore.getGuildChannels(guildId);
const channelsInCategory = React.useMemo(
() => channels.filter((ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY),
[channels, category.id],
);
const hasUnread = channelsInCategory.some((ch) => ReadStateStore.hasUnread(ch.id));
const handleMarkAsRead = React.useCallback(() => {
for (const channel of channelsInCategory) {
ReadStateActionCreators.ack(channel.id, true, true);
}
onClose();
}, [channelsInCategory, onClose]);
return (
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
{t`Mark as Read`}
</MenuItem>
);
});
export const CollapseCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const isCollapsed = UserGuildSettingsStore.isChannelCollapsed(guildId, category.id);
const handleToggleCollapse = React.useCallback(() => {
UserGuildSettingsActionCreators.toggleChannelCollapsed(guildId, category.id);
onClose();
}, [guildId, category.id, onClose]);
return (
<MenuItemCheckbox checked={isCollapsed} onChange={handleToggleCollapse}>{t`Collapse Category`}</MenuItemCheckbox>
);
});
export const CollapseAllCategoriesMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const channels = ChannelStore.getGuildChannels(guildId);
const categoryIds = React.useMemo(
() => channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id),
[channels],
);
const allCategoriesCollapsed = React.useMemo(() => {
if (categoryIds.length === 0) return false;
return categoryIds.every((categoryId) => UserGuildSettingsStore.isChannelCollapsed(guildId, categoryId));
}, [guildId, categoryIds]);
const handleToggleCollapseAll = React.useCallback(() => {
UserGuildSettingsActionCreators.toggleAllCategoriesCollapsed(guildId, categoryIds);
onClose();
}, [guildId, categoryIds, onClose]);
return (
<MenuItemCheckbox checked={allCategoriesCollapsed} onChange={handleToggleCollapseAll}>
{t`Collapse All Categories`}
</MenuItemCheckbox>
);
});
interface MuteDuration {
label: string;
value: number | null;
}
export const MuteCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const categoryOverride = UserGuildSettingsStore.getChannelOverride(guildId, category.id);
const isMuted = categoryOverride?.muted ?? false;
const muteConfig = categoryOverride?.mute_config;
const getMuteDurations = React.useCallback(
(): Array<MuteDuration> => [
{label: t`For 15 Minutes`, value: 15 * 60 * 1000},
{label: t`For 1 Hour`, value: 60 * 60 * 1000},
{label: t`For 3 Hours`, value: 3 * 60 * 60 * 1000},
{label: t`For 8 Hours`, value: 8 * 60 * 60 * 1000},
{label: t`For 24 Hours`, value: 24 * 60 * 60 * 1000},
{label: t`Until I turn it back on`, value: null},
],
[t],
);
const mutedText = getMutedText(isMuted, muteConfig);
const handleMute = React.useCallback(
(duration: number | null) => {
const nextMuteConfig = duration
? {
selected_time_window: duration,
end_time: new Date(Date.now() + duration).toISOString(),
}
: null;
UserGuildSettingsActionCreators.updateChannelOverride(guildId, category.id, {
muted: true,
mute_config: nextMuteConfig,
collapsed: true,
});
onClose();
},
[guildId, category.id, onClose],
);
const handleUnmute = React.useCallback(() => {
UserGuildSettingsActionCreators.updateChannelOverride(guildId, category.id, {
muted: false,
mute_config: null,
});
onClose();
}, [guildId, category.id, onClose]);
if (isMuted && mutedText) {
return (
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText}>
{t`Unmute Category`}
</MenuItem>
);
}
return (
<MenuItemSubmenu
label={t`Mute Category`}
icon={<MuteIcon />}
onTriggerSelect={() => handleMute(null)}
render={() => (
<MenuGroup>
{getMuteDurations().map((duration) => (
<MenuItem key={duration.label} onClick={() => handleMute(duration.value)}>
{duration.label}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
});
export const CategoryNotificationSettingsMenuItem: React.FC<CategoryMenuItemProps> = observer(({category}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const categoryNotifications = UserGuildSettingsStore.getChannelOverride(guildId, category.id)?.message_notifications;
const currentNotificationLevel = categoryNotifications ?? MessageNotifications.INHERIT;
const guildNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guildId);
const effectiveCurrentNotificationLevel =
currentNotificationLevel === MessageNotifications.INHERIT ? guildNotificationLevel : currentNotificationLevel;
const currentStateText = getNotificationSettingsLabel(effectiveCurrentNotificationLevel);
const defaultLabelParts = {
main: t`Community Default`,
sub: getNotificationSettingsLabel(guildNotificationLevel) ?? null,
};
const handleNotificationLevelChange = React.useCallback(
(level: number) => {
if (level === MessageNotifications.INHERIT) {
UserGuildSettingsActionCreators.updateChannelOverride(guildId, category.id, {
message_notifications: MessageNotifications.INHERIT,
});
} else {
UserGuildSettingsActionCreators.updateMessageNotifications(guildId, level, category.id);
}
},
[guildId, category.id],
);
return (
<MenuItemSubmenu
label={t`Notification Settings`}
icon={<NotificationSettingsIcon />}
hint={currentStateText}
render={() => (
<MenuGroup>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.INHERIT}
onSelect={() => handleNotificationLevelChange(MessageNotifications.INHERIT)}
>
<div className={itemStyles.flexColumn}>
<span>{defaultLabelParts.main}</span>
{defaultLabelParts.sub && <div className={menuItemStyles.subtext}>{defaultLabelParts.sub}</div>}
</div>
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.ALL_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
>
{t`All Messages`}
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.ONLY_MENTIONS}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
>
{t`Only @mentions`}
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.NO_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
>
{t`Nothing`}
</MenuItemRadio>
</MenuGroup>
)}
/>
);
});
export const EditCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
channelId: category.id,
guildId,
});
const handleEditCategory = React.useCallback(() => {
ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={category.id} />));
onClose();
}, [category.id, onClose]);
if (!canManageChannels) return null;
return (
<MenuItem icon={<SettingsIcon />} onClick={handleEditCategory}>
{t`Edit Category`}
</MenuItem>
);
});
export const DeleteCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t} = useLingui();
const guildId = category.guildId!;
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
channelId: category.id,
guildId,
});
const channels = ChannelStore.getGuildChannels(guildId);
const channelsInCategory = React.useMemo(
() => channels.filter((ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY),
[channels, category.id],
);
const handleDeleteCategory = React.useCallback(() => {
onClose();
const categoryName = category.name ?? '';
const channelCount = channelsInCategory.length;
const hasChannels = channelCount > 0;
const description = hasChannels
? channelCount === 1
? t`Are you sure you want to delete the category "${categoryName}"? All ${channelCount} channel inside will be moved to the top of the channel list.`
: t`Are you sure you want to delete the category "${categoryName}"? All ${channelCount} channels inside will be moved to the top of the channel list.`
: t`Are you sure you want to delete the category "${categoryName}"? This cannot be undone.`;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Delete Category`}
description={description}
primaryText={t`Delete Category`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await ChannelActionCreators.remove(category.id);
ToastActionCreators.createToast({
type: 'success',
children: t`Category deleted`,
});
} catch (error) {
console.error('Failed to delete category:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to delete category`,
});
}
}}
/>
)),
);
}, [category.id, category.name, channelsInCategory.length, onClose, t]);
if (!canManageChannels) return null;
return (
<MenuItem icon={<DeleteIcon />} onClick={handleDeleteCategory} danger>
{t`Delete Category`}
</MenuItem>
);
});
export const CopyCategoryIdMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {t, i18n} = useLingui();
const handleCopyId = React.useCallback(() => {
TextCopyActionCreators.copy(i18n, category.id);
onClose();
}, [category.id, onClose, i18n]);
return (
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
{t`Copy Category ID`}
</MenuItem>
);
});

View File

@@ -0,0 +1,440 @@
/*
* 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 {StarIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {ChannelTypes, ME, MessageNotifications, Permissions} from '~/Constants';
import {createMuteConfig, getMuteDurationOptions} from '~/components/channel/muteOptions';
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
import {InviteModal} from '~/components/modals/InviteModal';
import type {ChannelRecord} from '~/records/ChannelRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import FavoritesStore from '~/stores/FavoritesStore';
import PermissionStore from '~/stores/PermissionStore';
import ReadStateStore from '~/stores/ReadStateStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import {buildChannelLink} from '~/utils/messageLinkUtils';
import {
CopyIdIcon,
CopyLinkIcon,
DeleteIcon,
EditIcon,
InviteIcon,
MarkAsReadIcon,
MuteIcon,
NotificationSettingsIcon,
} from '../ContextMenuIcons';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import menuItemStyles from '../MenuItem.module.css';
import {MenuItemRadio} from '../MenuItemRadio';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
import itemStyles from './MenuItems.module.css';
interface ChannelMenuItemProps {
channel: ChannelRecord;
onClose: () => void;
}
export const MarkChannelAsReadMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const hasUnread = ReadStateStore.hasUnread(channel.id);
const handleMarkAsRead = React.useCallback(() => {
ReadStateActionCreators.ack(channel.id, true, true);
onClose();
}, [channel.id, onClose]);
return (
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
{t`Mark as Read`}
</MenuItem>
);
});
export const InvitePeopleToChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
const handleInvite = React.useCallback(() => {
ModalActionCreators.push(modal(() => <InviteModal channelId={channel.id} />));
onClose();
}, [channel.id, onClose]);
if (!canInvite) return null;
return (
<MenuItem icon={<InviteIcon />} onClick={handleInvite}>
{t`Invite People`}
</MenuItem>
);
});
export const CopyChannelLinkMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t, i18n} = useLingui();
const handleCopyLink = React.useCallback(() => {
const channelLink = buildChannelLink({
guildId: channel.guildId,
channelId: channel.id,
});
TextCopyActionCreators.copy(i18n, channelLink);
onClose();
}, [channel.id, channel.guildId, onClose, i18n]);
return (
<MenuItem icon={<CopyLinkIcon />} onClick={handleCopyLink}>
{t`Copy Link`}
</MenuItem>
);
});
export const MuteChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const guildId = channel.guildId!;
const channelOverride = UserGuildSettingsStore.getChannelOverride(guildId, channel.id);
const isMuted = channelOverride?.muted ?? false;
const muteConfig = channelOverride?.mute_config;
const mutedText = getMutedText(isMuted, muteConfig);
const handleMute = React.useCallback(
(duration: number | null) => {
UserGuildSettingsActionCreators.updateChannelOverride(
channel.guildId!,
channel.id,
{
muted: true,
mute_config: createMuteConfig(duration),
},
{persistImmediately: true},
);
onClose();
},
[channel.guildId, channel.id, onClose],
);
const handleUnmute = React.useCallback(() => {
UserGuildSettingsActionCreators.updateChannelOverride(
channel.guildId!,
channel.id,
{
muted: false,
mute_config: null,
},
{persistImmediately: true},
);
onClose();
}, [channel.guildId, channel.id, onClose]);
if (isMuted) {
return (
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
{t`Unmute Channel`}
</MenuItem>
);
}
return (
<MenuItemSubmenu
label={t`Mute Channel`}
icon={<MuteIcon />}
onTriggerSelect={() => handleMute(null)}
render={() => (
<MenuGroup>
{getMuteDurationOptions(t).map((option) => (
<MenuItem key={option.label} onClick={() => handleMute(option.value)}>
{option.label}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
});
export const ChannelNotificationSettingsMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const guildId = channel.guildId;
const handleNotificationLevelChange = React.useCallback(
(level: number) => {
if (!guildId) return;
if (level === MessageNotifications.INHERIT) {
UserGuildSettingsActionCreators.updateChannelOverride(
guildId,
channel.id,
{
message_notifications: MessageNotifications.INHERIT,
},
{persistImmediately: true},
);
} else {
UserGuildSettingsActionCreators.updateMessageNotifications(guildId, level, channel.id, {
persistImmediately: true,
});
}
},
[guildId, channel.id],
);
const handleOpenGuildNotificationSettings = React.useCallback(() => {
if (!guildId) return;
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guildId} />));
onClose();
}, [guildId, onClose]);
if (!guildId) return null;
const channelNotifications = UserGuildSettingsStore.getChannelOverride(guildId, channel.id)?.message_notifications;
const currentNotificationLevel = channelNotifications ?? MessageNotifications.INHERIT;
const guildNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guildId);
const categoryId = channel.parentId;
const categoryOverride = UserGuildSettingsStore.getChannelOverride(guildId, categoryId ?? '');
const categoryNotifications = categoryId ? categoryOverride?.message_notifications : undefined;
const resolveEffectiveLevel = (level: number | undefined, fallback: number): number => {
if (level === undefined || level === MessageNotifications.INHERIT) {
return fallback;
}
return level;
};
const categoryDefaultLevel = resolveEffectiveLevel(categoryNotifications, guildNotificationLevel);
const effectiveCurrentNotificationLevel =
currentNotificationLevel === MessageNotifications.INHERIT ? categoryDefaultLevel : currentNotificationLevel;
const hasCategory = categoryId != null;
const currentStateText = getNotificationSettingsLabel(effectiveCurrentNotificationLevel);
const defaultLabelParts = {
main: hasCategory ? t`Category Default` : t`Community Default`,
sub: getNotificationSettingsLabel(categoryDefaultLevel) ?? null,
};
return (
<MenuItemSubmenu
label={t`Notification Settings`}
icon={<NotificationSettingsIcon />}
hint={currentStateText}
onTriggerSelect={handleOpenGuildNotificationSettings}
render={() => (
<MenuGroup>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.INHERIT}
onSelect={() => handleNotificationLevelChange(MessageNotifications.INHERIT)}
>
<div className={itemStyles.flexColumn}>
<span>{defaultLabelParts.main}</span>
{defaultLabelParts.sub && <div className={menuItemStyles.subtext}>{defaultLabelParts.sub}</div>}
</div>
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.ALL_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
>
{t`All Messages`}
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.ONLY_MENTIONS}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
>
{t`Only @mentions`}
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.NO_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
>
{t`Nothing`}
</MenuItemRadio>
</MenuGroup>
)}
/>
);
});
export const EditChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
channelId: channel.id,
guildId: channel.guildId,
});
const handleEditChannel = React.useCallback(() => {
ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={channel.id} />));
onClose();
}, [channel.id, onClose]);
if (!canManageChannels) return null;
return (
<MenuItem icon={<EditIcon />} onClick={handleEditChannel}>
{t`Edit Channel`}
</MenuItem>
);
});
export const DeleteChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
channelId: channel.id,
guildId: channel.guildId,
});
const handleDeleteChannel = React.useCallback(() => {
onClose();
const channelType = channel.type === ChannelTypes.GUILD_VOICE ? t`Voice Channel` : t`Text Channel`;
const channelName = channel.name ?? 'this channel';
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Delete ${channelType}`}
description={t`Are you sure you want to delete #${channelName}? This cannot be undone.`}
primaryText={t`Delete Channel`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await ChannelActionCreators.remove(channel.id);
ToastActionCreators.createToast({
type: 'success',
children: t`Channel deleted`,
});
} catch (error) {
console.error('Failed to delete channel:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to delete channel`,
});
}
}}
/>
)),
);
}, [channel.id, channel.name, channel.type, onClose]);
if (!canManageChannels) return null;
return (
<MenuItem icon={<DeleteIcon />} onClick={handleDeleteChannel} danger>
{t`Delete Channel`}
</MenuItem>
);
});
export const CopyChannelIdMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t, i18n} = useLingui();
const handleCopyId = React.useCallback(() => {
TextCopyActionCreators.copy(i18n, channel.id);
onClose();
}, [channel.id, onClose, i18n]);
return (
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
{t`Copy Channel ID`}
</MenuItem>
);
});
export const FavoriteChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const categories = FavoritesStore.sortedCategories;
const isAlreadyFavorite = !!FavoritesStore.getChannel(channel.id);
const favoriteLabel = React.useMemo(() => {
if (channel.isDM()) {
return t`Favorite DM`;
}
if (channel.isGroupDM()) {
return t`Favorite Group DM`;
}
return t`Favorite Channel`;
}, [channel]);
const unfavoriteLabel = React.useMemo(() => {
if (channel.isDM()) {
return t`Unfavorite DM`;
}
if (channel.isGroupDM()) {
return t`Unfavorite Group DM`;
}
return t`Unfavorite Channel`;
}, [channel]);
const handleAddToCategory = React.useCallback(
(categoryId: string | null) => {
const guildId = channel.guildId ?? ME;
FavoritesStore.addChannel(channel.id, guildId, categoryId);
ToastActionCreators.createToast({type: 'success', children: t`Channel added to favorites`});
onClose();
},
[channel.id, channel.guildId, onClose],
);
const handleRemoveFromFavorites = React.useCallback(() => {
FavoritesStore.removeChannel(channel.id);
ToastActionCreators.createToast({type: 'success', children: t`Channel removed from favorites`});
onClose();
}, [channel.id, onClose]);
if (!AccessibilityStore.showFavorites) return null;
if (isAlreadyFavorite) {
return (
<MenuItem icon={<StarIcon weight="fill" />} onClick={handleRemoveFromFavorites}>
{unfavoriteLabel}
</MenuItem>
);
}
if (categories.length === 0) {
return (
<MenuItem icon={<StarIcon weight="regular" />} onClick={() => handleAddToCategory(null)}>
{favoriteLabel}
</MenuItem>
);
}
return (
<MenuItemSubmenu
label={favoriteLabel}
icon={<StarIcon weight="regular" />}
render={() => (
<MenuGroup>
<MenuItem onClick={() => handleAddToCategory(null)}>{t`Uncategorized`}</MenuItem>
{categories.map((category) => (
<MenuItem key={category.id} onClick={() => handleAddToCategory(category.id)}>
{category.name}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
});

View File

@@ -0,0 +1,45 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import type {UserRecord} from '~/records/UserRecord';
import {CopyUserIdIcon} from '../ContextMenuIcons';
import {MenuItem} from '../MenuItem';
interface CopyUserIdMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const CopyUserIdMenuItem: React.FC<CopyUserIdMenuItemProps> = observer(({user, onClose}) => {
const {t, i18n} = useLingui();
const handleCopyUserId = React.useCallback(() => {
onClose();
TextCopyActionCreators.copy(i18n, user.id, true);
}, [user.id, onClose, i18n]);
return (
<MenuItem icon={<CopyUserIdIcon />} onClick={handleCopyUserId}>
{t`Copy User ID`}
</MenuItem>
);
});

View File

@@ -0,0 +1,144 @@
/*
* 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 {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import type {ChannelRecord} from '~/records/ChannelRecord';
import ReadStateStore from '~/stores/ReadStateStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {getMutedText} from '~/utils/ContextMenuUtils';
import {MarkAsReadIcon, MuteIcon} from '../ContextMenuIcons';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
interface DMMenuItemProps {
channel: ChannelRecord;
onClose: () => void;
}
export const MarkDMAsReadMenuItem: React.FC<DMMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const hasUnread = ReadStateStore.hasUnread(channel.id);
const handleMarkAsRead = React.useCallback(() => {
ReadStateActionCreators.ack(channel.id, true, true);
onClose();
}, [channel.id, onClose]);
return (
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
{t`Mark as Read`}
</MenuItem>
);
});
interface MuteDuration {
label: MessageDescriptor;
value: number | null;
}
const MUTE_DURATIONS: Array<MuteDuration> = [
{label: msg`For 15 Minutes`, value: 15 * 60 * 1000},
{label: msg`For 1 Hour`, value: 60 * 60 * 1000},
{label: msg`For 3 Hours`, value: 3 * 60 * 60 * 1000},
{label: msg`For 8 Hours`, value: 8 * 60 * 60 * 1000},
{label: msg`For 24 Hours`, value: 24 * 60 * 60 * 1000},
{label: msg`Until I turn it back on`, value: null},
];
export const MuteDMMenuItem: React.FC<DMMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const channelOverride = UserGuildSettingsStore.getChannelOverride(null, channel.id);
const isMuted = channelOverride?.muted ?? false;
const muteConfig = channelOverride?.mute_config;
const mutedText = getMutedText(isMuted, muteConfig);
const dmDisplayName = ChannelUtils.getDMDisplayName(channel);
const displayLabel = channel.isDM() ? `@${dmDisplayName}` : dmDisplayName;
const muteLabel = t`Mute ${displayLabel}`;
const unmuteLabel = t`Unmute ${displayLabel}`;
const handleMute = React.useCallback(
(duration: number | null) => {
const muteConfig = duration
? {
selected_time_window: duration,
end_time: new Date(Date.now() + duration).toISOString(),
}
: null;
UserGuildSettingsActionCreators.updateChannelOverride(
null,
channel.id,
{
muted: true,
mute_config: muteConfig,
},
{persistImmediately: true},
);
onClose();
},
[channel.id, onClose],
);
const handleUnmute = React.useCallback(() => {
UserGuildSettingsActionCreators.updateChannelOverride(
null,
channel.id,
{
muted: false,
mute_config: null,
},
{persistImmediately: true},
);
onClose();
}, [channel.id, onClose]);
if (isMuted) {
return (
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
{unmuteLabel}
</MenuItem>
);
}
return (
<MenuItemSubmenu
label={muteLabel}
icon={<MuteIcon />}
onTriggerSelect={() => handleMute(null)}
render={() => (
<MenuGroup>
{MUTE_DURATIONS.map((duration) => (
<MenuItem key={duration.value ?? 'until'} onClick={() => handleMute(duration.value)}>
{t(duration.label)}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
});

View File

@@ -0,0 +1,110 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ChannelDebugModal} from '~/components/debug/ChannelDebugModal';
import {GuildDebugModal} from '~/components/debug/GuildDebugModal';
import {GuildMemberDebugModal} from '~/components/debug/GuildMemberDebugModal';
import {UserDebugModal} from '~/components/debug/UserDebugModal';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {UserRecord} from '~/records/UserRecord';
import {DebugIcon} from '../ContextMenuIcons';
import {MenuItem} from '../MenuItem';
interface BaseDebugMenuItemProps {
onClose: () => void;
}
type DebugUserMenuItemProps = BaseDebugMenuItemProps & {
user: UserRecord;
};
export const DebugUserMenuItem: React.FC<DebugUserMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const handleDebug = React.useCallback(() => {
ModalActionCreators.push(modal(() => <UserDebugModal title={t`User Debug`} user={user} />));
onClose();
}, [user, onClose]);
return (
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
{t`Debug User`}
</MenuItem>
);
});
type DebugChannelMenuItemProps = BaseDebugMenuItemProps & {
channel: ChannelRecord;
};
export const DebugChannelMenuItem: React.FC<DebugChannelMenuItemProps> = observer(({channel, onClose}) => {
const {t} = useLingui();
const handleDebug = React.useCallback(() => {
ModalActionCreators.push(modal(() => <ChannelDebugModal title={t`Channel Debug`} channel={channel} />));
onClose();
}, [channel, onClose]);
return (
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
{t`Debug Channel`}
</MenuItem>
);
});
type DebugGuildMenuItemProps = BaseDebugMenuItemProps & {
guild: GuildRecord;
};
export const DebugGuildMenuItem: React.FC<DebugGuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const handleDebug = React.useCallback(() => {
ModalActionCreators.push(modal(() => <GuildDebugModal title={t`Community Debug`} guild={guild} />));
onClose();
}, [guild, onClose]);
return (
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
{t`Debug Guild`}
</MenuItem>
);
});
type DebugGuildMemberMenuItemProps = BaseDebugMenuItemProps & {
member: GuildMemberRecord;
};
export const DebugGuildMemberMenuItem: React.FC<DebugGuildMemberMenuItemProps> = observer(({member, onClose}) => {
const {t} = useLingui();
const handleDebug = React.useCallback(() => {
ModalActionCreators.push(modal(() => <GuildMemberDebugModal title={t`Community Member Debug`} member={member} />));
onClose();
}, [member, onClose]);
return (
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
{t`Debug Member`}
</MenuItem>
);
});

View File

@@ -0,0 +1,275 @@
/*
* 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 {ClockIcon, CrownIcon, PencilIcon, UserListIcon, UsersIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Permissions} from '~/Constants';
import {BanMemberModal} from '~/components/modals/BanMemberModal';
import {ChangeNicknameModal} from '~/components/modals/ChangeNicknameModal';
import {KickMemberModal} from '~/components/modals/KickMemberModal';
import {RemoveTimeoutModal} from '~/components/modals/RemoveTimeoutModal';
import {TimeoutMemberModal} from '~/components/modals/TimeoutMemberModal';
import {TransferOwnershipModal} from '~/components/modals/TransferOwnershipModal';
import {useRoleHierarchy} from '~/hooks/useRoleHierarchy';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import * as ColorUtils from '~/utils/ColorUtils';
import * as PermissionUtils from '~/utils/PermissionUtils';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import {MenuItemCheckbox} from '../MenuItemCheckbox';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
import itemStyles from './MenuItems.module.css';
interface TransferOwnershipMenuItemProps {
guildId: string;
user: UserRecord;
member: GuildMemberRecord;
onClose: () => void;
}
export const TransferOwnershipMenuItem: React.FC<TransferOwnershipMenuItemProps> = observer(
function TransferOwnershipMenuItem({guildId, user, member, onClose}) {
const {t} = useLingui();
const handleTransferOwnership = React.useCallback(() => {
onClose();
ModalActionCreators.push(
modal(() => <TransferOwnershipModal guildId={guildId} targetUser={user} targetMember={member} />),
);
}, [guildId, user, member, onClose]);
return (
<MenuItem icon={<CrownIcon size={16} />} onClick={handleTransferOwnership}>
{t`Transfer Ownership`}
</MenuItem>
);
},
);
interface KickMemberMenuItemProps {
guildId: string;
user: UserRecord;
onClose: () => void;
}
export const KickMemberMenuItem: React.FC<KickMemberMenuItemProps> = observer(function KickMemberMenuItem({
guildId,
user,
onClose,
}) {
const {t} = useLingui();
const handleKickMember = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <KickMemberModal guildId={guildId} targetUser={user} />));
}, [guildId, user, onClose]);
return (
<MenuItem icon={<UsersIcon size={16} />} onClick={handleKickMember} danger>
{t`Kick Member`}
</MenuItem>
);
});
interface BanMemberMenuItemProps {
guildId: string;
user: UserRecord;
onClose: () => void;
}
export const BanMemberMenuItem: React.FC<BanMemberMenuItemProps> = observer(function BanMemberMenuItem({
guildId,
user,
onClose,
}) {
const {t} = useLingui();
const handleBanMember = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <BanMemberModal guildId={guildId} targetUser={user} />));
}, [guildId, user, onClose]);
return (
<MenuItem icon={<UsersIcon size={16} />} onClick={handleBanMember} danger>
{t`Ban Member`}
</MenuItem>
);
});
interface ManageRolesMenuItemProps {
guildId: string;
member: GuildMemberRecord;
}
export const ManageRolesMenuItem: React.FC<ManageRolesMenuItemProps> = observer(function ManageRolesMenuItem({
guildId,
member,
}) {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const currentMember = GuildMemberStore.getMember(guildId, member.user.id);
const {canManageRole} = useRoleHierarchy(guild);
const canManageRoles = PermissionStore.can(Permissions.MANAGE_ROLES, {guildId});
const allRoles = React.useMemo(() => {
if (!guild) return [];
return Object.values(guild.roles)
.filter((role) => !role.isEveryone)
.sort((a, b) => b.position - a.position)
.map((role) => ({
role,
canManage: canManageRole({id: role.id, position: role.position, permissions: role.permissions}),
}));
}, [guild, canManageRole]);
const handleToggleRole = React.useCallback(
async (roleId: string, hasRole: boolean, canToggle: boolean) => {
if (!canToggle) return;
if (hasRole) {
await GuildMemberActionCreators.removeRole(guildId, member.user.id, roleId);
} else {
await GuildMemberActionCreators.addRole(guildId, member.user.id, roleId);
}
},
[guildId, member.user.id],
);
if (allRoles.length === 0) return null;
return (
<MenuItemSubmenu
label={t`Roles`}
icon={<UserListIcon className={itemStyles.icon} />}
selectionMode="multiple"
render={() => (
<MenuGroup>
{allRoles.map(({role, canManage}) => {
const hasRole = currentMember?.roles.has(role.id) ?? false;
const canToggle = canManageRoles && canManage;
return (
<MenuItemCheckbox
key={role.id}
checked={hasRole}
disabled={!canToggle}
onChange={() => handleToggleRole(role.id, hasRole, canToggle)}
closeOnChange={false}
>
<div className={itemStyles.roleContainer}>
<div className={itemStyles.roleIcon} style={{backgroundColor: ColorUtils.int2rgb(role.color)}} />
<span className={!canToggle ? itemStyles.roleDisabled : undefined}>{role.name}</span>
</div>
</MenuItemCheckbox>
);
})}
</MenuGroup>
)}
/>
);
});
interface ChangeNicknameMenuItemProps {
guildId: string;
user: UserRecord;
member: GuildMemberRecord;
onClose: () => void;
}
export const ChangeNicknameMenuItem: React.FC<ChangeNicknameMenuItemProps> = observer(function ChangeNicknameMenuItem({
guildId,
user,
member,
onClose,
}) {
const {t} = useLingui();
const currentUserId = AuthenticationStore.currentUserId;
const isCurrentUser = user.id === currentUserId;
const guild = GuildStore.getGuild(guildId);
const {canManageTarget} = useRoleHierarchy(guild);
const hasChangeNicknamePermission = PermissionStore.can(Permissions.CHANGE_NICKNAME, {guildId});
const hasManageNicknamesPermission = PermissionStore.can(Permissions.MANAGE_NICKNAMES, {guildId});
const canManageNicknames =
(isCurrentUser && hasChangeNicknamePermission) || (hasManageNicknamesPermission && canManageTarget(user.id));
const handleChangeNickname = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <ChangeNicknameModal guildId={guildId} user={user} member={member} />));
}, [guildId, user, member, onClose]);
if (!canManageNicknames) return null;
return (
<MenuItem icon={<PencilIcon size={16} />} onClick={handleChangeNickname}>
{isCurrentUser ? t`Change Nickname` : t`Change Nickname`}
</MenuItem>
);
});
interface TimeoutMemberMenuItemProps {
guildId: string;
user: UserRecord;
member: GuildMemberRecord;
onClose: () => void;
}
export const TimeoutMemberMenuItem: React.FC<TimeoutMemberMenuItemProps> = observer(function TimeoutMemberMenuItem({
guildId,
user,
member,
onClose,
}) {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const guildSnapshot = guild?.toJSON();
const targetHasModerateMembersPermission =
guildSnapshot !== undefined && PermissionUtils.can(Permissions.MODERATE_MEMBERS, user.id, guildSnapshot);
const handleTimeoutMember = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <TimeoutMemberModal guildId={guildId} targetUser={user} />));
}, [guildId, user, onClose]);
const handleRemoveTimeout = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <RemoveTimeoutModal guildId={guildId} targetUser={user} />));
}, [guildId, user, onClose]);
if (targetHasModerateMembersPermission) {
return null;
}
const isTimedOut = member.isTimedOut();
const handleClick = isTimedOut ? handleRemoveTimeout : handleTimeoutMember;
return (
<MenuItem icon={<ClockIcon size={16} />} onClick={handleClick} danger={!isTimedOut}>
{isTimedOut ? t`Remove Timeout` : t`Timeout`}
</MenuItem>
);
});

View File

@@ -0,0 +1,478 @@
/*
* 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 {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {MessageNotifications, Permissions} from '~/Constants';
import {CategoryCreateModal} from '~/components/modals/CategoryCreateModal';
import {ChannelCreateModal} from '~/components/modals/ChannelCreateModal';
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
import {GuildPrivacySettingsModal} from '~/components/modals/GuildPrivacySettingsModal';
import {GuildSettingsModal} from '~/components/modals/GuildSettingsModal';
import {InviteModal} from '~/components/modals/InviteModal';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {type GuildSettingsTab, getGuildSettingsTabs} from '~/components/modals/utils/guildSettingsConstants';
import {useLeaveGuild} from '~/hooks/useLeaveGuild';
import type {GuildRecord} from '~/records/GuildRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import PermissionStore from '~/stores/PermissionStore';
import ReadStateStore from '~/stores/ReadStateStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import {
CopyIdIcon,
CreateCategoryIcon,
CreateChannelIcon,
EditProfileIcon,
InviteIcon,
LeaveIcon,
MarkAsReadIcon,
MuteIcon,
NotificationSettingsIcon,
PrivacySettingsIcon,
SettingsIcon,
} from '../ContextMenuIcons';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import {MenuItemCheckbox} from '../MenuItemCheckbox';
import {MenuItemRadio} from '../MenuItemRadio';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
interface GuildMenuItemProps {
guild: GuildRecord;
onClose: () => void;
}
export const MarkAsReadMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const channels = ChannelStore.getGuildChannels(guild.id);
const hasUnread = React.useMemo(() => {
return channels.some((channel) => ReadStateStore.hasUnread(channel.id));
}, [channels]);
const handleMarkAsRead = React.useCallback(() => {
const channelIds = channels
.filter((channel) => ReadStateStore.getUnreadCount(channel.id) > 0)
.map((channel) => channel.id);
if (channelIds.length > 0) {
void ReadStateActionCreators.bulkAckChannels(channelIds);
}
onClose();
}, [channels, onClose]);
return (
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
{t(msg`Mark as Read`)}
</MenuItem>
);
});
export const InvitePeopleMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const channelId = InviteUtils.getInvitableChannelId(guild.id);
const canInvite = InviteUtils.canInviteToChannel(channelId, guild.id);
const handleInvite = React.useCallback(() => {
ModalActionCreators.push(modal(() => <InviteModal channelId={channelId ?? ''} />));
onClose();
}, [channelId, onClose]);
if (!canInvite) return null;
return (
<MenuItem icon={<InviteIcon />} onClick={handleInvite}>
{t(msg`Invite People`)}
</MenuItem>
);
});
interface MuteDuration {
label: string;
value: number | null;
}
const getMuteDurations = (t: (message: MessageDescriptor) => string): Array<MuteDuration> => {
return [
{label: t(msg`For 15 Minutes`), value: 15 * 60 * 1000},
{label: t(msg`For 1 Hour`), value: 60 * 60 * 1000},
{label: t(msg`For 3 Hours`), value: 3 * 60 * 60 * 1000},
{label: t(msg`For 8 Hours`), value: 8 * 60 * 60 * 1000},
{label: t(msg`For 24 Hours`), value: 24 * 60 * 60 * 1000},
{label: t(msg`Until I turn it back on`), value: null},
];
};
export const MuteCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const settings = UserGuildSettingsStore.getSettings(guild.id);
const isMuted = settings?.muted ?? false;
const muteConfig = settings?.mute_config;
const mutedText = getMutedText(isMuted, muteConfig);
const MUTE_DURATIONS = React.useMemo(() => getMuteDurations(t), [t]);
const handleMute = React.useCallback(
(duration: number | null) => {
const computedMuteConfig = duration
? {
selected_time_window: duration,
end_time: new Date(Date.now() + duration).toISOString(),
}
: null;
UserGuildSettingsActionCreators.updateGuildSettings(
guild.id,
{
muted: true,
mute_config: computedMuteConfig,
},
{persistImmediately: true},
);
onClose();
},
[guild.id, onClose],
);
const handleUnmute = React.useCallback(() => {
UserGuildSettingsActionCreators.updateGuildSettings(
guild.id,
{
muted: false,
mute_config: null,
},
{persistImmediately: true},
);
onClose();
}, [guild.id, onClose]);
if (isMuted) {
return (
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
{t(msg`Unmute Community`)}
</MenuItem>
);
}
return (
<MenuItemSubmenu
label={t(msg`Mute Community`)}
icon={<MuteIcon />}
onTriggerSelect={() => handleMute(null)}
render={() => (
<MenuGroup>
{MUTE_DURATIONS.map((duration) => (
<MenuItem key={duration.label} onClick={() => handleMute(duration.value)}>
{duration.label}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
});
export const NotificationSettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const settings = UserGuildSettingsStore.getSettings(guild.id);
const suppressEveryone = settings?.suppress_everyone ?? false;
const suppressRoles = settings?.suppress_roles ?? false;
const mobilePush = settings?.mobile_push ?? true;
const effectiveNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guild.id);
const currentStateText = getNotificationSettingsLabel(effectiveNotificationLevel);
const handleNotificationLevelChange = React.useCallback(
(level: number) => {
UserGuildSettingsActionCreators.updateMessageNotifications(guild.id, level, undefined, {
persistImmediately: true,
});
},
[guild.id],
);
const handleToggleSuppressEveryone = React.useCallback(
(checked: boolean) => {
UserGuildSettingsActionCreators.updateGuildSettings(
guild.id,
{suppress_everyone: checked},
{persistImmediately: true},
);
},
[guild.id],
);
const handleToggleSuppressRoles = React.useCallback(
(checked: boolean) => {
UserGuildSettingsActionCreators.updateGuildSettings(
guild.id,
{suppress_roles: checked},
{persistImmediately: true},
);
},
[guild.id],
);
const handleToggleMobilePush = React.useCallback(
(checked: boolean) => {
UserGuildSettingsActionCreators.updateGuildSettings(guild.id, {mobile_push: checked}, {persistImmediately: true});
},
[guild.id],
);
const handleOpenModal = React.useCallback(() => {
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guild.id} />));
onClose();
}, [guild.id, onClose]);
return (
<MenuItemSubmenu
label={t(msg`Notification Settings`)}
icon={<NotificationSettingsIcon />}
hint={currentStateText}
onTriggerSelect={handleOpenModal}
render={() => (
<>
<MenuGroup>
<MenuItemRadio
selected={effectiveNotificationLevel === MessageNotifications.ALL_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
>
{t(msg`All Messages`)}
</MenuItemRadio>
<MenuItemRadio
selected={effectiveNotificationLevel === MessageNotifications.ONLY_MENTIONS}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
>
{t(msg`Only @mentions`)}
</MenuItemRadio>
<MenuItemRadio
selected={effectiveNotificationLevel === MessageNotifications.NO_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
>
{t(msg`Nothing`)}
</MenuItemRadio>
</MenuGroup>
<MenuGroup>
<MenuItemCheckbox checked={suppressEveryone} onChange={handleToggleSuppressEveryone}>
{t(msg`Suppress @everyone and @here`)}
</MenuItemCheckbox>
<MenuItemCheckbox checked={suppressRoles} onChange={handleToggleSuppressRoles}>
{t(msg`Suppress All Role @mentions`)}
</MenuItemCheckbox>
<MenuItemCheckbox checked={mobilePush} onChange={handleToggleMobilePush}>
{t(msg`Mobile Push Notifications`)}
</MenuItemCheckbox>
</MenuGroup>
</>
)}
/>
);
});
export const HideMutedChannelsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild}) => {
const {t} = useLingui();
const settings = UserGuildSettingsStore.getSettings(guild.id);
const hideMutedChannels = settings?.hide_muted_channels ?? false;
const handleToggle = React.useCallback(
(checked: boolean) => {
const currentSettings = UserGuildSettingsStore.getSettings(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return;
UserGuildSettingsActionCreators.toggleHideMutedChannels(guild.id);
},
[guild.id],
);
return (
<MenuItemCheckbox checked={hideMutedChannels} onChange={handleToggle}>
{t(msg`Hide Muted Channels`)}
</MenuItemCheckbox>
);
});
export const CommunitySettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const accessibleTabs = React.useMemo(() => {
const guildTabs = getGuildSettingsTabs(t);
return guildTabs.filter((tab) => {
if (tab.permission && !PermissionStore.can(tab.permission, {guildId: guild.id})) {
return false;
}
if (tab.requireFeature && !guild.features.has(tab.requireFeature)) {
return false;
}
return true;
});
}, [guild, t]);
const defaultTab = React.useMemo(() => {
const overviewTab = accessibleTabs.find((tab) => tab.type === 'overview');
return overviewTab ?? accessibleTabs[0] ?? null;
}, [accessibleTabs]);
const handleOpenSettings = React.useCallback(
(tab: GuildSettingsTab) => {
ModalActionCreators.push(modal(() => <GuildSettingsModal guildId={guild.id} initialTab={tab.type} />));
onClose();
},
[guild.id, onClose],
);
const handleOpenDefaultTab = React.useCallback(() => {
if (!defaultTab) return;
handleOpenSettings(defaultTab);
}, [defaultTab, handleOpenSettings]);
if (accessibleTabs.length === 0) return null;
return (
<MenuItemSubmenu
label={t(msg`Community Settings`)}
icon={<SettingsIcon />}
onTriggerSelect={handleOpenDefaultTab}
render={() => (
<>
{accessibleTabs.map((tab) => {
const IconComponent = tab.icon;
return (
<MenuItem
key={tab.type}
icon={<IconComponent size={16} weight={tab.iconWeight ?? 'fill'} />}
onClick={() => handleOpenSettings(tab)}
>
{tab.label}
</MenuItem>
);
})}
</>
)}
/>
);
});
export const PrivacySettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const handleOpenPrivacySettings = React.useCallback(() => {
ModalActionCreators.push(modal(() => <GuildPrivacySettingsModal guildId={guild.id} />));
onClose();
}, [guild.id, onClose]);
return (
<MenuItem icon={<PrivacySettingsIcon />} onClick={handleOpenPrivacySettings}>
{t(msg`Privacy Settings`)}
</MenuItem>
);
});
export const EditCommunityProfileMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const handleEditProfile = React.useCallback(() => {
ModalActionCreators.push(modal(() => <UserSettingsModal initialGuildId={guild.id} initialTab="my_profile" />));
onClose();
}, [guild.id, onClose]);
return (
<MenuItem icon={<EditProfileIcon />} onClick={handleEditProfile}>
{t(msg`Edit Community Profile`)}
</MenuItem>
);
});
export const CreateChannelMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id});
const handleCreateChannel = React.useCallback(() => {
ModalActionCreators.push(modal(() => <ChannelCreateModal guildId={guild.id} />));
onClose();
}, [guild.id, onClose]);
if (!canManageChannels) return null;
return (
<MenuItem icon={<CreateChannelIcon />} onClick={handleCreateChannel}>
{t(msg`Create Channel`)}
</MenuItem>
);
});
export const CreateCategoryMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id});
const handleCreateCategory = React.useCallback(() => {
ModalActionCreators.push(modal(() => <CategoryCreateModal guildId={guild.id} />));
onClose();
}, [guild.id, onClose]);
if (!canManageChannels) return null;
return (
<MenuItem icon={<CreateCategoryIcon />} onClick={handleCreateCategory}>
{t(msg`Create Category`)}
</MenuItem>
);
});
export const LeaveCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const isOwner = guild.isOwner(AuthenticationStore.currentUserId);
const leaveGuild = useLeaveGuild();
const handleLeave = React.useCallback(() => {
leaveGuild(guild.id);
onClose();
}, [guild.id, onClose, leaveGuild]);
if (isOwner) return null;
return (
<MenuItem icon={<LeaveIcon />} onClick={handleLeave} danger>
{t(msg`Leave Community`)}
</MenuItem>
);
});
export const CopyGuildIdMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {t, i18n} = useLingui();
const handleCopyId = React.useCallback(() => {
TextCopyActionCreators.copy(i18n, guild.id);
onClose();
}, [guild.id, onClose, i18n]);
return (
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
{t(msg`Copy Guild ID`)}
</MenuItem>
);
});

View File

@@ -0,0 +1,137 @@
/*
* 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 {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import * as InviteUtils from '~/utils/InviteUtils';
import {fromTimestamp} from '~/utils/SnowflakeUtils';
import {InviteIcon} from '../ContextMenuIcons';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
interface InviteCandidate {
guild: GuildRecord;
channelId: string;
}
const canInviteInChannel = (channel: ChannelRecord | undefined): channel is ChannelRecord => {
if (!channel || !channel.guildId) {
return false;
}
return InviteUtils.canInviteToChannel(channel.id, channel.guildId);
};
const getDefaultInviteChannelId = (guildId: string): string | null => {
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(guildId);
if (selectedChannelId) {
const selectedChannel = ChannelStore.getChannel(selectedChannelId);
if (canInviteInChannel(selectedChannel)) {
return selectedChannel!.id;
}
}
const guildChannels = ChannelStore.getGuildChannels(guildId);
for (const channel of guildChannels) {
if (channel.type === ChannelTypes.GUILD_TEXT && canInviteInChannel(channel)) {
return channel.id;
}
}
return null;
};
interface InviteToCommunityMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const InviteToCommunityMenuItem: React.FC<InviteToCommunityMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const candidates = React.useMemo(() => {
return GuildStore.getGuilds()
.filter((guild) => !GuildMemberStore.getMember(guild.id, user.id))
.map((guild): InviteCandidate | null => {
const channelId = getDefaultInviteChannelId(guild.id);
return channelId ? {guild, channelId} : null;
})
.filter((candidate): candidate is InviteCandidate => candidate !== null)
.sort((a, b) => a.guild.name.localeCompare(b.guild.name));
}, [user.id]);
const handleSendInvite = React.useCallback(
async (candidate: InviteCandidate) => {
onClose();
try {
const invite = await InviteActionCreators.create(candidate.channelId);
const inviteUrl = `${RuntimeConfigStore.inviteEndpoint}/${invite.code}`;
const dmChannelId = await PrivateChannelActionCreators.ensureDMChannel(user.id);
await MessageActionCreators.send(dmChannelId, {
content: inviteUrl,
nonce: fromTimestamp(Date.now()),
});
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Invite sent for {candidate.guild.name}</Trans>,
});
} catch (error) {
console.error('Failed to send invite from context menu:', error);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to send invite</Trans>,
});
}
},
[onClose, user.id],
);
if (user.bot || candidates.length === 0) {
return null;
}
return (
<MenuItemSubmenu
label={t`Invite to Community`}
icon={<InviteIcon />}
render={() => (
<MenuGroup>
{candidates.map((candidate) => (
<MenuItem key={candidate.guild.id} onClick={() => handleSendInvite(candidate)}>
{candidate.guild.name}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
});

View File

@@ -0,0 +1,45 @@
/*
* 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 {AtIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import type {UserRecord} from '~/records/UserRecord';
import {MenuItem} from '../MenuItem';
interface MentionUserMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const MentionUserMenuItem: React.FC<MentionUserMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const handleMentionUser = React.useCallback(() => {
onClose();
ComponentDispatch.dispatch('INSERT_MENTION', {userId: user.id});
}, [user.id, onClose]);
return (
<MenuItem icon={<AtIcon size={16} />} onClick={handleMentionUser}>
{t`Mention`}
</MenuItem>
);
});

View File

@@ -0,0 +1,102 @@
/*
* 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/>.
*/
.roleIcon {
height: 12px;
width: 12px;
flex-shrink: 0;
border-radius: 50%;
}
.roleContainer {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.roleName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
max-height: 1.2em;
}
.roleDisabled {
opacity: 0.5;
}
.flexContainer {
display: flex;
align-items: center;
gap: 8px;
}
.flexColumn {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.icon {
height: 1rem;
width: 1rem;
}
.submenuContainer {
position: relative;
}
.submenuIcon {
height: 16px;
width: 16px;
}
.submenuPopup {
position: absolute;
top: 0;
left: 100%;
z-index: 50;
margin-left: 4px;
min-width: max-content;
border-radius: 6px;
border: 1px solid var(--background-secondary);
background-color: var(--background-primary);
padding-top: 4px;
padding-bottom: 4px;
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.submenuItem {
cursor: pointer;
padding: 4px 8px;
font-size: 14px;
color: var(--text-primary);
}
.submenuItem:hover {
background-color: var(--background-modifier-hover);
}

View File

@@ -0,0 +1,338 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {isEmbedsSuppressed, triggerAddReaction} from '~/components/channel/messageActionUtils';
import {MessageDebugModal} from '~/components/debug/MessageDebugModal';
import type {MessageRecord} from '~/records/MessageRecord';
import SavedMessagesStore from '~/stores/SavedMessagesStore';
import * as TtsUtils from '~/utils/TtsUtils';
import {
AddReactionIcon,
BookmarkIcon,
CopyIdIcon,
CopyLinkIcon,
CopyTextIcon,
DebugIcon,
DeleteIcon,
EditIcon,
ForwardIcon,
MarkAsUnreadIcon,
PinIcon,
RemoveAllReactionsIcon,
ReplyIcon,
SpeakIcon,
SuppressEmbedsIcon,
} from '../ContextMenuIcons';
import {MenuItem} from '../MenuItem';
interface MessageMenuItemProps {
message: MessageRecord;
onClose: () => void;
}
type AddReactionMenuItemProps = MessageMenuItemProps;
export const AddReactionMenuItem: React.FC<AddReactionMenuItemProps> = observer(({message, onClose}) => {
const {t} = useLingui();
const handleAddReaction = React.useCallback(() => {
triggerAddReaction(message.id);
onClose();
}, [message.id, onClose]);
return (
<MenuItem icon={<AddReactionIcon />} onClick={handleAddReaction} shortcut="+">
{t`Add Reaction`}
</MenuItem>
);
});
type EditMessageMenuItemProps = MessageMenuItemProps & {
onEdit: () => void;
};
export const EditMessageMenuItem: React.FC<EditMessageMenuItemProps> = observer(({onEdit, onClose}) => {
const {t} = useLingui();
const handleEdit = React.useCallback(() => {
onEdit();
onClose();
}, [onEdit, onClose]);
return (
<MenuItem icon={<EditIcon />} onClick={handleEdit} shortcut="e">
{t`Edit Message`}
</MenuItem>
);
});
type ReplyMessageMenuItemProps = MessageMenuItemProps & {
onReply: () => void;
};
export const ReplyMessageMenuItem: React.FC<ReplyMessageMenuItemProps> = observer(({onReply, onClose}) => {
const {t} = useLingui();
const handleReply = React.useCallback(() => {
onReply();
onClose();
}, [onReply, onClose]);
return (
<MenuItem icon={<ReplyIcon />} onClick={handleReply} shortcut="r">
{t`Reply`}
</MenuItem>
);
});
type ForwardMessageMenuItemProps = MessageMenuItemProps & {
onForward: () => void;
};
export const ForwardMessageMenuItem: React.FC<ForwardMessageMenuItemProps> = observer(({onForward, onClose}) => {
const {t} = useLingui();
const handleForward = React.useCallback(() => {
onForward();
onClose();
}, [onForward, onClose]);
return (
<MenuItem icon={<ForwardIcon />} onClick={handleForward} shortcut="f">
{t`Forward`}
</MenuItem>
);
});
type BookmarkMessageMenuItemProps = MessageMenuItemProps & {
onSave: (isSaved: boolean) => () => void;
};
export const BookmarkMessageMenuItem: React.FC<BookmarkMessageMenuItemProps> = observer(
({message, onSave, onClose}) => {
const {t} = useLingui();
const isSaved = SavedMessagesStore.isSaved(message.id);
const handleSave = React.useCallback(() => {
onSave(isSaved)();
onClose();
}, [isSaved, onSave, onClose]);
return (
<MenuItem icon={<BookmarkIcon filled={isSaved} />} onClick={handleSave} shortcut="b">
{isSaved ? t`Remove Bookmark` : t`Bookmark Message`}
</MenuItem>
);
},
);
type PinMessageMenuItemProps = MessageMenuItemProps & {
onPin: () => void;
};
export const PinMessageMenuItem: React.FC<PinMessageMenuItemProps> = observer(({message, onPin, onClose}) => {
const {t} = useLingui();
const handlePin = React.useCallback(() => {
onPin();
onClose();
}, [onPin, onClose]);
return (
<MenuItem icon={<PinIcon />} onClick={handlePin} shortcut="p">
{message.pinned ? t`Unpin Message` : t`Pin Message`}
</MenuItem>
);
});
type SuppressEmbedsMenuItemProps = MessageMenuItemProps & {
onToggleSuppressEmbeds: () => void;
};
export const SuppressEmbedsMenuItem: React.FC<SuppressEmbedsMenuItemProps> = observer(
({message, onToggleSuppressEmbeds, onClose}) => {
const {t} = useLingui();
const handleToggle = React.useCallback(() => {
onToggleSuppressEmbeds();
onClose();
}, [onToggleSuppressEmbeds, onClose]);
return (
<MenuItem icon={<SuppressEmbedsIcon />} onClick={handleToggle} shortcut="s">
{isEmbedsSuppressed(message) ? t`Unsuppress Embeds` : t`Suppress Embeds`}
</MenuItem>
);
},
);
type CopyMessageTextMenuItemProps = MessageMenuItemProps & {
onCopyMessage: () => void;
};
export const CopyMessageTextMenuItem: React.FC<CopyMessageTextMenuItemProps> = observer(({onCopyMessage, onClose}) => {
const {t} = useLingui();
const handleCopy = React.useCallback(() => {
onCopyMessage();
onClose();
}, [onCopyMessage, onClose]);
return (
<MenuItem icon={<CopyTextIcon />} onClick={handleCopy} shortcut="c">
{t`Copy Text`}
</MenuItem>
);
});
type CopyMessageLinkMenuItemProps = MessageMenuItemProps & {
onCopyMessageLink: () => void;
};
export const CopyMessageLinkMenuItem: React.FC<CopyMessageLinkMenuItemProps> = observer(
({onCopyMessageLink, onClose}) => {
const {t} = useLingui();
const handleCopyLink = React.useCallback(() => {
onCopyMessageLink();
onClose();
}, [onCopyMessageLink, onClose]);
return (
<MenuItem icon={<CopyLinkIcon />} onClick={handleCopyLink} shortcut="l">
{t`Copy Message Link`}
</MenuItem>
);
},
);
type CopyMessageIdMenuItemProps = MessageMenuItemProps & {
onCopyMessageId: () => void;
};
export const CopyMessageIdMenuItem: React.FC<CopyMessageIdMenuItemProps> = observer(({onCopyMessageId, onClose}) => {
const {t} = useLingui();
const handleCopyId = React.useCallback(() => {
onCopyMessageId();
onClose();
}, [onCopyMessageId, onClose]);
return (
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
{t`Copy Message ID`}
</MenuItem>
);
});
type DebugMessageMenuItemProps = MessageMenuItemProps;
export const DebugMessageMenuItem: React.FC<DebugMessageMenuItemProps> = observer(({message, onClose}) => {
const {t} = useLingui();
const handleDebug = React.useCallback(() => {
ModalActionCreators.push(modal(() => <MessageDebugModal title={t`Message Debug`} message={message} />));
onClose();
}, [message, onClose]);
return (
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
{t`Debug Message`}
</MenuItem>
);
});
type DeleteMessageMenuItemProps = MessageMenuItemProps & {
onDelete: (bypassConfirm?: boolean) => void;
};
export const DeleteMessageMenuItem: React.FC<DeleteMessageMenuItemProps> = observer(({onDelete, onClose}) => {
const {t} = useLingui();
const handleDelete = React.useCallback(
(event?: unknown) => {
const shiftKey = Boolean((event as {shiftKey?: boolean} | undefined)?.shiftKey);
onDelete(shiftKey);
onClose();
},
[onDelete, onClose],
);
return (
<MenuItem icon={<DeleteIcon />} onClick={handleDelete} danger shortcut="d">
{t`Delete Message`}
</MenuItem>
);
});
type RemoveAllReactionsMenuItemProps = MessageMenuItemProps & {
onRemoveAllReactions: () => void;
};
export const RemoveAllReactionsMenuItem: React.FC<RemoveAllReactionsMenuItemProps> = observer(
({onRemoveAllReactions, onClose}) => {
const {t} = useLingui();
const handleRemoveAll = React.useCallback(() => {
onRemoveAllReactions();
onClose();
}, [onRemoveAllReactions, onClose]);
return (
<MenuItem icon={<RemoveAllReactionsIcon />} onClick={handleRemoveAll} danger>
{t`Remove All Reactions`}
</MenuItem>
);
},
);
type MarkAsUnreadMenuItemProps = MessageMenuItemProps & {
onMarkAsUnread: () => void;
};
export const MarkAsUnreadMenuItem: React.FC<MarkAsUnreadMenuItemProps> = observer(({onMarkAsUnread, onClose}) => {
const {t} = useLingui();
const handleMarkAsUnread = React.useCallback(() => {
onMarkAsUnread();
onClose();
}, [onMarkAsUnread, onClose]);
return (
<MenuItem icon={<MarkAsUnreadIcon />} onClick={handleMarkAsUnread} shortcut="u">
{t`Mark as Unread`}
</MenuItem>
);
});
type SpeakMessageMenuItemProps = MessageMenuItemProps;
export const SpeakMessageMenuItem: React.FC<SpeakMessageMenuItemProps> = observer(({message, onClose}) => {
const {t} = useLingui();
const handleSpeak = React.useCallback(() => {
TtsUtils.speakMessage(message.content);
onClose();
}, [message.content, onClose]);
if (!TtsUtils.isSupported()) {
return null;
}
if (!message.content.trim()) {
return null;
}
return (
<MenuItem icon={<SpeakIcon />} onClick={handleSpeak}>
{TtsUtils.isSpeaking() ? t`Stop Speaking` : t`Speak Message`}
</MenuItem>
);
});

View 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 {ChatCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import type {UserRecord} from '~/records/UserRecord';
import {MenuItem} from '../MenuItem';
interface MessageUserMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const MessageUserMenuItem: React.FC<MessageUserMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const handleMessageUser = React.useCallback(async () => {
onClose();
try {
await PrivateChannelActionCreators.openDMChannel(user.id);
} catch (error) {
console.error('Failed to open DM channel:', error);
}
}, [user.id, onClose]);
return (
<MenuItem icon={<ChatCircleIcon size={16} />} onClick={handleMessageUser}>
{t`Message`}
</MenuItem>
);
});

View File

@@ -0,0 +1,132 @@
/*
* 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 {ArrowsLeftRightIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
import {ChannelTypes, Permissions} from '~/Constants';
import ChannelStore from '~/stores/ChannelStore';
import ConnectionStore from '~/stores/ConnectionStore';
import PermissionStore from '~/stores/PermissionStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import {MenuGroup} from '../MenuGroup';
import {MenuItem} from '../MenuItem';
import {MenuItemSubmenu} from '../MenuItemSubmenu';
interface MoveToChannelSubmenuProps {
userId: string;
guildId: string;
connectionId?: string;
connectionIds?: Array<string>;
onClose: () => void;
label?: string;
}
export const MoveToChannelSubmenu: React.FC<MoveToChannelSubmenuProps> = observer(
({userId, guildId, connectionId, connectionIds, onClose, label}) => {
const {t} = useLingui();
const channels = ChannelStore.getGuildChannels(guildId);
const userVoiceState = MediaEngineStore.getVoiceState(guildId, userId);
const currentUser = UserStore.currentUser;
const isSelf = currentUser?.id === userId;
const voiceChannels = React.useMemo(() => {
return channels.filter((channel) => {
if (channel.type !== ChannelTypes.GUILD_VOICE) {
return false;
}
if (userVoiceState?.channel_id === channel.id) {
return false;
}
const canConnect = PermissionStore.can(Permissions.CONNECT, {
guildId,
channelId: channel.id,
});
return canConnect;
});
}, [channels, guildId, userVoiceState]);
const handleMoveToChannel = React.useCallback(
async (channelId: string) => {
onClose();
if (connectionIds && connectionIds.length > 0) {
try {
await VoiceStateActionCreators.bulkMoveConnections(connectionIds, channelId);
} catch (error) {
console.error('Failed to bulk move connections:', error);
}
return;
}
if (isSelf) {
const socket = ConnectionStore.socket;
if (socket) {
socket.updateVoiceState({
guild_id: guildId,
channel_id: channelId,
self_mute: true,
self_deaf: true,
self_video: false,
self_stream: false,
connection_id: MediaEngineStore.connectionId ?? null,
});
}
} else {
try {
await GuildMemberActionCreators.update(guildId, userId, {
channel_id: channelId,
connection_id: connectionId,
});
} catch (error) {
console.error('Failed to move member to channel:', error);
}
}
},
[guildId, userId, connectionId, connectionIds, onClose, isSelf],
);
if (voiceChannels.length === 0) {
return null;
}
return (
<MenuItemSubmenu
icon={<ArrowsLeftRightIcon weight="fill" style={{width: 16, height: 16}} />}
label={label ?? t`Move To...`}
render={() => (
<MenuGroup>
{voiceChannels.map((channel) => (
<MenuItem key={channel.id} onClick={() => handleMoveToChannel(channel.id)}>
{channel.name}
</MenuItem>
))}
</MenuGroup>
)}
/>
);
},
);

View File

@@ -0,0 +1,258 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {RelationshipTypes} from '~/Constants';
import {ChangeFriendNicknameModal} from '~/components/modals/ChangeFriendNicknameModal';
import type {UserRecord} from '~/records/UserRecord';
import RelationshipStore from '~/stores/RelationshipStore';
import * as RelationshipActionUtils from '~/utils/RelationshipActionUtils';
import {
AcceptFriendRequestIcon,
BlockUserIcon,
CancelFriendRequestIcon,
EditIcon,
IgnoreFriendRequestIcon,
RemoveFriendIcon,
SendFriendRequestIcon,
} from '../ContextMenuIcons';
import {MenuItem} from '../MenuItem';
interface SendFriendRequestMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const SendFriendRequestMenuItem: React.FC<SendFriendRequestMenuItemProps> = observer(
({user, onClose: _onClose}) => {
const {t, i18n} = useLingui();
const relationshipType = RelationshipStore.getRelationship(user.id)?.type;
const [submitting, setSubmitting] = React.useState(false);
const showFriendRequestSent = relationshipType === RelationshipTypes.OUTGOING_REQUEST;
const handleSendFriendRequest = React.useCallback(async () => {
if (submitting || showFriendRequestSent) return;
setSubmitting(true);
await RelationshipActionUtils.sendFriendRequest(i18n, user.id);
setSubmitting(false);
}, [i18n, showFriendRequestSent, submitting, user.id]);
return (
<MenuItem
icon={<SendFriendRequestIcon />}
onClick={handleSendFriendRequest}
disabled={submitting || showFriendRequestSent}
closeOnSelect={false}
>
{showFriendRequestSent ? t`Friend Request Sent` : t`Add Friend`}
</MenuItem>
);
},
);
interface AcceptFriendRequestMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const AcceptFriendRequestMenuItem: React.FC<AcceptFriendRequestMenuItemProps> = observer(({user, onClose}) => {
const {t, i18n} = useLingui();
const handleAcceptFriendRequest = React.useCallback(() => {
onClose();
RelationshipActionUtils.acceptFriendRequest(i18n, user.id);
}, [i18n, user.id, onClose]);
return (
<MenuItem icon={<AcceptFriendRequestIcon />} onClick={handleAcceptFriendRequest}>
{t`Accept Friend Request`}
</MenuItem>
);
});
interface RemoveFriendMenuItemProps {
user: UserRecord;
onClose: () => void;
danger?: boolean;
}
export const RemoveFriendMenuItem: React.FC<RemoveFriendMenuItemProps> = observer(({user, onClose, danger = true}) => {
const {t, i18n} = useLingui();
const handleRemoveFriend = React.useCallback(() => {
onClose();
RelationshipActionUtils.showRemoveFriendConfirmation(i18n, user);
}, [i18n, user, onClose]);
return (
<MenuItem icon={<RemoveFriendIcon />} onClick={handleRemoveFriend} danger={danger}>
{t`Remove Friend`}
</MenuItem>
);
});
interface ChangeFriendNicknameMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const ChangeFriendNicknameMenuItem: React.FC<ChangeFriendNicknameMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const relationship = RelationshipStore.getRelationship(user.id);
const handleChangeNickname = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <ChangeFriendNicknameModal user={user} />));
}, [onClose, user]);
if (relationship?.type !== RelationshipTypes.FRIEND) {
return null;
}
return (
<MenuItem icon={<EditIcon />} onClick={handleChangeNickname}>
{t`Change Friend Nickname`}
</MenuItem>
);
});
interface IgnoreFriendRequestMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const IgnoreFriendRequestMenuItem: React.FC<IgnoreFriendRequestMenuItemProps> = observer(({user, onClose}) => {
const {t, i18n} = useLingui();
const handleIgnoreFriendRequest = React.useCallback(() => {
onClose();
RelationshipActionUtils.ignoreFriendRequest(i18n, user.id);
}, [i18n, user.id, onClose]);
return (
<MenuItem icon={<IgnoreFriendRequestIcon />} onClick={handleIgnoreFriendRequest}>
{t`Ignore Friend Request`}
</MenuItem>
);
});
interface CancelFriendRequestMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const CancelFriendRequestMenuItem: React.FC<CancelFriendRequestMenuItemProps> = observer(({user, onClose}) => {
const {t, i18n} = useLingui();
const handleCancelFriendRequest = React.useCallback(() => {
onClose();
RelationshipActionUtils.cancelFriendRequest(i18n, user.id);
}, [i18n, user.id, onClose]);
return (
<MenuItem icon={<CancelFriendRequestIcon />} onClick={handleCancelFriendRequest}>
{t`Cancel Friend Request`}
</MenuItem>
);
});
interface BlockUserMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const BlockUserMenuItem: React.FC<BlockUserMenuItemProps> = observer(({user, onClose}) => {
const {t, i18n} = useLingui();
const handleBlockUser = React.useCallback(() => {
onClose();
RelationshipActionUtils.showBlockUserConfirmation(i18n, user);
}, [i18n, user, onClose]);
return (
<MenuItem icon={<BlockUserIcon />} onClick={handleBlockUser} danger>
{t`Block`}
</MenuItem>
);
});
interface UnblockUserMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const UnblockUserMenuItem: React.FC<UnblockUserMenuItemProps> = observer(({user, onClose}) => {
const {t, i18n} = useLingui();
const handleUnblockUser = React.useCallback(() => {
onClose();
RelationshipActionUtils.unblockUser(i18n, user.id);
}, [i18n, user.id, onClose]);
return (
<MenuItem icon={<BlockUserIcon />} onClick={handleUnblockUser}>
{t`Unblock`}
</MenuItem>
);
});
interface RelationshipActionMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const RelationshipActionMenuItem: React.FC<RelationshipActionMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const relationship = RelationshipStore.getRelationship(user.id);
const relationshipType = relationship?.type;
if (user.bot) {
if (relationshipType === RelationshipTypes.FRIEND) {
return <RemoveFriendMenuItem user={user} onClose={onClose} danger={false} />;
}
if (relationshipType === RelationshipTypes.INCOMING_REQUEST) {
return <IgnoreFriendRequestMenuItem user={user} onClose={onClose} />;
}
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
return <CancelFriendRequestMenuItem user={user} onClose={onClose} />;
}
return null;
}
switch (relationshipType) {
case RelationshipTypes.FRIEND:
return <RemoveFriendMenuItem user={user} onClose={onClose} danger={false} />;
case RelationshipTypes.INCOMING_REQUEST:
return (
<>
<AcceptFriendRequestMenuItem user={user} onClose={onClose} />
<IgnoreFriendRequestMenuItem user={user} onClose={onClose} />
</>
);
case RelationshipTypes.OUTGOING_REQUEST:
return (
<MenuItem icon={<SendFriendRequestIcon />} disabled closeOnSelect={false}>
{t`Friend Request Sent`}
</MenuItem>
);
case RelationshipTypes.BLOCKED:
return null;
default:
return <SendFriendRequestMenuItem user={user} onClose={onClose} />;
}
});

View 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 {FlagIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import type {IARContext} from '~/components/modals/IARModal';
import {IARModal} from '~/components/modals/IARModal';
import type {GuildRecord} from '~/records/GuildRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import {MenuItem} from '../MenuItem';
interface ReportGuildMenuItemProps {
guild: GuildRecord;
onClose: () => void;
}
export const ReportGuildMenuItem: React.FC<ReportGuildMenuItemProps> = observer(({guild, onClose}) => {
const {t} = useLingui();
const isOwner = guild.ownerId === AuthenticationStore.currentUserId;
const handleReportGuild = React.useCallback(() => {
onClose();
const context: IARContext = {
type: 'guild',
guild,
};
ModalActionCreators.push(modal(() => <IARModal context={context} />));
}, [guild, onClose]);
if (isOwner) {
return null;
}
return (
<MenuItem icon={<FlagIcon size={16} />} onClick={handleReportGuild} danger>
{t`Report Community`}
</MenuItem>
);
});

View File

@@ -0,0 +1,56 @@
/*
* 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 {FlagIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import type {IARContext} from '~/components/modals/IARModal';
import {IARModal} from '~/components/modals/IARModal';
import type {MessageRecord} from '~/records/MessageRecord';
import {MenuItem} from '../MenuItem';
interface ReportMessageMenuItemProps {
message: MessageRecord;
onClose: () => void;
}
export const ReportMessageMenuItem: React.FC<ReportMessageMenuItemProps> = observer(({message, onClose}) => {
const {t} = useLingui();
const handleReportMessage = React.useCallback(() => {
onClose();
const context: IARContext = {
type: 'message',
message,
};
ModalActionCreators.push(modal(() => <IARModal context={context} />));
}, [message, onClose]);
if (message.isCurrentUserAuthor()) {
return null;
}
return (
<MenuItem icon={<FlagIcon size={16} />} onClick={handleReportMessage} danger>
{t`Report Message`}
</MenuItem>
);
});

View File

@@ -0,0 +1,45 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
import type {UserRecord} from '~/records/UserRecord';
import {AddNoteIcon} from '../ContextMenuIcons';
import {MenuItem} from '../MenuItem';
interface AddNoteMenuItemProps {
user: UserRecord;
onClose: () => void;
}
export const AddNoteMenuItem: React.FC<AddNoteMenuItemProps> = observer(({user, onClose}) => {
const {t} = useLingui();
const handleAddNote = React.useCallback(() => {
UserProfileActionCreators.openUserProfile(user.id, undefined, true);
onClose();
}, [onClose, user.id]);
return (
<MenuItem icon={<AddNoteIcon />} onClick={handleAddNote}>
{t`Add Note`}
</MenuItem>
);
});

View File

@@ -0,0 +1,46 @@
/*
* 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 {UserIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
import type {UserRecord} from '~/records/UserRecord';
import {MenuItem} from '../MenuItem';
interface UserProfileMenuItemProps {
user: UserRecord;
guildId?: string;
onClose: () => void;
}
export const UserProfileMenuItem: React.FC<UserProfileMenuItemProps> = observer(({user, guildId, onClose}) => {
const {t} = useLingui();
const handleViewProfile = React.useCallback(() => {
onClose();
UserProfileActionCreators.openUserProfile(user.id, guildId);
}, [onClose, user.id, guildId]);
return (
<MenuItem icon={<UserIcon size={16} />} onClick={handleViewProfile}>
{t`View Profile`}
</MenuItem>
);
});

View File

@@ -0,0 +1,594 @@
/*
* 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 {
CopyIcon,
EyeIcon,
EyeSlashIcon,
GearIcon,
MicrophoneSlashIcon,
PhoneXIcon,
ProjectorScreenIcon,
SpeakerSlashIcon,
VideoCameraSlashIcon,
VideoIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as SoundActionCreators from '~/actions/SoundActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as VoiceCallLayoutActionCreators from '~/actions/VoiceCallLayoutActionCreators';
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import CallMediaPrefsStore from '~/stores/CallMediaPrefsStore';
import ConnectionStore from '~/stores/ConnectionStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import ParticipantVolumeStore from '~/stores/ParticipantVolumeStore';
import UserStore from '~/stores/UserStore';
import VoiceCallLayoutStore from '~/stores/VoiceCallLayoutStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import type {VoiceState} from '~/stores/voice/VoiceStateManager';
import {SoundType} from '~/utils/SoundUtils';
import {MenuItem} from '../MenuItem';
import {MenuItemCheckbox} from '../MenuItemCheckbox';
import {MenuItemSlider} from '../MenuItemSlider';
import styles from './MenuItems.module.css';
interface SelfMuteMenuItemProps {
onClose: () => void;
connectionId?: string;
isDeviceSpecific?: boolean;
label?: string;
}
export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer(
({connectionId, isDeviceSpecific = false, label}) => {
const {t} = useLingui();
const voiceState = connectionId
? MediaEngineStore.getVoiceStateByConnectionId(connectionId)
: MediaEngineStore.getCurrentUserVoiceState();
const isSelfMuted = voiceState?.self_mute ?? false;
const handleToggle = React.useCallback(() => {
if (isDeviceSpecific && connectionId) {
VoiceStateActionCreators.toggleSelfMuteForConnection(connectionId);
} else {
VoiceStateActionCreators.toggleSelfMute(null);
}
}, [connectionId, isDeviceSpecific]);
return (
<MenuItemCheckbox
icon={<MicrophoneSlashIcon weight="fill" className={styles.icon} />}
checked={isSelfMuted}
onChange={handleToggle}
>
{label ?? t`Mute`}
</MenuItemCheckbox>
);
},
);
interface SelfDeafenMenuItemProps {
onClose: () => void;
connectionId?: string;
isDeviceSpecific?: boolean;
label?: string;
}
export const SelfDeafenMenuItem: React.FC<SelfDeafenMenuItemProps> = observer(
({connectionId, isDeviceSpecific = false, label}) => {
const {t} = useLingui();
const voiceState = connectionId
? MediaEngineStore.getVoiceStateByConnectionId(connectionId)
: MediaEngineStore.getCurrentUserVoiceState();
const isSelfDeafened = voiceState?.self_deaf ?? false;
const handleToggle = React.useCallback(() => {
if (isDeviceSpecific && connectionId) {
VoiceStateActionCreators.toggleSelfDeafenForConnection(connectionId);
} else {
VoiceStateActionCreators.toggleSelfDeaf(null);
}
}, [connectionId, isDeviceSpecific]);
return (
<MenuItemCheckbox
icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />}
checked={isSelfDeafened}
onChange={handleToggle}
>
{label ?? t`Deafen`}
</MenuItemCheckbox>
);
},
);
interface VoiceVideoSettingsMenuItemProps {
onClose: () => void;
}
export const VoiceVideoSettingsMenuItem: React.FC<VoiceVideoSettingsMenuItemProps> = observer(({onClose}) => {
const {t} = useLingui();
const handleClick = React.useCallback(() => {
onClose();
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="voice_video" />));
}, [onClose]);
return (
<MenuItem icon={<GearIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Voice & Video Settings`}
</MenuItem>
);
});
interface SelfTurnOffCameraMenuItemProps {
onClose: () => void;
}
export const SelfTurnOffCameraMenuItem: React.FC<SelfTurnOffCameraMenuItemProps> = observer(({onClose}) => {
const {t} = useLingui();
const connectionId = MediaEngineStore.connectionId;
const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null;
const isCameraOn = voiceState?.self_video ?? false;
const handleClick = React.useCallback(() => {
if (connectionId) VoiceStateActionCreators.turnOffCameraForConnection(connectionId);
onClose();
}, [connectionId, onClose]);
if (!isCameraOn) return null;
return (
<MenuItem icon={<VideoCameraSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Turn Off Camera`}
</MenuItem>
);
});
interface SelfTurnOffStreamMenuItemProps {
onClose: () => void;
}
export const SelfTurnOffStreamMenuItem: React.FC<SelfTurnOffStreamMenuItemProps> = observer(({onClose}) => {
const {t} = useLingui();
const connectionId = MediaEngineStore.connectionId;
const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null;
const isStreaming = voiceState?.self_stream ?? false;
const handleClick = React.useCallback(() => {
if (connectionId) VoiceStateActionCreators.turnOffStreamForConnection(connectionId);
onClose();
}, [connectionId, onClose]);
if (!isStreaming) return null;
return (
<MenuItem icon={<ProjectorScreenIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Turn Off Stream`}
</MenuItem>
);
});
interface ParticipantVolumeSliderProps {
userId: string;
}
export const ParticipantVolumeSlider: React.FC<ParticipantVolumeSliderProps> = observer(({userId}) => {
const {t} = useLingui();
const participantVolume = ParticipantVolumeStore.getVolume(userId);
const handleChange = React.useCallback(
(value: number) => {
ParticipantVolumeStore.setVolume(userId, value);
MediaEngineStore.applyLocalAudioPreferencesForUser(userId);
},
[userId],
);
return (
<MenuItemSlider
label={t`User Volume`}
value={participantVolume}
minValue={0}
maxValue={200}
onChange={handleChange}
onFormat={(value) => `${Math.round(value)}%`}
/>
);
});
interface LocalMuteParticipantMenuItemProps {
userId: string;
onClose: () => void;
}
export const LocalMuteParticipantMenuItem: React.FC<LocalMuteParticipantMenuItemProps> = observer(({userId}) => {
const {t} = useLingui();
const isLocalMuted = ParticipantVolumeStore.isLocalMuted(userId);
const handleToggle = React.useCallback(
(checked: boolean) => {
ParticipantVolumeStore.setLocalMute(userId, checked);
MediaEngineStore.applyLocalAudioPreferencesForUser(userId);
},
[userId],
);
return (
<MenuItemCheckbox
icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />}
checked={isLocalMuted}
onChange={handleToggle}
>
{t`Mute`}
</MenuItemCheckbox>
);
});
interface LocalDisableVideoMenuItemProps {
userId: string;
connectionId: string;
onClose: () => void;
}
export const LocalDisableVideoMenuItem: React.FC<LocalDisableVideoMenuItemProps> = observer(
({userId, connectionId, onClose}) => {
const {t} = useLingui();
const callId = MediaEngineStore.connectionId ?? '';
const identity = `user_${userId}_${connectionId}`;
const disabled = callId ? CallMediaPrefsStore.isVideoDisabled(callId, identity) : false;
const handleToggle = React.useCallback(
(checked: boolean) => {
const id = MediaEngineStore.connectionId ?? '';
if (!id) return;
MediaEngineStore.setLocalVideoDisabled(identity, checked);
onClose();
},
[identity, onClose],
);
return (
<MenuItemCheckbox
icon={<VideoCameraSlashIcon weight="fill" className={styles.icon} />}
checked={disabled}
onChange={handleToggle}
>
{t`Disable Video (Local)`}
</MenuItemCheckbox>
);
},
);
interface GuildMuteMenuItemProps {
userId: string;
guildId: string;
onClose: () => void;
}
export const GuildMuteMenuItem: React.FC<GuildMuteMenuItemProps> = observer(function GuildMuteMenuItem({
userId,
guildId,
}) {
const {t} = useLingui();
const member = GuildMemberStore.getMember(guildId, userId);
const isGuildMuted = member?.mute ?? false;
const isTimedOut = member?.isTimedOut() ?? false;
const handleToggle = React.useCallback(
async (checked: boolean) => {
try {
await GuildMemberActionCreators.update(guildId, userId, {mute: checked});
if (checked) SoundActionCreators.playSound(SoundType.Mute);
else SoundActionCreators.playSound(SoundType.Unmute);
} catch {}
},
[guildId, userId],
);
return (
<MenuItemCheckbox
icon={<MicrophoneSlashIcon weight="fill" className={styles.icon} />}
checked={!!isGuildMuted}
onChange={handleToggle}
danger
disabled={isTimedOut}
description={isTimedOut ? t`Disabled while the member is timed out.` : undefined}
>
{t`Community Mute`}
</MenuItemCheckbox>
);
});
interface GuildDeafenMenuItemProps {
userId: string;
guildId: string;
onClose: () => void;
}
export const GuildDeafenMenuItem: React.FC<GuildDeafenMenuItemProps> = observer(function GuildDeafenMenuItem({
userId,
guildId,
}) {
const {t} = useLingui();
const member = GuildMemberStore.getMember(guildId, userId);
const isGuildDeafened = member?.deaf ?? false;
const handleToggle = React.useCallback(
async (checked: boolean) => {
try {
await GuildMemberActionCreators.update(guildId, userId, {deaf: checked});
if (checked) SoundActionCreators.playSound(SoundType.Deaf);
else SoundActionCreators.playSound(SoundType.Undeaf);
} catch {}
},
[guildId, userId],
);
return (
<MenuItemCheckbox
icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />}
checked={!!isGuildDeafened}
onChange={handleToggle}
danger
>
{t`Community Deafen`}
</MenuItemCheckbox>
);
});
interface DisconnectParticipantMenuItemProps {
userId: string;
guildId: string;
participantName: string;
connectionId?: string;
onClose: () => void;
label?: string;
}
export const DisconnectParticipantMenuItem: React.FC<DisconnectParticipantMenuItemProps> = observer(
function DisconnectParticipantMenuItem({userId, guildId, connectionId, onClose, label}) {
const {t} = useLingui();
const currentUser = UserStore.currentUser;
const isSelf = currentUser?.id === userId;
const handleClick = React.useCallback(async () => {
onClose();
if (isSelf) {
const socket = ConnectionStore.socket;
const cid = connectionId ?? MediaEngineStore.connectionId ?? null;
if (socket && cid) {
socket.updateVoiceState({
guild_id: guildId,
channel_id: null,
self_mute: true,
self_deaf: true,
self_video: false,
self_stream: false,
connection_id: cid,
});
}
} else {
try {
await GuildMemberActionCreators.update(guildId, userId, {channel_id: null, connection_id: connectionId});
} catch {}
}
}, [guildId, userId, connectionId, onClose, isSelf]);
const defaultLabel = connectionId ? t`Disconnect Device` : t`Disconnect`;
return (
<MenuItem icon={<PhoneXIcon weight="fill" className={styles.icon} />} onClick={handleClick} danger>
{label ?? defaultLabel}
</MenuItem>
);
},
);
interface TurnOffDeviceCameraMenuItemProps {
onClose: () => void;
connectionId: string;
}
export const TurnOffDeviceCameraMenuItem: React.FC<TurnOffDeviceCameraMenuItemProps> = observer(
({connectionId, onClose}) => {
const {t} = useLingui();
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
const isCameraOn = voiceState?.self_video ?? false;
const handleClick = React.useCallback(() => {
VoiceStateActionCreators.turnOffCameraForConnection(connectionId);
onClose();
}, [connectionId, onClose]);
if (!isCameraOn) return null;
return (
<MenuItem icon={<VideoCameraSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Turn Off Device Camera`}
</MenuItem>
);
},
);
interface TurnOffDeviceStreamMenuItemProps {
onClose: () => void;
connectionId: string;
}
export const TurnOffDeviceStreamMenuItem: React.FC<TurnOffDeviceStreamMenuItemProps> = observer(
({connectionId, onClose}) => {
const {t} = useLingui();
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
const isStreaming = voiceState?.self_stream ?? false;
const handleClick = React.useCallback(() => {
VoiceStateActionCreators.turnOffStreamForConnection(connectionId);
onClose();
}, [connectionId, onClose]);
if (!isStreaming) return null;
return (
<MenuItem icon={<ProjectorScreenIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Turn Off Device Stream`}
</MenuItem>
);
},
);
interface CopyDeviceIdMenuItemProps {
onClose: () => void;
connectionId: string;
}
export const CopyDeviceIdMenuItem: React.FC<CopyDeviceIdMenuItemProps> = observer(({connectionId, onClose}) => {
const {t, i18n} = useLingui();
const handleClick = React.useCallback(() => {
TextCopyActionCreators.copy(i18n, connectionId, true).catch(() => {});
onClose();
}, [connectionId, onClose, i18n]);
return (
<MenuItem icon={<CopyIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Copy Device ID`}
</MenuItem>
);
});
interface BulkMuteDevicesMenuItemProps {
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
onClose: () => void;
}
export const BulkMuteDevicesMenuItem: React.FC<BulkMuteDevicesMenuItemProps> = observer(
({userVoiceStates, onClose}) => {
const {t} = useLingui();
const allMuted = React.useMemo(
() => userVoiceStates.every(({voiceState}) => voiceState.self_mute),
[userVoiceStates],
);
const handleClick = React.useCallback(() => {
const connectionIds = userVoiceStates.map(({connectionId}) => connectionId);
const targetMute = !allMuted;
VoiceStateActionCreators.bulkMuteConnections(connectionIds, targetMute);
if (targetMute) SoundActionCreators.playSound(SoundType.Mute);
else SoundActionCreators.playSound(SoundType.Unmute);
onClose();
}, [userVoiceStates, allMuted, onClose]);
return (
<MenuItem icon={<MicrophoneSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{allMuted ? t`Unmute All Devices` : t`Mute All Devices`}
</MenuItem>
);
},
);
interface BulkDeafenDevicesMenuItemProps {
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
onClose: () => void;
}
export const BulkDeafenDevicesMenuItem: React.FC<BulkDeafenDevicesMenuItemProps> = observer(
({userVoiceStates, onClose}) => {
const {t} = useLingui();
const allDeafened = React.useMemo(
() => userVoiceStates.every(({voiceState}) => voiceState.self_deaf),
[userVoiceStates],
);
const handleClick = React.useCallback(() => {
const connectionIds = userVoiceStates.map(({connectionId}) => connectionId);
const targetDeafen = !allDeafened;
VoiceStateActionCreators.bulkDeafenConnections(connectionIds, targetDeafen);
if (targetDeafen) SoundActionCreators.playSound(SoundType.Deaf);
else SoundActionCreators.playSound(SoundType.Undeaf);
onClose();
}, [userVoiceStates, allDeafened, onClose]);
return (
<MenuItem icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{allDeafened ? t`Undeafen All Devices` : t`Deafen All Devices`}
</MenuItem>
);
},
);
interface BulkCameraDevicesMenuItemProps {
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
onClose: () => void;
}
export const BulkCameraDevicesMenuItem: React.FC<BulkCameraDevicesMenuItemProps> = observer(
({userVoiceStates, onClose}) => {
const {t} = useLingui();
const handleClick = React.useCallback(() => {
const connectionIds = userVoiceStates.map(({connectionId}) => connectionId);
VoiceStateActionCreators.bulkTurnOffCameras(connectionIds);
onClose();
}, [userVoiceStates, onClose]);
return (
<MenuItem icon={<VideoIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
{t`Turn Off All Device Cameras`}
</MenuItem>
);
},
);
interface BulkDisconnectDevicesMenuItemProps {
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
onClose: () => void;
}
export const BulkDisconnectDevicesMenuItem: React.FC<BulkDisconnectDevicesMenuItemProps> = observer(
({userVoiceStates, onClose}) => {
const {t} = useLingui();
const handleClick = React.useCallback(async () => {
await VoiceStateActionCreators.bulkDisconnect(userVoiceStates.map(({connectionId}) => connectionId));
onClose();
}, [userVoiceStates, onClose]);
return (
<MenuItem icon={<PhoneXIcon weight="fill" className={styles.icon} />} onClick={handleClick} danger>
{t`Disconnect All Devices`}
</MenuItem>
);
},
);
interface FocusParticipantMenuItemProps {
userId: string;
connectionId: string;
onClose: () => void;
}
export const FocusParticipantMenuItem: React.FC<FocusParticipantMenuItemProps> = observer(
({userId, connectionId, onClose}) => {
const {t} = useLingui();
const identity = `user_${userId}_${connectionId}`;
const isFocused = VoiceCallLayoutStore.pinnedParticipantIdentity === identity;
const hasMultipleConnections = React.useMemo(() => {
const allStates = MediaEngineStore.getAllVoiceStates();
let count = 0;
Object.values(allStates).forEach((guildData: any) => {
Object.values(guildData).forEach((channelData: any) => {
Object.values(channelData).forEach((vs: any) => {
if ((vs as any)?.user_id === userId) count++;
});
});
});
return count > 1;
}, [userId]);
const handleClick = React.useCallback(() => {
if (isFocused) {
VoiceCallLayoutActionCreators.setPinnedParticipant(null);
VoiceCallLayoutActionCreators.setLayoutMode('grid');
VoiceCallLayoutActionCreators.markUserOverride();
} else {
VoiceCallLayoutActionCreators.setLayoutMode('focus');
VoiceCallLayoutActionCreators.setPinnedParticipant(identity);
VoiceCallLayoutActionCreators.markUserOverride();
}
onClose();
}, [identity, onClose, isFocused]);
return (
<MenuItem
icon={
isFocused ? (
<EyeSlashIcon weight="fill" className={styles.icon} />
) : (
<EyeIcon weight="fill" className={styles.icon} />
)
}
onClick={handleClick}
>
{isFocused ? t`Unfocus` : hasMultipleConnections ? t`Focus This Device` : t`Focus This Person`}
</MenuItem>
);
},
);

View File

@@ -0,0 +1,75 @@
/*
* 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 {
max-width: 288px;
padding: 16px;
overflow: hidden;
background: var(--background-secondary);
font-weight: 600;
color: var(--text-primary);
border-radius: 0.75rem;
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.emoji {
width: 32px;
height: 32px;
}
.textContainer {
margin-left: 12px;
font-size: 14px;
line-height: 1.28571;
color: var(--text-primary);
display: flex;
flex-direction: column;
gap: 4px;
min-height: 56px;
min-width: 160px;
justify-content: center;
}
.loading {
align-items: center;
}
.subtext {
font-size: 12px;
font-weight: 400;
color: var(--text-secondary);
opacity: 0.8;
}
.inner {
pointer-events: all;
display: flex;
align-items: center;
word-break: break-word;
hyphens: auto;
}
button.inner {
cursor: pointer;
}
button.inner:hover a {
text-decoration: underline;
}

View File

@@ -0,0 +1,113 @@
/*
* 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 React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Spinner} from '~/components/uikit/Spinner';
import styles from './EmojiTooltipContent.module.css';
interface EmojiTooltipContentProps {
emoji?: React.ReactNode;
emojiUrl?: string | null;
emojiAlt?: string;
emojiKey?: string;
primaryContent?: React.ReactNode;
subtext?: React.ReactNode;
isLoading?: boolean;
className?: string;
emojiClassName?: string;
innerClassName?: string;
onClick?: () => void;
interactive?: boolean;
}
export const EmojiTooltipContent = React.forwardRef<HTMLDivElement, EmojiTooltipContentProps>(
(
{
emoji,
emojiUrl,
emojiAlt,
emojiKey,
primaryContent,
subtext,
isLoading = false,
className,
emojiClassName,
innerClassName,
onClick,
interactive = false,
},
ref,
) => {
const renderEmoji = () => {
if (emoji) {
return emoji;
}
if (emojiUrl) {
return (
<img
key={emojiKey}
src={emojiUrl}
alt={emojiAlt}
draggable={false}
className={clsx('emoji', styles.emoji, 'jumboable', emojiClassName)}
/>
);
}
return null;
};
const content = (
<>
{renderEmoji()}
{isLoading ? (
<div className={clsx(styles.textContainer, styles.loading)}>
<Spinner />
</div>
) : (
<div className={styles.textContainer}>
{primaryContent}
{subtext && <div className={styles.subtext}>{subtext}</div>}
</div>
)}
</>
);
if (interactive && onClick) {
return (
<div ref={ref} className={clsx(styles.container, className)}>
<FocusRing offset={-2}>
<button type="button" className={clsx(styles.inner, innerClassName)} onClick={onClick}>
{content}
</button>
</FocusRing>
</div>
);
}
return (
<div ref={ref} className={clsx(styles.container, className)}>
<div className={clsx(styles.inner, innerClassName)}>{content}</div>
</div>
);
},
);
EmojiTooltipContent.displayName = 'EmojiTooltipContent';

View File

@@ -0,0 +1,34 @@
/*
* 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/>.
*/
export {default as FocusRing, default} from './FocusRing/FocusRing';
export {default as FocusRingManager} from './FocusRing/FocusRingManager';
export {default as FocusRingScope} from './FocusRing/FocusRingScope';
export type {
FocusRingAncestry,
FocusRingProps,
FocusRingShowOpts,
FocusRingStyleProperties,
Offset,
ThemeOptions,
} from './FocusRing/types';
export {
FOCUS_RING_COLOR_CSS_PROPERTY,
FOCUS_RING_RADIUS_CSS_PROPERTY,
} from './FocusRing/types';

View File

@@ -0,0 +1,30 @@
/*
* 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/>.
*/
.focusRing {
position: absolute;
display: block;
pointer-events: none;
background: none;
margin: 0;
padding: 0;
border-radius: var(--focus-ring-radius, 4px);
box-shadow: 0 0 0 4px var(--focus-ring-color, var(--focus-primary));
}

View File

@@ -0,0 +1,224 @@
/*
* 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 {ClassValue} from 'clsx';
import {clsx} from 'clsx';
import type {CSSProperties} from 'react';
import * as React from 'react';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {elementSupportsRef} from '~/utils/react';
import FocusRingContext from './FocusRingContext';
import type {FocusRingProps} from './types';
type ForwardableProps = React.HTMLAttributes<Element>;
type FluxerFocusRingProps = FocusRingProps &
ForwardableProps & {
children: React.ReactElement;
};
const EVENT_HANDLER_REGEX = /^on[A-Z]/;
interface FocusableChildProps extends React.HTMLAttributes<Element> {
onFocus: (event: React.FocusEvent<Element>) => unknown;
onBlur: (event: React.FocusEvent<Element>) => unknown;
}
const useIsomorphicLayoutEffect = React.useLayoutEffect;
const FocusRing = React.forwardRef<HTMLElement, FluxerFocusRingProps>(function FluxerFocusRing(
{
children,
within = false,
enabled = true,
focused,
offset = 0,
focusTarget,
ringTarget,
ringClassName,
focusClassName,
focusWithinClassName,
...passthroughProps
},
forwardedRef,
) {
const focusedRef = React.useRef(false);
const [isFocusWithin, setFocusWithin] = React.useState(false);
const ringContext = React.useContext(FocusRingContext);
const child = React.Children.only(children) as React.ReactElement<FocusableChildProps & Record<string, unknown>>;
const childProps = child.props as Record<string, unknown>;
const {onBlur: childOnBlur, onFocus: childOnFocus} = child.props;
const supportsRef = elementSupportsRef(child);
const childRef = supportsRef ? (childProps.ref as React.Ref<HTMLElement> | null) : null;
const refs = supportsRef ? ([childRef, forwardedRef].filter(Boolean) as Array<React.Ref<HTMLElement>>) : [];
const mergedRef = useMergeRefs(refs);
const ringOptions = React.useMemo(
() => ({
className: ringClassName,
offset,
}),
[ringClassName, offset],
);
useIsomorphicLayoutEffect(() => {
if (!enabled) return;
if (focusedRef.current || isFocusWithin) {
ringContext.invalidate();
}
}, [enabled, ringContext, ringOptions, isFocusWithin]);
React.useEffect(() => {
if (!enabled) ringContext.hide();
}, [enabled, ringContext]);
React.useEffect(() => {
return () => {
if (focusedRef.current) ringContext.hide();
};
}, [ringContext]);
React.useEffect(() => {
const container = ringTarget?.current;
if (focused == null || container == null) return;
focusedRef.current = focused;
if (focused) {
ringContext.showElement(container, ringOptions);
} else if (focused === false) {
ringContext.hide();
}
}, [focused, ringOptions, ringContext, ringTarget]);
useIsomorphicLayoutEffect(() => {
if (focused != null) return;
const target = focusTarget?.current;
const container = ringTarget?.current;
if (target == null || container == null) return;
function onFocus(event: FocusEvent) {
if (container == null) return;
if (event.currentTarget === event.target) {
focusedRef.current = true;
ringContext.showElement(container, ringOptions);
return;
}
setFocusWithin(true);
if (within) ringContext.showElement(container, ringOptions);
}
function onBlur() {
ringContext.hide();
focusedRef.current = false;
setFocusWithin(false);
}
(target as HTMLElement).addEventListener('focusin', onFocus, true);
(target as HTMLElement).addEventListener('focusout', onBlur, true);
return () => {
(target as HTMLElement).removeEventListener('focusin', onFocus, true);
(target as HTMLElement).removeEventListener('focusout', onBlur, true);
};
}, [within, ringOptions, focused, ringContext, focusTarget, ringTarget]);
const onBlur = React.useCallback(
(event: React.FocusEvent<Element>) => {
ringContext.hide();
focusedRef.current = false;
setFocusWithin(false);
childOnBlur?.(event);
},
[childOnBlur, ringContext],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<Element>) => {
const container = ringTarget?.current;
if (event.currentTarget === event.target) {
focusedRef.current = true;
ringContext.showElement(container ?? event.currentTarget, ringOptions);
} else {
setFocusWithin(true);
if (within) ringContext.showElement(container ?? event.currentTarget, ringOptions);
}
childOnFocus?.(event);
},
[ringTarget, within, childOnFocus, ringContext, ringOptions],
);
const mergedChildProps: Record<string, unknown> = {...childProps};
if (supportsRef && refs.length > 0) {
mergedChildProps.ref = mergedRef;
}
for (const [propKey, propValue] of Object.entries(passthroughProps as Record<string, unknown>)) {
if (propKey === 'className') {
mergedChildProps.className = clsx(childProps.className as ClassValue, propValue as ClassValue);
continue;
}
if (propKey === 'style') {
mergedChildProps.style = {
...(childProps.style as CSSProperties | undefined),
...(propValue as CSSProperties | undefined),
};
continue;
}
if (EVENT_HANDLER_REGEX.test(propKey) && typeof propValue === 'function') {
const existing = childProps[propKey];
if (typeof existing === 'function') {
mergedChildProps[propKey] = (...args: Array<unknown>) => {
(propValue as (...params: Array<unknown>) => void)(...args);
(existing as (...params: Array<unknown>) => void)(...args);
};
} else {
mergedChildProps[propKey] = propValue;
}
continue;
}
mergedChildProps[propKey] = propValue;
}
if (!enabled || focusTarget != null || focused != null) {
return React.cloneElement(child, mergedChildProps);
}
mergedChildProps.className = clsx(
mergedChildProps.className as ClassValue,
focusedRef.current ? focusClassName : undefined,
isFocusWithin ? focusWithinClassName : undefined,
);
mergedChildProps.onBlur = onBlur;
mergedChildProps.onFocus = onFocus;
return React.cloneElement(child, mergedChildProps);
});
FocusRing.displayName = 'FocusRing';
export default FocusRing;

View File

@@ -0,0 +1,180 @@
/*
* 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 React from 'react';
import type {FocusRingAncestry, FocusRingShowOpts, FocusRingStyleProperties, Offset} from './types';
import {FOCUS_RING_COLOR_CSS_PROPERTY, FOCUS_RING_RADIUS_CSS_PROPERTY} from './types';
export let ACTIVE_RING_CONTEXT_MANAGER: FocusRingContextManager | undefined;
function setActiveRingContextManager(manager: FocusRingContextManager) {
if (manager !== ACTIVE_RING_CONTEXT_MANAGER) {
ACTIVE_RING_CONTEXT_MANAGER?.hide();
ACTIVE_RING_CONTEXT_MANAGER = manager;
}
}
function parseBorderRadius(radius: string | undefined) {
if (radius) {
return parseInt(radius, 10) > 0 ? radius : undefined;
}
return undefined;
}
export class FocusRingContextManager {
targetElement?: Element;
targetAncestry?: FocusRingAncestry;
boundingBox?: DOMRect;
className?: string;
offset: Offset | number = 0;
zIndex?: number;
container: Element | null = null;
invalidate: () => void = () => null;
setContainer(element: Element | null) {
this.container = element;
}
showElement(element: Element, opts: FocusRingShowOpts = {}) {
this.targetElement = element;
this.targetAncestry = this.getElementAncestors(this.targetElement);
this.boundingBox = undefined;
this.className = opts.className;
this.offset = opts.offset ?? 0;
this.zIndex = opts.zIndex;
setActiveRingContextManager(this);
this.invalidate();
}
hide() {
this.targetElement = undefined;
this.targetAncestry = undefined;
this.boundingBox = undefined;
this.className = undefined;
this.offset = 0;
this.zIndex = undefined;
this.invalidate();
}
get visible() {
return this.targetElement != null || this.boundingBox != null;
}
private getElementAncestors(element?: Element): FocusRingAncestry {
if (element == null) return {elements: [], styles: []};
const elements: Array<Element> = [];
const styles: Array<CSSStyleDeclaration> = [];
let current: Element | null = element;
while (current != null) {
elements.push(current);
styles.push(window.getComputedStyle(current));
current = current.parentElement;
}
return {elements, styles};
}
private getNextZIndexForAncestry(ancestry: FocusRingAncestry) {
for (let i = 0; i < ancestry.elements.length; i++) {
const element = ancestry.elements[i];
const style = ancestry.styles[i];
const zIndex = parseInt(style.getPropertyValue('z-index'), 10);
if (!Number.isNaN(zIndex)) return zIndex + 1;
if (element === this.container) break;
}
return undefined;
}
private getBorderRadius(ancestry: FocusRingAncestry) {
const topLeft = parseBorderRadius(ancestry.styles[0]?.borderTopLeftRadius) ?? '0';
const topRight = parseBorderRadius(ancestry.styles[0]?.borderTopRightRadius) ?? '0';
const bottomRight = parseBorderRadius(ancestry.styles[0]?.borderBottomRightRadius) ?? '0';
const bottomLeft = parseBorderRadius(ancestry.styles[0]?.borderBottomLeftRadius) ?? '0';
if (topLeft === '0' && topRight === '0' && bottomRight === '0' && bottomLeft === '0') {
return undefined;
}
return `${topLeft} ${topRight} ${bottomRight} ${bottomLeft}`;
}
private makePositionFromDOMRect(rect: DOMRect) {
if (this.container == null) return {};
const containerRect = this.container.getBoundingClientRect();
const {scrollTop, scrollLeft} = this.container;
let top = 0;
let right = 0;
let bottom = 0;
let left = 0;
if (typeof this.offset === 'number') {
top = this.offset;
right = this.offset;
bottom = this.offset;
left = this.offset;
} else {
top = this.offset.top ?? 0;
right = this.offset.right ?? 0;
bottom = this.offset.bottom ?? 0;
left = this.offset.left ?? 0;
}
return {
top: scrollTop + rect.top - containerRect.top + top,
width: rect.width - (right + left),
height: rect.height - (bottom + top),
left: scrollLeft + rect.left - containerRect.left + left,
};
}
getStyle(): FocusRingStyleProperties {
let styles = {};
if (this.boundingBox != null) {
styles = {
...this.makePositionFromDOMRect(this.boundingBox),
zIndex: this.zIndex,
[FOCUS_RING_COLOR_CSS_PROPERTY]: 'var(--focus-primary)',
};
}
if (this.targetElement != null && this.targetAncestry != null) {
styles = {
...this.makePositionFromDOMRect(this.targetElement.getBoundingClientRect()),
zIndex: this.zIndex ?? this.getNextZIndexForAncestry(this.targetAncestry),
[FOCUS_RING_COLOR_CSS_PROPERTY]: 'var(--focus-primary)',
[FOCUS_RING_RADIUS_CSS_PROPERTY]: this.getBorderRadius(this.targetAncestry),
};
}
return styles;
}
}
const GLOBAL_FOCUS_RING_CONTEXT = new FocusRingContextManager();
GLOBAL_FOCUS_RING_CONTEXT.setContainer(document.body);
const FocusRingContext = React.createContext(GLOBAL_FOCUS_RING_CONTEXT);
export default FocusRingContext;

View File

@@ -0,0 +1,35 @@
/*
* 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 {ACTIVE_RING_CONTEXT_MANAGER} from './FocusRingContext';
class FocusRingManagerClass {
ringsEnabled = true;
setRingsEnabled(enabled: boolean) {
this.ringsEnabled = enabled;
if (!enabled) {
ACTIVE_RING_CONTEXT_MANAGER?.hide();
}
}
}
const FocusRingManager = new FocusRingManagerClass();
export default FocusRingManager;

View File

@@ -0,0 +1,61 @@
/*
* 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 * as React from 'react';
import styles from './FocusRing.module.css';
import FocusRingContext, {FocusRingContextManager} from './FocusRingContext';
import FocusRingManager from './FocusRingManager';
interface FocusRingScopeProps {
containerRef: React.RefObject<Element | null>;
children: React.ReactNode;
}
export default function FocusRingScope(props: FocusRingScopeProps) {
const {containerRef, children} = props;
const manager = React.useRef(new FocusRingContextManager());
React.useEffect(() => {
manager.current.setContainer(containerRef.current);
}, [containerRef]);
return (
<FocusRingContext.Provider value={manager.current}>
{children}
<Ring />
</FocusRingContext.Provider>
);
}
function Ring() {
const ringContext = React.useContext(FocusRingContext);
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
React.useEffect(() => {
ringContext.invalidate = () => forceUpdate();
return () => {
ringContext.invalidate = () => null;
};
}, [ringContext]);
if (!FocusRingManager.ringsEnabled || !ringContext.visible) return null;
return <div className={clsx(styles.focusRing, ringContext.className)} style={ringContext.getStyle()} />;
}

View File

@@ -0,0 +1,65 @@
/*
* 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 * as React from 'react';
export const FOCUS_RING_COLOR_CSS_PROPERTY = '--focus-ring-color';
export const FOCUS_RING_RADIUS_CSS_PROPERTY = '--focus-ring-radius';
export interface Offset {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface FocusRingShowOpts {
className?: string;
offset?: number | Offset;
zIndex?: number;
}
export interface FocusRingAncestry {
elements: Array<Element>;
styles: Array<CSSStyleDeclaration>;
}
export interface FocusRingStyleProperties extends React.CSSProperties {
[FOCUS_RING_COLOR_CSS_PROPERTY]?: string;
[FOCUS_RING_RADIUS_CSS_PROPERTY]?: string;
}
export interface ThemeOptions {
focusColor?: string;
lightColor?: string;
darkColor?: string;
threshold?: number;
}
export interface FocusRingProps {
within?: boolean;
enabled?: boolean;
focused?: boolean;
offset?: number | Offset;
focusTarget?: React.RefObject<Element | null>;
ringTarget?: React.RefObject<Element | null>;
ringClassName?: string;
focusClassName?: string;
focusWithinClassName?: string;
}

View File

@@ -0,0 +1,101 @@
/*
* 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 React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {elementSupportsRef} from '~/utils/react';
export type FocusRingWrapperProps<T extends HTMLElement> = {
children: React.ReactElement;
focusRingOffset?: number;
focusRingEnabled?: boolean;
focusRingWithin?: boolean;
focusRingClassName?: string;
focusClassName?: string;
focusWithinClassName?: string;
} & React.HTMLAttributes<T>;
export const FocusRingWrapper = React.forwardRef<HTMLElement, FocusRingWrapperProps<HTMLElement>>(
(
{
children,
focusRingOffset = -2,
focusRingEnabled = true,
focusRingWithin = false,
focusRingClassName,
focusClassName,
focusWithinClassName,
className,
...passThroughProps
},
forwardedRef,
) => {
type FocusRingWrapperChild = React.ReactElement<Record<string, unknown>> & {
props: Record<string, unknown> & {ref?: React.Ref<HTMLElement> | null};
};
const child = React.Children.only(children) as FocusRingWrapperChild;
const childProps = child.props as Record<string, unknown>;
const supportsRef = elementSupportsRef(child);
const childRef = supportsRef ? (childProps.ref as React.Ref<HTMLElement> | null) : null;
const refs = supportsRef ? ([forwardedRef, childRef].filter(Boolean) as Array<React.Ref<HTMLElement>>) : [];
const mergedRef = useMergeRefs(refs);
const mergedProps: Record<string, unknown> = {...childProps};
Object.entries(passThroughProps).forEach(([key, value]) => {
if (key.startsWith('on') && typeof value === 'function' && typeof childProps[key] === 'function') {
const childHandler = childProps[key];
mergedProps[key] = (...args: Array<unknown>) => {
(value as (...args: Array<unknown>) => void)(...args);
(childHandler as (...args: Array<unknown>) => void)(...args);
};
} else if (key.startsWith('on') && typeof value === 'function') {
mergedProps[key] = value;
} else if (key === 'style') {
mergedProps.style = {...((childProps.style as React.CSSProperties) ?? {}), ...(value as React.CSSProperties)};
} else {
mergedProps[key] = value;
}
});
if (className) {
mergedProps.className = clsx(childProps.className as string | undefined, className);
}
if (supportsRef && refs.length > 0) {
mergedProps.ref = mergedRef;
}
return (
<FocusRing
offset={focusRingOffset}
enabled={focusRingEnabled}
within={focusRingWithin}
ringClassName={focusRingClassName}
focusClassName={focusClassName}
focusWithinClassName={focusWithinClassName}
>
{React.cloneElement(child, mergedProps)}
</FocusRing>
);
},
);
FocusRingWrapper.displayName = 'FocusRingWrapper';

View File

@@ -0,0 +1,110 @@
/*
* 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: inline-flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.inlineTextBase {
font-size: inherit;
line-height: inherit;
font-family: inherit;
font-weight: inherit;
letter-spacing: inherit;
color: inherit;
white-space: pre;
}
.idleButton {
cursor: pointer;
background: none;
border: none;
padding: 0;
margin: 0;
text-align: left;
min-width: 0;
outline: none;
}
.idleButton:focus {
outline: none;
}
.idleButton:active {
background: none;
}
.wrapper {
display: inline-flex;
align-items: center;
min-width: 0;
padding: 4px 8px;
border-radius: 4px;
box-sizing: border-box;
border: 1px solid transparent;
background-color: transparent;
transition: background-color 0.1s ease;
max-width: 100%;
width: 100%;
}
.idleButton:hover .wrapper {
background-color: var(--background-secondary);
}
.placeholder .wrapper {
color: var(--text-tertiary);
}
.container:has(.editable) .wrapper {
background-color: var(--background-tertiary);
}
.affix {
display: inline-block;
flex-shrink: 0;
}
.text {
display: inline-block;
min-width: 0;
flex-shrink: 1;
}
.editable {
padding: 0;
margin: 0;
outline: none;
border: none;
background: none;
}
.editable:empty:before {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
}
.error {
font-size: 12px;
color: var(--status-danger);
}

View File

@@ -0,0 +1,246 @@
/*
* 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 React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import styles from './InlineEdit.module.css';
interface InlineEditProps {
value: string;
onSave: (value: string) => Promise<void> | void;
prefix?: string;
suffix?: string;
placeholder?: string;
maxLength?: number;
validate?: (value: string) => boolean;
className?: string;
inputClassName?: string;
buttonClassName?: string;
width?: number | string;
allowEmpty?: boolean;
}
type Mode = 'idle' | 'editing' | 'saving';
function sanitizeDraft(draft: string): string {
return draft.replace(/[\r\n\t]/g, '');
}
export const InlineEdit: React.FC<InlineEditProps> = observer((props) => {
const {
className = '',
inputClassName = '',
buttonClassName = '',
placeholder,
width,
onSave,
value,
prefix = '',
suffix = '',
maxLength,
validate,
allowEmpty = false,
} = props;
const [mode, setMode] = React.useState<Mode>('idle');
const [draft, setDraft] = React.useState<string>(value);
const [error, setError] = React.useState<string | null>(null);
const editableRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (mode === 'idle') {
setDraft(value);
}
}, [value, mode]);
const fieldStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if (!width) return undefined;
return {
minWidth: typeof width === 'number' ? `${width}px` : width,
};
}, [width]);
const canSave = (raw: string): boolean => {
const trimmed = raw.trim();
if (trimmed === value.trim()) return true;
if (!trimmed.length) return !!allowEmpty;
if (validate && !validate(trimmed)) return false;
return true;
};
const startEdit = () => {
setError(null);
setDraft(value);
setMode('editing');
};
React.useEffect(() => {
if (mode !== 'editing') return;
const el = editableRef.current;
if (!el) return;
el.textContent = draft;
requestAnimationFrame(() => {
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
});
}, [mode]);
const cancelEdit = () => {
setError(null);
setDraft(value);
setMode('idle');
};
const doSave = async () => {
const next = draft.trim();
if (!canSave(next)) {
setError('VALIDATION_FAILED');
return;
}
if (next === value.trim()) {
setMode('idle');
return;
}
setMode('saving');
setError(null);
try {
await Promise.resolve(onSave(next));
setMode('idle');
} catch (e: any) {
setError(e?.message || 'SAVE_FAILED');
setMode('editing');
}
};
const handleEditableInput: React.FormEventHandler<HTMLDivElement> = (e) => {
const el = e.currentTarget;
const raw = el.textContent ?? '';
let next = sanitizeDraft(raw);
if (typeof maxLength === 'number' && maxLength > 0 && next.length > maxLength) {
next = next.slice(0, maxLength);
}
if (next !== raw) {
el.textContent = next;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
}
setError(null);
setDraft(next);
};
const handleEditableKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
void doSave();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancelEdit();
editableRef.current?.blur();
return;
}
};
const handleEditableBlur: React.FocusEventHandler<HTMLDivElement> = () => {
if (!canSave(draft)) {
cancelEdit();
} else {
void doSave();
}
};
const isEditing = mode === 'editing' || mode === 'saving';
const hasValue = value.trim().length > 0;
const showPlaceholder = !hasValue && !isEditing;
if (!isEditing) {
return (
<div className={clsx(styles.container, className)}>
<FocusRing offset={-2}>
<button
type="button"
onClick={startEdit}
className={clsx(styles.idleButton, {[styles.placeholder]: showPlaceholder})}
style={fieldStyle}
>
<span className={clsx(styles.wrapper, buttonClassName)}>
{prefix && <span className={clsx(styles.inlineTextBase, styles.affix)}>{prefix}</span>}
<span className={clsx(styles.inlineTextBase, styles.text, inputClassName)}>
{hasValue ? value : placeholder || ''}
</span>
{suffix && <span className={clsx(styles.inlineTextBase, styles.affix)}>{suffix}</span>}
</span>
</button>
</FocusRing>
{error && <span className={styles.error}>{error}</span>}
</div>
);
}
return (
<div className={clsx(styles.container, className)}>
<div className={clsx(styles.wrapper, buttonClassName)} style={fieldStyle}>
{prefix && <span className={clsx(styles.inlineTextBase, styles.affix)}>{prefix}</span>}
{/* biome-ignore lint/a11y/useFocusableInteractive: contentEditable makes this focusable */}
<div
ref={editableRef}
className={clsx(styles.inlineTextBase, styles.text, styles.editable, inputClassName)}
contentEditable
suppressContentEditableWarning
onInput={handleEditableInput}
onKeyDown={handleEditableKeyDown}
onBlur={handleEditableBlur}
data-placeholder={placeholder ?? ''}
role="textbox"
/>
{suffix && <span className={clsx(styles.inlineTextBase, styles.affix)}>{suffix}</span>}
</div>
{error && <span className={styles.error}>{error}</span>}
</div>
);
});

View File

@@ -0,0 +1,64 @@
/*
* 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/>.
*/
.tooltipContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.label {
color: var(--text-primary);
}
.keybindHint {
display: inline-flex;
align-items: center;
gap: 2px;
}
.key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 4px;
background-color: var(--background-secondary);
color: var(--text-secondary);
font-family: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
}
.keySymbol {
composes: key;
font-size: 13px;
min-width: 22px;
}
:global(.theme-light) .key,
:global(.theme-light) .keySymbol {
background-color: hsl(0, 0%, 18%);
color: var(--text-on-brand-primary);
}

View File

@@ -0,0 +1,141 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import AccessibilityStore from '~/stores/AccessibilityStore';
import type {KeybindAction, KeyCombo} from '~/stores/KeybindStore';
import KeybindStore from '~/stores/KeybindStore';
import {SHIFT_KEY_SYMBOL} from '~/utils/KeyboardUtils';
import styles from './KeybindHint.module.css';
const isMac = () => /Mac|iPod|iPhone|iPad/.test(navigator.platform);
interface KeyPart {
label: string;
isSymbol?: boolean;
}
const formatKeyParts = (combo: KeyCombo): Array<KeyPart> => {
const parts: Array<KeyPart> = [];
const mac = isMac();
if (combo.ctrl) {
parts.push(mac ? {label: '⌃', isSymbol: true} : {label: 'Ctrl'});
} else if (combo.ctrlOrMeta) {
parts.push(mac ? {label: '⌘', isSymbol: true} : {label: 'Ctrl'});
}
if (combo.meta) {
parts.push(mac ? {label: '⌘', isSymbol: true} : {label: 'Win'});
}
if (combo.shift) {
parts.push({label: SHIFT_KEY_SYMBOL, isSymbol: true});
}
if (combo.alt) {
parts.push(mac ? {label: '⌥', isSymbol: true} : {label: 'Alt'});
}
const key = combo.code ?? combo.key ?? '';
if (key === ' ') {
parts.push({label: 'Space'});
} else if (key === 'ArrowUp') {
parts.push({label: '↑', isSymbol: true});
} else if (key === 'ArrowDown') {
parts.push({label: '↓', isSymbol: true});
} else if (key === 'ArrowLeft') {
parts.push({label: '←', isSymbol: true});
} else if (key === 'ArrowRight') {
parts.push({label: '→', isSymbol: true});
} else if (key === 'Enter') {
parts.push(mac ? {label: '↵', isSymbol: true} : {label: 'Enter'});
} else if (key === 'Escape') {
parts.push({label: 'Esc'});
} else if (key === 'Tab') {
parts.push({label: 'Tab'});
} else if (key === 'Backspace') {
parts.push(mac ? {label: '⌫', isSymbol: true} : {label: 'Backspace'});
} else if (key === 'PageUp') {
parts.push({label: 'PgUp'});
} else if (key === 'PageDown') {
parts.push({label: 'PgDn'});
} else if (key.length === 1) {
parts.push({label: key.toUpperCase()});
} else if (key) {
parts.push({label: key});
}
return parts;
};
export interface KeybindHintProps {
action?: KeybindAction;
combo?: KeyCombo;
}
export const KeybindHint = ({action, combo}: KeybindHintProps) => {
const resolvedCombo = React.useMemo(() => {
if (combo) return combo;
if (action) return KeybindStore.getByAction(action).combo;
return null;
}, [action, combo]);
if (!resolvedCombo || (!resolvedCombo.key && !resolvedCombo.code)) {
return null;
}
const parts = formatKeyParts(resolvedCombo);
if (parts.length === 0) {
return null;
}
return (
<span className={styles.keybindHint}>
{parts.map((part, index) => (
<kbd key={index} className={part.isSymbol ? styles.keySymbol : styles.key}>
{part.label}
</kbd>
))}
</span>
);
};
export interface TooltipWithKeybindProps {
label: string;
action?: KeybindAction;
combo?: KeyCombo;
}
export const TooltipWithKeybind = observer(({label, action, combo}: TooltipWithKeybindProps) => {
const resolvedCombo = React.useMemo(() => {
if (combo) return combo;
if (action) return KeybindStore.getByAction(action).combo;
return null;
}, [action, combo]);
const hasKeybind = resolvedCombo && (resolvedCombo.key || resolvedCombo.code);
const shouldShowKeybind = hasKeybind && !AccessibilityStore.hideKeyboardHints;
return (
<div className={styles.tooltipContent}>
<span className={styles.label}>{label}</span>
{shouldShowKeybind && <KeybindHint combo={resolvedCombo} />}
</div>
);
});

View File

@@ -0,0 +1,43 @@
/*
* 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/>.
*/
.key {
display: inline-flex;
height: 2rem;
min-width: 2rem;
user-select: none;
-webkit-user-select: none;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-tertiary);
padding-left: 0.75rem;
padding-right: 0.75rem;
font-weight: 500;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
}
.keyWide {
min-width: 3rem;
}

View File

@@ -0,0 +1,26 @@
/*
* 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 styles from './KeyboardKey.module.css';
export const KeyboardKey = observer(({children}: {children: string}) => (
<kbd className={clsx(styles.key, children === '↵' && styles.keyWide)}>{children}</kbd>
));

View File

@@ -0,0 +1,48 @@
/*
* 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: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
background-color: var(--status-danger);
text-align: center;
font-weight: 600;
color: white;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.badgeSmall {
height: 1.25rem;
min-width: 1.25rem;
padding: 0.25rem 0.375rem;
font-size: 11px;
}
.badgeMedium {
height: 1.5rem;
min-width: 1.25rem;
padding: 0.375rem 0.375rem;
font-size: 0.75rem;
line-height: 1rem;
}

View File

@@ -0,0 +1,83 @@
/*
* 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 {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import AccessibilityStore from '~/stores/AccessibilityStore';
import {getCurrentLocale} from '~/utils/LocaleUtils';
import styles from './MentionBadge.module.css';
const formatMentionCount = (mentionCount: number) => {
const locale = getCurrentLocale();
if (mentionCount > 99 && mentionCount < 1000) {
return '99+';
}
if (mentionCount >= 1000) {
const formatter = new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 0,
});
return formatter.format(mentionCount).replace(/\s/g, '');
}
return new Intl.NumberFormat(locale).format(mentionCount);
};
interface MentionBadgeProps {
mentionCount: number;
size?: 'small' | 'medium';
}
export const MentionBadge = observer(({mentionCount, size = 'medium'}: MentionBadgeProps) => {
if (mentionCount === 0) {
return null;
}
return (
<div className={clsx(styles.badge, size === 'small' ? styles.badgeSmall : styles.badgeMedium)}>
{formatMentionCount(mentionCount)}
</div>
);
});
export const MentionBadgeAnimated = observer(({mentionCount, size = 'medium'}: MentionBadgeProps) => {
const shouldAnimate = !AccessibilityStore.useReducedMotion;
if (!shouldAnimate) {
return mentionCount > 0 ? <MentionBadge mentionCount={mentionCount} size={size} /> : null;
}
return (
<AnimatePresence initial={false} mode="wait">
{mentionCount > 0 && (
<motion.div
initial={{opacity: 0, scale: 0.85}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0, scale: 0.85}}
transition={{type: 'spring', stiffness: 500, damping: 22}}
>
<MentionBadge mentionCount={mentionCount} size={size} />
</motion.div>
)}
</AnimatePresence>
);
});

View File

@@ -0,0 +1,208 @@
/*
* 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/>.
*/
.menuItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.75rem;
padding: 1rem;
text-align: left;
transition: background-color 0.15s;
appearance: none;
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
.menuItem:hover {
background-color: var(--background-secondary-alt);
}
.menuItem:not(.disabled):not(.danger):active {
background-color: var(--background-modifier-hover);
}
.menuItem.danger:not(.disabled):active {
background-color: var(--background-secondary);
}
}
.menuItem:not(.disabled):not(.danger) {
color: var(--text-primary);
}
.menuItem.danger:not(.disabled) {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}
.menuItem.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.menuItem.pressed {
background-color: var(--background-modifier-hover);
}
.menuItem.pressedDanger {
background-color: var(--background-secondary);
}
.iconContainer {
display: flex;
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.label {
flex: 1;
font-weight: 500;
font-size: 1rem;
}
.checkboxContainer {
display: flex;
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.checkbox {
display: flex;
height: 1.25rem;
width: 1.25rem;
align-items: center;
justify-content: center;
border-radius: 3px;
border: 2px solid var(--background-header-secondary);
transition:
border-color 0.15s,
background-color 0.15s;
}
.checkbox.checked {
border-color: var(--brand-primary);
background-color: var(--brand-primary);
}
.checkIcon {
height: 0.75rem;
width: 0.75rem;
color: white;
}
.radioContainer {
display: flex;
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.radio {
display: flex;
height: 1.25rem;
width: 1.25rem;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 2px solid var(--background-header-secondary);
transition:
border-color 0.15s,
background-color 0.15s;
}
.radio.radioSelected {
border-color: var(--brand-primary);
}
.radioInner {
height: 0.625rem;
width: 0.625rem;
border-radius: 50%;
background-color: var(--brand-primary);
}
.labelColumn {
display: flex;
flex-direction: column;
flex: 1;
gap: 0.125rem;
}
.subtext {
font-size: 0.75rem;
color: var(--text-muted);
}
.divider {
margin-left: 1rem;
margin-right: 1rem;
height: 1px;
background-color: var(--background-header-secondary);
opacity: 0.3;
}
.sliderContainer {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
}
.sliderLabel {
font-weight: 500;
font-size: 1rem;
color: var(--text-primary);
}
.groupContainer {
overflow: hidden;
border-radius: 0.75rem;
background-color: var(--background-secondary-alt);
}
.groupSpacer {
height: 1rem;
}
.bottomSheetContent {
display: flex;
flex-direction: column;
gap: 0;
}
.headerSlot {
padding-bottom: 0.75rem;
}
.groupStack {
display: flex;
flex-direction: column;
}
.groupStackWithHeader {
padding-top: 0.75rem;
}

View File

@@ -0,0 +1,247 @@
/*
* 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 React from 'react';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import {Slider} from '~/components/uikit/Slider';
import {usePressable} from '~/hooks/usePressable';
import styles from './MenuBottomSheet.module.css';
export interface MenuItemType {
id?: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
interface MenuSliderType {
label: string;
value: number;
minValue: number;
maxValue: number;
onChange: (value: number) => void;
onFormat?: (value: number) => string;
factoryDefaultValue?: number;
}
interface MenuCheckboxType {
icon?: React.ReactNode;
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export interface MenuRadioType {
label: string;
subtext?: string;
selected: boolean;
onSelect: () => void;
disabled?: boolean;
}
export interface MenuGroupType {
items: Array<MenuItemType | MenuSliderType | MenuCheckboxType | MenuRadioType>;
}
interface MenuBottomSheetProps {
isOpen: boolean;
onClose: () => void;
title?: string;
groups: Array<MenuGroupType>;
headerContent?: React.ReactNode;
showCloseButton?: boolean;
}
const MenuCheckboxItem: React.FC<{item: MenuCheckboxType; isLast: boolean}> = observer(({item, isLast}) => {
const {isPressed, pressableProps} = usePressable(item.disabled);
return (
<>
<button
type="button"
role="checkbox"
aria-checked={item.checked}
aria-label={item.label}
onClick={() => item.onChange(!item.checked)}
disabled={item.disabled}
className={clsx(styles.menuItem, item.disabled && styles.disabled, isPressed && styles.pressed)}
{...pressableProps}
>
{item.icon && (
<div className={styles.iconContainer} aria-hidden="true">
{item.icon}
</div>
)}
<span className={styles.label}>{item.label}</span>
<div className={styles.checkboxContainer} aria-hidden="true">
<div className={clsx(styles.checkbox, item.checked && styles.checked)}>
{item.checked && (
<svg className={styles.checkIcon} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
</button>
{!isLast && <div className={styles.divider} />}
</>
);
});
const MenuRadioItem: React.FC<{item: MenuRadioType; isLast: boolean}> = observer(({item, isLast}) => {
const {isPressed, pressableProps} = usePressable(item.disabled);
return (
<>
<button
type="button"
role="radio"
aria-checked={item.selected}
aria-label={item.label}
onClick={item.onSelect}
disabled={item.disabled}
className={clsx(styles.menuItem, item.disabled && styles.disabled, isPressed && styles.pressed)}
{...pressableProps}
>
<div className={styles.radioContainer} aria-hidden="true">
<div className={clsx(styles.radio, item.selected && styles.radioSelected)}>
{item.selected && <div className={styles.radioInner} />}
</div>
</div>
<div className={styles.labelColumn}>
<span className={styles.label}>{item.label}</span>
{item.subtext && <span className={styles.subtext}>{item.subtext}</span>}
</div>
</button>
{!isLast && <div className={styles.divider} />}
</>
);
});
const MenuActionItem: React.FC<{item: MenuItemType; isLast: boolean}> = observer(({item, isLast}) => {
const {isPressed, pressableProps} = usePressable(item.disabled);
return (
<>
<button
type="button"
onClick={item.onClick}
disabled={item.disabled}
className={clsx(
styles.menuItem,
item.disabled && styles.disabled,
item.danger && styles.danger,
isPressed && styles.pressed,
isPressed && item.danger && styles.pressedDanger,
)}
{...pressableProps}
>
<div className={styles.iconContainer}>{item.icon}</div>
<span className={styles.label}>{item.label}</span>
</button>
{!isLast && <div className={styles.divider} />}
</>
);
});
const MenuSliderItem: React.FC<{item: MenuSliderType; isLast: boolean}> = observer(({item, isLast}) => {
return (
<>
<div className={styles.sliderContainer}>
<span className={styles.sliderLabel}>{item.label}</span>
<Slider
defaultValue={item.value}
factoryDefaultValue={item.factoryDefaultValue ?? item.value}
minValue={item.minValue}
maxValue={item.maxValue}
onValueChange={item.onChange}
onValueRender={item.onFormat}
value={item.value}
mini={true}
/>
</div>
{!isLast && <div className={styles.divider} />}
</>
);
});
const MenuItem: React.FC<{item: MenuItemType | MenuSliderType | MenuCheckboxType | MenuRadioType; isLast?: boolean}> =
observer(({item, isLast = false}) => {
if ('checked' in item) {
return <MenuCheckboxItem item={item as MenuCheckboxType} isLast={isLast} />;
}
if ('selected' in item) {
return <MenuRadioItem item={item as MenuRadioType} isLast={isLast} />;
}
if ('onClick' in item) {
return <MenuActionItem item={item as MenuItemType} isLast={isLast} />;
}
return <MenuSliderItem item={item as MenuSliderType} isLast={isLast} />;
});
const MenuGroup: React.FC<{group: MenuGroupType; isLast?: boolean}> = observer(({group, isLast = false}) => {
return (
<>
<div className={styles.groupContainer}>
{group.items.map((item, index) => (
<MenuItem
key={`${'label' in item ? item.label : 'slider'}-${index}`}
item={item}
isLast={index === group.items.length - 1}
/>
))}
</div>
{!isLast && <div className={styles.groupSpacer} />}
</>
);
});
export const MenuBottomSheet: React.FC<MenuBottomSheetProps> = observer(
({isOpen, onClose, title, groups, headerContent, showCloseButton = false}) => {
const hasHeader = Boolean(title || headerContent);
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={[0, 0.6, 1]}
initialSnap={1}
title={title}
showCloseButton={showCloseButton}
disableDefaultHeader={!title && !showCloseButton}
>
<div className={styles.bottomSheetContent}>
{headerContent && <div className={styles.headerSlot}>{headerContent}</div>}
<div className={clsx(styles.groupStack, hasHeader && styles.groupStackWithHeader)}>
{groups.map((group, index) => (
<MenuGroup key={index} group={group} isLast={index === groups.length - 1} />
))}
</div>
</div>
</BottomSheet>
);
},
);

View 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/>.
*/
import {useLingui} from '@lingui/react/macro';
import React from 'react';
import {getStatusTypeLabel} from '~/Constants';
import {BaseAvatar} from '~/components/uikit/BaseAvatar';
import {cdnUrl} from '~/utils/UrlUtils';
interface MockAvatarProps {
size: 12 | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 56 | 80 | 120;
avatarUrl?: string;
hoverAvatarUrl?: string;
status?: string | null;
isTyping?: boolean;
showOffline?: boolean;
className?: string;
isClickable?: boolean;
userTag?: string;
disableStatusTooltip?: boolean;
shouldPlayAnimated?: boolean;
isMobileStatus?: boolean;
}
export const MockAvatar = React.forwardRef<HTMLDivElement, MockAvatarProps>(
(
{
size,
avatarUrl = cdnUrl('avatars/0.png'),
hoverAvatarUrl,
status,
isTyping = false,
showOffline = true,
className,
isClickable = false,
userTag = 'Mock User',
disableStatusTooltip = false,
shouldPlayAnimated = false,
isMobileStatus = false,
...props
},
ref,
) => {
const {i18n} = useLingui();
const statusLabel = status != null ? getStatusTypeLabel(i18n, status) : null;
return (
<BaseAvatar
ref={ref}
size={size}
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
status={status}
shouldPlayAnimated={shouldPlayAnimated}
isTyping={isTyping}
showOffline={showOffline}
className={className}
isClickable={isClickable}
userTag={userTag}
statusLabel={statusLabel}
disableStatusTooltip={disableStatusTooltip}
isMobileStatus={isMobileStatus}
{...props}
/>
);
},
);
MockAvatar.displayName = 'MockAvatar';

View File

@@ -0,0 +1,33 @@
/*
* 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/>.
*/
.link {
display: inline;
color: var(--text-link);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
}
.link:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,35 @@
/*
* 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 PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
import styles from './PlutoniumLink.module.css';
export const PlutoniumLink = () => (
// biome-ignore lint/a11y/useValidAnchor: we need an anchor tag for styling purposes
<a
href="#"
onClick={(event) => {
event.preventDefault();
PremiumModalActionCreators.open();
}}
className={styles.link}
>
Plutonium
</a>
);

View File

@@ -0,0 +1,71 @@
/*
* 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/>.
*/
.upsell {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border-radius: var(--radius-md);
background-color: var(--brand-primary);
}
.icon {
flex-shrink: 0;
margin-top: 0.125rem;
color: white;
}
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.text {
font-size: 0.8125rem;
color: white;
margin: 0;
line-height: 1.4;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.25rem;
}
.dismissLink {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: none;
}
.dismissLink:hover {
color: white;
text-decoration: underline;
}

View File

@@ -0,0 +1,70 @@
/*
* 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 {Trans, useLingui} from '@lingui/react/macro';
import {CrownIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import type React from 'react';
import * as PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
import {Button} from '~/components/uikit/Button/Button';
import styles from './PlutoniumUpsell.module.css';
interface PlutoniumUpsellProps {
children: React.ReactNode;
className?: string;
buttonText?: React.ReactNode;
onButtonClick?: () => void;
dismissible?: boolean;
onDismiss?: () => void;
}
export const PlutoniumUpsell: React.FC<PlutoniumUpsellProps> = ({
children,
className,
buttonText,
onButtonClick,
dismissible,
onDismiss,
}) => {
const {t} = useLingui();
return (
<div className={clsx(styles.upsell, className)}>
<CrownIcon size={16} weight="fill" className={styles.icon} />
<div className={styles.content}>
<p className={styles.text}>{children}</p>
<div className={styles.actions}>
<Button
variant="inverted"
superCompact={true}
fitContent={true}
onClick={onButtonClick ?? (() => PremiumModalActionCreators.open())}
aria-label={t`Get Plutonium`}
>
{buttonText ?? <Trans>Get Plutonium</Trans>}
</Button>
{dismissible && onDismiss && (
<button type="button" className={styles.dismissLink} onClick={onDismiss}>
<Trans>Don't show this again</Trans>
</button>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
/*
* 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/>.
*/
.popout {
pointer-events: auto;
transition: none;
-webkit-app-region: no-drag;
}
.popouts {
background: none;
inset: 0;
pointer-events: none;
position: fixed;
z-index: var(--z-index-popout);
}
:global(html.platform-native:not(.platform-macos)) .popouts {
top: var(--native-titlebar-height);
}
.backdrop {
position: absolute;
inset: 0;
background: transparent;
pointer-events: auto;
z-index: -1;
}
.triggerWrapper {
display: inline-flex;
}

View File

@@ -0,0 +1,368 @@
/*
* 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 {autorun} from 'mobx';
import React from 'react';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import {type PopoutKey, type PopoutPosition, usePopoutKeyContext} from '~/components/uikit/Popout';
import type {TooltipPosition} from '~/components/uikit/Tooltip';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import type {ComponentActionType} from '~/lib/ComponentDispatch';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import PopoutStore from '~/stores/PopoutStore';
import {getExtendedWindow, supportsRequestIdleCallback} from '~/types/browser';
import {elementSupportsRef} from '~/utils/react';
import styles from './Popout.module.css';
let currentId = 1;
interface PopoutProps {
children?: React.ReactNode;
render?: (props: {popoutKey: PopoutKey; onClose: () => void}) => React.ReactNode;
position?: PopoutPosition;
dependsOn?: string | number;
uniqueId?: string | number;
tooltip?: string | (() => React.ReactNode);
tooltipPosition?: TooltipPosition;
tooltipAlign?: 'center' | 'top' | 'bottom' | 'left' | 'right';
zIndexBoost?: number;
shouldAutoUpdate?: boolean;
offsetMainAxis?: number;
offsetCrossAxis?: number;
animationType?: 'smooth' | 'none';
containerClass?: string;
preventInvert?: boolean;
hoverDelay?: number;
toggleClose?: boolean;
subscribeTo?: ComponentActionType;
onOpen?: () => void;
onClose?: () => void;
onCloseRequest?: (event?: Event) => boolean;
returnFocusRef?: React.RefObject<HTMLElement | null>;
closeOnChildrenUnmount?: boolean;
disableBackdrop?: boolean;
}
interface OpenPopoutOptions extends Partial<PopoutProps> {
hoverMode?: boolean;
onContentMouseEnter?: () => void;
onContentMouseLeave?: () => void;
}
export const openPopout = (target: HTMLElement, props: OpenPopoutOptions, key: string | number, clickPos = 0) => {
PopoutActionCreators.open({
key: key || currentId++,
dependsOn: props.dependsOn,
position: props.position!,
render: props.render as (props: {popoutKey: PopoutKey; onClose: () => void}) => React.ReactNode,
target,
zIndexBoost: props.zIndexBoost,
shouldAutoUpdate: props.shouldAutoUpdate,
offsetMainAxis: props.offsetMainAxis,
offsetCrossAxis: props.offsetCrossAxis,
animationType: props.animationType,
clickPos,
containerClass: props.containerClass,
preventInvert: props.preventInvert,
onOpen: props.onOpen,
onClose: props.onClose,
onCloseRequest: props.onCloseRequest,
returnFocusRef: props.returnFocusRef,
disableBackdrop: props.disableBackdrop,
hoverMode: props.hoverMode,
onContentMouseEnter: props.onContentMouseEnter,
onContentMouseLeave: props.onContentMouseLeave,
});
};
export const Popout = React.forwardRef<HTMLElement, PopoutProps>((props, ref) => {
const [state, setState] = React.useState({
id: props.uniqueId || currentId++,
isOpen: false,
lastAction: null as 'open' | 'close' | null,
lastValidChildren: null as React.ReactNode,
});
const parentPopoutKey = usePopoutKeyContext();
const targetRef = React.useRef<HTMLElement | null>(null);
const isTriggerHoveringRef = React.useRef(false);
const isContentHoveringRef = React.useRef(false);
const hoverTimerRef = React.useRef<NodeJS.Timeout | null>(null);
const closeTimerRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (props.children) {
setState((prev) => ({...prev, lastValidChildren: props.children}));
}
}, [props.children]);
const clearTimers = React.useCallback(() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
React.useEffect(() => {
const dispose = autorun(() => {
const isOpenInStore = Boolean(PopoutStore.popouts[state.id]);
setState((prev) => ({
...prev,
isOpen: isOpenInStore,
lastAction: null,
}));
});
return () => dispose();
}, [state.id]);
React.useEffect(() => {
return () => {
clearTimers();
if (state.isOpen) {
PopoutActionCreators.close(state.id);
}
const cleanupPortals = () => {
const portals = document.querySelectorAll(`[data-floating-ui-portal][aria-describedby="${state.id}"]`);
if (!portals.length) return;
for (let i = 0; i < portals.length; i++) {
const portal = portals[i];
portal.parentNode?.removeChild(portal);
}
};
if (supportsRequestIdleCallback(window)) {
const extendedWindow = getExtendedWindow();
extendedWindow.requestIdleCallback?.(cleanupPortals, {timeout: 200});
} else {
requestAnimationFrame(cleanupPortals);
}
};
}, []);
const close = React.useCallback(
(event?: Event) => {
if (props.onCloseRequest && !props.onCloseRequest(event)) {
return;
}
if (state.lastAction !== 'close') {
setState((prev) => ({...prev, lastAction: 'close'}));
PopoutActionCreators.close(state.id);
props.onClose?.();
}
if (props.returnFocusRef?.current) {
props.returnFocusRef.current.focus();
}
},
[state.id, state.lastAction, props],
);
const scheduleClose = React.useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
closeTimerRef.current = setTimeout(() => {
const hasActiveDependents = PopoutStore.hasDependents(state.id);
if (!isTriggerHoveringRef.current && !isContentHoveringRef.current && !hasActiveDependents) {
close();
}
}, 300);
}, [close, state.id]);
const handleContentMouseEnter = React.useCallback(() => {
isContentHoveringRef.current = true;
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const handleContentMouseLeave = React.useCallback(() => {
isContentHoveringRef.current = false;
if (props.hoverDelay != null) {
scheduleClose();
}
}, [props.hoverDelay, scheduleClose]);
const open = React.useCallback(
(clickPos?: number) => {
if (!targetRef.current) return;
if (state.lastAction !== 'open') {
setState((prev) => ({...prev, lastAction: 'open'}));
const isHoverMode = props.hoverDelay != null;
const effectiveDependsOn = props.dependsOn ?? (parentPopoutKey != null ? parentPopoutKey : undefined);
openPopout(
targetRef.current,
{
...props,
dependsOn: effectiveDependsOn,
hoverMode: isHoverMode,
onContentMouseEnter: isHoverMode ? handleContentMouseEnter : undefined,
onContentMouseLeave: isHoverMode ? handleContentMouseLeave : undefined,
disableBackdrop: isHoverMode ? true : props.disableBackdrop,
},
state.id,
clickPos,
);
props.onOpen?.();
}
},
[state.id, state.lastAction, props, parentPopoutKey, handleContentMouseEnter, handleContentMouseLeave],
);
const toggle = React.useCallback(
(clickPos?: number) => {
if (PopoutStore.isOpen(state.id)) {
close();
} else {
open(clickPos);
}
},
[state.id, open, close],
);
const handleHover = React.useCallback(
(isEntering: boolean) => {
if (props.hoverDelay == null) return;
clearTimers();
isTriggerHoveringRef.current = isEntering;
if (isEntering) {
hoverTimerRef.current = setTimeout(() => {
open();
}, props.hoverDelay);
} else {
scheduleClose();
}
},
[props.hoverDelay, open, clearTimers, scheduleClose],
);
React.useEffect(() => {
if (!props.subscribeTo) return;
const handler = () => toggle();
ComponentDispatch.subscribe(props.subscribeTo, handler);
return () => ComponentDispatch.unsubscribe(props.subscribeTo!, handler);
}, [props.subscribeTo, toggle]);
const childToRender =
(props.children as React.ReactNode) || (!props.closeOnChildrenUnmount ? state.lastValidChildren : null);
type PopoutChildProps = React.HTMLAttributes<HTMLElement> & {ref?: React.Ref<HTMLElement>};
const child =
childToRender && React.isValidElement<PopoutChildProps>(childToRender) ? React.Children.only(childToRender) : null;
const childSupportsRef = child ? elementSupportsRef(child) : false;
const childRef = childSupportsRef && child ? (child.props.ref ?? null) : null;
const mergedChildRef = useMergeRefs(childSupportsRef ? [ref, targetRef, childRef] : [ref, targetRef]);
const wrapperRef = useMergeRefs([ref, targetRef]);
const popoutId = String(state.id);
if (!props.children) {
return state.isOpen && props.render ? props.render({popoutKey: state.id, onClose: close}) : null;
}
if (!childToRender || !child) {
if (state.isOpen) {
close();
}
return null;
}
const childProps = child.props as React.HTMLAttributes<HTMLElement>;
const {onClick, onMouseEnter, onMouseLeave, onKeyDown} = childProps;
const handleKeyboardToggle = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.defaultPrevented) return;
const key = event.key;
if (key !== 'Enter' && key !== ' ' && key !== 'Spacebar') return;
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const target = event.currentTarget;
if (target instanceof HTMLElement) {
const nativeTag = target.tagName;
if (
nativeTag === 'BUTTON' ||
nativeTag === 'A' ||
nativeTag === 'INPUT' ||
nativeTag === 'TEXTAREA' ||
nativeTag === 'SUMMARY'
) {
return;
}
}
event.preventDefault();
event.stopPropagation();
toggle();
};
const enhancedChild = React.cloneElement(child, {
'aria-describedby': popoutId,
'aria-expanded': state.isOpen,
'aria-controls': state.isOpen ? popoutId : undefined,
'aria-haspopup': true,
'aria-label': typeof props.tooltip === 'string' ? props.tooltip : undefined,
onClick: (event: React.MouseEvent<HTMLElement>) => {
const clickPos = event.pageX - event.currentTarget.getBoundingClientRect().left;
event.preventDefault();
event.stopPropagation();
toggle(clickPos);
onClick?.(event);
},
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
handleHover(true);
onMouseEnter?.(event);
},
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
handleHover(false);
onMouseLeave?.(event);
},
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
handleKeyboardToggle(event);
onKeyDown?.(event);
},
...(childSupportsRef ? {ref: mergedChildRef} : {}),
});
const trigger = childSupportsRef ? (
enhancedChild
) : (
<span className={styles.triggerWrapper} ref={wrapperRef}>
{enhancedChild}
</span>
);
return props.tooltip ? (
<Tooltip text={props.tooltip} position={props.tooltipPosition} align={props.tooltipAlign}>
{trigger}
</Tooltip>
) : (
trigger
);
});

View File

@@ -0,0 +1,344 @@
/*
* 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 {FloatingFocusManager, useFloating, useMergeRefs} from '@floating-ui/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import {type Popout, PopoutKeyContext} from '~/components/uikit/Popout';
import styles from '~/components/uikit/Popout/Popout.module.css';
import {useAntiShiftFloating} from '~/hooks/useAntiShiftFloating';
import AccessibilityStore from '~/stores/AccessibilityStore';
import LayerManager from '~/stores/LayerManager';
import PopoutStore from '~/stores/PopoutStore';
import {isScrollbarDragActive} from '~/utils/ScrollbarDragState';
type PopoutItemProps = Omit<Popout, 'key'> & {
popoutKey: string;
isTopmost: boolean;
hoverMode?: boolean;
onContentMouseEnter?: () => void;
onContentMouseLeave?: () => void;
};
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [contenteditable=""], [contenteditable="true"]';
const findInitialFocusTarget = (root: HTMLElement): HTMLElement | null => {
const explicit = root.querySelector<HTMLElement>('[data-autofocus], [autofocus]');
if (explicit) {
return explicit;
}
const focusable = root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
for (const element of focusable) {
if (element.getAttribute('aria-hidden') === 'true') continue;
return element;
}
return null;
};
const PopoutItem: React.FC<PopoutItemProps> = observer(
({
popoutKey,
isTopmost,
render,
position,
target,
zIndexBoost,
shouldAutoUpdate = true,
offsetMainAxis = 8,
offsetCrossAxis = 0,
animationType = 'smooth',
containerClass,
onCloseRequest,
onClose,
returnFocusRef,
hoverMode,
onContentMouseEnter,
onContentMouseLeave,
}) => {
const {
ref: popoutRef,
state,
style,
} = useAntiShiftFloating(target, true, {
placement: position,
offsetMainAxis,
offsetCrossAxis,
shouldAutoUpdate,
enableSmartBoundary: true,
constrainHeight: true,
});
const {refs: focusRefs, context: focusContext} = useFloating({open: true});
React.useLayoutEffect(() => {
focusRefs.setReference(target);
}, [focusRefs, target]);
const mergedPopoutRef = useMergeRefs([popoutRef, focusRefs.setFloating]);
const prefersReducedMotion = AccessibilityStore.useReducedMotion;
const [isVisible, setIsVisible] = React.useState(true);
const [targetInDOM, setTargetInDOM] = React.useState(true);
const hasFocusedInitialRef = React.useRef(false);
React.useLayoutEffect(() => {
if (!document.contains(target)) {
setTargetInDOM(false);
setIsVisible(false);
}
});
React.useLayoutEffect(() => {
if (!state.isReady || !isVisible || !targetInDOM || hasFocusedInitialRef.current) {
return;
}
const root = popoutRef.current;
if (!root) return;
const focusTarget = findInitialFocusTarget(root) ?? root;
if (focusTarget === root && !root.hasAttribute('tabindex')) {
root.tabIndex = -1;
}
focusTarget?.focus({preventScroll: true});
hasFocusedInitialRef.current = true;
}, [state.isReady, isVisible, targetInDOM, popoutRef]);
const transitionStyles = React.useMemo(() => {
const shouldAnimate = animationType === 'smooth' && !prefersReducedMotion;
const duration = shouldAnimate ? '250ms' : '0ms';
const isPositioned = state.isReady;
const transform = getTransform(shouldAnimate, isVisible, isPositioned, targetInDOM);
return {
opacity: isVisible && isPositioned && targetInDOM ? 1 : 0,
transform,
transition: `opacity ${duration} ease-in-out${shouldAnimate ? `, transform ${duration} ease-in-out` : ''}`,
pointerEvents: isPositioned && targetInDOM ? ('auto' as const) : ('none' as const),
display: targetInDOM ? undefined : ('none' as const),
};
}, [isVisible, state.isReady, animationType, prefersReducedMotion, targetInDOM]);
const closeSelf = React.useCallback(() => {
setIsVisible(false);
const closeDuration = animationType === 'smooth' && !prefersReducedMotion ? 250 : 0;
setTimeout(() => {
onClose?.();
PopoutActionCreators.close(popoutKey);
}, closeDuration);
}, [animationType, prefersReducedMotion, onClose, popoutKey]);
React.useEffect(() => {
const el = popoutRef.current;
if (!document.contains(target)) {
setIsVisible(false);
const closeDuration = animationType === 'smooth' && !prefersReducedMotion ? 250 : 0;
setTimeout(() => {
onClose?.();
PopoutActionCreators.close(popoutKey);
}, closeDuration);
return;
}
const handleOutsideClick = (event: MouseEvent) => {
if (isScrollbarDragActive()) {
return;
}
if (LayerManager.hasType('contextmenu')) {
return;
}
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) return;
if (
targetElement.closest('[role="dialog"][aria-modal="true"]') ||
targetElement.className.includes('backdrop') ||
targetElement.closest('.focusLock')
) {
return;
}
if (el && !el.contains(targetElement)) {
if (onCloseRequest && !onCloseRequest(event)) {
return;
}
setIsVisible(false);
const closeDuration = animationType === 'smooth' && !prefersReducedMotion ? 250 : 0;
setTimeout(() => {
onClose?.();
PopoutActionCreators.close(popoutKey);
}, closeDuration);
}
};
const observer = new MutationObserver(() => {
if (!document.contains(target)) {
setTargetInDOM(false);
setIsVisible(false);
const closeDuration = animationType === 'smooth' && !prefersReducedMotion ? 250 : 0;
setTimeout(() => {
onClose?.();
PopoutActionCreators.close(popoutKey);
}, closeDuration);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
document.addEventListener('click', handleOutsideClick, true);
return () => {
observer.disconnect();
document.removeEventListener('click', handleOutsideClick, true);
};
}, [popoutKey, target, onCloseRequest, onClose, animationType, prefersReducedMotion, popoutRef]);
const handleMouseEnter = React.useCallback(() => {
if (hoverMode && onContentMouseEnter) {
onContentMouseEnter();
}
}, [hoverMode, onContentMouseEnter]);
const handleMouseLeave = React.useCallback(() => {
if (hoverMode && onContentMouseLeave) {
onContentMouseLeave();
}
}, [hoverMode, onContentMouseLeave]);
return (
<FloatingFocusManager
context={focusContext}
disabled={!isTopmost}
returnFocus={returnFocusRef ?? true}
initialFocus={focusRefs.floating}
>
<PopoutKeyContext.Provider value={popoutKey}>
<div
ref={mergedPopoutRef}
className={clsx(styles.popout, containerClass)}
aria-modal
role="dialog"
tabIndex={-1}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{
...style,
zIndex: zIndexBoost != null ? 1000 + zIndexBoost : undefined,
...transitionStyles,
visibility: state.isReady && isVisible && targetInDOM ? 'visible' : 'hidden',
}}
>
{render({
popoutKey,
onClose: closeSelf,
})}
</div>
</PopoutKeyContext.Provider>
</FloatingFocusManager>
);
},
);
export const Popouts: React.FC = observer(() => {
const prevPopoutKeysRef = React.useRef<Set<string>>(new Set());
const popouts = PopoutStore.getPopouts();
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
const needsBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
React.useEffect(() => {
const currentKeys = new Set(Object.keys(PopoutStore.popouts));
const prevKeys = prevPopoutKeysRef.current;
currentKeys.forEach((key) => {
if (!prevKeys.has(key)) {
LayerManager.addLayer('popout', key);
}
});
prevKeys.forEach((key) => {
if (!currentKeys.has(key)) {
LayerManager.removeLayer('popout', key);
}
});
prevPopoutKeysRef.current = currentKeys;
}, [PopoutStore.popouts]);
React.useEffect(() => {
return () => {
prevPopoutKeysRef.current.forEach((key) => {
LayerManager.removeLayer('popout', key);
});
queueMicrotask(() => {
document.querySelectorAll('[data-floating-ui-portal]').forEach((portal) => {
if (!portal.hasChildNodes() || !document.body.contains(portal.parentElement)) {
portal.remove();
}
});
});
};
}, []);
const handleBackdropPointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isScrollbarDragActive()) {
return;
}
event.preventDefault();
event.stopPropagation();
PopoutActionCreators.closeAll();
}, []);
return (
<div className={styles.popouts} data-popouts-root data-overlay-pass-through="true">
{needsBackdrop && (
<div className={styles.backdrop} onPointerDown={handleBackdropPointerDown} aria-hidden="true" />
)}
{popouts.map((popout) => (
<PopoutItem
{...popout}
key={popout.key}
popoutKey={popout.key.toString()}
isTopmost={topPopout?.key === popout.key}
/>
))}
</div>
);
});
const getTransform = (
shouldAnimate: boolean,
isVisible: boolean,
isPositioned: boolean,
targetInDOM: boolean,
): string => {
if (!shouldAnimate) return 'scale(1)';
return isVisible && isPositioned && targetInDOM ? 'scale(1)' : 'scale(0.98)';
};

View File

@@ -0,0 +1,68 @@
/*
* 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 React from 'react';
export const PopoutKeyContext = React.createContext<PopoutKey | null>(null);
export const usePopoutKeyContext = (): PopoutKey | null => {
return React.useContext(PopoutKeyContext);
};
export type PopoutKey = string | number;
export type PopoutPosition =
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-start'
| 'top-end'
| 'bottom-start'
| 'bottom-end'
| 'left-start'
| 'left-end'
| 'right-start'
| 'right-end';
export interface Popout {
key: PopoutKey;
dependsOn?: PopoutKey;
position: PopoutPosition;
target: HTMLElement;
render: (props: {popoutKey: PopoutKey; onClose: () => void}) => React.ReactNode;
zIndexBoost?: number;
shouldAutoUpdate?: boolean;
shouldReposition?: boolean;
offsetMainAxis?: number;
offsetCrossAxis?: number;
animationType?: 'smooth' | 'none';
containerClass?: string;
onOpen?: () => void;
onClose?: () => void;
onCloseRequest?: (event?: Event) => boolean;
returnFocusRef?: React.RefObject<HTMLElement | null> | React.RefObject<HTMLElement>;
lastPosition?: {x: number; y: number};
clickPos?: number;
preventInvert?: boolean;
disableBackdrop?: boolean;
hoverMode?: boolean;
onContentMouseEnter?: () => void;
onContentMouseLeave?: () => void;
}

View File

@@ -0,0 +1,71 @@
/*
* 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 {observer} from 'mobx-react-lite';
import qrCode from 'qrcode';
import React from 'react';
export const QRCodeCanvas = observer(({data}: {data: string}) => {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const qrSize = 100;
const padding = 10;
const totalSize = qrSize + padding * 2;
if (canvas) {
canvas.width = totalSize;
canvas.height = totalSize;
const context = canvas.getContext('2d');
if (context) {
context.fillStyle = 'white';
context.fillRect(0, 0, totalSize, totalSize);
context.fillStyle = 'white';
context.beginPath();
context.moveTo(padding, 0);
context.lineTo(totalSize - padding, 0);
context.quadraticCurveTo(totalSize, 0, totalSize, padding);
context.lineTo(totalSize, totalSize - padding);
context.quadraticCurveTo(totalSize, totalSize, totalSize - padding, totalSize);
context.lineTo(padding, totalSize);
context.quadraticCurveTo(0, totalSize, 0, totalSize - padding);
context.lineTo(0, padding);
context.quadraticCurveTo(0, 0, padding, 0);
context.closePath();
context.fill();
const tempCanvas = document.createElement('canvas');
qrCode.toCanvas(
tempCanvas,
data,
{width: qrSize, margin: 0, color: {dark: '#000000', light: '#FFFFFF00'}},
(error: Error | null | undefined) => {
if (error) {
console.error(error);
} else {
context.drawImage(tempCanvas, padding, padding);
}
},
);
}
}
}, [data]);
return <canvas ref={canvasRef} style={{borderRadius: 10, backgroundColor: 'white'}} />;
});

View File

@@ -0,0 +1,193 @@
/*
* 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/>.
*/
.group {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--spacing-1-5);
--radio-indicator-size: 18px;
--radio-indicator-border: color-mix(in srgb, var(--border-color) 70%, #fff 30%);
--radio-indicator-border-selected: var(--brand-primary);
--radio-dot-bg: var(--brand-primary);
--radio-dot-fill: #fff;
--radio-transition: var(--transition-normal, 150ms ease);
}
:global(.theme-light) .group {
--radio-indicator-border: color-mix(in srgb, var(--text-secondary) 70%, #000 30%);
--radio-indicator-border-selected: color-mix(in srgb, var(--brand-primary) 85%, var(--text-primary) 15%);
--radio-dot-bg: var(--brand-primary);
--radio-dot-fill: #fff;
}
.radioGroupOption {
align-items: flex-start;
color: var(--text-secondary);
cursor: pointer;
display: flex;
gap: var(--spacing-2);
line-height: 1.3;
padding: var(--spacing-1) 0;
width: 100%;
}
.label {
align-items: flex-start;
display: flex;
gap: var(--spacing-1);
width: 100%;
}
.labelText {
color: var(--text-primary);
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.4;
}
.description {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.4;
}
.stack {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
width: 100%;
align-items: flex-start;
text-align: left;
}
.customContent {
color: var(--text-secondary);
width: 100%;
}
.radioGroupOption[data-state='checked'] .labelText,
.radioGroupOption[data-state='checked'] .customContent {
color: var(--text-primary);
}
.radioGroupOption[data-state='checked'] .description {
color: var(--text-secondary);
}
.radioGroupOption[data-disabled] {
cursor: not-allowed;
opacity: 0.55;
}
.radioGroupOption[data-disabled] .labelText,
.radioGroupOption[data-disabled] .description,
.radioGroupOption[data-disabled] .customContent {
color: var(--text-secondary);
}
.radioGroupOption:not([data-disabled]):hover .labelText,
.radioGroupOption:not([data-disabled]):hover .description,
.radioGroupOption:not([data-disabled]):hover .customContent {
color: var(--text-primary);
}
.radioIndicator {
border-radius: 50%;
box-sizing: border-box;
display: block;
flex-shrink: 0;
height: var(--radio-indicator-size);
image-rendering: crisp-edges;
margin-top: 2px;
overflow: visible;
width: var(--radio-indicator-size);
}
.innerDotRadio,
.outerRadioBase,
.outerRadioFill,
.radioIndicator {
transform-box: fill-box;
transform-origin: center;
fill: none;
overflow: visible;
}
.outerRadioBase {
fill: color-mix(in srgb, var(--background-primary) 55%, var(--radio-indicator-border) 45%);
stroke: var(--radio-indicator-border);
stroke-width: 2;
transition:
stroke var(--radio-transition),
fill var(--radio-transition);
}
.outerRadioFill {
fill: none;
stroke: none;
}
.innerDotRadio {
fill: var(--radio-dot-fill);
opacity: 0;
transition: opacity var(--radio-transition);
}
.radioGroupOption[data-state='checked'] .outerRadioBase {
fill: var(--radio-dot-bg);
stroke: var(--radio-indicator-border-selected);
}
.radioGroupOption[data-state='checked'] .innerDotRadio {
opacity: 1;
}
.focusRing {
border-radius: var(--radius-sm);
}
.enable-forced-colors .outerRadioBase {
fill: Canvas;
}
.enable-forced-colors .innerDotRadio {
fill: HighlightText;
}
.enable-forced-colors .radioGroupOption[data-disabled] {
opacity: 1;
}
.enable-forced-colors .radioGroupOption[data-disabled],
.enable-forced-colors .radioGroupOption[data-disabled]:hover {
color: GrayText;
}
.enable-forced-colors .radioGroupOption[data-state='checked'] .outerRadioBase {
fill: Highlight;
}
.enable-forced-colors .radioGroupOption[data-disabled] .outerRadioBase {
fill: Canvas;
}
.enable-forced-colors .radioGroupOption[data-disabled] .innerDotRadio {
fill: GrayText;
}

View File

@@ -0,0 +1,156 @@
/*
* 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 RadixRadioGroup from '@radix-ui/react-radio-group';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import styles from './RadioGroup.module.css';
export interface RadioOption<T = unknown> {
value: T;
name: string | React.ReactNode;
desc?: string | React.ReactNode;
disabled?: boolean;
}
interface RadioGroupProps<T> {
options: ReadonlyArray<RadioOption<T>>;
value: T | null;
disabled?: boolean;
className?: string;
onChange: (value: T) => void;
renderContent?: (option: RadioOption<T>, checked: boolean) => React.ReactNode;
'aria-label'?: string;
}
interface RadioOptionItemProps<T> {
option: RadioOption<T>;
value: string;
renderContent?: (option: RadioOption<T>, checked: boolean) => React.ReactNode;
isSelected: boolean;
groupDisabled: boolean;
}
const RadioOptionItem = <T,>({option, value, renderContent, isSelected, groupDisabled}: RadioOptionItemProps<T>) => {
const radioRef = React.useRef<HTMLButtonElement | null>(null);
return (
<FocusRing
focusTarget={radioRef}
ringTarget={radioRef}
offset={-2}
ringClassName={styles.focusRing}
enabled={!option.disabled && !groupDisabled}
>
<RadixRadioGroup.Item
ref={radioRef}
value={value}
disabled={option.disabled || groupDisabled}
className={styles.radioGroupOption}
>
<svg
className={styles.radioIndicator}
width="20"
height="20"
viewBox="0 0 40 40"
fill="none"
shapeRendering="geometricPrecision"
aria-hidden="true"
>
<circle cx="20" cy="20" r="20" className={styles.outerRadioBase} />
<circle cx="20" cy="20" r="20" className={styles.outerRadioFill} />
<circle cx="20" cy="20" r="8" className={styles.innerDotRadio} />
</svg>
<div className={styles.stack}>
{renderContent ? (
<div className={styles.customContent}>{renderContent(option, isSelected)}</div>
) : (
<>
<span className={styles.label}>
<div className={styles.labelText}>{option.name}</div>
</span>
{option.desc && <div className={styles.description}>{option.desc}</div>}
</>
)}
</div>
</RadixRadioGroup.Item>
</FocusRing>
);
};
export const RadioGroup = observer(
<T,>({
options,
value,
disabled = false,
className,
onChange,
renderContent,
'aria-label': ariaLabel,
}: RadioGroupProps<T>) => {
const valueToString = (val: T): string => {
if (typeof val === 'string') return val;
if (typeof val === 'number') return String(val);
return JSON.stringify(val);
};
const stringToValue = (str: string): T | undefined => {
const option = options.find((opt) => valueToString(opt.value) === str);
return option?.value;
};
const currentStringValue = value !== null ? valueToString(value) : undefined;
const handleChange = (newStringValue: string) => {
const nextValue = stringToValue(newStringValue);
if (nextValue !== undefined) {
onChange(nextValue);
}
};
return (
<RadixRadioGroup.Root
className={clsx(styles.group, className)}
value={currentStringValue}
onValueChange={handleChange}
disabled={disabled}
orientation="vertical"
aria-label={ariaLabel}
>
{options.map((option) => {
const stringValue = valueToString(option.value);
const isSelected = currentStringValue === stringValue;
return (
<RadioOptionItem
key={stringValue}
option={option}
value={stringValue}
renderContent={renderContent}
isSelected={isSelected}
groupDisabled={disabled}
/>
);
})}
</RadixRadioGroup.Root>
);
},
);

View File

@@ -0,0 +1,178 @@
/*
* 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/>.
*/
.scrollerWrap {
--scroller-track-size: 8px;
position: relative;
display: flex;
min-height: 0;
min-width: 0;
height: 100%;
flex: 1 1 0%;
padding-inline-end: var(--scroller-track-size);
}
.scroller {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
height: 100%;
flex: 1 1 0%;
overscroll-behavior: contain;
overflow-anchor: none;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg, transparent);
margin-inline-end: calc(var(--scroller-track-size) * -1);
}
.scroller::-webkit-scrollbar {
width: var(--scroller-track-size);
height: var(--scroller-track-size);
}
.scroller::-webkit-scrollbar-track {
background-color: var(--scrollbar-track-bg, transparent);
}
.scroller::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-bg);
border-radius: 4px;
transition: background-color 0.2s ease;
}
.scroller::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-bg-hover);
}
.fade::-webkit-scrollbar-thumb {
background-color: transparent;
transition: background-color 0.15s ease-out;
}
.fade::-webkit-scrollbar-track {
background-color: transparent;
}
.fade:hover::-webkit-scrollbar-thumb,
.fade.scrolling::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-bg);
}
.fade:hover::-webkit-scrollbar-thumb:hover,
.fade.scrolling::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-bg-hover);
}
.fade {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.fade:hover,
.fade.scrolling {
scrollbar-color: var(--scrollbar-thumb-bg) transparent;
}
.horizontal .scroller {
overscroll-behavior-x: contain;
overscroll-behavior-y: none;
}
.noScrollbarReserve {
padding-inline-end: 0;
}
.noScrollbarReserve .scroller {
margin-inline-end: 0;
scrollbar-gutter: auto;
}
.noScrollbarReserve.horizontal {
padding-block-end: 0;
}
.noScrollbarReserve.horizontal .scroller {
margin-block-end: 0;
}
.scrollerChildren {
display: flex;
flex: 1 1 0%;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.horizontal .scrollerChildren {
margin-inline-end: 0;
}
.regular {
--scroller-track-size: 16px;
}
.scroller.regular {
scrollbar-width: auto;
scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg, transparent);
}
.regular::-webkit-scrollbar-thumb {
min-height: 40px;
}
.regular::-webkit-scrollbar-thumb,
.regular::-webkit-scrollbar-track {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 8px;
}
.windowBlurred {
scrollbar-color: transparent transparent;
}
.windowBlurred::-webkit-scrollbar-track {
background-color: transparent;
}
.windowBlurred::-webkit-scrollbar-thumb,
.windowBlurred:hover::-webkit-scrollbar-thumb,
.windowBlurred.scrolling::-webkit-scrollbar-thumb,
.windowBlurred.fade:hover::-webkit-scrollbar-thumb,
.windowBlurred.fade.scrolling::-webkit-scrollbar-thumb {
background-color: transparent;
}
.horizontal.scrollerWrap {
padding-inline-end: 0;
padding-block-end: var(--scroller-track-size);
height: auto;
flex: 0 1 auto;
width: 100%;
}
.horizontal .scroller {
margin-inline-end: 0;
margin-block-end: calc(var(--scroller-track-size) * -1);
height: auto;
flex: 0 1 auto;
width: 100%;
}

View File

@@ -0,0 +1,550 @@
/*
* 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 {
type CSSProperties,
forwardRef,
type KeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type UIEvent,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import FocusRingScope from '~/components/uikit/FocusRing/FocusRingScope';
import styles from './Scroller.module.css';
export interface ScrollerState {
scrollTop: number;
scrollHeight: number;
offsetHeight: number;
scrollLeft: number;
scrollWidth: number;
offsetWidth: number;
}
export interface ScrollerHandle {
getScrollerNode(): HTMLElement | null;
getScrollerState(): ScrollerState;
scrollTo(options: {to: number; animate?: boolean; callback?: () => void}): void;
mergeTo(options: {to: number; callback?: () => void}): void;
scrollIntoViewRect(options: {
start: number;
end: number;
shouldScrollToStart?: boolean;
padding?: number;
animate?: boolean;
callback?: () => void;
}): void;
scrollIntoViewNode(options: {
node: HTMLElement;
shouldScrollToStart?: boolean;
padding?: number;
animate?: boolean;
callback?: () => void;
}): void;
scrollPageUp(options?: {animate?: boolean; callback?: () => void}): void;
scrollPageDown(options?: {animate?: boolean; callback?: () => void}): void;
scrollToTop(options?: {animate?: boolean; callback?: () => void}): void;
scrollToBottom(options?: {animate?: boolean; callback?: () => void}): void;
isScrolledToTop(): boolean;
isScrolledToBottom(): boolean;
getDistanceFromTop(): number;
getDistanceFromBottom(): number;
}
type ScrollAxis = 'vertical' | 'horizontal';
type ScrollOverflow = 'scroll' | 'auto' | 'hidden';
interface ScrollerProps {
children: ReactNode;
className?: string;
dir?: 'ltr' | 'rtl';
orientation?: ScrollAxis;
overflow?: ScrollOverflow;
fade?: boolean;
scrollbar?: 'thin' | 'regular';
hideThumbWhenWindowBlurred?: boolean;
reserveScrollbarTrack?: boolean;
style?: CSSProperties;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: ReactMouseEvent<HTMLDivElement>) => void;
onResize?: (entry: ResizeObserverEntry, type: 'container' | 'content') => void;
}
function getScrollState(element: HTMLElement | null): ScrollerState {
if (!element) {
return {
scrollTop: 0,
scrollHeight: 0,
offsetHeight: 0,
scrollLeft: 0,
scrollWidth: 0,
offsetWidth: 0,
};
}
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
offsetHeight: element.offsetHeight,
scrollLeft: element.scrollLeft,
scrollWidth: element.scrollWidth,
offsetWidth: element.offsetWidth,
};
}
function measureElementOffset(element: HTMLElement, axis: ScrollAxis, container: HTMLElement): number {
const elementRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const scrollPos = axis === 'horizontal' ? container.scrollLeft : container.scrollTop;
const delta = axis === 'horizontal' ? elementRect.left - containerRect.left : elementRect.top - containerRect.top;
return scrollPos + delta;
}
type SpringConfig = {
stiffness: number;
damping: number;
mass: number;
precision: number;
maxDurationMs: number;
};
const DEFAULT_SPRING: SpringConfig = {
stiffness: 520,
damping: 70,
mass: 1,
precision: 0.5,
maxDurationMs: 1400,
};
function prefersReducedMotion(): boolean {
return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
}
export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scroller(
{
children,
className,
dir = 'ltr',
orientation = 'vertical',
overflow = 'auto',
fade = true,
scrollbar = 'thin',
hideThumbWhenWindowBlurred = false,
reserveScrollbarTrack = true,
style,
onScroll,
onKeyDown,
onMouseLeave,
onResize,
},
forwardedRef,
) {
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const scrollTimeoutRef = useRef<number>(0);
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const animRef = useRef<{
token: number;
rafId: number | null;
velocity: number;
lastTime: number;
startTime: number;
target: number;
callback?: () => void;
} | null>(null);
const cancelAnimation = useCallback(() => {
const anim = animRef.current;
if (!anim) return;
if (anim.rafId != null) {
cancelAnimationFrame(anim.rafId);
}
animRef.current = null;
}, []);
const getMaxScroll = useCallback(
(node: HTMLDivElement): number => {
if (orientation === 'vertical') {
return Math.max(0, node.scrollHeight - node.offsetHeight);
}
return Math.max(0, node.scrollWidth - node.offsetWidth);
},
[orientation],
);
const getScrollPos = useCallback(
(node: HTMLDivElement): number => (orientation === 'vertical' ? node.scrollTop : node.scrollLeft),
[orientation],
);
const setScrollPos = useCallback(
(node: HTMLDivElement, value: number) => {
if (orientation === 'vertical') {
node.scrollTop = value;
} else {
node.scrollLeft = value;
}
},
[orientation],
);
const clampToRange = useCallback(
(node: HTMLDivElement, value: number): number => {
const max = getMaxScroll(node);
return Math.max(0, Math.min(value, max));
},
[getMaxScroll],
);
const springScrollTo = useCallback(
(node: HTMLDivElement, target: number, callback?: () => void, config: SpringConfig = DEFAULT_SPRING) => {
cancelAnimation();
const token = (animRef.current?.token ?? 0) + 1;
const now = performance.now();
animRef.current = {
token,
rafId: null,
velocity: 0,
lastTime: now,
startTime: now,
target: clampToRange(node, target),
callback,
};
const step = (t: number) => {
const anim = animRef.current;
if (!anim || anim.token !== token) return;
anim.target = clampToRange(node, anim.target);
const dtRaw = (t - anim.lastTime) / 1000;
const dt = Math.max(0, Math.min(0.04, dtRaw));
anim.lastTime = t;
const x = getScrollPos(node);
const displacement = anim.target - x;
const a = (config.stiffness * displacement - config.damping * anim.velocity) / config.mass;
anim.velocity += a * dt;
let next = x + anim.velocity * dt;
next = clampToRange(node, next);
setScrollPos(node, next);
const elapsed = t - anim.startTime;
const settled = Math.abs(displacement) <= config.precision && Math.abs(anim.velocity) <= config.precision;
const timedOut = elapsed >= config.maxDurationMs;
if (settled || timedOut) {
setScrollPos(node, clampToRange(node, anim.target));
const cb = anim.callback;
animRef.current = null;
cb?.();
return;
}
anim.rafId = requestAnimationFrame(step);
};
animRef.current.rafId = requestAnimationFrame(step);
},
[cancelAnimation, clampToRange, getScrollPos, setScrollPos],
);
useEffect(() => {
if (!onResizeRef.current) return;
const containerEl = rootRef.current;
const contentEl = scrollRef.current;
if (!containerEl || !contentEl) return;
const containerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
onResizeRef.current?.(entry, 'container');
}
});
const contentObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
onResizeRef.current?.(entry, 'content');
}
});
containerObserver.observe(containerEl);
contentObserver.observe(contentEl);
return () => {
containerObserver.disconnect();
contentObserver.disconnect();
};
}, []);
useEffect(() => {
return () => cancelAnimation();
}, [cancelAnimation]);
useEffect(() => {
if (!hideThumbWhenWindowBlurred) return;
const el = scrollRef.current;
if (!el) return;
const update = () => {
el.classList.toggle(styles.windowBlurred, !document.hasFocus());
};
update();
window.addEventListener('blur', update);
window.addEventListener('focus', update);
document.addEventListener('visibilitychange', update);
return () => {
window.removeEventListener('blur', update);
window.removeEventListener('focus', update);
document.removeEventListener('visibilitychange', update);
};
}, [hideThumbWhenWindowBlurred]);
useImperativeHandle(
forwardedRef,
() => ({
getScrollerNode: () => scrollRef.current,
getScrollerState: () => getScrollState(scrollRef.current),
scrollTo({to, animate = false, callback}) {
const node = scrollRef.current;
if (!node) {
callback?.();
return;
}
const clampedTo = clampToRange(node, to);
if (!animate || prefersReducedMotion()) {
cancelAnimation();
setScrollPos(node, clampedTo);
callback?.();
return;
}
springScrollTo(node, clampedTo, callback);
},
mergeTo({to, callback}) {
const node = scrollRef.current;
if (!node) {
callback?.();
return;
}
cancelAnimation();
setScrollPos(node, clampToRange(node, to));
callback?.();
},
scrollIntoViewRect({start, end, shouldScrollToStart = false, padding = 0, animate = false, callback}) {
const node = scrollRef.current;
if (!node) {
callback?.();
return;
}
const scrollPos = getScrollPos(node);
const viewportSize = orientation === 'vertical' ? node.offsetHeight : node.offsetWidth;
const paddedStart = start - padding;
const paddedEnd = end + padding;
const visibleEnd = scrollPos + viewportSize;
if (paddedStart >= scrollPos && paddedEnd <= visibleEnd) {
callback?.();
return;
}
let targetScroll: number;
if (paddedStart < scrollPos || shouldScrollToStart) {
targetScroll = paddedStart;
} else {
targetScroll = paddedEnd - viewportSize;
}
this.scrollTo({to: targetScroll, animate, callback});
},
scrollIntoViewNode({node: targetNode, shouldScrollToStart = false, padding = 0, animate = false, callback}) {
const container = scrollRef.current;
if (!container) {
callback?.();
return;
}
const offset = measureElementOffset(targetNode, orientation, container);
const size = orientation === 'vertical' ? targetNode.offsetHeight : targetNode.offsetWidth;
this.scrollIntoViewRect({
start: offset,
end: offset + size,
shouldScrollToStart,
padding,
animate,
callback,
});
},
scrollPageUp({animate = false, callback} = {}) {
const node = scrollRef.current;
if (!node) {
callback?.();
return;
}
const scrollPos = getScrollPos(node);
const viewportSize = orientation === 'vertical' ? node.offsetHeight : node.offsetWidth;
const target = Math.max(0, scrollPos - 0.9 * viewportSize);
this.scrollTo({to: target, animate, callback});
},
scrollPageDown({animate = false, callback} = {}) {
const node = scrollRef.current;
if (!node) {
callback?.();
return;
}
const scrollPos = getScrollPos(node);
const viewportSize = orientation === 'vertical' ? node.offsetHeight : node.offsetWidth;
const maxScroll = getMaxScroll(node);
const target = Math.min(maxScroll, scrollPos + 0.9 * viewportSize);
this.scrollTo({to: target, animate, callback});
},
scrollToTop({animate = false, callback} = {}) {
this.scrollTo({to: 0, animate, callback});
},
scrollToBottom({animate = false, callback} = {}) {
const node = scrollRef.current;
if (!node) {
callback?.();
return;
}
this.scrollTo({to: getMaxScroll(node), animate, callback});
},
isScrolledToTop() {
const node = scrollRef.current;
if (!node) return false;
return getScrollPos(node) === 0;
},
isScrolledToBottom() {
const node = scrollRef.current;
if (!node) return false;
const scrollPos = getScrollPos(node);
const maxScroll = getMaxScroll(node);
return scrollPos >= maxScroll;
},
getDistanceFromTop() {
const node = scrollRef.current;
if (!node) return 0;
return Math.max(0, getScrollPos(node));
},
getDistanceFromBottom() {
const node = scrollRef.current;
if (!node) return 0;
const scrollPos = getScrollPos(node);
const maxScroll = getMaxScroll(node);
return Math.max(0, maxScroll - scrollPos);
},
}),
[orientation, cancelAnimation, clampToRange, getMaxScroll, getScrollPos, setScrollPos, springScrollTo],
);
const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
if (fade) {
const el = scrollRef.current;
if (el) {
el.classList.add(styles.scrolling);
window.clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = window.setTimeout(() => {
el.classList.remove(styles.scrolling);
}, 1000);
}
}
onScroll?.(event);
},
[fade, onScroll],
);
const scrollerStyle: CSSProperties = {
...style,
overflowY: orientation === 'vertical' ? overflow : 'hidden',
overflowX: orientation === 'horizontal' ? overflow : 'hidden',
};
const containerClassName = clsx(styles.scrollerWrap, {
[styles.horizontal]: orientation === 'horizontal',
[styles.regular]: scrollbar === 'regular',
[styles.noScrollbarReserve]: !reserveScrollbarTrack,
});
const scrollerClassName = clsx(styles.scroller, className, {
[styles.fade]: fade,
[styles.regular]: scrollbar === 'regular',
});
return (
<div className={containerClassName} ref={rootRef}>
<FocusRingScope containerRef={rootRef}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: this is fine */}
<div
ref={scrollRef}
className={scrollerClassName}
style={scrollerStyle}
dir={dir}
onScroll={handleScroll}
onKeyDown={onKeyDown}
onMouseLeave={onMouseLeave}
>
<div className={styles.scrollerChildren}>{children}</div>
</div>
</FocusRingScope>
</div>
);
});
Scroller.displayName = 'Scroller';

View File

@@ -0,0 +1,68 @@
/*
* 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 {
padding: 4px 16px 8px;
}
.tabList {
position: relative;
display: flex;
border-radius: 10px;
background: var(--background-tertiary);
padding: 3px;
}
.tab {
position: relative;
z-index: 10;
flex: 1;
border: none;
border-radius: 8px;
padding: 6px 12px;
font-size: 14px;
font-weight: 600;
line-height: 18px;
text-align: center;
background: transparent;
transition: color 150ms ease;
cursor: pointer;
}
.tabInactive {
color: var(--text-secondary);
}
.tabInactive:active {
color: var(--text-primary);
}
.tabActive {
color: var(--text-primary);
}
.tabBackground {
position: absolute;
top: 3px;
bottom: 3px;
height: calc(100% - 6px);
border-radius: 8px;
background: var(--background-secondary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}

View File

@@ -0,0 +1,77 @@
/*
* 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 {motion} from 'framer-motion';
import styles from './SegmentedTabs.module.css';
export type SegmentedTab<T extends string = string> = {
id: T;
label: string;
};
type SegmentedTabsProps<T extends string = string> = {
tabs: Array<SegmentedTab<T>>;
selectedTab: T;
onTabChange: (tab: T) => void;
ariaLabel?: string;
className?: string;
};
export function SegmentedTabs<T extends string = string>({
tabs,
selectedTab,
onTabChange,
ariaLabel,
className,
}: SegmentedTabsProps<T>) {
const selectedIndex = tabs.findIndex((tab) => tab.id === selectedTab);
return (
<div className={clsx(styles.container, className)}>
<div className={styles.tabList} role="tablist" aria-label={ariaLabel}>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selectedTab === tab.id}
onClick={() => onTabChange(tab.id)}
className={clsx(styles.tab, selectedTab === tab.id ? styles.tabActive : styles.tabInactive)}
>
{tab.label}
</button>
))}
<motion.div
className={styles.tabBackground}
layout
transition={{
type: 'spring',
stiffness: 500,
damping: 35,
}}
style={{
width: `calc((100% - 6px) / ${tabs.length})`,
left: `calc(3px + (100% - 6px) * ${selectedIndex} / ${tabs.length})`,
}}
/>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More