initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
142
fluxer_app/src/components/uikit/Accordion/Accordion.tsx
Normal file
142
fluxer_app/src/components/uikit/Accordion/Accordion.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
147
fluxer_app/src/components/uikit/Avatar.tsx
Normal file
147
fluxer_app/src/components/uikit/Avatar.tsx
Normal 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);
|
||||
119
fluxer_app/src/components/uikit/AvatarStatusLayout.tsx
Normal file
119
fluxer_app/src/components/uikit/AvatarStatusLayout.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
79
fluxer_app/src/components/uikit/BaseAvatar.module.css
Normal file
79
fluxer_app/src/components/uikit/BaseAvatar.module.css
Normal 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;
|
||||
}
|
||||
313
fluxer_app/src/components/uikit/BaseAvatar.tsx
Normal file
313
fluxer_app/src/components/uikit/BaseAvatar.tsx
Normal 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>
|
||||
);
|
||||
109
fluxer_app/src/components/uikit/BottomSheet/BottomSheet.tsx
Normal file
109
fluxer_app/src/components/uikit/BottomSheet/BottomSheet.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
314
fluxer_app/src/components/uikit/Button/Button.module.css
Normal file
314
fluxer_app/src/components/uikit/Button/Button.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
180
fluxer_app/src/components/uikit/Button/Button.tsx
Normal file
180
fluxer_app/src/components/uikit/Button/Button.tsx
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
194
fluxer_app/src/components/uikit/Checkbox/Checkbox.module.css
Normal file
194
fluxer_app/src/components/uikit/Checkbox/Checkbox.module.css
Normal 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;
|
||||
}
|
||||
324
fluxer_app/src/components/uikit/Checkbox/Checkbox.tsx
Normal file
324
fluxer_app/src/components/uikit/Checkbox/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
482
fluxer_app/src/components/uikit/ContextMenu/ContextMenu.tsx
Normal file
482
fluxer_app/src/components/uikit/ContextMenu/ContextMenu.tsx
Normal 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);
|
||||
});
|
||||
176
fluxer_app/src/components/uikit/ContextMenu/ContextMenuIcons.tsx
Normal file
176
fluxer_app/src/components/uikit/ContextMenu/ContextMenuIcons.tsx
Normal 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" />);
|
||||
203
fluxer_app/src/components/uikit/ContextMenu/DMContextMenu.tsx
Normal file
203
fluxer_app/src/components/uikit/ContextMenu/DMContextMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
106
fluxer_app/src/components/uikit/ContextMenu/GuildContextMenu.tsx
Normal file
106
fluxer_app/src/components/uikit/ContextMenu/GuildContextMenu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
379
fluxer_app/src/components/uikit/ContextMenu/MediaContextMenu.tsx
Normal file
379
fluxer_app/src/components/uikit/ContextMenu/MediaContextMenu.tsx
Normal 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`;
|
||||
}
|
||||
}
|
||||
45
fluxer_app/src/components/uikit/ContextMenu/MenuGroup.tsx
Normal file
45
fluxer_app/src/components/uikit/ContextMenu/MenuGroup.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
});
|
||||
50
fluxer_app/src/components/uikit/ContextMenu/MenuGroups.tsx
Normal file
50
fluxer_app/src/components/uikit/ContextMenu/MenuGroups.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
192
fluxer_app/src/components/uikit/ContextMenu/MenuItem.module.css
Normal file
192
fluxer_app/src/components/uikit/ContextMenu/MenuItem.module.css
Normal 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%;
|
||||
}
|
||||
78
fluxer_app/src/components/uikit/ContextMenu/MenuItem.tsx
Normal file
78
fluxer_app/src/components/uikit/ContextMenu/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
108
fluxer_app/src/components/uikit/ContextMenu/MenuItemSlider.tsx
Normal file
108
fluxer_app/src/components/uikit/ContextMenu/MenuItemSlider.tsx
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
339
fluxer_app/src/components/uikit/ContextMenu/UserContextMenu.tsx
Normal file
339
fluxer_app/src/components/uikit/ContextMenu/UserContextMenu.tsx
Normal 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();
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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} />;
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
34
fluxer_app/src/components/uikit/FocusRing.tsx
Normal file
34
fluxer_app/src/components/uikit/FocusRing.tsx
Normal 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';
|
||||
@@ -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));
|
||||
}
|
||||
224
fluxer_app/src/components/uikit/FocusRing/FocusRing.tsx
Normal file
224
fluxer_app/src/components/uikit/FocusRing/FocusRing.tsx
Normal 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;
|
||||
180
fluxer_app/src/components/uikit/FocusRing/FocusRingContext.ts
Normal file
180
fluxer_app/src/components/uikit/FocusRing/FocusRingContext.ts
Normal 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;
|
||||
@@ -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;
|
||||
61
fluxer_app/src/components/uikit/FocusRing/FocusRingScope.tsx
Normal file
61
fluxer_app/src/components/uikit/FocusRing/FocusRingScope.tsx
Normal 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()} />;
|
||||
}
|
||||
65
fluxer_app/src/components/uikit/FocusRing/types.ts
Normal file
65
fluxer_app/src/components/uikit/FocusRing/types.ts
Normal 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;
|
||||
}
|
||||
101
fluxer_app/src/components/uikit/FocusRingWrapper.tsx
Normal file
101
fluxer_app/src/components/uikit/FocusRingWrapper.tsx
Normal 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';
|
||||
110
fluxer_app/src/components/uikit/InlineEdit.module.css
Normal file
110
fluxer_app/src/components/uikit/InlineEdit.module.css
Normal 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);
|
||||
}
|
||||
246
fluxer_app/src/components/uikit/InlineEdit.tsx
Normal file
246
fluxer_app/src/components/uikit/InlineEdit.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
141
fluxer_app/src/components/uikit/KeybindHint/KeybindHint.tsx
Normal file
141
fluxer_app/src/components/uikit/KeybindHint/KeybindHint.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
43
fluxer_app/src/components/uikit/KeyboardKey.module.css
Normal file
43
fluxer_app/src/components/uikit/KeyboardKey.module.css
Normal 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;
|
||||
}
|
||||
26
fluxer_app/src/components/uikit/KeyboardKey.tsx
Normal file
26
fluxer_app/src/components/uikit/KeyboardKey.tsx
Normal 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>
|
||||
));
|
||||
48
fluxer_app/src/components/uikit/MentionBadge.module.css
Normal file
48
fluxer_app/src/components/uikit/MentionBadge.module.css
Normal 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;
|
||||
}
|
||||
83
fluxer_app/src/components/uikit/MentionBadge.tsx
Normal file
83
fluxer_app/src/components/uikit/MentionBadge.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
85
fluxer_app/src/components/uikit/MockAvatar.tsx
Normal file
85
fluxer_app/src/components/uikit/MockAvatar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
48
fluxer_app/src/components/uikit/Popout/Popout.module.css
Normal file
48
fluxer_app/src/components/uikit/Popout/Popout.module.css
Normal 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;
|
||||
}
|
||||
368
fluxer_app/src/components/uikit/Popout/Popout.tsx
Normal file
368
fluxer_app/src/components/uikit/Popout/Popout.tsx
Normal 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
|
||||
);
|
||||
});
|
||||
344
fluxer_app/src/components/uikit/Popout/Popouts.tsx
Normal file
344
fluxer_app/src/components/uikit/Popout/Popouts.tsx
Normal 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)';
|
||||
};
|
||||
68
fluxer_app/src/components/uikit/Popout/index.tsx
Normal file
68
fluxer_app/src/components/uikit/Popout/index.tsx
Normal 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;
|
||||
}
|
||||
71
fluxer_app/src/components/uikit/QRCodeCanvas.tsx
Normal file
71
fluxer_app/src/components/uikit/QRCodeCanvas.tsx
Normal 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'}} />;
|
||||
});
|
||||
193
fluxer_app/src/components/uikit/RadioGroup/RadioGroup.module.css
Normal file
193
fluxer_app/src/components/uikit/RadioGroup/RadioGroup.module.css
Normal 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;
|
||||
}
|
||||
156
fluxer_app/src/components/uikit/RadioGroup/RadioGroup.tsx
Normal file
156
fluxer_app/src/components/uikit/RadioGroup/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
178
fluxer_app/src/components/uikit/Scroller.module.css
Normal file
178
fluxer_app/src/components/uikit/Scroller.module.css
Normal 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%;
|
||||
}
|
||||
550
fluxer_app/src/components/uikit/Scroller.tsx
Normal file
550
fluxer_app/src/components/uikit/Scroller.tsx
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user