initial commit

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

View File

@@ -0,0 +1,32 @@
/*
* 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/>.
*/
.appLayout {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
overflow: hidden;
height: 100svh;
background-color: transparent;
color: var(--text-primary);
}
.appLayoutStandalone {
height: 100svh;
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {SplashScreen} from '~/components/layout/SplashScreen';
import RequiredActionModal from '~/components/modals/RequiredActionModal';
import {NewDeviceMonitoringManager} from '~/components/voice/NewDeviceMonitoringManager';
import {VoiceReconnectionManager} from '~/components/voice/VoiceReconnectionManager';
import AccountManager from '~/stores/AccountManager';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ConnectionStore from '~/stores/ConnectionStore';
import InitializationStore from '~/stores/InitializationStore';
import ModalStore from '~/stores/ModalStore';
import UserStore from '~/stores/UserStore';
import styles from './AppLayout.module.css';
import {useAppLayoutState} from './app-layout/hooks';
export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
const isAuthenticated = AuthenticationStore.isAuthenticated;
const socket = ConnectionStore.socket;
const user = UserStore.currentUser;
const appState = useAppLayoutState();
React.useEffect(() => {
if (InitializationStore.isLoading) {
return;
}
void AuthenticationActionCreators.ensureSessionStarted();
}, [
isAuthenticated,
socket,
ConnectionStore.isConnected,
ConnectionStore.isConnecting,
InitializationStore.isLoading,
AccountManager.isSwitching,
]);
React.useEffect(() => {
const hasRequired = !!(user?.requiredActions && user.requiredActions.length > 0);
const isOpen = ModalStore.getModal()?.key === 'required-actions';
if (hasRequired && !isOpen) {
ModalActionCreators.pushWithKey(
modal(() => <RequiredActionModal mock={false} />),
'required-actions',
);
}
if (!hasRequired && isOpen) {
ModalActionCreators.pop();
}
}, [user?.requiredActions?.length]);
return (
<>
{isAuthenticated && <SplashScreen />}
{isAuthenticated && socket && <VoiceReconnectionManager />}
{isAuthenticated && <NewDeviceMonitoringManager />}
<div className={clsx(styles.appLayout, appState.isStandalone && styles.appLayoutStandalone)}>{children}</div>
</>
);
});

View File

@@ -0,0 +1,259 @@
/*
* 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/>.
*/
.topDragRegion {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--layout-header-height);
z-index: var(--z-index-titlebar);
pointer-events: none;
}
:global(html.platform-native.platform-macos) .topDragRegion {
pointer-events: auto;
}
.scrollerWrapper {
position: fixed;
inset: 0;
display: flex;
background-color: var(--background-secondary);
}
.container {
position: relative;
min-height: 100svh;
width: 100%;
background-color: var(--brand-primary);
}
:global(.auth-page),
:global(.auth-page *) {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.characterBackground {
min-height: 100svh;
overflow: auto;
position: relative;
width: 100%;
}
.rightSplit {
bottom: 0;
inset-inline-end: 0;
opacity: 1;
pointer-events: none;
position: fixed;
transition: opacity 0.4s ease;
width: auto;
z-index: 0;
}
.leftSplit {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100svh;
position: relative;
width: 100%;
}
.leftSplitWrapper {
align-items: center;
display: flex;
justify-content: center;
position: relative;
width: 100%;
flex: 1;
}
.leftSplitAnimated {
width: 100%;
display: flex;
justify-content: center;
}
.splashImage {
position: fixed;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.splashOverlay {
pointer-events: none;
position: absolute;
inset: 0;
}
.patternHost {
position: absolute;
inset: 0;
opacity: 0.06;
pointer-events: none;
z-index: 0;
background-repeat: repeat;
background-size: 260px 260px;
filter: invert(1);
}
.cardContainer {
position: relative;
z-index: 10;
display: flex;
flex: 1;
min-height: 100svh;
width: 100%;
align-items: center;
justify-content: center;
padding: clamp(2rem, 6vw, 4rem);
box-sizing: border-box;
}
.card {
margin: 0;
display: flex;
height: auto;
min-height: 500px;
width: 100%;
max-width: 56rem;
overflow: hidden;
border-radius: 1rem;
background-color: var(--background-secondary);
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
.cardSingle {
max-width: 42rem;
}
.logoSide {
display: flex;
width: 33.333333%;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
border-right: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
}
.logo {
margin-bottom: 1.5rem;
height: 8rem;
width: 8rem;
}
.wordmark {
height: 2rem;
}
.formSide {
display: flex;
width: 66.666667%;
flex-direction: column;
justify-content: center;
padding: 3rem;
background: var(--background-secondary);
}
.formSideSingle {
width: 100%;
}
.mobileContainer {
min-height: 100dvh;
background-color: var(--background-secondary);
padding: calc(2rem + env(safe-area-inset-top, 0px)) 1.5rem calc(2.5rem + env(safe-area-inset-bottom, 0px));
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.mobileContent {
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: 28rem;
}
.mobileLogoContainer {
margin-bottom: 2rem;
text-align: center;
}
.mobileWordmark {
margin-left: auto;
margin-right: auto;
height: 2rem;
color: var(--text-primary);
}
@media (min-width: 1600px) {
.leftSplit.alignLeft {
align-items: flex-start;
padding-left: clamp(10rem, 18vw, 22rem);
}
.alignLeft .leftSplitWrapper,
.alignLeft .leftSplitAnimated,
.alignLeft .cardContainer {
justify-content: flex-start;
}
.leftSplit.alignRight {
align-items: flex-end;
padding-right: clamp(10rem, 18vw, 22rem);
}
.alignRight .leftSplitWrapper,
.alignRight .leftSplitAnimated,
.alignRight .cardContainer {
justify-content: flex-end;
}
}
:global(html:not(.auth-page) body),
:global(html.auth-page body) {
overflow: hidden;
}
:global(html.auth-page),
:global(html.auth-page body) {
height: 100%;
background-color: var(--background-secondary);
}
@supports (padding: env(safe-area-inset-top)) {
:global(.auth-page body.is-standalone) {
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
}
}

View File

@@ -0,0 +1,196 @@
/*
* 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 {I18nProvider} from '@lingui/react';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {GuildSplashCardAlignmentValue} from '~/Constants';
import {GuildSplashCardAlignment} from '~/Constants';
import {AuthBackground} from '~/components/auth/AuthBackground';
import {AuthCardContainer} from '~/components/auth/AuthCardContainer';
import {NativeDragRegion} from '~/components/layout/NativeDragRegion';
import {NativeTitlebar} from '~/components/layout/NativeTitlebar';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {AuthLayoutContext} from '~/contexts/AuthLayoutContext';
import {useSetLayoutVariant} from '~/contexts/LayoutVariantContext';
import {useAuthBackground} from '~/hooks/useAuthBackground';
import {useNativePlatform} from '~/hooks/useNativePlatform';
import i18n, {initI18n} from '~/i18n';
import FluxerWordmarkMonochrome from '~/images/fluxer-logo-wordmark-monochrome.svg?react';
import foodPatternUrl from '~/images/i-like-food.svg';
import {useLocation} from '~/lib/router';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import styles from './AuthLayout.module.css';
const AuthLayoutContent = observer(function AuthLayoutContent({children}: {children?: ReactNode}) {
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const [splashUrl, setSplashUrl] = useState<string | null>(null);
const [showLogoSide, setShowLogoSide] = useState(true);
const [splashAlignment, setSplashAlignment] = useState<GuildSplashCardAlignmentValue>(
GuildSplashCardAlignment.CENTER,
);
const {isNative, isMacOS, platform} = useNativePlatform();
const splashUrlRef = useRef<string | null>(null);
const scrollerRef = useRef<ScrollerHandle>(null);
const location = useLocation();
const {patternReady, splashLoaded, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
const handleSetSplashUrl = useCallback(
(url: string | null) => {
if (splashUrlRef.current === url) return;
splashUrlRef.current = url;
setSplashUrl(url);
if (!url) {
setSplashAlignment(GuildSplashCardAlignment.CENTER);
}
},
[setSplashAlignment],
);
useEffect(() => {
const handleResize = () => {
setViewportWidth(window.innerWidth);
setViewportHeight(window.innerHeight);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
document.documentElement.classList.add('auth-page');
return () => {
document.documentElement.classList.remove('auth-page');
};
}, []);
useEffect(() => {
scrollerRef.current?.scrollToTop();
}, [location.pathname]);
const splashScale = useMemo(() => {
if (!splashDimensions) return null;
const {width, height} = splashDimensions;
if (width <= 0 || height <= 0) return null;
const heightScale = viewportHeight / height;
const widthScale = viewportWidth / width;
return Math.max(heightScale, widthScale);
}, [splashDimensions, viewportHeight, viewportWidth]);
const isMobileExperience = isMobileExperienceEnabled();
if (isMobileExperience) {
return (
<AuthLayoutContext.Provider
value={{
setSplashUrl: handleSetSplashUrl,
setShowLogoSide,
setSplashCardAlignment: setSplashAlignment,
}}
>
<NativeDragRegion className={styles.topDragRegion} />
<div className={styles.scrollerWrapper}>
<Scroller ref={scrollerRef} className={styles.mobileContainer} fade={false} key="auth-layout-mobile-scroller">
<div className={styles.mobileContent}>
<div className={styles.mobileLogoContainer}>
<FluxerWordmarkMonochrome className={styles.mobileWordmark} />
</div>
{children}
</div>
</Scroller>
</div>
</AuthLayoutContext.Provider>
);
}
return (
<AuthLayoutContext.Provider
value={{
setSplashUrl: handleSetSplashUrl,
setShowLogoSide,
setSplashCardAlignment: setSplashAlignment,
}}
>
<NativeDragRegion className={styles.topDragRegion} />
<div className={styles.scrollerWrapper}>
<Scroller ref={scrollerRef} className={styles.container} key="auth-layout-scroller">
{isNative && !isMacOS && <NativeTitlebar platform={platform} />}
<div className={styles.characterBackground}>
<AuthBackground
splashUrl={splashUrl}
splashLoaded={splashLoaded}
splashDimensions={splashDimensions}
splashScale={splashScale}
patternReady={patternReady}
patternImageUrl={foodPatternUrl}
splashAlignment={splashAlignment}
useFullCover={false}
/>
<div
className={clsx(
styles.leftSplit,
splashAlignment === GuildSplashCardAlignment.LEFT && styles.alignLeft,
splashAlignment === GuildSplashCardAlignment.RIGHT && styles.alignRight,
)}
>
<div className={styles.leftSplitWrapper}>
<div className={styles.leftSplitAnimated}>
<AuthCardContainer showLogoSide={showLogoSide} isInert={false}>
{children}
</AuthCardContainer>
</div>
</div>
</div>
</div>
</Scroller>
</div>
</AuthLayoutContext.Provider>
);
});
export const AuthLayout = observer(function AuthLayout({children}: {children?: ReactNode}) {
const [isI18nInitialized, setIsI18nInitialized] = useState(false);
const setLayoutVariant = useSetLayoutVariant();
useEffect(() => {
setLayoutVariant('auth');
return () => {
setLayoutVariant('app');
};
}, [setLayoutVariant]);
useEffect(() => {
initI18n().then(() => {
setIsI18nInitialized(true);
});
}, []);
if (!isI18nInitialized) {
return null;
}
return (
<I18nProvider i18n={i18n}>
<AuthLayoutContent>{children}</AuthLayoutContent>
</I18nProvider>
);
});

View File

@@ -0,0 +1,363 @@
/*
* 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/>.
*/
.channelItemCore {
position: relative;
margin-left: 0.5rem;
margin-right: 0;
display: flex;
min-width: 0;
flex: 1;
cursor: pointer;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 0.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
gap: 0.375rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.channelItemCoreNoScrollbar {
margin-right: 0.5rem;
}
.channelItemCoreSelected {
background-color: var(--background-modifier-selected);
color: var(--surface-interactive-selected-color);
}
.channelItemCoreUnselected {
color: var(--text-tertiary-muted);
cursor: pointer;
}
.channelItemCoreUnselected:hover {
background-color: var(--background-modifier-hover);
}
.typingTooltip {
max-width: 32rem;
white-space: break-spaces;
word-break: break-word;
color: var(--text-chat);
}
.channelTypingIndicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
color: var(--text-primary);
flex-shrink: 0;
min-width: 1.5rem;
height: 1.25rem;
}
.typingIndicatorIcon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
:global(.theme-light) .channelItemSelected .typingIndicatorIcon {
--typing-indicator-color: var(--surface-interactive-selected-color);
color: var(--surface-interactive-selected-color);
}
.typingAvatars {
display: flex;
align-items: center;
}
.channelItemIcon {
height: 1.25rem;
width: 1.25rem;
}
.channelItemIconSelected {
color: var(--surface-interactive-selected-color);
}
.channelItemIconUnselected {
color: var(--text-tertiary-muted);
}
.channelItemIconHighlight {
color: var(--text-secondary);
}
.channelItemLabel {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
font-size: 1rem;
line-height: 1.25rem;
max-height: 1.25rem;
min-width: 0;
}
.channelItemActions {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.container {
position: relative;
width: 100%;
}
.unreadIndicator {
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
height: 0.5rem;
width: 0.5rem;
border-radius: 0 9999px 9999px 0;
background-color: var(--text-primary);
}
.channelItem {
position: relative;
margin-left: 0.5rem;
margin-right: 0;
display: flex;
min-width: 0;
flex: 1;
cursor: pointer;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 0.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.channelItemNoScrollbar {
margin-right: 0.5rem;
}
.channelItemCategory {
margin-top: 0.25rem;
margin-bottom: 0;
gap: 0.25rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
color: var(--text-tertiary-muted);
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.channelItemCategory:hover {
color: var(--text-primary);
}
}
.channelItemRegular {
gap: 0.375rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
color: var(--text-tertiary-muted);
}
.channelItemHighlight {
color: var(--text-secondary);
}
.channelItemMuted {
color: var(--text-tertiary-muted);
}
.channelItemSelected {
background-color: var(--background-modifier-selected);
color: var(--text-primary);
}
.channelItemSelectedWithUnread {
color: var(--text-primary);
}
@media (hover: hover) and (pointer: fine) {
.channelItemHoverable:hover {
background-color: var(--background-modifier-hover);
color: var(--text-chat);
}
}
.channelItemPressed {
background-color: var(--background-modifier-hover);
color: var(--text-chat);
}
.channelItemOver {
background-color: var(--background-modifier-hover);
color: var(--text-chat);
}
.channelItemContextMenu {
background-color: var(--background-modifier-hover) !important;
color: var(--text-chat) !important;
}
.channelItemCategoryContextMenu {
color: var(--text-primary) !important;
}
.channelItemDragging {
opacity: 0.3;
}
.channelItemDimmed {
opacity: 0.6;
}
.channelItemMutedState {
color: var(--text-tertiary-muted);
opacity: 0.5;
}
.hoverAffordance {
display: none;
}
.channelItemCategoryContextMenu .hoverAffordance,
.channelItemCategory.keyboardFocus .hoverAffordance,
.channelItemContextMenu .hoverAffordance,
.channelItem.keyboardFocus .hoverAffordance,
.channelItemSelected .hoverAffordance {
display: flex;
}
@media (hover: hover) and (pointer: fine) {
.channelItemCategory:hover .hoverAffordance,
.channelItemHoverable:hover .hoverAffordance {
display: flex;
}
}
.channelItemAutocompleteHighlight {
box-shadow: 0 0 0 2px var(--brand-primary);
}
.categoryContent {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.25rem;
}
.categoryName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 0.875rem;
line-height: 1.25rem;
max-height: 1.25rem;
min-width: 0;
}
.categoryIcon {
height: 0.75rem;
width: 0.75rem;
flex-shrink: 0;
color: var(--text-tertiary-muted);
}
.channelName {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
font-size: 1rem;
line-height: 1.25rem;
max-height: 1.25rem;
min-width: 0;
}
.createChannelButton {
display: flex;
height: 1rem;
width: 1rem;
cursor: pointer;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: none;
background-color: transparent;
padding: 0;
color: var(--text-tertiary-muted);
}
.createChannelButton:hover {
color: var(--text-primary);
}
.createChannelIcon {
height: 1rem;
width: 1rem;
}
.voiceUserCount {
display: flex;
flex-shrink: 0;
}
.channelItemVoice:hover .voiceUserCount,
.channelItemVoice.contextMenuOpen .voiceUserCount {
display: none;
}
:global(.theme-light) {
.channelItemCategory {
color: var(--text-primary);
}
.channelItemRegular {
color: var(--text-primary);
}
.channelItemMuted {
color: var(--text-primary);
}
.channelItemIconUnselected {
color: var(--text-primary);
}
.categoryIcon {
color: var(--text-primary);
}
.createChannelButton {
color: var(--text-primary);
}
}

View File

@@ -0,0 +1,665 @@
/*
* 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, GearIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import React, {useCallback, useState} from 'react';
import type {ConnectableElement} from 'react-dnd';
import {useDrag, useDrop} from 'react-dnd';
import {getEmptyImage} from 'react-dnd-html5-backend';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import * as LayoutActionCreators from '~/actions/LayoutActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes, Permissions} from '~/Constants';
import {ChannelBottomSheet} from '~/components/bottomsheets/ChannelBottomSheet';
import {VoiceLobbyBottomSheet} from '~/components/bottomsheets/VoiceLobbyBottomSheet';
import {Typing} from '~/components/channel/Typing';
import {getTypingText, usePresentableTypingUsers} from '~/components/channel/TypingUsers';
import {GenericChannelItem} from '~/components/layout/GenericChannelItem';
import {ChannelCreateModal} from '~/components/modals/ChannelCreateModal';
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
import {ExternalLinkWarningModal} from '~/components/modals/ExternalLinkWarningModal';
import {InviteModal} from '~/components/modals/InviteModal';
import {Avatar} from '~/components/uikit/Avatar';
import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
import {CategoryContextMenu} from '~/components/uikit/ContextMenu/CategoryContextMenu';
import {ChannelContextMenu} from '~/components/uikit/ContextMenu/ChannelContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {MentionBadge} from '~/components/uikit/MentionBadge';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useConnectedVoiceSession} from '~/hooks/useConnectedVoiceSession';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {useTextOverflow} from '~/hooks/useTextOverflow';
import {useLocation} from '~/lib/router';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import AccessibilityStore, {ChannelTypingIndicatorMode} from '~/stores/AccessibilityStore';
import AuthenticationStore from '~/stores/AuthenticationStore';
import AutocompleteStore from '~/stores/AutocompleteStore';
import ChannelStore from '~/stores/ChannelStore';
import ContextMenuStore, {isContextMenuNodeTarget} from '~/stores/ContextMenuStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import ReadStateStore from '~/stores/ReadStateStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import TrustedDomainStore from '~/stores/TrustedDomainStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import {stopPropagationOnEnterSpace} from '~/utils/KeyboardUtils';
import {openExternalUrl} from '~/utils/NativeUtils';
import * as PermissionUtils from '~/utils/PermissionUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import styles from './ChannelItem.module.css';
import {ChannelItemIcon} from './ChannelItemIcon';
import channelItemSurfaceStyles from './ChannelItemSurface.module.css';
import type {ScrollIndicatorSeverity} from './ScrollIndicatorOverlay';
import {DND_TYPES, type DragItem, type DropResult} from './types/dnd';
import {isCategory, isTextChannel} from './utils/channelOrganization';
import {VoiceChannelUserCount} from './VoiceChannelUserCount';
export interface ChannelItemCoreProps {
channel: {
name: string;
type: number;
};
isSelected?: boolean;
typingIndicator?: React.ReactNode;
className?: string;
}
export const ChannelItemCore: React.FC<ChannelItemCoreProps> = observer(
({channel, isSelected = false, typingIndicator, className}) => {
const channelLabelRef = React.useRef<HTMLSpanElement>(null);
const isChannelNameOverflowing = useTextOverflow(channelLabelRef);
return (
<div
className={clsx(
styles.channelItemCore,
isSelected ? styles.channelItemCoreSelected : styles.channelItemCoreUnselected,
className,
)}
>
<Tooltip text={channel.name}>
<div>
{ChannelUtils.getIcon(channel, {
className: clsx(
styles.channelItemIcon,
isSelected ? styles.channelItemIconSelected : styles.channelItemIconUnselected,
),
})}
</div>
</Tooltip>
<Tooltip text={isChannelNameOverflowing ? channel.name : ''}>
<span ref={channelLabelRef} className={styles.channelItemLabel}>
{channel.name}
</span>
</Tooltip>
<div className={styles.channelItemActions}>{typingIndicator}</div>
</div>
);
},
);
export const ChannelItem = observer(
({
guild,
channel,
isCollapsed,
onToggle,
isDraggingAnything,
activeDragItem,
onChannelDrop,
onDragStateChange,
}: {
guild: GuildRecord;
channel: ChannelRecord;
isCollapsed?: boolean;
onToggle?: () => void;
isDraggingAnything: boolean;
activeDragItem?: DragItem | null;
onChannelDrop?: (item: DragItem, result: DropResult) => void;
onDragStateChange?: (item: DragItem | null) => void;
}) => {
const {t} = useLingui();
const elementRef = React.useRef<HTMLDivElement | null>(null);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const categoryNameRef = React.useRef<HTMLSpanElement>(null);
const channelNameRef = React.useRef<HTMLSpanElement>(null);
const isCategoryNameOverflowing = useTextOverflow(categoryNameRef);
const isChannelNameOverflowing = useTextOverflow(channelNameRef);
const channelIsCategory = isCategory(channel);
const channelIsVoice = channel.type === ChannelTypes.GUILD_VOICE;
const channelIsText = isTextChannel(channel);
const draggingChannel = activeDragItem?.type === DND_TYPES.CHANNEL ? activeDragItem : null;
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
const location = useLocation();
const channelPath = `/channels/${guild.id}/${channel.id}`;
const unreadCount = ReadStateStore.getUnreadCount(channel.id);
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(guild.id);
const {guildId: connectedVoiceGuildId, channelId: connectedVoiceChannelId} = useConnectedVoiceSession();
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, channel);
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
const mobileLayout = MobileLayoutStore;
const isMuted = UserGuildSettingsStore.isChannelMuted(guild.id, channel.id);
const voiceStatesInChannel = MediaEngineStore.getAllVoiceStatesInChannel(guild.id, channel.id);
const currentUserCount = Object.keys(voiceStatesInChannel).length;
const isMobileLayout = MobileLayoutStore.isMobileLayout();
const allowHoverAffordances = !isMobileLayout;
const [menuOpen, setMenuOpen] = useState(false);
const [voiceLobbyOpen, setVoiceLobbyOpen] = useState(false);
const lastClickTime = React.useRef<number>(0);
const voiceChannelJoinRequiresDoubleClick = AccessibilityStore.voiceChannelJoinRequiresDoubleClick;
const [isFocused, setIsFocused] = useState(false);
const {keyboardModeEnabled} = KeyboardModeStore;
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
const currentUserId = AuthenticationStore.currentUserId;
const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null;
const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut());
const voiceTooltipText =
channelIsVoice && isCurrentUserTimedOut ? t`You can't join while you're on timeout.` : undefined;
const isVoiceSelected =
channel.type === ChannelTypes.GUILD_VOICE &&
connectedVoiceGuildId === guild.id &&
connectedVoiceChannelId === channel.id;
const isSelected = isVoiceSelected || location.pathname.startsWith(channelPath) || selectedChannelId === channel.id;
const mentionCount = ReadStateStore.getMentionCount(channel.id);
const hasUnreadMessages = unreadCount > 0;
const isHighlight = mentionCount > 0 || hasUnreadMessages;
const scrollIndicatorSeverity: ScrollIndicatorSeverity | undefined = channelIsCategory
? undefined
: mentionCount > 0
? 'mention'
: hasUnreadMessages
? 'unread'
: undefined;
const scrollIndicatorId = `channel-${channel.id}`;
const isAutocompleteHighlight = AutocompleteStore.highlightChannelId === channel.id;
const typingUsers = usePresentableTypingUsers(channel);
const channelTypingIndicatorMode = AccessibilityStore.channelTypingIndicatorMode;
const showSelectedChannelTypingIndicator = AccessibilityStore.showSelectedChannelTypingIndicator;
const [dropIndicator, setDropIndicator] = React.useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(
null,
);
const dragItemData = React.useMemo<DragItem>(
() => ({
type: channelIsCategory ? DND_TYPES.CATEGORY : DND_TYPES.CHANNEL,
id: channel.id,
channelType: channel.type,
parentId: channel.parentId,
guildId: guild.id,
}),
[channelIsCategory, channel.id, channel.type, channel.parentId, guild.id],
);
const [{isDragging}, dragRef, preview] = useDrag(
() => ({
type: dragItemData.type,
item: () => {
onDragStateChange?.(dragItemData);
return dragItemData;
},
canDrag: canManageChannels && !mobileLayout.enabled,
collect: (monitor) => ({isDragging: monitor.isDragging()}),
end: () => {
onDragStateChange?.(null);
setDropIndicator(null);
},
}),
[dragItemData, canManageChannels, mobileLayout.enabled, onDragStateChange],
);
const [{isOver}, dropRef] = useDrop(
() => ({
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY, DND_TYPES.VOICE_PARTICIPANT],
canDrop: (item: DragItem) => {
if (item.id === channel.id) return false;
if (channelIsCategory && item.type === DND_TYPES.CHANNEL && item.parentId !== null) return false;
if (item.type === DND_TYPES.VOICE_PARTICIPANT) return channelIsVoice;
if (item.type === DND_TYPES.CHANNEL) {
if (item.channelType === ChannelTypes.GUILD_VOICE) {
if (!channelIsCategory && !channelIsVoice && !channelIsText) return false;
}
if (item.channelType !== ChannelTypes.GUILD_VOICE && channelIsVoice) return false;
}
if (item.type === DND_TYPES.CATEGORY && channel.parentId !== null && !channelIsCategory) return false;
return true;
},
hover: (_item: DragItem, monitor) => {
const node = elementRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
setDropIndicator({
position: hoverClientY < hoverMiddleY ? 'top' : 'bottom',
isValid: monitor.canDrop(),
});
},
drop: (item: DragItem, monitor): DropResult | undefined => {
if (!monitor.canDrop()) {
setDropIndicator(null);
return;
}
if (item.type === DND_TYPES.VOICE_PARTICIPANT && channelIsVoice) {
const canMove = PermissionStore.can(Permissions.MOVE_MEMBERS, {guildId: guild.id});
if (!canMove || item.currentChannelId === channel.id) {
setDropIndicator(null);
return;
}
const targetChannel = ChannelStore.getChannel(channel.id);
if (targetChannel) {
const canTargetConnect = PermissionUtils.can(Permissions.CONNECT, item.userId!, targetChannel.toJSON());
if (!canTargetConnect) {
setDropIndicator(null);
return;
}
}
void GuildMemberActionCreators.update(guild.id, item.userId!, {channel_id: channel.id});
setDropIndicator(null);
return;
}
const node = elementRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
let result: DropResult;
if (channelIsCategory) {
result = {
targetId: channel.id,
position: hoverClientY < hoverMiddleY ? 'before' : 'inside',
targetParentId: hoverClientY < hoverMiddleY ? channel.parentId : channel.id,
};
} else {
result = {
targetId: channel.id,
position: hoverClientY < hoverMiddleY ? 'before' : 'after',
targetParentId: channel.parentId,
};
}
onChannelDrop?.(item, result);
setDropIndicator(null);
return result;
},
collect: (monitor) => ({
isOver: monitor.isOver({shallow: true}),
canDrop: monitor.canDrop(),
}),
}),
[channel.id, channel.type, channel.parentId, guild.id, channelIsCategory, channelIsVoice, onChannelDrop],
);
React.useEffect(() => {
if (!isOver) setDropIndicator(null);
}, [isOver]);
React.useEffect(() => {
preview(getEmptyImage(), {captureDraggingState: true});
}, [preview]);
React.useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenuStore.contextMenu;
const contextMenuTarget = contextMenu?.target?.target ?? null;
const element = elementRef.current;
const isOpen =
Boolean(contextMenu) &&
isContextMenuNodeTarget(contextMenuTarget) &&
Boolean(element?.contains(contextMenuTarget));
setContextMenuOpen(!!isOpen);
});
return () => disposer();
}, []);
const handleSelect = useCallback(() => {
if (channel.type === ChannelTypes.GUILD_VOICE && isCurrentUserTimedOut) {
ToastActionCreators.createToast({
type: 'error',
children: t`You can't join while you're on timeout.`,
});
return;
}
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
onToggle?.();
return;
}
if (channel.type === ChannelTypes.GUILD_LINK && channel.url) {
try {
const parsed = new URL(channel.url);
const isTrusted = TrustedDomainStore.isTrustedDomain(parsed.hostname);
if (!isTrusted) {
ModalActionCreators.push(
modal(() => <ExternalLinkWarningModal url={channel.url!} hostname={parsed.hostname} />),
);
} else {
void openExternalUrl(channel.url);
}
} catch {}
return;
}
if (channel.type === ChannelTypes.GUILD_VOICE) {
if (isMobileLayout) {
setVoiceLobbyOpen(true);
return;
}
if (isVoiceSelected) {
RouterUtils.transitionTo(channelPath);
if (MobileLayoutStore.isMobileLayout()) {
LayoutActionCreators.updateMobileLayoutState(false, true);
}
} else {
if (voiceChannelJoinRequiresDoubleClick) {
const now = Date.now();
const timeSinceLastClick = now - lastClickTime.current;
lastClickTime.current = now;
if (timeSinceLastClick < 500) {
void MediaEngineStore.connectToVoiceChannel(guild.id, channel.id);
} else {
RouterUtils.transitionTo(channelPath);
if (MobileLayoutStore.isMobileLayout()) {
LayoutActionCreators.updateMobileLayoutState(false, true);
}
}
} else {
void MediaEngineStore.connectToVoiceChannel(guild.id, channel.id);
}
}
return;
}
RouterUtils.transitionTo(channelPath);
if (MobileLayoutStore.isMobileLayout()) {
LayoutActionCreators.updateMobileLayoutState(false, true);
}
}, [
channel,
channelPath,
guild.id,
isVoiceSelected,
onToggle,
isMobileLayout,
voiceChannelJoinRequiresDoubleClick,
]);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (isMobileLayout) {
return;
}
ContextMenuActionCreators.openFromEvent(event, ({onClose}) =>
channelIsCategory ? (
<CategoryContextMenu category={channel} onClose={onClose} />
) : (
<ChannelContextMenu channel={channel} onClose={onClose} />
),
);
},
[channel, channelIsCategory, isMobileLayout],
);
const dragConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dragRef(node);
},
[dragRef],
);
const dropConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
},
[dropRef],
);
const mergedRef = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]);
const shouldShowSelectedState =
!channelIsCategory &&
isSelected &&
(channel.type !== ChannelTypes.GUILD_VOICE || location.pathname.startsWith(channelPath));
const hasMountedRef = React.useRef(false);
React.useEffect(() => {
if (shouldShowSelectedState && hasMountedRef.current) {
elementRef.current?.scrollIntoView({block: 'nearest'});
}
hasMountedRef.current = true;
}, [shouldShowSelectedState]);
const channelItem = (
<GenericChannelItem
innerRef={mergedRef}
containerClassName={styles.container}
extraContent={hasUnreadMessages && <div className={styles.unreadIndicator} />}
isOver={isOver}
dropIndicator={dropIndicator}
disabled={!isMobileLayout}
data-dnd-name={channel.name}
dataScrollIndicator={scrollIndicatorSeverity}
dataScrollId={scrollIndicatorId}
aria-label={`${channel.name} ${channelIsCategory ? 'category' : 'channel'}`}
className={clsx(
styles.channelItem,
channelItemSurfaceStyles.channelItemSurface,
shouldShowSelectedState && channelItemSurfaceStyles.channelItemSurfaceSelected,
isAutocompleteHighlight && styles.channelItemAutocompleteHighlight,
channelIsCategory ? styles.channelItemCategory : styles.channelItemRegular,
!channelIsCategory && isHighlight && !shouldShowSelectedState && styles.channelItemHighlight,
!channelIsCategory && !(isHighlight || isSelected || isVoiceSelected) && styles.channelItemMuted,
shouldShowSelectedState && styles.channelItemSelected,
shouldShowSelectedState && isHighlight && styles.channelItemSelectedWithUnread,
!channelIsCategory &&
(!isSelected ||
(channel.type === ChannelTypes.GUILD_VOICE && !location.pathname.startsWith(channelPath))) &&
styles.channelItemHoverable,
isOver && styles.channelItemOver,
contextMenuOpen && !isSelected && !channelIsCategory && styles.channelItemContextMenu,
contextMenuOpen && channelIsCategory && styles.channelItemCategoryContextMenu,
isDragging && styles.channelItemDragging,
shouldDimForVoiceDrag && !isSelected && styles.channelItemDimmed,
isMuted && styles.channelItemMutedState,
contextMenuOpen && styles.contextMenuOpen,
showKeyboardAffordances && styles.keyboardFocus,
channelIsVoice && styles.channelItemVoice,
)}
onClick={handleSelect}
onContextMenu={handleContextMenu}
onKeyDown={(e) => e.key === 'Enter' && handleSelect()}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onLongPress={() => {
if (isMobileLayout) setMenuOpen(true);
}}
>
{!channelIsCategory && (
<Tooltip text={ChannelUtils.getName(channel)}>
<div>
{ChannelUtils.getIcon(channel, {
className: clsx(
styles.channelItemIcon,
shouldShowSelectedState || (isHighlight && isSelected)
? styles.channelItemIconSelected
: isVoiceSelected && channel.type === ChannelTypes.GUILD_VOICE
? styles.channelItemHighlight
: isHighlight && !isSelected
? styles.channelItemIconHighlight
: styles.channelItemIconUnselected,
),
})}
</div>
</Tooltip>
)}
{channelIsCategory ? (
<div className={styles.categoryContent}>
<Tooltip text={isCategoryNameOverflowing && channel.name ? channel.name : ''}>
<span ref={categoryNameRef} className={styles.categoryName}>
{channel.name ?? ''}
</span>
</Tooltip>
<CaretDownIcon
weight="bold"
className={styles.categoryIcon}
style={{transform: `rotate(${isCollapsed ? -90 : 0}deg)`}}
/>
</div>
) : (
<Tooltip text={isChannelNameOverflowing && channel.name ? channel.name : ''}>
<span ref={channelNameRef} className={styles.channelName}>
{channel.name ?? ''}
</span>
</Tooltip>
)}
{!isDraggingAnything && (
<div className={styles.channelItemActions}>
{!channelIsCategory && channel.type !== ChannelTypes.GUILD_VOICE && (
<>
{typingUsers.length > 0 &&
channelTypingIndicatorMode !== ChannelTypingIndicatorMode.HIDDEN &&
(showSelectedChannelTypingIndicator || !isSelected) && (
<Tooltip
text={() => (
<span className={styles.typingTooltip}>{getTypingText(t, typingUsers, channel)}</span>
)}
>
<div className={styles.channelTypingIndicator}>
<Typing className={styles.typingIndicatorIcon} size={20} />
{channelTypingIndicatorMode === ChannelTypingIndicatorMode.AVATARS && (
<AvatarStack size={12} maxVisible={5} className={styles.typingAvatars}>
{typingUsers.map((user) => (
<Avatar key={user.id} user={user} size={12} guildId={channel.guildId} />
))}
</AvatarStack>
)}
</div>
</Tooltip>
)}
{!isSelected && <MentionBadge mentionCount={mentionCount} size="small" />}
</>
)}
{channelIsVoice && channel.userLimit != null && channel.userLimit > 0 && (
<div className={styles.voiceUserCount}>
<VoiceChannelUserCount currentUserCount={currentUserCount} userLimit={channel.userLimit} />
</div>
)}
{allowHoverAffordances && canInvite && !channelIsCategory && (
<div className={styles.hoverAffordance}>
<ChannelItemIcon
icon={UserPlusIcon}
label={t`Invite Members`}
selected={shouldShowSelectedState}
onClick={() => ModalActionCreators.push(modal(() => <InviteModal channelId={channel.id} />))}
/>
</div>
)}
{allowHoverAffordances && channelIsCategory && canManageChannels && (
<div className={styles.hoverAffordance}>
<Tooltip text={t`Create Channel`}>
<FocusRing offset={-2}>
<button
type="button"
className={styles.createChannelButton}
onClick={(e) => {
e.stopPropagation();
ModalActionCreators.push(
modal(() => <ChannelCreateModal guildId={guild.id} parentId={channel.id} />),
);
}}
onKeyDown={stopPropagationOnEnterSpace}
>
<PlusIcon weight="bold" className={styles.createChannelIcon} />
</button>
</FocusRing>
</Tooltip>
</div>
)}
{allowHoverAffordances && canManageChannels && (
<div className={styles.hoverAffordance}>
<ChannelItemIcon
icon={GearIcon}
label={channelIsCategory ? t`Edit Category` : t`Channel Settings`}
selected={shouldShowSelectedState}
onClick={() => ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={channel.id} />))}
/>
</div>
)}
</div>
)}
</GenericChannelItem>
);
const channelWrapper = voiceTooltipText ? (
<Tooltip text={voiceTooltipText} position="top">
{channelItem}
</Tooltip>
) : (
channelItem
);
return (
<>
{channelWrapper}
{isMobileLayout && (
<ChannelBottomSheet isOpen={menuOpen} onClose={() => setMenuOpen(false)} channel={channel} guild={guild} />
)}
{isMobileLayout && channel.type === ChannelTypes.GUILD_VOICE && (
<VoiceLobbyBottomSheet
isOpen={voiceLobbyOpen}
onClose={() => setVoiceLobbyOpen(false)}
channel={channel}
guild={guild}
/>
)}
</>
);
},
);

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.iconButton {
display: flex;
height: 1rem;
width: 1rem;
cursor: pointer;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: none;
background-color: transparent;
padding: 0;
transition: color 200ms;
}
.iconButtonDefault {
color: var(--text-primary-muted);
cursor: pointer;
}
.iconButtonDefault:hover {
color: var(--text-primary);
}
.iconButtonSelected {
color: var(--surface-interactive-selected-color);
cursor: pointer;
}
.iconButtonSelected:hover {
color: var(--surface-interactive-selected-color);
}
.icon {
height: 1rem;
width: 1rem;
}
.iconFocusRing {
border-radius: 9999px;
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Icon} from '@phosphor-icons/react';
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 {stopPropagationOnEnterSpace} from '~/utils/KeyboardUtils';
import styles from './ChannelItemIcon.module.css';
interface ChannelItemIconProps {
icon: Icon;
label: string;
onClick?: () => void;
className?: string;
selected?: boolean;
}
export const ChannelItemIcon = observer(
({icon: Icon, label, onClick, className, selected = false}: ChannelItemIconProps) => {
return (
<Tooltip text={label}>
<FocusRing offset={-2} ringClassName={styles.iconFocusRing}>
<button
type="button"
className={clsx(
styles.iconButton,
selected ? styles.iconButtonSelected : styles.iconButtonDefault,
className,
)}
aria-label={label}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
onKeyDown={stopPropagationOnEnterSpace}
>
<Icon className={styles.icon} />
</button>
</FocusRing>
</Tooltip>
);
},
);

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.channelItemSurface {
--channel-item-hover-background: var(--surface-interactive-hover-bg);
--channel-item-selected-background: var(--surface-interactive-selected-bg);
--background-modifier-hover: var(--channel-item-hover-background);
--background-modifier-selected: var(--channel-item-selected-background);
}
.channelItemSurfaceSelected {
color: var(--surface-interactive-selected-color);
}
.channelItemFocusRing {
border-radius: 0.5rem;
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.channelListScroller {
background-color: var(--background-secondary);
}
.channelListScrollerWrapper {
position: relative;
width: 100%;
min-height: 0;
min-width: 0;
}
.navigationContainer {
width: 100%;
min-width: 0;
min-height: 100%;
}
.topDropZone {
position: relative;
height: 0.625rem;
}
.channelGroupsContainer {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.channelGroup {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.bottomDropZone {
position: relative;
height: 0.625rem;
}
.bottomSpacer {
height: 0.5rem;
}

View File

@@ -0,0 +1,408 @@
/*
* 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 type {MotionValue} from 'motion';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as DimensionActionCreators from '~/actions/DimensionActionCreators';
import * as GuildActionCreators from '~/actions/GuildActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {APIErrorCodes, MAX_CHANNELS_PER_CATEGORY} from '~/Constants';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ChannelListContextMenu} from '~/components/uikit/ContextMenu/ChannelListContextMenu';
import type {ScrollerHandle} from '~/components/uikit/Scroller';
import {Scroller} from '~/components/uikit/Scroller';
import {ChannelListScrollbarProvider} from '~/contexts/ChannelListScrollbarContext';
import {HttpError} from '~/lib/HttpError';
import {useLocation} from '~/lib/router';
import type {GuildRecord} from '~/records/GuildRecord';
import ChannelStore from '~/stores/ChannelStore';
import DimensionStore from '~/stores/DimensionStore';
import ReadStateStore from '~/stores/ReadStateStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import {ChannelItem} from './ChannelItem';
import styles from './ChannelListContent.module.css';
import {CollapsedCategoryVoiceParticipants, CollapsedChannelAvatarStack} from './CollapsedCategoryVoiceParticipants';
import {GuildDetachedBanner} from './GuildDetachedBanner';
import {NullSpaceDropIndicator} from './NullSpaceDropIndicator';
import {ScrollIndicatorOverlay} from './ScrollIndicatorOverlay';
import type {DragItem, DropResult} from './types/dnd';
import {createChannelMoveOperation} from './utils/channelMoveOperation';
import {organizeChannels} from './utils/channelOrganization';
import {VoiceParticipantsList} from './VoiceParticipantsList';
const mergeUniqueById = <T extends {id: string}>(items: ReadonlyArray<T>): Array<T> => {
const seen = new Set<string>();
const unique: Array<T> = [];
for (const item of items) {
if (seen.has(item.id)) {
continue;
}
seen.add(item.id);
unique.push(item);
}
return unique;
};
export const ChannelListContent = observer(({guild, scrollY}: {guild: GuildRecord; scrollY: MotionValue<number>}) => {
const {t} = useLingui();
const channels = ChannelStore.getGuildChannels(guild.id);
const location = useLocation();
const userGuildSettings = UserGuildSettingsStore.getSettings(guild.id);
const [isDraggingAnything, setIsDraggingAnything] = React.useState(false);
const [activeDragItem, setActiveDragItem] = React.useState<DragItem | null>(null);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const stickToBottomRef = React.useRef(false);
const hasScrollbar = true;
const connectedChannelId = MediaEngineStore.channelId;
const hideMutedChannels = userGuildSettings?.hide_muted_channels ?? false;
const collapsedCategories = React.useMemo(() => {
const collapsed = new Set<string>();
if (userGuildSettings?.channel_overrides) {
for (const [channelId, override] of Object.entries(userGuildSettings.channel_overrides)) {
if (override.collapsed) collapsed.add(channelId);
}
}
return collapsed;
}, [userGuildSettings]);
const toggleCategory = React.useCallback(
(categoryId: string) => {
UserGuildSettingsActionCreators.toggleChannelCollapsed(guild.id, categoryId);
},
[guild.id],
);
const channelGroups = React.useMemo(() => organizeChannels(channels), [channels]);
const showTrailingDropZone = channelGroups.length > 0;
const channelIndicatorDependencies = React.useMemo(
() => [channels.length, ReadStateStore.version],
[channels.length, ReadStateStore.version],
);
const getChannelScrollContainer = React.useCallback(
() => scrollerRef.current?.getScrollerNode() ?? null,
[scrollerRef],
);
const handleChannelDrop = React.useCallback(
(item: DragItem, result: DropResult) => {
if (!result) return;
const guildChannels = ChannelStore.getGuildChannels(guild.id);
const operation = createChannelMoveOperation({
channels: guildChannels,
dragItem: item,
dropResult: result,
});
if (!operation) return;
void (async () => {
try {
await GuildActionCreators.moveChannel(guild.id, operation);
} catch (error) {
if (error instanceof HttpError) {
const body = error.body as {code?: string} | undefined;
if (body?.code === APIErrorCodes.MAX_CATEGORY_CHANNELS) {
ModalActionCreators.push(
ModalActionCreators.modal(() => (
<ConfirmModal
title={t`Category full`}
description={t`This category already contains the maximum of ${MAX_CHANNELS_PER_CATEGORY} channels.`}
primaryText={t`Understood`}
onPrimary={() => {}}
/>
)),
);
return;
}
}
throw error;
}
})();
},
[guild.id],
);
React.useEffect(() => {
const handleDragStart = () => setIsDraggingAnything(true);
const handleDragEnd = () => setIsDraggingAnything(false);
document.addEventListener('dragstart', handleDragStart);
document.addEventListener('dragend', handleDragEnd);
return () => {
document.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('dragend', handleDragEnd);
};
}, []);
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const scrollTop = event.currentTarget.scrollTop;
const scrollHeight = event.currentTarget.scrollHeight;
const offsetHeight = event.currentTarget.offsetHeight;
stickToBottomRef.current = scrollHeight - (scrollTop + offsetHeight) <= 8;
scrollY.set(scrollTop);
DimensionActionCreators.updateChannelListScroll(guild.id, scrollTop);
},
[scrollY, guild.id],
);
const handleResize = React.useCallback((_entry: ResizeObserverEntry, type: 'container' | 'content') => {
if (type !== 'content') return;
if (stickToBottomRef.current && scrollerRef.current) {
scrollerRef.current.scrollToBottom({animate: false});
}
}, []);
React.useEffect(() => {
const guildDimensions = DimensionStore.getGuildDimensions(guild.id);
if (guildDimensions.scrollTo) {
const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`);
if (element && scrollerRef.current) {
scrollerRef.current.scrollIntoViewNode({node: element as HTMLElement, shouldScrollToStart: false});
}
DimensionActionCreators.clearChannelListScrollTo(guild.id);
} else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) {
scrollerRef.current.scrollTo({to: guildDimensions.scrollTop, animate: false});
}
}, [guild.id]);
const handleContextMenu = React.useCallback(
(event: React.MouseEvent) => {
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<ChannelListContextMenu guild={guild} onClose={onClose} />
));
},
[guild],
);
return (
<ChannelListScrollbarProvider value={{hasScrollbar}}>
<div className={styles.channelListScrollerWrapper}>
<Scroller
ref={scrollerRef}
className={styles.channelListScroller}
onScroll={handleScroll}
onResize={handleResize}
key={guild.id}
>
<div className={styles.navigationContainer} onContextMenu={handleContextMenu} role="navigation">
<GuildDetachedBanner guild={guild} />
<div className={styles.topDropZone}>
<NullSpaceDropIndicator
isDraggingAnything={isDraggingAnything}
onChannelDrop={handleChannelDrop}
variant="top"
/>
</div>
<div className={styles.channelGroupsContainer}>
{channelGroups.map((group) => {
const isCollapsed = group.category ? collapsedCategories.has(group.category.id) : false;
const isNullSpace = !group.category;
const selectedTextChannels = group.textChannels.filter((ch) =>
location.pathname.startsWith(`/channels/${guild.id}/${ch.id}`),
);
const selectedVoiceChannels = group.voiceChannels.filter((ch) =>
location.pathname.startsWith(`/channels/${guild.id}/${ch.id}`),
);
const unreadTextChannels = group.textChannels.filter((ch) => {
const unreadCount = ReadStateStore.getUnreadCount(ch.id);
const mentionCount = ReadStateStore.getMentionCount(ch.id);
return unreadCount > 0 || mentionCount > 0;
});
const unreadVoiceChannels = group.voiceChannels.filter((ch) => {
const unreadCount = ReadStateStore.getUnreadCount(ch.id);
const mentionCount = ReadStateStore.getMentionCount(ch.id);
return unreadCount > 0 || mentionCount > 0;
});
const selectedTextIds = new Set(selectedTextChannels.map((ch) => ch.id));
const selectedVoiceIds = new Set(selectedVoiceChannels.map((ch) => ch.id));
const filteredTextChannels = hideMutedChannels
? group.textChannels.filter(
(ch) =>
selectedTextIds.has(ch.id) || !UserGuildSettingsStore.isGuildOrChannelMuted(guild.id, ch.id),
)
: group.textChannels;
const filteredVoiceChannels = hideMutedChannels
? group.voiceChannels.filter(
(ch) =>
selectedVoiceIds.has(ch.id) ||
ch.id === connectedChannelId ||
!UserGuildSettingsStore.isGuildOrChannelMuted(guild.id, ch.id),
)
: group.voiceChannels;
const visibleTextChannels = isCollapsed
? hideMutedChannels
? mergeUniqueById(filteredTextChannels.filter((ch) => selectedTextIds.has(ch.id)))
: mergeUniqueById([...selectedTextChannels, ...unreadTextChannels])
: filteredTextChannels;
let visibleVoiceChannels: typeof filteredVoiceChannels = filteredVoiceChannels;
if (isCollapsed) {
if (hideMutedChannels) {
const collapsedVoiceChannels: typeof filteredVoiceChannels = [];
if (connectedChannelId) {
const connected = filteredVoiceChannels.find((ch) => ch.id === connectedChannelId);
if (connected) collapsedVoiceChannels.push(connected);
}
for (const ch of filteredVoiceChannels) {
if (selectedVoiceIds.has(ch.id)) {
collapsedVoiceChannels.push(ch);
}
}
visibleVoiceChannels = mergeUniqueById(collapsedVoiceChannels);
} else {
visibleVoiceChannels = mergeUniqueById([...selectedVoiceChannels, ...unreadVoiceChannels]);
}
}
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
return null;
}
if (
hideMutedChannels &&
group.category &&
filteredTextChannels.length === 0 &&
filteredVoiceChannels.length === 0
) {
return null;
}
const connectedChannelInGroup =
isCollapsed && connectedChannelId
? filteredVoiceChannels.some((vc) => vc.id === connectedChannelId)
: false;
if (isCollapsed && connectedChannelInGroup && connectedChannelId) {
const hasIt = visibleVoiceChannels.some((c) => c.id === connectedChannelId);
if (!hasIt) {
const connected = filteredVoiceChannels.find((c) => c.id === connectedChannelId);
if (connected) visibleVoiceChannels = [connected, ...visibleVoiceChannels];
} else {
visibleVoiceChannels = [
...visibleVoiceChannels.filter((c) => c.id === connectedChannelId),
...visibleVoiceChannels.filter((c) => c.id !== connectedChannelId),
];
}
}
const showTextChannels = !isCollapsed || visibleTextChannels.length > 0;
const showVoiceChannels = !isCollapsed || visibleVoiceChannels.length > 0;
return (
<div key={group.category?.id || 'null-space'} className={styles.channelGroup}>
{group.category && (
<ChannelItem
guild={guild}
channel={group.category}
isCollapsed={isCollapsed}
onToggle={() => toggleCategory(group.category!.id)}
isDraggingAnything={isDraggingAnything}
activeDragItem={activeDragItem}
onChannelDrop={handleChannelDrop}
onDragStateChange={setActiveDragItem}
/>
)}
{isCollapsed && group.category && !connectedChannelInGroup && (
<CollapsedCategoryVoiceParticipants guild={guild} voiceChannels={filteredVoiceChannels} />
)}
{showTextChannels &&
visibleTextChannels.map((ch) => (
<ChannelItem
key={ch.id}
guild={guild}
channel={ch}
isDraggingAnything={isDraggingAnything}
activeDragItem={activeDragItem}
onChannelDrop={handleChannelDrop}
onDragStateChange={setActiveDragItem}
/>
))}
{showVoiceChannels &&
visibleVoiceChannels.map((ch) => {
const channelRow = (
<ChannelItem
key={ch.id}
guild={guild}
channel={ch}
isDraggingAnything={isDraggingAnything}
activeDragItem={activeDragItem}
onChannelDrop={handleChannelDrop}
onDragStateChange={setActiveDragItem}
/>
);
if (isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
return (
<React.Fragment key={ch.id}>
{channelRow}
<CollapsedChannelAvatarStack guild={guild} channel={ch} />
</React.Fragment>
);
}
return (
<React.Fragment key={ch.id}>
{channelRow}
{!isCollapsed && <VoiceParticipantsList guild={guild} channel={ch} />}
</React.Fragment>
);
})}
</div>
);
})}
</div>
{showTrailingDropZone && (
<div className={styles.bottomDropZone}>
<NullSpaceDropIndicator
isDraggingAnything={isDraggingAnything}
onChannelDrop={handleChannelDrop}
variant="bottom"
/>
</div>
)}
<div className={styles.bottomSpacer} />
</div>
</Scroller>
<ScrollIndicatorOverlay
getScrollContainer={getChannelScrollContainer}
dependencies={channelIndicatorDependencies}
label={t`New Messages`}
/>
</div>
</ChannelListScrollbarProvider>
);
});

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type React from 'react';
import styles from './GuildNavbarSkeleton.module.css';
export const ChannelListSkeleton: React.FC = () => {
return (
<div className={styles.skeletonContent}>
<div className={styles.skeletonCategory}>
<div className={styles.skeletonCategoryPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonCategory}>
<div className={styles.skeletonCategoryPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonCategory}>
<div className={styles.skeletonCategoryPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
margin-left: 0.5rem;
margin-right: 0.5rem;
display: flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
color: var(--text-primary-muted);
}
.icon {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.channelContainer {
margin-top: 0.25rem;
margin-right: 0.5rem;
margin-left: 1.5rem;
display: flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,87 @@
/*
* 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 {SpeakerHighIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
import {StackUserAvatar} from '~/components/uikit/avatars/StackUserAvatar';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import styles from './CollapsedCategoryVoiceParticipants.module.css';
export const CollapsedCategoryVoiceParticipants = observer(
({guild, voiceChannels}: {guild: GuildRecord; voiceChannels: Array<ChannelRecord>}) => {
const allVoiceStates = MediaEngineStore.getAllVoiceStates();
const userIds = React.useMemo(() => {
const ids = new Set<string>();
for (const channel of voiceChannels) {
const states = allVoiceStates[guild.id]?.[channel.id];
if (!states) continue;
for (const s of Object.values(states)) ids.add(s.user_id);
}
return Array.from(ids).sort((a, b) => a.localeCompare(b));
}, [voiceChannels, allVoiceStates, guild.id]);
if (userIds.length === 0) return null;
const firstChannelForUser = (uid: string): ChannelRecord | undefined =>
voiceChannels.find((ch) => {
const states = allVoiceStates[guild.id]?.[ch.id];
return !!states && Object.values(states).some((s) => s.user_id === uid);
});
return (
<div className={styles.container}>
<SpeakerHighIcon className={styles.icon} />
<AvatarStack size={28} maxVisible={7}>
{userIds.map((uid) => {
const ch = firstChannelForUser(uid);
if (!ch) return null;
return <StackUserAvatar key={uid} guild={guild} channel={ch} userId={uid} />;
})}
</AvatarStack>
</div>
);
},
);
export const CollapsedChannelAvatarStack = observer(
({guild, channel}: {guild: GuildRecord; channel: ChannelRecord}) => {
const channelStates = MediaEngineStore.getAllVoiceStatesInChannel(guild.id, channel.id);
const uniqueUserIds = React.useMemo(() => {
const set = new Set<string>();
for (const s of Object.values(channelStates)) set.add(s.user_id);
return Array.from(set);
}, [channelStates]);
return (
<div className={styles.channelContainer}>
<AvatarStack size={28} maxVisible={10}>
{uniqueUserIds.map((uid) => (
<StackUserAvatar key={uid} guild={guild} channel={channel} userId={uid} />
))}
</AvatarStack>
</div>
);
},
);

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type React from 'react';
import KeyboardBackend, {isKeyboardDragTrigger} from 'react-dnd-accessible-backend';
import {HTML5Backend} from 'react-dnd-html5-backend';
import {createTransition, DndProvider, MouseTransition} from 'react-dnd-multi-backend';
const KeyboardTransition = createTransition('keydown', (event: Event) => {
if (!isKeyboardDragTrigger(event as KeyboardEvent)) return false;
event.preventDefault();
return true;
});
const DND_OPTIONS = {
backends: [
{
id: 'html5',
backend: HTML5Backend,
transition: MouseTransition,
},
{
id: 'keyboard',
backend: KeyboardBackend,
context: {window, document},
preview: true,
transition: KeyboardTransition,
},
],
};
interface DndContextProps {
children: React.ReactNode;
}
export const DndContext = ({children}: DndContextProps) => {
return <DndProvider options={DND_OPTIONS}>{children}</DndProvider>;
};

View File

@@ -0,0 +1,44 @@
/*
* 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/>.
*/
.dropIndicator {
position: absolute;
right: 0;
left: 0;
height: 0.125rem;
border-radius: 9999px;
transition: background-color 150ms;
}
.dropIndicatorTop {
top: -0.125rem;
}
.dropIndicatorBottom {
bottom: -0.125rem;
}
.dropIndicatorValid {
background-color: var(--brand-primary);
}
.dropIndicatorInvalid {
background-color: var(--text-primary-muted);
opacity: 0.6;
}

View File

@@ -0,0 +1,32 @@
/*
* 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 './DropIndicator.module.css';
export const DropIndicator = observer(({position, isValid = true}: {position: 'top' | 'bottom'; isValid?: boolean}) => (
<div
className={clsx(
styles.dropIndicator,
position === 'top' ? styles.dropIndicatorTop : styles.dropIndicatorBottom,
isValid ? styles.dropIndicatorValid : styles.dropIndicatorInvalid,
)}
/>
));

View File

@@ -0,0 +1,322 @@
/*
* 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/>.
*/
.channelBadgeSelected {
background-color: var(--background-primary);
}
.channelBadgeSelectedIcon {
color: var(--surface-interactive-selected-color);
}
:global(.theme-light) .channelBadgeSelected {
background-color: var(--brand-primary);
}
:global(.theme-light) .channelBadgeSelectedIcon {
color: #fff;
}
.notFoundItem {
margin-left: 0.5rem;
margin-right: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
color: var(--text-tertiary);
opacity: 0.5;
}
.notFoundIcon {
height: 1.25rem;
width: 1.25rem;
}
.notFoundText {
flex: 1;
font-size: 0.875rem;
}
.favoriteItemContainer {
position: relative;
}
.favoriteItem {
position: relative;
margin-left: 0.5rem;
margin-right: 0.5rem;
display: flex;
min-width: 0;
flex: 1;
cursor: pointer;
align-items: center;
gap: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
}
.favoriteItemDefault {
background-color: transparent;
color: var(--text-primary-muted);
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.favoriteItemDefault:hover {
background-color: var(--background-modifier-hover);
color: var(--text-chat);
}
}
.favoriteItemPressed {
background-color: var(--background-modifier-hover);
color: var(--text-chat);
}
.favoriteItemSelected {
background-color: var(--background-modifier-selected);
}
.favoriteItemOver {
background-color: color-mix(in srgb, var(--brand-primary) 20%, transparent);
}
.favoriteItemMuted {
color: var(--text-tertiary-muted);
opacity: 0.5;
}
.avatarContainer {
position: relative;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
}
.avatar {
height: 1.5rem;
width: 1.5rem;
border-radius: 9999px;
object-fit: cover;
}
.avatarPlaceholder {
display: flex;
height: 1.5rem;
width: 1.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: white;
font-size: 0.75rem;
font-weight: 600;
color: var(--brand-primary);
}
.channelBadge {
position: absolute;
bottom: -0.125rem;
right: -0.125rem;
display: flex;
height: 0.875rem;
width: 0.875rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--background-primary);
padding: 0.0625rem;
}
.channelBadgeIcon {
height: 0.75rem;
width: 0.75rem;
color: var(--text-primary-muted);
}
.displayName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1rem;
font-weight: 500;
line-height: 1.25rem;
max-height: 1.25rem;
}
.actionsContainer {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.25rem;
}
.categoryItem {
position: relative;
margin-left: 0.5rem;
margin-right: 0.5rem;
margin-top: 0.25rem;
display: flex;
min-width: 0;
flex: 1;
cursor: pointer;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
color: var(--text-primary-muted);
}
@media (hover: hover) and (pointer: fine) {
.categoryItem:hover {
color: var(--text-primary);
}
}
.categoryContent {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.25rem;
}
.categoryName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25rem;
max-height: 1.25rem;
}
.categoryIcon {
height: 0.75rem;
width: 0.75rem;
flex-shrink: 0;
color: var(--text-primary-muted);
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.categoryActions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.25rem;
}
.hoverAffordance {
display: none;
}
.addButton {
display: flex;
height: 1rem;
width: 1rem;
cursor: pointer;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: none;
background-color: transparent;
padding: 0;
color: var(--text-primary-muted);
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.addButton:hover {
color: var(--text-primary);
}
.addButtonIcon {
height: 1rem;
width: 1rem;
}
.navigationContainer {
width: 100%;
min-width: 0;
min-height: 100%;
}
.channelGroupsContainer {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.uncategorizedGroup {
position: relative;
min-height: 0.5rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.emptyStateContainer {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.emptyStateTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-tertiary);
}
.emptyStateDescription {
max-width: 24rem;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.favoriteItemFavoriteItemSelected .hoverAffordance,
.favoriteItem.keyboardFocus .hoverAffordance,
.categoryItem.keyboardFocus .categoryActions .hoverAffordance {
display: flex;
}
@media (hover: hover) and (pointer: fine) {
.favoriteItem:hover .hoverAffordance,
.categoryItem:hover .categoryActions .hoverAffordance {
display: flex;
}
}

View File

@@ -0,0 +1,556 @@
/*
* 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, HashIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {ConnectableElement} from 'react-dnd';
import {useDrag, useDrop} from 'react-dnd';
import {getEmptyImage} from 'react-dnd-html5-backend';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ME} from '~/Constants';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {ChannelItemIcon} from '~/components/layout/ChannelItemIcon';
import {ChannelListSkeleton} from '~/components/layout/ChannelListSkeleton';
import {GenericChannelItem} from '~/components/layout/GenericChannelItem';
import {AddFavoriteChannelModal} from '~/components/modals/AddFavoriteChannelModal';
import {InviteModal} from '~/components/modals/InviteModal';
import {FavoritesCategoryContextMenu} from '~/components/uikit/ContextMenu/FavoritesCategoryContextMenu';
import {FavoritesChannelContextMenu} from '~/components/uikit/ContextMenu/FavoritesChannelContextMenu';
import {FavoritesChannelListContextMenu} from '~/components/uikit/ContextMenu/FavoritesChannelListContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {MentionBadge} from '~/components/uikit/MentionBadge';
import {Scroller} from '~/components/uikit/Scroller';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import ChannelStore from '~/stores/ChannelStore';
import FavoritesStore, {type FavoriteChannel} from '~/stores/FavoritesStore';
import GuildStore from '~/stores/GuildStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import ReadStateStore from '~/stores/ReadStateStore';
import TypingStore from '~/stores/TypingStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import UserStore from '~/stores/UserStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import channelItemSurfaceStyles from './ChannelItemSurface.module.css';
import styles from './ChannelListContent.module.css';
import favoritesChannelListStyles from './FavoritesChannelListContent.module.css';
const DND_TYPES = {
FAVORITES_CHANNEL: 'favorites-channel',
FAVORITES_CATEGORY: 'favorites-category',
} as const;
interface DragItem {
type: string;
channelId: string;
parentId: string | null;
}
interface FavoriteChannelGroup {
category: {id: string; name: string} | null;
channels: Array<{
favoriteChannel: FavoriteChannel;
channel: ChannelRecord | null;
guild: GuildRecord | null;
}>;
}
const FavoriteChannelItem = observer(
({
favoriteChannel,
channel,
guild,
}: {
favoriteChannel: FavoriteChannel;
channel: ChannelRecord | null;
guild: GuildRecord | null;
}) => {
const {t} = useLingui();
const elementRef = React.useRef<HTMLDivElement | null>(null);
const [dropIndicator, setDropIndicator] = React.useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(
null,
);
const location = useLocation();
const isSelected = location.pathname === Routes.favoritesChannel(favoriteChannel.channelId);
const shouldShowSelectedState = isSelected;
React.useEffect(() => {
if (isSelected) {
elementRef.current?.scrollIntoView({block: 'nearest'});
}
}, [isSelected]);
const [isFocused, setIsFocused] = React.useState(false);
const {keyboardModeEnabled} = KeyboardModeStore;
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
const [{isDragging}, dragRef, preview] = useDrag<DragItem, unknown, {isDragging: boolean}>({
type: DND_TYPES.FAVORITES_CHANNEL,
item: {
type: DND_TYPES.FAVORITES_CHANNEL,
channelId: favoriteChannel.channelId,
parentId: favoriteChannel.parentId,
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{isOver}, dropRef] = useDrop<DragItem, unknown, {isOver: boolean}>({
accept: DND_TYPES.FAVORITES_CHANNEL,
hover: (_item, monitor) => {
const node = elementRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
setDropIndicator({
position: hoverClientY < hoverMiddleY ? 'top' : 'bottom',
isValid: true,
});
},
drop: (item, monitor) => {
setDropIndicator(null);
if (item.channelId === favoriteChannel.channelId) return;
const node = elementRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
const position = hoverClientY < hoverMiddleY ? 'before' : 'after';
const channels = FavoritesStore.getChannelsInCategory(favoriteChannel.parentId);
let targetIndex = channels.findIndex((ch) => ch.channelId === favoriteChannel.channelId);
if (targetIndex !== -1) {
if (position === 'after') {
targetIndex += 1;
}
FavoritesStore.moveChannel(item.channelId, favoriteChannel.parentId, targetIndex);
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
});
React.useEffect(() => {
if (!isOver) setDropIndicator(null);
}, [isOver]);
React.useEffect(() => {
preview(getEmptyImage());
}, [preview]);
const dragConnectorRef = React.useCallback(
(node: ConnectableElement | null) => {
dragRef(node);
},
[dragRef],
);
const dropConnectorRef = React.useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
},
[dropRef],
);
const refs = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]);
if (!channel) {
return (
<div className={favoritesChannelListStyles.notFoundItem}>
<HashIcon weight="regular" className={favoritesChannelListStyles.notFoundIcon} />
<span className={favoritesChannelListStyles.notFoundText}>{t`Channel not found`}</span>
</div>
);
}
const unreadCount = ReadStateStore.getUnreadCount(channel.id);
const mentionCount = ReadStateStore.getMentionCount(channel.id);
const hasUnread = unreadCount > 0 || mentionCount > 0;
const isGroupDM = channel.isGroupDM();
const isDM = channel.isDM();
const recipientId = isDM ? (channel.recipientIds[0] ?? '') : '';
const recipient = recipientId ? (UserStore.getUser(recipientId) ?? null) : null;
const isTyping = recipientId ? TypingStore.isTyping(channel.id, recipientId) : false;
const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name;
const displayName = favoriteChannel.nickname || channelDisplayName || t`Unknown Channel`;
const guildIconUrl = guild ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : null;
const isMuted = channel.guildId ? UserGuildSettingsStore.isChannelMuted(channel.guildId, channel.id) : false;
const handleClick = () => {
RouterUtils.transitionTo(Routes.favoritesChannel(favoriteChannel.channelId));
};
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<FavoritesChannelContextMenu
favoriteChannel={favoriteChannel}
channel={channel}
guild={guild}
onClose={onClose}
/>
));
};
const handleInvite = () => {
ModalActionCreators.push(modal(() => <InviteModal channelId={channel.id} />));
};
const canInvite = channel.guildId && InviteUtils.canInviteToChannel(channel.id, channel.guildId);
return (
<GenericChannelItem
ref={refs}
containerClassName={favoritesChannelListStyles.favoriteItemContainer}
style={{opacity: isDragging ? 0.5 : 1}}
isOver={isOver}
dropIndicator={dropIndicator}
className={clsx(
favoritesChannelListStyles.favoriteItem,
shouldShowSelectedState && favoritesChannelListStyles.favoriteItemSelected,
!shouldShowSelectedState && favoritesChannelListStyles.favoriteItemDefault,
isOver && favoritesChannelListStyles.favoriteItemOver,
isMuted && favoritesChannelListStyles.favoriteItemMuted,
showKeyboardAffordances && favoritesChannelListStyles.keyboardFocus,
)}
isSelected={shouldShowSelectedState}
onClick={handleClick}
onContextMenu={handleContextMenu}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onLongPress={() => {}}
>
<div className={favoritesChannelListStyles.avatarContainer}>
{isGroupDM ? (
<GroupDMAvatar channel={channel} size={24} />
) : recipient ? (
<StatusAwareAvatar
user={recipient}
size={24}
isTyping={isTyping}
showOffline={true}
className={favoritesChannelListStyles.avatar}
/>
) : guildIconUrl ? (
<img src={guildIconUrl} alt="" className={favoritesChannelListStyles.avatar} />
) : (
<div className={favoritesChannelListStyles.avatarPlaceholder}>
{guild ? guild.name.charAt(0).toUpperCase() : 'DM'}
</div>
)}
{!channel.isPrivate() && (
<div
className={clsx(
favoritesChannelListStyles.channelBadge,
shouldShowSelectedState && favoritesChannelListStyles.channelBadgeSelected,
)}
>
{ChannelUtils.getIcon(channel, {
className: clsx(
favoritesChannelListStyles.channelBadgeIcon,
shouldShowSelectedState && favoritesChannelListStyles.channelBadgeSelectedIcon,
),
})}
</div>
)}
</div>
<span className={favoritesChannelListStyles.displayName}>{displayName}</span>
<div className={favoritesChannelListStyles.actionsContainer}>
{canInvite && (
<div className={favoritesChannelListStyles.hoverAffordance}>
<ChannelItemIcon
icon={UserPlusIcon}
label={t`Invite People`}
onClick={handleInvite}
selected={shouldShowSelectedState}
/>
</div>
)}
{hasUnread && <MentionBadge mentionCount={mentionCount} size="small" />}
</div>
</GenericChannelItem>
);
},
);
const FavoriteCategoryItem = observer(
({
category,
isCollapsed,
onToggle,
onAddChannel,
}: {
category: {id: string; name: string};
isCollapsed: boolean;
onToggle: () => void;
onAddChannel: () => void;
}) => {
const {t} = useLingui();
const [isFocused, setIsFocused] = React.useState(false);
const {keyboardModeEnabled} = KeyboardModeStore;
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
const [{isOver}, dropRef] = useDrop<DragItem, unknown, {isOver: boolean}>({
accept: DND_TYPES.FAVORITES_CHANNEL,
drop: (item) => {
if (item.parentId === category.id) return;
const channels = FavoritesStore.getChannelsInCategory(category.id);
FavoritesStore.moveChannel(item.channelId, category.id, channels.length);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
});
const dropConnectorRef = React.useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
},
[dropRef],
);
const refs = useMergeRefs([dropConnectorRef]);
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<FavoritesCategoryContextMenu category={category} onClose={onClose} onAddChannel={onAddChannel} />
));
};
return (
<GenericChannelItem
ref={refs}
className={clsx(
favoritesChannelListStyles.categoryItem,
isOver && favoritesChannelListStyles.favoriteItemOver,
showKeyboardAffordances && favoritesChannelListStyles.keyboardFocus,
)}
isOver={isOver}
onClick={onToggle}
onContextMenu={handleContextMenu}
onKeyDown={(e) => e.key === 'Enter' && onToggle()}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
>
<div className={favoritesChannelListStyles.categoryContent}>
<span className={favoritesChannelListStyles.categoryName}>{category.name}</span>
<CaretDownIcon
weight="bold"
className={favoritesChannelListStyles.categoryIcon}
style={{transform: `rotate(${isCollapsed ? -90 : 0}deg)`}}
/>
</div>
<div className={favoritesChannelListStyles.categoryActions}>
<div className={favoritesChannelListStyles.hoverAffordance}>
<Tooltip text={t`Add Channel`}>
<FocusRing offset={-2} ringClassName={channelItemSurfaceStyles.channelItemFocusRing}>
<button
type="button"
className={favoritesChannelListStyles.addButton}
onClick={(e) => {
e.stopPropagation();
onAddChannel();
}}
>
<PlusIcon weight="bold" className={favoritesChannelListStyles.addButtonIcon} />
</button>
</FocusRing>
</Tooltip>
</div>
</div>
</GenericChannelItem>
);
},
);
const UncategorizedGroup = ({children}: {children: React.ReactNode}) => {
const [{isOver}, dropRef] = useDrop<DragItem, unknown, {isOver: boolean}>({
accept: DND_TYPES.FAVORITES_CHANNEL,
drop: (item, monitor) => {
if (monitor.didDrop()) return;
if (item.parentId === null) return;
const channels = FavoritesStore.getChannelsInCategory(null);
FavoritesStore.moveChannel(item.channelId, null, channels.length);
},
collect: (monitor) => ({
isOver: monitor.isOver({shallow: true}),
}),
});
const dropConnectorRef = React.useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
},
[dropRef],
);
return (
<div
ref={dropConnectorRef}
className={clsx(
favoritesChannelListStyles.uncategorizedGroup,
isOver && favoritesChannelListStyles.favoriteItemOver,
)}
>
{children}
</div>
);
};
export const FavoritesChannelListContent = observer(() => {
const favorites = FavoritesStore.sortedChannels;
const categories = FavoritesStore.sortedCategories;
const hideMutedChannels = FavoritesStore.hideMutedChannels;
const channelGroups = React.useMemo(() => {
const groups: Array<FavoriteChannelGroup> = [];
const categoryMap = new Map<string | null, FavoriteChannelGroup>();
categoryMap.set(null, {category: null, channels: []});
for (const cat of categories) {
categoryMap.set(cat.id, {category: cat, channels: []});
}
for (const fav of favorites) {
const channel = ChannelStore.getChannel(fav.channelId);
const guild = fav.guildId === ME ? null : GuildStore.getGuild(fav.guildId);
if (hideMutedChannels && channel && channel.guildId) {
if (UserGuildSettingsStore.isGuildOrChannelMuted(channel.guildId, channel.id)) {
continue;
}
}
const group = categoryMap.get(fav.parentId);
if (group) {
group.channels.push({favoriteChannel: fav, channel: channel ?? null, guild: guild ?? null});
}
}
for (const [, group] of categoryMap) {
if (group.category || group.channels.length > 0 || group === categoryMap.get(null)) {
groups.push(group);
}
}
return groups;
}, [favorites, categories, hideMutedChannels]);
const handleContextMenu = React.useCallback((event: React.MouseEvent) => {
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<FavoritesChannelListContextMenu onClose={onClose} />
));
}, []);
if (favorites.length === 0) {
return (
<Scroller
className={styles.channelListScroller}
reserveScrollbarTrack={false}
key="favorites-channel-list-empty-scroller"
>
<div onContextMenu={handleContextMenu} role="region" aria-label="Empty favorites">
<ChannelListSkeleton />
</div>
</Scroller>
);
}
return (
<Scroller
className={styles.channelListScroller}
reserveScrollbarTrack={false}
key="favorites-channel-list-scroller"
>
<div
className={favoritesChannelListStyles.navigationContainer}
onContextMenu={handleContextMenu}
role="navigation"
>
<div className={favoritesChannelListStyles.channelGroupsContainer}>
{channelGroups.map((group) => {
const isCollapsed = group.category ? FavoritesStore.isCategoryCollapsed(group.category.id) : false;
const handleAddChannel = () => {
ModalActionCreators.push(modal(() => <AddFavoriteChannelModal categoryId={group.category?.id} />));
};
const content = (
<>
{group.category && (
<FavoriteCategoryItem
category={group.category}
isCollapsed={isCollapsed}
onToggle={() => FavoritesStore.toggleCategoryCollapsed(group.category!.id)}
onAddChannel={handleAddChannel}
/>
)}
{!isCollapsed &&
group.channels.map(({favoriteChannel, channel, guild}) => (
<FavoriteChannelItem
key={favoriteChannel.channelId}
favoriteChannel={favoriteChannel}
channel={channel}
guild={guild}
/>
))}
</>
);
return (
<div key={group.category?.id || 'uncategorized'} className={styles.channelGroup}>
{group.category ? content : <UncategorizedGroup>{content}</UncategorizedGroup>}
</div>
);
})}
</div>
</div>
</Scroller>
);
});

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.headerIconContainer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.headerIcon {
color: var(--text-primary);
}

View File

@@ -0,0 +1,82 @@
/*
* 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, DotsThreeIcon, StarIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {FavoritesGuildHeaderBottomSheet} from '~/components/bottomsheets/FavoritesGuildHeaderBottomSheet';
import {GuildHeaderShell} from '~/components/layout/GuildHeaderShell';
import {FavoritesGuildHeaderPopout} from '~/components/popouts/FavoritesGuildHeaderPopout';
import {FavoritesGuildContextMenu} from '~/components/uikit/ContextMenu/FavoritesGuildContextMenu';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PopoutStore from '~/stores/PopoutStore';
import styles from './FavoritesGuildHeader.module.css';
import guildHeaderStyles from './GuildHeader.module.css';
export const FavoritesGuildHeader = observer(() => {
const {t} = useLingui();
const {popouts} = PopoutStore;
const isOpen = 'favorites-guild-header' in popouts;
const isMobile = MobileLayoutStore.isMobileLayout();
const mobileHeaderRef = React.useRef<HTMLDivElement | null>(null);
const handleContextMenu = React.useCallback((event: React.MouseEvent) => {
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => <FavoritesGuildContextMenu onClose={onClose} />);
}, []);
return (
<div
className={clsx(
guildHeaderStyles.headerContainer,
guildHeaderStyles.headerContainerNoBanner,
isOpen && guildHeaderStyles.headerContainerActive,
)}
style={{height: 56}}
>
<GuildHeaderShell
popoutId="favorites-guild-header"
renderPopout={() => <FavoritesGuildHeaderPopout />}
renderBottomSheet={({isOpen, onClose}) => <FavoritesGuildHeaderBottomSheet isOpen={isOpen} onClose={onClose} />}
onContextMenu={handleContextMenu}
className={guildHeaderStyles.headerContent}
triggerRef={mobileHeaderRef}
>
{(isOpen) => (
<>
<div className={styles.headerIconContainer}>
<StarIcon weight="fill" className={clsx(guildHeaderStyles.verifiedIconDefault, styles.headerIcon)} />
<span className={guildHeaderStyles.guildNameDefault}>{t`Favorites`}</span>
</div>
{isMobile ? (
<DotsThreeIcon weight="bold" className={guildHeaderStyles.dotsIconDefault} />
) : (
<CaretDownIcon
weight="bold"
className={clsx(guildHeaderStyles.caretIconDefault, isOpen && guildHeaderStyles.caretIconOpen)}
/>
)}
</>
)}
</GuildHeaderShell>
</div>
);
});

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
import {FAVORITES_GUILD_ID} from '~/Constants';
import {FavoritesWelcomeSection} from '~/components/favorites/FavoritesWelcomeSection';
import {DndContext} from '~/components/layout/DndContext';
import {FavoritesChannelListContent} from '~/components/layout/FavoritesChannelListContent';
import {FavoritesGuildHeader} from '~/components/layout/FavoritesGuildHeader';
import {GuildSidebar} from '~/components/layout/GuildSidebar';
import {useParams} from '~/lib/router';
import {Routes} from '~/Routes';
import FavoritesStore from '~/stores/FavoritesStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import * as RouterUtils from '~/utils/RouterUtils';
import styles from './GuildLayout.module.css';
export const FavoritesLayout = observer(({children}: {children?: React.ReactNode}) => {
const mobileLayout = MobileLayoutStore;
const {channelId} = useParams() as {channelId?: string};
const hasAccessibleChannels = FavoritesStore.getFirstAccessibleChannel() !== undefined;
const showWelcomeScreen = !channelId && !hasAccessibleChannels;
const shouldRenderWelcomeScreen = showWelcomeScreen && !mobileLayout.enabled;
React.useEffect(() => {
if (channelId) {
NavigationActionCreators.selectChannel(FAVORITES_GUILD_ID, channelId);
}
}, [channelId]);
React.useEffect(() => {
if (!channelId) return;
const isStillFavorited = FavoritesStore.getChannel(channelId);
if (!isStillFavorited) {
const validChannelId = SelectedChannelStore.getValidatedFavoritesChannel();
if (validChannelId) {
RouterUtils.transitionTo(Routes.favoritesChannel(validChannelId));
} else {
RouterUtils.transitionTo(Routes.FAVORITES);
}
}
}, [channelId, FavoritesStore.channels]);
if (shouldRenderWelcomeScreen) {
return (
<DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildLayoutContent}>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
<div className={styles.guildMainContent}>
<FavoritesWelcomeSection />
</div>
</div>
</div>
</DndContext>
);
}
if (mobileLayout.enabled) {
if (!channelId) {
return (
<DndContext>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
</DndContext>
);
}
return (
<DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildMainContent}>{children}</div>
</div>
</DndContext>
);
}
return (
<DndContext>
<div className={styles.guildLayoutContainer}>
<div className={styles.guildLayoutContent}>
<GuildSidebar header={<FavoritesGuildHeader />} content={<FavoritesChannelListContent />} />
<div className={styles.guildMainContent}>{children}</div>
</div>
</div>
</DndContext>
);
});

View File

@@ -0,0 +1,29 @@
/*
* 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 interface FrameSides {
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
}
export const FrameContext = React.createContext<FrameSides | null>(null);

View File

@@ -0,0 +1,159 @@
/*
* 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 React from 'react';
import {LongPressable} from '~/components/LongPressable';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import channelItemStyles from './ChannelItem.module.css';
import channelItemSurfaceStyles from './ChannelItemSurface.module.css';
import {DropIndicator} from './DropIndicator';
import type {ScrollIndicatorSeverity} from './ScrollIndicatorOverlay';
interface GenericChannelItemProps {
icon?: React.ReactNode;
name?: string;
actions?: React.ReactNode;
badges?: React.ReactNode;
isSelected?: boolean;
isMuted?: boolean;
isDragging?: boolean;
isOver?: boolean;
dropIndicator?: {position: 'top' | 'bottom'; isValid: boolean} | null;
onClick?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
onLongPress?: () => void;
innerRef?: React.Ref<HTMLDivElement>;
className?: string;
pressedClassName?: string;
containerClassName?: string;
style?: React.CSSProperties;
isCategory?: boolean;
isCollapsed?: boolean;
onToggle?: () => void;
disabled?: boolean;
role?: string;
tabIndex?: number;
children?: React.ReactNode;
extraContent?: React.ReactNode;
'aria-label'?: string;
'data-dnd-name'?: string;
dataScrollIndicator?: ScrollIndicatorSeverity;
dataScrollId?: string;
}
export const GenericChannelItem = React.forwardRef<HTMLDivElement, GenericChannelItemProps>(
(
{
icon,
name,
actions,
badges,
isSelected,
isOver,
dropIndicator,
onClick,
onContextMenu,
onKeyDown,
onFocus,
onBlur,
onLongPress,
innerRef,
className,
pressedClassName,
containerClassName,
style,
isCategory,
isCollapsed,
disabled,
role = 'button',
tabIndex = 0,
children,
extraContent,
'aria-label': ariaLabel,
'data-dnd-name': dataDndName,
dataScrollIndicator,
dataScrollId,
},
ref,
) => {
return (
<div className={containerClassName} style={{position: 'relative', ...style}} ref={ref}>
{extraContent}
{isOver && dropIndicator && <DropIndicator position={dropIndicator.position} isValid={dropIndicator.isValid} />}
<FocusRing offset={-2} ringClassName={channelItemSurfaceStyles.channelItemFocusRing}>
<LongPressable
ref={innerRef}
disabled={disabled}
className={clsx(
channelItemSurfaceStyles.channelItemSurface,
isSelected && channelItemSurfaceStyles.channelItemSurfaceSelected,
className,
)}
pressedClassName={pressedClassName ?? channelItemStyles.channelItemPressed}
onClick={onClick}
onContextMenu={onContextMenu}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
role={role}
tabIndex={tabIndex}
onLongPress={onLongPress}
aria-label={ariaLabel}
data-dnd-name={dataDndName}
data-scroll-indicator={dataScrollIndicator}
data-scroll-id={dataScrollId}
>
{children ? (
children
) : (
<>
{isCategory ? (
<div style={{display: 'flex', alignItems: 'center', flex: 1, minWidth: 0}}>
<span style={{flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>
{name}
</span>
<CaretDownIcon weight="bold" style={{transform: `rotate(${isCollapsed ? -90 : 0}deg)`}} />
</div>
) : (
<>
{icon && <div style={{marginRight: 8, display: 'flex', alignItems: 'center'}}>{icon}</div>}
<span style={{flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>
{name}
</span>
</>
)}
<div style={{display: 'flex', alignItems: 'center', marginLeft: 8}}>
{actions}
{badges}
</div>
</>
)}
</LongPressable>
</FocusRing>
</div>
);
},
);
GenericChannelItem.displayName = 'GenericChannelItem';

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as QuickSwitcherActionCreators from '~/actions/QuickSwitcherActionCreators';
import {QuickSwitcherBottomSheet} from '~/components/bottomsheets/QuickSwitcherBottomSheet';
import {Modals} from '~/components/modals/Modals';
import {ContextMenu} from '~/components/uikit/ContextMenu/ContextMenu';
import {Popouts} from '~/components/uikit/Popout/Popouts';
import {Toasts} from '~/components/uikit/Toast/Toasts';
import LayerManager from '~/stores/LayerManager';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
import {handleContextMenu} from '~/utils/ContextMenuUtils';
const GlobalOverlays: React.FC = observer(() => {
const isMobile = MobileLayoutStore.isMobileLayout();
const quickSwitcherOpen = QuickSwitcherStore.isOpen;
React.useEffect(() => {
LayerManager.init();
document.addEventListener('contextmenu', handleContextMenu, false);
return () => {
document.removeEventListener('contextmenu', handleContextMenu, false);
};
}, []);
return (
<>
<Modals />
<Popouts />
<ContextMenu />
<Toasts />
{isMobile && <QuickSwitcherBottomSheet isOpen={quickSwitcherOpen} onClose={QuickSwitcherActionCreators.hide} />}
</>
);
});
export default GlobalOverlays;

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
}
.participantButton {
display: flex;
width: 100%;
align-items: center;
gap: 0.375rem;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
transition-property: color, background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
text-align: left;
color: var(--text-primary-muted);
}
.participantButtonSpeaking {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.participantButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.avatarAndName {
display: flex;
flex: 1;
align-items: center;
gap: 0.375rem;
}
.nameContainer {
display: flex;
min-width: 0;
flex: 1;
align-items: baseline;
gap: 0.25rem;
}
.participantName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
}
.participantNameSpeaking {
color: var(--text-primary);
}
.participantNameCurrent {
color: var(--text-primary);
}
.deviceCount {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.iconsAndToggle {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.25rem;
}
.toggleButton {
display: inline-flex;
height: 1.5rem;
width: 1.5rem;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
border: none;
background-color: transparent;
padding: 0;
cursor: pointer;
}
.toggleButton:hover {
background-color: var(--background-modifier-hover);
}
.toggleButton:focus {
outline: none;
}
.toggleButton:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand-primary) 50%, transparent);
}
.toggleIcon {
height: 1rem;
width: 1rem;
color: var(--text-secondary);
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.toggleIconCollapsed {
transform: rotate(-90deg);
}
.devicesContainer {
margin-top: 0.125rem;
margin-left: 1rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.flexShrinkZero {
flex-shrink: 0;
}

View File

@@ -0,0 +1,216 @@
/*
* 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} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
import {AvatarWithPresence} from '~/components/uikit/avatars/AvatarWithPresence';
import {VoiceParticipantContextMenu} from '~/components/uikit/ContextMenu/VoiceParticipantContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip';
import type {UserRecord} from '~/records/UserRecord';
import LocalVoiceStateStore from '~/stores/LocalVoiceStateStore';
import UserStore from '~/stores/UserStore';
import type {VoiceState} from '~/stores/voice/MediaEngineFacade';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as NicknameUtils from '~/utils/NicknameUtils';
import styles from './GroupedVoiceParticipant.module.css';
import {VoiceParticipantItem} from './VoiceParticipantItem';
import {VoiceStateIcons} from './VoiceStateIcons';
interface GroupedVoiceParticipantProps {
user: UserRecord;
voiceStates: Array<VoiceState>;
guildId: string;
anySpeaking?: boolean;
}
export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant({
user,
voiceStates,
guildId,
anySpeaking: propAnySpeaking,
}: GroupedVoiceParticipantProps) {
const {t} = useLingui();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentUser = UserStore.getCurrentUser();
const isCurrentUser = currentUser?.id === user.id;
const currentConnectionId = MediaEngineStore.connectionId;
const localSelfVideo = LocalVoiceStateStore.selfVideo;
const localSelfStream = LocalVoiceStateStore.selfStream;
const toggleExpanded = React.useCallback(() => setIsExpanded((prev) => !prev), []);
const handleContextMenu = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<VoiceParticipantContextMenu
user={user}
participantName={NicknameUtils.getNickname(user)}
onClose={onClose}
guildId={guildId}
isGroupedItem={true}
isParentGroupedItem={true}
/>
));
},
[user, guildId],
);
const connectionCount = voiceStates.length;
const stateAgg = React.useMemo(() => {
let anyCameraOn = false;
let anyLive = false;
let guildMuted = false;
let guildDeaf = false;
let allSelfMuted = true;
let allSelfDeaf = true;
for (const state of voiceStates) {
const connectionId = state.connection_id ?? '';
const participant = MediaEngineStore.getParticipantByUserIdAndConnectionId(user.id, connectionId);
const selfMuted = state.self_mute ?? (participant ? !participant.isMicrophoneEnabled : false);
const selfDeaf = !!state.self_deaf;
const camera = state.self_video === true || (participant ? participant.isCameraEnabled : false);
const live = state.self_stream === true || (participant ? participant.isScreenShareEnabled : false);
anyCameraOn = anyCameraOn || camera;
anyLive = anyLive || live;
guildMuted = guildMuted || !!state.mute;
guildDeaf = guildDeaf || !!state.deaf;
allSelfMuted = allSelfMuted && !!selfMuted;
allSelfDeaf = allSelfDeaf && !!selfDeaf;
}
if (isCurrentUser) {
anyCameraOn = anyCameraOn || localSelfVideo;
anyLive = anyLive || localSelfStream;
}
let anySpeaking = propAnySpeaking !== undefined ? propAnySpeaking : false;
if (propAnySpeaking === undefined) {
for (const state of voiceStates) {
const connectionId = state.connection_id ?? '';
const participant = MediaEngineStore.getParticipantByUserIdAndConnectionId(user.id, connectionId);
const selfMuted = state.self_mute ?? (participant ? !participant.isMicrophoneEnabled : false);
const speaking = !!(participant?.isSpeaking && !selfMuted && !(state.mute ?? false));
anySpeaking = anySpeaking || speaking;
}
}
return {anySpeaking, anyCameraOn, anyLive, guildMuted, guildDeaf, allSelfMuted, allSelfDeaf};
}, [voiceStates, user.id, isCurrentUser, localSelfVideo, localSelfStream, propAnySpeaking]);
return (
<div className={styles.container}>
<PreloadableUserPopout
user={user}
isWebhook={false}
guildId={guildId}
position="right-start"
disableContextMenu={true}
>
<div
className={clsx(styles.participantButton, stateAgg.anySpeaking && styles.participantButtonSpeaking)}
role="button"
tabIndex={0}
aria-label={`Open profile for ${NicknameUtils.getNickname(user)}`}
onContextMenu={handleContextMenu}
>
<div className={styles.avatarAndName}>
<AvatarWithPresence user={user} size={24} speaking={stateAgg.anySpeaking} guildId={guildId} />
<div className={styles.nameContainer}>
<span
className={clsx(
styles.participantName,
stateAgg.anySpeaking && styles.participantNameSpeaking,
isCurrentUser && !stateAgg.anySpeaking && styles.participantNameCurrent,
)}
>
{NicknameUtils.getNickname(user)}
</span>
{connectionCount > 1 && (
<Tooltip text={connectionCount === 1 ? t`${connectionCount} device` : t`${connectionCount} devices`}>
<span className={styles.deviceCount}>({connectionCount})</span>
</Tooltip>
)}
</div>
</div>
<div className={styles.iconsAndToggle}>
<VoiceStateIcons
isSelfMuted={stateAgg.allSelfMuted && !stateAgg.guildMuted}
isSelfDeafened={stateAgg.allSelfDeaf && !stateAgg.guildDeaf}
isGuildMuted={stateAgg.guildMuted}
isGuildDeafened={stateAgg.guildDeaf}
isCameraOn={stateAgg.anyCameraOn}
isScreenSharing={stateAgg.anyLive}
className={styles.flexShrinkZero}
/>
<Tooltip text={isExpanded ? 'Collapse devices' : 'Expand devices'}>
<FocusRing offset={-2}>
<button
type="button"
aria-label={isExpanded ? 'Collapse devices' : 'Expand devices'}
aria-expanded={isExpanded}
onClick={(e) => {
e.stopPropagation();
toggleExpanded();
}}
className={styles.toggleButton}
>
<CaretDownIcon
weight="bold"
className={clsx(styles.toggleIcon, !isExpanded && styles.toggleIconCollapsed)}
/>
</button>
</FocusRing>
</Tooltip>
</div>
</div>
</PreloadableUserPopout>
{isExpanded && (
<div className={styles.devicesContainer}>
{[...voiceStates]
.sort((a, b) => (a.connection_id || '').localeCompare(b.connection_id || ''))
.map((voiceState, index) => (
<VoiceParticipantItem
key={voiceState.connection_id || `${user.id}-${index}`}
user={user}
voiceState={voiceState}
guildId={guildId}
isGroupedItem={true}
isCurrentUserConnection={isCurrentUser && voiceState.connection_id === currentConnectionId}
/>
))}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
position: relative;
width: 100%;
overflow: hidden;
border-bottom: 1px solid var(--user-area-divider-color);
background-color: var(--background-secondary);
}
.banner {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useMemo} from 'react';
import {GuildFeatures} from '~/Constants';
import type {GuildRecord} from '~/records/GuildRecord';
import * as AvatarUtils from '~/utils/AvatarUtils';
import styles from './GuildDetachedBanner.module.css';
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
const DEFAULT_BANNER_HEIGHT = 240;
export function GuildDetachedBanner({guild}: {guild: GuildRecord}) {
const aspectRatio = useMemo(
() => (guild.bannerWidth && guild.bannerHeight ? guild.bannerWidth / guild.bannerHeight : undefined),
[guild.bannerHeight, guild.bannerWidth],
);
const bannerURL = AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true);
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
if (!bannerURL || !isDetachedBanner) return null;
const maxHeight = `${MAX_VIEWPORT_HEIGHT_FRACTION * 100}vh`;
const bannerHeight = guild.bannerHeight ?? DEFAULT_BANNER_HEIGHT;
return (
<div
className={styles.container}
style={{maxHeight, ...(aspectRatio ? {aspectRatio: `${aspectRatio}`} : {height: bannerHeight})}}
>
<img src={bannerURL} alt="" className={styles.banner} draggable={false} />
</div>
);
}

View File

@@ -0,0 +1,168 @@
/*
* 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/>.
*/
.headerWrapper {
min-width: 0;
}
.headerContainer {
position: relative;
display: flex;
align-items: flex-start;
overflow: hidden;
min-height: var(--layout-header-height);
min-width: 0;
border-bottom: 1px solid var(--user-area-divider-color);
background-color: var(--background-secondary);
transition: background-color var(--transition-normal);
}
.headerRounded {
border-top-left-radius: 0;
}
.headerContainerNoBanner:hover,
.headerContainerActive {
background-color: var(--background-modifier-hover);
}
.bannerBackground {
position: absolute;
inset: 0;
background-size: cover;
background-position: top center;
background-repeat: no-repeat;
background-color: var(--background-secondary);
pointer-events: none;
}
.bannerBackgroundCentered {
background-position: center;
}
.bannerGradient {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 2.5rem;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.3), transparent);
pointer-events: none;
}
.headerContent {
position: relative;
z-index: 30;
display: flex;
align-items: center;
gap: var(--spacing-1);
height: var(--layout-header-height);
width: 100%;
min-width: 0;
padding: 0 var(--spacing-4);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
-webkit-app-region: no-drag;
}
.headerFocusRing {
border-radius: 0;
}
.verifiedIcon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
}
.verifiedIconDefault {
composes: verifiedIcon;
color: var(--text-primary);
}
.verifiedIconWithBanner {
composes: verifiedIcon;
color: white;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.9));
}
.guildName {
flex: 1;
min-width: 0;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25rem;
max-height: 1.25rem;
}
.guildNameDefault {
composes: guildName;
color: var(--text-primary);
}
.guildNameWithBanner {
composes: guildName;
color: white;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.9));
}
.caretIcon {
margin-left: auto;
height: 1rem;
width: 1rem;
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.caretIconDefault {
composes: caretIcon;
color: var(--text-primary);
}
.caretIconWithBanner {
composes: caretIcon;
color: white;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.9));
}
.caretIconOpen {
transform: rotate(180deg);
}
.dotsIcon {
margin-left: auto;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
}
.dotsIconDefault {
composes: dotsIcon;
color: var(--text-primary);
}
.dotsIconWithBanner {
composes: dotsIcon;
color: white;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.9));
}

View File

@@ -0,0 +1,161 @@
/*
* 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, DotsThreeIcon, SealCheckIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {GuildFeatures} from '~/Constants';
import {GuildHeaderBottomSheet} from '~/components/bottomsheets/GuildHeaderBottomSheet';
import {GuildHeaderShell} from '~/components/layout/GuildHeaderShell';
import {NativeDragRegion} from '~/components/layout/NativeDragRegion';
import {GuildHeaderPopout} from '~/components/popouts/GuildHeaderPopout';
import {GuildContextMenu} from '~/components/uikit/ContextMenu/GuildContextMenu';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {GuildRecord} from '~/records/GuildRecord';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PopoutStore from '~/stores/PopoutStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import styles from './GuildHeader.module.css';
const HEADER_MIN_HEIGHT = 56;
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
export const GuildHeader = observer(({guild}: {guild: GuildRecord}) => {
const {t} = useLingui();
const {popouts} = PopoutStore;
const isOpen = 'guild-header' in popouts;
const isMobile = MobileLayoutStore.isMobileLayout();
const bannerURL = AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true);
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
const headerContainerRef = React.useRef<HTMLDivElement | null>(null);
const calculateBannerLayout = React.useCallback(() => {
if (!showIntegratedBanner || !bannerURL) {
return {height: HEADER_MIN_HEIGHT, centerCrop: false};
}
const width = headerContainerRef.current?.clientWidth ?? window.innerWidth;
if (!width) return {height: HEADER_MIN_HEIGHT, centerCrop: false};
const aspectRatio =
guild.bannerWidth && guild.bannerHeight ? guild.bannerWidth / guild.bannerHeight : DEFAULT_BANNER_ASPECT_RATIO;
const idealHeight = width / aspectRatio;
const viewportCap = window.innerHeight * MAX_VIEWPORT_HEIGHT_FRACTION;
const isCapped = idealHeight > viewportCap;
return {
height: Math.max(HEADER_MIN_HEIGHT, Math.min(idealHeight, viewportCap)),
centerCrop: isMobile && isCapped,
};
}, [showIntegratedBanner, bannerURL, guild.bannerWidth, guild.bannerHeight, isMobile]);
const [{height: bannerMaxHeight, centerCrop}, setBannerLayout] = React.useState(() => calculateBannerLayout());
React.useLayoutEffect(() => {
const updateLayout = () => setBannerLayout(calculateBannerLayout());
updateLayout();
window.addEventListener('resize', updateLayout);
return () => window.removeEventListener('resize', updateLayout);
}, [calculateBannerLayout]);
const handleContextMenu = React.useCallback(
(event: React.MouseEvent) => {
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<GuildContextMenu guild={guild} onClose={onClose} />
));
},
[guild],
);
const headerButtonRef = React.useRef<HTMLDivElement | null>(null);
return (
<div className={styles.headerWrapper}>
<NativeDragRegion
as={motion.div}
ref={headerContainerRef}
className={clsx(
styles.headerContainer,
!showIntegratedBanner && styles.headerContainerNoBanner,
!showIntegratedBanner && isOpen && styles.headerContainerActive,
)}
style={{height: showIntegratedBanner ? bannerMaxHeight : HEADER_MIN_HEIGHT}}
>
{showIntegratedBanner && (
<>
<div
className={clsx(styles.bannerBackground, centerCrop && styles.bannerBackgroundCentered)}
style={{backgroundImage: `url(${bannerURL})`}}
/>
<div className={styles.bannerGradient} />
</>
)}
<GuildHeaderShell
popoutId="guild-header"
renderPopout={() => <GuildHeaderPopout guild={guild} />}
renderBottomSheet={({isOpen, onClose}) => (
<GuildHeaderBottomSheet isOpen={isOpen} onClose={onClose} guild={guild} />
)}
onContextMenu={handleContextMenu}
className={styles.headerContent}
triggerRef={headerButtonRef}
>
{(isOpen) => (
<>
{guild.features.has(GuildFeatures.VERIFIED) && (
<Tooltip text={t`Verified Community`} position="bottom">
<SealCheckIcon
className={showIntegratedBanner ? styles.verifiedIconWithBanner : styles.verifiedIconDefault}
/>
</Tooltip>
)}
<span className={showIntegratedBanner ? styles.guildNameWithBanner : styles.guildNameDefault}>
{guild.name}
</span>
{isMobile ? (
<DotsThreeIcon
weight="bold"
className={showIntegratedBanner ? styles.dotsIconWithBanner : styles.dotsIconDefault}
/>
) : (
<CaretDownIcon
weight="bold"
className={clsx(
showIntegratedBanner ? styles.caretIconWithBanner : styles.caretIconDefault,
isOpen && styles.caretIconOpen,
)}
/>
)}
</>
)}
</GuildHeaderShell>
</NativeDragRegion>
</div>
);
});

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import React, {useState} from 'react';
import {LongPressable} from '~/components/LongPressable';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Popout} from '~/components/uikit/Popout/Popout';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import PopoutStore from '~/stores/PopoutStore';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import styles from './GuildHeader.module.css';
interface GuildHeaderShellProps {
popoutId: string;
renderPopout: () => React.ReactNode;
renderBottomSheet: (props: {isOpen: boolean; onClose: () => void}) => React.ReactNode;
onContextMenu: (event: React.MouseEvent) => void;
children: React.ReactNode | ((isOpen: boolean) => React.ReactNode);
className?: string;
triggerRef?: React.Ref<HTMLDivElement>;
}
const GuildHeaderTrigger = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
(props, forwardedRef) => {
const {children, ...rest} = props;
const triggerRef = React.useRef<HTMLDivElement | null>(null);
const mergedRef = useMergeRefs([triggerRef, forwardedRef]);
return (
<FocusRing ringClassName={styles.headerFocusRing} focusTarget={triggerRef} ringTarget={triggerRef} offset={0}>
<div {...rest} ref={mergedRef}>
{children}
</div>
</FocusRing>
);
},
);
GuildHeaderTrigger.displayName = 'GuildHeaderTrigger';
export const GuildHeaderShell = observer(
({
popoutId,
renderPopout,
renderBottomSheet,
onContextMenu,
children,
className,
triggerRef,
}: GuildHeaderShellProps) => {
const {popouts} = PopoutStore;
const isOpen = popoutId in popouts;
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
const isMobile = isMobileExperienceEnabled();
const internalRef = React.useRef<HTMLDivElement | null>(null);
const mergedRef = useMergeRefs([internalRef, triggerRef]);
const handleOpenBottomSheet = React.useCallback(() => {
setBottomSheetOpen(true);
}, []);
const handleCloseBottomSheet = React.useCallback(() => {
setBottomSheetOpen(false);
}, []);
const handleContextMenuWrapper = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (isMobile) {
handleOpenBottomSheet();
} else {
onContextMenu(event);
}
},
[isMobile, handleOpenBottomSheet, onContextMenu],
);
if (isMobile) {
return (
<>
<FocusRing
ringClassName={styles.headerFocusRing}
focusTarget={internalRef}
ringTarget={internalRef}
offset={0}
>
<LongPressable
className={className}
onClick={handleOpenBottomSheet}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenBottomSheet();
}
}}
onContextMenu={handleContextMenuWrapper}
onLongPress={handleOpenBottomSheet}
role="button"
tabIndex={0}
ref={mergedRef}
>
{typeof children === 'function' ? children(bottomSheetOpen) : children}
</LongPressable>
</FocusRing>
{renderBottomSheet({isOpen: bottomSheetOpen, onClose: handleCloseBottomSheet})}
</>
);
}
return (
<Popout uniqueId={popoutId} render={renderPopout} position="bottom">
<GuildHeaderTrigger
className={className}
onContextMenu={handleContextMenuWrapper}
role="button"
tabIndex={0}
ref={mergedRef}
>
{typeof children === 'function' ? children(isOpen) : children}
</GuildHeaderTrigger>
</Popout>
);
},
);

View File

@@ -0,0 +1,178 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.guildLayoutContainer {
display: grid;
grid-template-rows: minmax(0, 1fr);
grid-auto-rows: minmax(0, 1fr);
align-content: stretch;
height: 100%;
min-height: 0;
max-height: 100%;
width: 100%;
min-width: 0;
max-width: 100%;
background-color: var(--background-secondary);
}
.guildLayoutContainerWithNagbar {
composes: guildLayoutContainer;
grid-template-rows: auto minmax(0, 1fr);
}
.guildLayoutContent {
display: grid;
grid-template-columns: var(--layout-sidebar-width) 1fr;
align-items: stretch;
align-content: stretch;
height: 100%;
min-height: 0;
max-height: 100%;
width: 100%;
min-width: 0;
max-width: 100%;
}
.guildLayoutContentMobile {
composes: guildLayoutContent;
grid-template-columns: 1fr;
background-color: var(--background-tertiary);
}
.guildMainContent {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
height: 100%;
min-height: 0;
max-height: 100%;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
background-color: var(--background-secondary);
}
.guildUnavailableContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
height: 100%;
min-height: 0;
width: 100%;
min-width: 0;
padding: var(--spacing-8);
background-color: var(--background-secondary);
}
.guildUnavailableContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-1);
text-align: center;
}
.guildUnavailableIcon {
height: 4rem;
width: 4rem;
color: var(--text-tertiary);
}
.guildUnavailableTitle {
font-weight: 600;
font-size: 1.5rem;
line-height: 2rem;
color: var(--text-primary);
}
.guildUnavailableDescription {
color: var(--text-tertiary);
}
.nagbarContent {
display: flex;
align-items: center;
}
.nagbarContentMobile {
composes: nagbarContent;
flex-direction: column;
gap: var(--spacing-2);
}
.nagbarText {
text-align: center;
}
.nagbarActions {
display: flex;
gap: var(--spacing-2);
}
.nagbarActionsDesktop {
composes: nagbarActions;
margin-left: var(--spacing-3);
}
.nagbarButton {
border-radius: var(--radius-md);
border: 1px solid white;
background-color: transparent;
padding: var(--spacing-1) var(--spacing-3);
color: white;
font-size: 0.75rem;
font-weight: 400;
line-height: 1rem;
transition: background-color var(--transition-fast);
cursor: pointer;
}
.nagbarButton:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.nagbarButtonPrimary {
composes: nagbarButton;
background-color: white;
font-weight: 600;
}
.nagbarButtonPrimaryOrange {
composes: nagbarButtonPrimary;
color: rgb(234, 88, 12);
cursor: pointer;
}
.nagbarButtonPrimaryOrange:hover {
background-color: rgba(255, 255, 255, 0.9);
}
.nagbarButtonPrimaryRed {
composes: nagbarButtonPrimary;
color: var(--status-danger);
cursor: pointer;
}
.nagbarButtonPrimaryRed:hover {
background-color: rgba(255, 255, 255, 0.9);
}

View File

@@ -0,0 +1,416 @@
/*
* 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 {type Icon, NetworkSlashIcon, SmileySadIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GuildActionCreators from '~/actions/GuildActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
import {ChannelTypes, GuildFeatures, Permissions} from '~/Constants';
import {DndContext} from '~/components/layout/DndContext';
import {GuildNavbar} from '~/components/layout/GuildNavbar';
import {GuildNavbarSkeleton} from '~/components/layout/GuildNavbarSkeleton';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {useParams} from '~/lib/router';
import {Routes} from '~/Routes';
import ChannelStore from '~/stores/ChannelStore';
import GuildAvailabilityStore from '~/stores/GuildAvailabilityStore';
import GuildStore from '~/stores/GuildStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import NagbarStore from '~/stores/NagbarStore';
import PermissionStore from '~/stores/PermissionStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import {openExternalUrl} from '~/utils/NativeUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import {adminUrl} from '~/utils/UrlUtils';
import {TopNagbarContext} from './app-layout/TopNagbarContext';
import styles from './GuildLayout.module.css';
const InvitesDisabledNagbar = observer(({isMobile, guildId}: {isMobile: boolean; guildId: string}) => {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const selectedChannelId = SelectedChannelStore.currentChannelId;
const canManageGuild = selectedChannelId ? PermissionStore.can(Permissions.MANAGE_GUILD, {guildId}) : false;
if (!guild) return null;
const handleEnableInvites = () => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Enable invites for this community`}
description={
<Trans>
Are you sure you want to enable invites? This will allow users to join this community through invite links
again.
</Trans>
}
primaryText={t`Enable`}
primaryVariant="primary"
secondaryText={t`Cancel`}
onPrimary={async () => {
await GuildActionCreators.toggleInvitesDisabled(guildId, false);
}}
/>
)),
);
};
const handleDismiss = () => {
NagbarActionCreators.dismissInvitesDisabledNagbar(guildId);
};
return (
<Nagbar
isMobile={isMobile}
backgroundColor="rgb(234 88 12)"
textColor="white"
dismissible
onDismiss={handleDismiss}
>
<div className={isMobile ? styles.nagbarContentMobile : styles.nagbarContent}>
<p className={styles.nagbarText}>
<Trans>
Invites to <strong>{guild.name}</strong> are currently disabled
</Trans>
</p>
<div className={isMobile ? styles.nagbarActions : styles.nagbarActionsDesktop}>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
{canManageGuild && (
<NagbarButton isMobile={isMobile} onClick={handleEnableInvites}>
<Trans>Enable Invites Again</Trans>
</NagbarButton>
)}
</div>
</div>
</Nagbar>
);
});
const StaffOnlyGuildNagbar = observer(({isMobile, guildId}: {isMobile: boolean; guildId: string}) => {
const guild = GuildStore.getGuild(guildId);
if (!guild) return null;
const handleManageFeatures = () => {
void openExternalUrl(adminUrl(`guilds/${guildId}?tab=features`));
};
return (
<Nagbar isMobile={isMobile} backgroundColor="var(--status-danger)" textColor="white">
<div className={isMobile ? styles.nagbarContentMobile : styles.nagbarContent}>
<p className={styles.nagbarText}>
<Trans>
<strong>{guild.name}</strong> is currently only accessible to Fluxer staff members
</Trans>
</p>
<div className={isMobile ? styles.nagbarActions : styles.nagbarActionsDesktop}>
<NagbarButton isMobile={isMobile} onClick={handleManageFeatures}>
<Trans>Manage Guild Features</Trans>
</NagbarButton>
</div>
</div>
</Nagbar>
);
});
const GuildUnavailable = observer(function GuildUnavailable({
icon: Icon,
title,
description,
}: {
icon: Icon;
title: string;
description: string;
}) {
return (
<div className={styles.guildUnavailableContainer}>
<div className={styles.guildUnavailableContent}>
<Icon className={styles.guildUnavailableIcon} />
<h1 className={styles.guildUnavailableTitle}>{title}</h1>
<p className={styles.guildUnavailableDescription}>{description}</p>
</div>
</div>
);
});
export const GuildLayout = observer(({children}: {children: React.ReactNode}) => {
const {t} = useLingui();
const {guildId, channelId, messageId} = useParams() as {guildId: string; channelId?: string; messageId?: string};
const mobileLayout = MobileLayoutStore;
const guild = GuildStore.getGuild(guildId);
const unavailableGuilds = GuildAvailabilityStore.unavailableGuilds;
const channels = ChannelStore.getGuildChannels(guildId);
const user = UserStore.currentUser;
const nagbarState = NagbarStore;
const selectedChannelId = SelectedChannelStore.currentChannelId;
const channel = ChannelStore.getChannel(selectedChannelId ?? '');
const isStaff = user?.isStaff() ?? false;
const invitesDisabledDismissed = NagbarStore.getInvitesDisabledDismissed(guild?.id ?? '');
const guildUnavailable = guildId && (unavailableGuilds.has(guildId) || guild?.unavailable);
const guildNotFound = !guildUnavailable && !guild;
const firstAccessibleTextChannel = React.useMemo(() => {
if (!guild) return null;
const textChannels = channels
.filter((ch) => ch.type === ChannelTypes.GUILD_TEXT || ch.type === ChannelTypes.GUILD_VOICE)
.sort(ChannelUtils.compareChannels);
return textChannels.length > 0 ? textChannels[0] : null;
}, [guild, channels]);
const shouldShowInvitesDisabled = React.useMemo(() => {
if (!selectedChannelId) return false;
if (!channel?.guildId) return false;
if (!guild) return false;
if (nagbarState.forceHideInvitesDisabled) return false;
if (nagbarState.forceInvitesDisabled) return true;
const hasInvitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED);
if (!hasInvitesDisabled) return false;
const canInvite = InviteUtils.canInviteToChannel(selectedChannelId, channel.guildId);
const canManageGuild = PermissionStore.can(Permissions.MANAGE_GUILD, {guildId: channel.guildId});
if (!canInvite && !canManageGuild) return false;
if (invitesDisabledDismissed && !nagbarState.forceInvitesDisabled) return false;
return true;
}, [
selectedChannelId,
channel,
guild,
invitesDisabledDismissed,
nagbarState.forceInvitesDisabled,
nagbarState.forceHideInvitesDisabled,
]);
const shouldShowStaffOnlyGuild = React.useMemo(() => {
if (!selectedChannelId) return false;
if (!channel?.guildId) return false;
if (!guild) return false;
if (!isStaff) return false;
const isStaffOnly = guild.features.has(GuildFeatures.UNAVAILABLE_FOR_EVERYONE_BUT_STAFF);
return isStaffOnly;
}, [selectedChannelId, channel, guild, isStaff]);
const hasGuildNagbars = shouldShowStaffOnlyGuild || shouldShowInvitesDisabled;
const nagbarCount = (shouldShowStaffOnlyGuild ? 1 : 0) + (shouldShowInvitesDisabled ? 1 : 0);
const prevNagbarCount = React.useRef<number>(nagbarCount);
const hasTopNagbarAbove = React.useContext(TopNagbarContext);
const nagbarContextValue = hasTopNagbarAbove || hasGuildNagbars;
React.useEffect(() => {
if (prevNagbarCount.current !== nagbarCount) {
prevNagbarCount.current = nagbarCount;
ComponentDispatch.dispatch('LAYOUT_RESIZED');
}
}, [nagbarCount]);
React.useEffect(() => {
if (!guildId) {
NavigationActionCreators.deselectGuild();
return;
}
NavigationActionCreators.selectGuild(guildId);
return () => {
NavigationActionCreators.deselectGuild();
};
}, [guildId]);
React.useEffect(() => {
if (!guildId) return;
if (channelId) {
NavigationActionCreators.selectChannel(guildId, channelId, messageId);
}
}, [guildId, channelId, messageId]);
React.useEffect(() => {
if (!guild || !channelId || guildUnavailable || guildNotFound) return;
const currentChannel = ChannelStore.getChannel(channelId);
const currentPath = RouterUtils.getHistory()?.location.pathname ?? '';
const expectedPath = Routes.guildChannel(guildId, channelId);
if (currentPath === expectedPath && !currentChannel) {
if (firstAccessibleTextChannel) {
RouterUtils.replaceWith(Routes.guildChannel(guildId, firstAccessibleTextChannel.id));
}
}
}, [guild, guildId, channelId, firstAccessibleTextChannel, guildUnavailable, guildNotFound]);
const guildNagbars = (
<>
{shouldShowStaffOnlyGuild && guildId && (
<StaffOnlyGuildNagbar isMobile={mobileLayout.enabled} guildId={guildId} />
)}
{shouldShowInvitesDisabled && guildId && (
<InvitesDisabledNagbar isMobile={mobileLayout.enabled} guildId={guildId} />
)}
</>
);
if (mobileLayout.enabled) {
if (!channelId) {
if (guildUnavailable || guildNotFound) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
{guildUnavailable ? (
<GuildUnavailable
icon={NetworkSlashIcon}
title={t`Community temporarily unavailable`}
description={t`We fluxed up! Hang tight, we're working on it.`}
/>
) : (
<GuildUnavailable
icon={SmileySadIcon}
title={t`This is not the community you're looking for.`}
description={t`The community you're looking for may have been deleted or you may not have access to it.`}
/>
)}
</div>
</div>
</DndContext>
</TopNagbarContext.Provider>
);
}
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<GuildNavbar guild={guild!} />
</DndContext>
</TopNagbarContext.Provider>
);
}
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildMainContent}>{children}</div>
</div>
</DndContext>
</TopNagbarContext.Provider>
);
}
if (guildUnavailable) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={NetworkSlashIcon}
title={t`Community temporarily unavailable`}
description={t`We fluxed up! Hang tight, we're working on it.`}
/>
</div>
</div>
</div>
</DndContext>
</TopNagbarContext.Provider>
);
}
if (guildNotFound) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbarSkeleton />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={SmileySadIcon}
title={t`This is not the community you're looking for.`}
description={t`The community you're looking for may have been deleted or you may not have access to it.`}
/>
</div>
</div>
</div>
</DndContext>
</TopNagbarContext.Provider>
);
}
if (channelId && !ChannelStore.getChannel(channelId) && !firstAccessibleTextChannel) {
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbar guild={guild!} />
<div className={styles.guildMainContent}>
<GuildUnavailable
icon={SmileySadIcon}
title={t`No accessible channels`}
description={t`You don't have access to any channels in this community.`}
/>
</div>
</div>
</div>
</DndContext>
</TopNagbarContext.Provider>
);
}
return (
<TopNagbarContext.Provider value={nagbarContextValue}>
<DndContext>
<div className={hasGuildNagbars ? styles.guildLayoutContainerWithNagbar : styles.guildLayoutContainer}>
{guildNagbars}
<div className={styles.guildLayoutContent}>
<GuildNavbar guild={guild!} />
<div className={styles.guildMainContent}>{children}</div>
</div>
</div>
</DndContext>
</TopNagbarContext.Provider>
);
});

View File

@@ -0,0 +1,98 @@
/*
* 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/>.
*/
.guildNavbarContainer {
display: grid;
grid-template-rows: auto 1fr;
height: calc(
100% -
var(--layout-user-area-reserved-height, 0px) -
var(--layout-mobile-bottom-nav-reserved-height, 0px)
);
min-height: 0;
width: var(--layout-sidebar-width);
min-width: 0;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
background-color: var(--background-secondary);
position: relative;
}
.guildNavbarContainerMobile {
width: 100%;
}
.guildNavbarReserveMobileBottomNav {
--layout-mobile-bottom-nav-reserved-height: var(--mobile-bottom-nav-height);
}
:global(html.platform-native):not(.platform-macos) .guildNavbarContainer {
border-top-left-radius: clamp(8px, 1.2vw, 14px);
background-clip: padding-box;
overflow: hidden;
}
.hoverRoll {
display: inline-block;
vertical-align: top;
cursor: default;
text-align: left;
box-sizing: border-box;
position: relative;
width: 100%;
contain: paint;
}
.default,
.hovered {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
transform-style: preserve-3d;
pointer-events: none;
width: 100%;
transition:
opacity 0.22s ease,
transform 0.22s ease;
}
.hovered {
opacity: 0;
transform: translate3d(0, 107%, 0);
position: absolute;
top: 0;
left: 0;
right: 0;
}
.hoverRoll.forceHover:not(.disabled) .default,
.hoverRoll:hover:not(.disabled) .default {
transform: translate3d(0, -107%, 0);
opacity: 0;
user-select: none;
-webkit-user-select: none;
}
.hoverRoll.forceHover:not(.disabled) .hovered,
.hoverRoll:hover:not(.disabled) .hovered {
transform: translatez(0);
opacity: 1;
}

View File

@@ -0,0 +1,72 @@
/*
* 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 {useMotionValue} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useHotkeys} from 'react-hotkeys-hook';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {ChannelTypes} from '~/Constants';
import {useNativePlatform} from '~/hooks/useNativePlatform';
import type {GuildRecord} from '~/records/GuildRecord';
import ChannelStore from '~/stores/ChannelStore';
import {TopNagbarContext} from './app-layout/TopNagbarContext';
import {ChannelListContent} from './ChannelListContent';
import {GuildHeader} from './GuildHeader';
import {GuildSidebar} from './GuildSidebar';
export const GuildNavbar = observer(({guild}: {guild: GuildRecord}) => {
const scrollY = useMotionValue(0);
const {isNative, isWindows, isLinux} = useNativePlatform();
const hasTopNagbar = React.useContext(TopNagbarContext);
const shouldRoundTopLeft = isNative && (isWindows || isLinux) && !hasTopNagbar;
React.useEffect(() => {
scrollY.set(0);
}, [guild.id, scrollY]);
const channels = ChannelStore.getGuildChannels(guild.id);
const categoryIds = React.useMemo(() => {
return channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id);
}, [channels]);
useHotkeys(
'mod+shift+a',
() => {
if (categoryIds.length > 0) {
UserGuildSettingsActionCreators.toggleAllCategoriesCollapsed(guild.id, categoryIds);
}
},
{
enableOnFormTags: true,
enableOnContentEditable: true,
preventDefault: true,
},
[guild.id, categoryIds],
);
return (
<GuildSidebar
roundTopLeft={shouldRoundTopLeft}
header={<GuildHeader guild={guild} />}
content={<ChannelListContent guild={guild} scrollY={scrollY} />}
/>
);
});

View File

@@ -0,0 +1,176 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.skeletonContainer {
display: grid;
grid-template-rows: auto 1fr;
height: calc(100% - var(--layout-user-area-reserved-height, 0px));
min-height: 0;
width: var(--layout-sidebar-width);
min-width: 0;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
background-color: var(--background-secondary);
padding-bottom: var(--spacing-2);
position: relative;
}
.skeletonContainerMobile {
width: 100%;
}
:global(html.platform-native):not(.platform-macos) .skeletonContainer {
border-top-left-radius: clamp(8px, 1.2vw, 14px);
background-clip: padding-box;
overflow: hidden;
}
.skeletonHeader {
position: relative;
display: flex;
align-items: start;
overflow: hidden;
height: var(--layout-header-height);
min-height: var(--layout-header-height);
border-bottom: 1px solid var(--user-area-divider-color);
background-color: var(--background-secondary);
}
.skeletonHeaderPill {
position: relative;
z-index: 30;
display: flex;
align-items: center;
height: var(--layout-header-height);
width: 100%;
min-width: 0;
padding: 0 var(--spacing-4);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skeletonHeaderPill::after {
content: '';
display: block;
width: 80%;
height: 1.25rem;
background-color: var(--background-modifier-hover);
border-radius: var(--radius-md);
opacity: 0.4;
}
.skeletonContent {
padding: var(--spacing-3);
overflow-y: auto;
}
.skeletonCategory {
padding: var(--spacing-2) var(--spacing-2);
margin-top: var(--spacing-4);
margin-bottom: var(--spacing-2);
}
.skeletonCategoryPill {
width: 60%;
height: 1rem;
background-color: var(--background-modifier-hover);
border-radius: var(--radius-md);
opacity: 0.3;
}
.skeletonChannel {
padding: var(--spacing-2) var(--spacing-2);
margin-bottom: var(--spacing-1-5);
}
.skeletonChannelPill {
width: 75%;
height: 1.25rem;
background-color: var(--background-modifier-hover);
border-radius: var(--radius-md);
opacity: 0.35;
}
.skeletonChannel:nth-child(2) .skeletonChannelPill {
width: 62%;
opacity: 0.42;
}
.skeletonChannel:nth-child(3) .skeletonChannelPill {
width: 84%;
opacity: 0.28;
}
.skeletonChannel:nth-child(4) .skeletonChannelPill {
width: 58%;
opacity: 0.38;
}
.skeletonChannel:nth-child(6) .skeletonChannelPill {
width: 78%;
opacity: 0.31;
}
.skeletonChannel:nth-child(7) .skeletonChannelPill {
width: 55%;
opacity: 0.36;
}
.skeletonChannel:nth-child(10) .skeletonChannelPill {
width: 68%;
opacity: 0.33;
}
.skeletonChannel:nth-child(11) .skeletonChannelPill {
width: 82%;
opacity: 0.29;
}
.skeletonChannel:nth-child(12) .skeletonChannelPill {
width: 71%;
opacity: 0.37;
}
.skeletonChannel:nth-child(13) .skeletonChannelPill {
width: 64%;
opacity: 0.34;
}
.skeletonCategory:nth-child(1) .skeletonCategoryPill {
width: 48%;
opacity: 0.25;
}
.skeletonCategory:nth-child(5) .skeletonCategoryPill {
width: 68%;
opacity: 0.32;
}
.skeletonCategory:nth-child(9) .skeletonCategoryPill {
width: 53%;
opacity: 0.28;
}
.skeletonHeaderPill {
width: 78%;
opacity: 0.36;
}

View File

@@ -0,0 +1,76 @@
/*
* 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 MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './GuildNavbarSkeleton.module.css';
export const GuildNavbarSkeleton = observer(() => {
const mobileLayout = MobileLayoutStore;
return (
<div className={clsx(styles.skeletonContainer, mobileLayout.enabled && styles.skeletonContainerMobile)}>
<div className={styles.skeletonHeader}>
<div className={styles.skeletonHeaderPill} />
</div>
<div className={styles.skeletonContent}>
<div className={styles.skeletonCategory}>
<div className={styles.skeletonCategoryPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonCategory}>
<div className={styles.skeletonCategoryPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonCategory}>
<div className={styles.skeletonCategoryPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
<div className={styles.skeletonChannel}>
<div className={styles.skeletonChannelPill} />
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './GuildNavbar.module.css';
interface GuildSidebarProps {
header: React.ReactNode;
content: React.ReactNode;
roundTopLeft?: boolean;
}
export const GuildSidebar = observer(({header, content, roundTopLeft = true}: GuildSidebarProps) => {
const mobileLayout = MobileLayoutStore;
const location = useLocation();
const showBottomNav =
mobileLayout.enabled &&
(location.pathname === Routes.ME ||
Routes.isFavoritesRoute(location.pathname) ||
location.pathname === Routes.NOTIFICATIONS ||
location.pathname === Routes.YOU ||
(Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
return (
<div
className={clsx(
styles.guildNavbarContainer,
mobileLayout.enabled && styles.guildNavbarContainerMobile,
showBottomNav && styles.guildNavbarReserveMobileBottomNav,
)}
style={roundTopLeft ? undefined : {borderTopLeftRadius: 0}}
>
{header}
{content}
</div>
);
});

View File

@@ -0,0 +1,632 @@
/*
* 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/>.
*/
.guildsLayoutContainer {
--layout-user-area-reserved-height: 0px;
--layout-mobile-bottom-nav-reserved-height: 0px;
position: relative;
display: grid;
grid-template-columns: var(--layout-guild-list-width) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
grid-auto-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
max-height: 100%;
width: 100%;
min-width: 0;
max-width: 100%;
background-color: var(--background-secondary);
}
.guildsLayoutReserveSpace {
--layout-user-area-reserved-height: calc(var(--layout-user-area-height) + var(--layout-voice-connection-height, 0px));
}
.guildsLayoutReserveMobileBottomNav {
--layout-mobile-bottom-nav-reserved-height: var(--mobile-bottom-nav-height);
}
.guildsLayoutContainerMobile {
composes: guildsLayoutContainer;
grid-template-columns: 1fr;
background-color: var(--background-secondary);
}
.guildListScrollContainer {
grid-column: 1;
grid-row: 1;
min-height: 0;
height: calc(
100% -
var(--layout-user-area-reserved-height, 0px) -
var(--layout-mobile-bottom-nav-reserved-height, 0px)
);
width: var(--layout-guild-list-width);
min-width: 0;
overflow-y: auto;
background-color: var(--background-secondary);
padding-top: var(--spacing-1);
padding-bottom: var(--spacing-2);
scrollbar-width: none;
position: relative;
z-index: var(--z-index-elevated-1);
}
.guildListScrollContainer::-webkit-scrollbar {
display: none;
}
.guildListContent {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
padding-bottom: var(--spacing-2);
}
.guildListTopSection,
.guildListGuildsSection {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.guildListTopSection {
gap: var(--spacing-1);
}
.guildListGuildsSection {
gap: var(--spacing-1);
}
.contentContainer {
grid-column: 2;
grid-row: 1;
display: grid;
grid-template-rows: minmax(0, 1fr);
min-height: 0;
min-width: 0;
background-color: var(--background-secondary);
position: relative;
height: 100%;
max-height: 100%;
width: 100%;
max-width: 100%;
}
.contentContainerRounded {
border-top-left-radius: clamp(12px, 1.6vw, 18px);
background-clip: padding-box;
overflow: hidden;
}
.contentContainerMobile {
grid-column: 1 / -1;
}
.contentInner {
height: 100%;
min-height: 0;
max-height: 100%;
width: 100%;
min-width: 0;
background-color: var(--background-secondary);
}
.nagbarStack {
display: flex;
flex-direction: column;
gap: 0;
}
.userAreaWrapper {
position: absolute;
bottom: 0;
left: 0;
width: calc(var(--layout-guild-list-width) + var(--layout-sidebar-width));
display: flex;
align-items: flex-end;
padding: 0;
pointer-events: none;
z-index: var(--z-index-elevated-1);
}
.userAreaWrapper > * {
pointer-events: auto;
}
.guildListItem {
position: relative;
display: flex;
width: 100%;
justify-content: center;
margin-bottom: var(--spacing-1);
padding: 2px;
z-index: 0;
}
.guildListItemNoMargin {
margin-bottom: 0;
}
.guildIcon {
display: flex;
align-items: center;
justify-content: center;
height: var(--guild-icon-size);
width: var(--guild-icon-size);
flex-shrink: 0;
cursor: pointer;
border-radius: var(--radius-full);
background-color: transparent;
background-size: cover;
background-position: center;
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
transition:
border-radius 70ms ease-out,
background-color 70ms ease-out,
color 70ms ease-out;
container-type: size;
}
.guildIcon:active {
transform: translateY(1px);
}
.guildIconSelected:not(.guildIconNoImage) {
border-radius: 30%;
}
@media (hover: hover) and (pointer: fine) {
.guildListItem:hover .guildIcon:not(.guildIconNoImage),
.guildIcon:hover:not(.guildIconNoImage) {
border-radius: 30%;
}
}
.guildIconNoImage {
transition-property: background-color, color, border-radius;
transition-duration: 70ms;
transition-timing-function: ease-out;
background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground));
cursor: pointer;
}
.guildIconSelected.guildIconNoImage {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
}
@media (hover: hover) and (pointer: fine) {
.guildListItem:hover .guildIconNoImage,
.guildIconNoImage:hover {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
}
}
.guildIconInitials {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
width: 100%;
text-align: center;
line-height: 1;
color: inherit;
font-weight: 600;
font-size: clamp(0.85rem, 45cqi, 1.35rem);
letter-spacing: 0.06em;
}
.guildIcon[data-initials-length='medium'] .guildIconInitials {
font-size: clamp(0.85rem, 38cqi, 1.11rem);
letter-spacing: 0.02em;
}
.guildIcon[data-initials-length='long'] .guildIconInitials {
font-size: clamp(0.85rem, 32cqi, 0.87rem);
letter-spacing: -0.02em;
}
.guildIndicator {
position: absolute;
left: -0.15rem;
display: flex;
align-items: center;
justify-content: center;
height: var(--guild-icon-size);
width: 0.5rem;
pointer-events: none;
container-type: layout size;
z-index: 2;
}
.guildIndicatorBar {
display: block;
width: 0.35rem;
border-radius: 0 var(--radius-full) var(--radius-full) 0;
background-color: var(--text-primary);
}
.guildBadge {
position: absolute;
right: -0.25rem;
bottom: -0.25rem;
pointer-events: none;
border-radius: var(--radius-md);
}
.guildBadgeActive {
box-shadow: 0 0 0 3px var(--background-secondary);
}
.dmListSection {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
min-height: 0;
}
.guildVoiceBadge {
position: absolute;
right: -0.25rem;
top: -0.25rem;
pointer-events: none;
}
.guildVoiceBadgeInner {
display: flex;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--status-online);
box-shadow: 0 0 0 3px var(--background-secondary);
color: white;
}
.guildErrorBadge {
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.guildErrorBadgeInner {
display: flex;
align-items: center;
justify-content: center;
height: 1rem;
width: 1rem;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: white;
color: var(--status-danger);
box-shadow: 0 0 0 3px var(--background-secondary);
}
.dmListItem {
composes: guildListItem;
}
.dmListItemWrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: stretch;
}
.dmIcon {
composes: guildIcon;
}
.fluxerButton {
composes: guildListItem;
}
.fluxerButtonIcon {
composes: guildIcon;
background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground));
color: var(--text-primary);
}
.fluxerButtonIconSelected {
background-color: var(--brand-primary);
color: white;
}
@media (hover: hover) and (pointer: fine) {
.fluxerButton:hover .fluxerButtonIcon,
.fluxerButtonIcon:hover {
background-color: var(--brand-primary);
color: white;
}
}
.addGuildButton {
position: relative;
display: flex;
width: 100%;
justify-content: center;
margin-bottom: var(--spacing-1);
padding: 2px;
}
.addGuildButtonIcon {
display: flex;
align-items: center;
justify-content: center;
height: var(--guild-icon-size);
width: var(--guild-icon-size);
flex-shrink: 0;
cursor: pointer;
border-radius: var(--radius-full);
border: 2px dashed var(--background-modifier-accent);
background-color: transparent;
color: var(--text-primary);
transition-property: border-radius, border-color;
transition-duration: 70ms;
transition-timing-function: ease-out;
}
.addGuildButtonIcon:active {
transform: translateY(1px);
}
@media (hover: hover) and (pointer: fine) {
.addGuildButton:hover .addGuildButtonIcon,
.addGuildButtonIcon:hover {
border-color: var(--text-primary);
}
}
.guildDivider {
height: 0.125rem;
width: 2rem;
flex-shrink: 0;
margin-top: var(--spacing-2);
margin-bottom: var(--spacing-2);
border-radius: 1px;
background-color: var(--background-modifier-hover);
}
.dmUserAvatars {
display: flex;
align-items: center;
}
.dmUserAvatar {
height: 2rem;
width: 2rem;
border: 2px solid var(--background-primary);
z-index: 1;
}
.dmUserAvatarImage {
height: 100%;
width: 100%;
}
.fluxerSymbolIcon {
height: 3rem;
width: 3rem;
color: currentColor;
}
.relative {
position: relative;
}
.favoritesIcon {
height: 1.75rem;
width: 1.75rem;
}
.unavailableContainer {
position: relative;
margin-bottom: 0.25rem;
display: flex;
width: 100%;
justify-content: center;
}
.unavailableBadge {
display: flex;
height: 3rem;
width: 3rem;
flex-shrink: 0;
cursor: pointer;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
border: 2px solid var(--status-danger);
background-color: transparent;
color: var(--text-primary);
transition-property: color, background-color;
transition-timing-function: ease-out;
transition-duration: 150ms;
}
@media (hover: hover) and (pointer: fine) {
.unavailableBadge:hover {
background-color: var(--status-danger);
color: white;
}
}
.unavailableBadge:active {
transform: translateY(1px);
}
.unavailableIcon {
height: 2rem;
width: 2rem;
}
.guildTooltipContainer {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
padding: 0.125rem 0;
}
.guildTooltipHeader {
display: flex;
min-width: 0;
align-items: center;
gap: 0.375rem;
}
.guildVerifiedIcon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
color: var(--text-primary);
}
.guildTooltipName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.guildTooltipMessage {
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.guildTooltipError {
font-size: 0.875rem;
color: var(--status-danger);
}
.outlineFrame {
--outline-radius: 0px;
}
:global(html.platform-native:not(.platform-macos)) .guildsLayoutContainer {
padding-top: var(--native-titlebar-height);
}
:global(html.platform-native.platform-macos) .guildListScrollContainer {
padding-top: var(--native-titlebar-height);
}
:global(html:not(.platform-native)) .outlineFrame,
:global(html.platform-native.platform-macos) .outlineFrame {
border-top: none;
}
:global(html.platform-native:not(.platform-macos)) .outlineFrame {
border-top: 1px solid var(--user-area-divider-color);
--outline-radius: clamp(8px, 1.2vw, 14px);
}
.guildMutedInfo {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.375rem;
}
.guildMutedIcon {
height: 0.875rem;
width: 0.875rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.guildMutedText {
font-size: 0.8125rem;
font-weight: 400;
color: var(--text-primary-muted);
}
.guildVoiceInfo {
display: flex;
align-items: center;
gap: 0.375rem;
}
.guildVoiceIcon {
height: 1.75rem;
width: 1.75rem;
color: var(--text-primary-muted);
}
.guildVoiceBadgeIcon {
height: 0.75rem;
width: 0.75rem;
}
.guildErrorIcon {
height: 1rem;
width: 1rem;
}
.guildInvitesPausedBadge {
position: absolute;
right: calc(-0.25rem - 3px);
bottom: calc(-0.25rem - 3px);
pointer-events: none;
background-color: var(--background-secondary);
border-radius: var(--radius-full);
padding: 3px;
}
.guildInvitesPausedBadgeInner {
display: flex;
align-items: center;
justify-content: center;
height: 1.125rem;
width: 1.125rem;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--text-muted);
color: white;
}
.guildInvitesPausedIcon {
height: 0.75rem;
width: 0.75rem;
}
:global(.theme-light) .guildsLayoutContainer {
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 55%, var(--background-primary) 45%);
}
.roundedFull {
border-radius: var(--radius-full);
}

View File

@@ -0,0 +1,378 @@
/*
* 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 {DragEndEvent} from '@dnd-kit/core';
import {DndContext, PointerSensor, useSensor, useSensors} from '@dnd-kit/core';
import {restrictToVerticalAxis} from '@dnd-kit/modifiers';
import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
import {useLingui} from '@lingui/react/macro';
import {ExclamationMarkIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as DimensionActionCreators from '~/actions/DimensionActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {ChannelTypes} from '~/Constants';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {Platform} from '~/lib/Platform';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import CallStateStore from '~/stores/CallStateStore';
import ChannelStore from '~/stores/ChannelStore';
import DimensionStore from '~/stores/DimensionStore';
import GuildAvailabilityStore from '~/stores/GuildAvailabilityStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildReadStateStore from '~/stores/GuildReadStateStore';
import InitializationStore from '~/stores/InitializationStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import NagbarStore from '~/stores/NagbarStore';
import ReadStateStore from '~/stores/ReadStateStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import {useActiveNagbars, useNagbarConditions} from './app-layout/hooks';
import {NagbarContainer} from './app-layout/NagbarContainer';
import {TopNagbarContext} from './app-layout/TopNagbarContext';
import styles from './GuildsLayout.module.css';
import {AddGuildButton} from './guild-list/AddGuildButton';
import {DownloadButton} from './guild-list/DownloadButton';
import {FavoritesButton} from './guild-list/FavoritesButton';
import {FluxerButton} from './guild-list/FluxerButton';
import {DMListItem} from './guild-list/GuildListDMItem';
import {GuildListItem} from './guild-list/GuildListItem';
import {HelpButton} from './guild-list/HelpButton';
import {MobileMentionToast} from './MobileMentionToast';
import {OutlineFrame} from './OutlineFrame';
import {ScrollIndicatorOverlay} from './ScrollIndicatorOverlay';
import {UserArea} from './UserArea';
const isSelectedPath = (pathname: string, path: string) => {
return pathname.startsWith(path);
};
const DM_LIST_REMOVAL_DELAY_MS = 750;
const getUnreadDMChannels = () => {
const dmChannels = ChannelStore.dmChannels;
return dmChannels.filter((channel) => ReadStateStore.hasUnread(channel.id));
};
const GuildList = observer(() => {
const {t} = useLingui();
const [isDragging, setIsDragging] = React.useState(false);
const guilds = GuildListStore.guilds;
const mobileLayout = MobileLayoutStore;
const sensors = useSensors(useSensor(PointerSensor, {activationConstraint: {distance: 8}}));
const unavailableGuilds = GuildAvailabilityStore.unavailableGuilds;
const unreadDMChannelsRaw = getUnreadDMChannels();
const unreadDMChannelIds = unreadDMChannelsRaw.map((c) => c.id).join(',');
const unreadDMChannels = React.useMemo(() => unreadDMChannelsRaw, [unreadDMChannelIds]);
const scrollRef = React.useRef<HTMLDivElement>(null);
const location = useLocation();
const hasUnavailableGuilds = unavailableGuilds.size > 0;
const unavailableCount = unavailableGuilds.size;
const guildReadVersion = GuildReadStateStore.version;
const readVersion = ReadStateStore.version;
const guildIndicatorDependencies = React.useMemo(
() => [guilds.length, guildReadVersion, readVersion, unreadDMChannelIds],
[guilds.length, guildReadVersion, readVersion, unreadDMChannelIds],
);
const getGuildScrollContainer = React.useCallback(() => scrollRef.current, []);
const [visibleDMChannels, setVisibleDMChannels] = React.useState(unreadDMChannels);
const pinnedCallChannel =
MediaEngineStore.connected && MediaEngineStore.channelId
? (() => {
const channel = ChannelStore.getChannel(MediaEngineStore.channelId);
if (!channel) return null;
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) return null;
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
if (!hasActiveCall) return null;
return channel;
})()
: null;
const filteredDMChannels = pinnedCallChannel
? visibleDMChannels.filter((channel) => channel.id !== pinnedCallChannel.id)
: visibleDMChannels;
const hasVisibleDMChannels = filteredDMChannels.length > 0 || Boolean(pinnedCallChannel);
const shouldCollapseFavoritesSpacing = !hasVisibleDMChannels && !hasUnavailableGuilds;
const shouldShowTopDivider = (guilds.length > 0 || hasUnavailableGuilds) && !hasVisibleDMChannels;
const shouldShowEmptyStateDivider = !hasVisibleDMChannels && !hasUnavailableGuilds && guilds.length === 0;
const removalTimers = React.useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
React.useEffect(() => {
const unreadIds = new Set(unreadDMChannels.map((channel) => channel.id));
setVisibleDMChannels((current) => {
const leftover = current.filter((channel) => !unreadIds.has(channel.id));
for (const channel of leftover) {
if (!removalTimers.current.has(channel.id)) {
const timer = setTimeout(() => {
removalTimers.current.delete(channel.id);
setVisibleDMChannels((latest) => latest.filter((latestChannel) => latestChannel.id !== channel.id));
}, DM_LIST_REMOVAL_DELAY_MS);
removalTimers.current.set(channel.id, timer);
}
}
return [...unreadDMChannels, ...leftover];
});
for (const channel of unreadDMChannels) {
const timer = removalTimers.current.get(channel.id);
if (timer) {
clearTimeout(timer);
removalTimers.current.delete(channel.id);
}
}
}, [unreadDMChannels]);
React.useEffect(() => {
return () => {
removalTimers.current.forEach((timer) => clearTimeout(timer));
removalTimers.current.clear();
};
}, []);
const renderDMListItems = (channels: Array<ChannelRecord>) =>
channels.map((channel, index) => {
const isSelected = isSelectedPath(location.pathname, Routes.dmChannel(channel.id));
const isLastItem = index === channels.length - 1;
return (
<div key={channel.id} className={styles.dmListItemWrapper}>
<DMListItem
channel={channel}
isSelected={isSelected}
className={isLastItem ? styles.guildListItemNoMargin : undefined}
/>
</div>
);
});
const handleDragEnd = (event: DragEndEvent) => {
const {active, over} = event;
if (over && active.id !== over.id) {
const oldIndex = guilds.findIndex((guild) => guild.id === active.id);
const newIndex = guilds.findIndex((guild) => guild.id === over.id);
const newArray = arrayMove(guilds.slice(), oldIndex, newIndex);
UserSettingsActionCreators.update({guildPositions: newArray.map((guild) => guild.id)});
}
setIsDragging(false);
};
const handleScroll = React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const scrollTop = event.currentTarget.scrollTop;
DimensionActionCreators.updateGuildListScroll(scrollTop);
}, []);
React.useEffect(() => {
const scrollTop = DimensionStore.getGuildListDimensions().scrollTop;
if (scrollTop > 0 && scrollRef.current) {
scrollRef.current.scrollTop = scrollTop;
}
}, []);
return (
<div ref={scrollRef} className={styles.guildListScrollContainer} onScroll={handleScroll}>
<div className={styles.guildListContent}>
<div className={styles.guildListTopSection}>
<FluxerButton />
<FavoritesButton className={shouldCollapseFavoritesSpacing ? styles.guildListItemNoMargin : undefined} />
<div className={styles.dmListSection}>
{pinnedCallChannel && (
<div className={styles.dmListItemWrapper} key={`pinned-call-${pinnedCallChannel.id}`}>
<DMListItem
channel={pinnedCallChannel}
isSelected={isSelectedPath(location.pathname, Routes.dmChannel(pinnedCallChannel.id))}
voiceCallActive
/>
</div>
)}
{renderDMListItems(filteredDMChannels)}
</div>
{hasVisibleDMChannels && <div className={styles.guildDivider} />}
</div>
<div className={styles.guildListGuildsSection}>
{hasUnavailableGuilds && (
<Tooltip
position="right"
type={'error'}
maxWidth="xl"
size="large"
text={() =>
unavailableCount === 1
? t`${unavailableCount} community is temporarily unavailable due to a flux capacitor malfunction.`
: t`${unavailableCount} communities are temporarily unavailable due to a flux capacitor malfunction.`
}
>
<div className={styles.unavailableContainer}>
<div className={styles.unavailableBadge}>
<ExclamationMarkIcon weight="regular" className={styles.unavailableIcon} />
</div>
</div>
</Tooltip>
)}
{shouldShowTopDivider && <div className={styles.guildDivider} />}
{guilds.length > 0 && (
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
onDragStart={() => setIsDragging(true)}
sensors={sensors}
>
<SortableContext
disabled={guilds.length === 1 || mobileLayout.enabled}
items={guilds.map((guild) => guild.id)}
strategy={verticalListSortingStrategy}
>
{(() => {
const selectedGuildIndex = guilds.findIndex((g) =>
isSelectedPath(location.pathname, Routes.guildChannel(g.id)),
);
return guilds.map((guild, index) => (
<GuildListItem
key={guild.id}
isSortingList={isDragging}
guild={guild}
isSelected={isSelectedPath(location.pathname, Routes.guildChannel(guild.id))}
guildIndex={index}
selectedGuildIndex={selectedGuildIndex}
/>
));
})()}
</SortableContext>
</DndContext>
)}
{shouldShowEmptyStateDivider && <div className={styles.guildDivider} />}
<AddGuildButton />
{!Platform.isElectron && !Platform.isPWA && <DownloadButton />}
<HelpButton />
</div>
</div>
<ScrollIndicatorOverlay
getScrollContainer={getGuildScrollContainer}
dependencies={guildIndicatorDependencies}
label={t`New`}
/>
</div>
);
});
export const GuildsLayout = observer(({children}: {children: React.ReactNode}) => {
const mobileLayout = MobileLayoutStore;
const user = UserStore.currentUser;
const location = useLocation();
const shouldReserveUserAreaSpace = !!user && !mobileLayout.enabled;
const showGuildListOnMobile =
mobileLayout.enabled &&
(location.pathname === Routes.ME ||
(Routes.isChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
const showBottomNav =
mobileLayout.enabled &&
(location.pathname === Routes.ME ||
Routes.isFavoritesRoute(location.pathname) ||
location.pathname === Routes.NOTIFICATIONS ||
location.pathname === Routes.YOU ||
(Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
const nagbarConditions = useNagbarConditions();
const activeNagbars = useActiveNagbars(nagbarConditions);
const prevNagbarCount = React.useRef(activeNagbars.length);
const isReady = InitializationStore.isReady;
React.useEffect(() => {
if (prevNagbarCount.current !== activeNagbars.length) {
prevNagbarCount.current = activeNagbars.length;
ComponentDispatch.dispatch('LAYOUT_RESIZED');
}
}, [activeNagbars.length]);
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
React.useEffect(() => {
if (!isReady) return;
if (!user) return;
if (NagbarStore.claimAccountModalShownThisSession) return;
if (user.isClaimed()) return;
if (location.pathname === Routes.PENDING_VERIFICATION) return;
const accountAgeMs = SnowflakeUtils.age(user.id);
if (accountAgeMs < THIRTY_MINUTES_MS) return;
NagbarStore.markClaimAccountModalShown();
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
}, [isReady, user, location.pathname]);
const shouldShowSidebarDivider = !mobileLayout.enabled;
return (
<div
className={clsx(
styles.guildsLayoutContainer,
mobileLayout.enabled && !showGuildListOnMobile && styles.guildsLayoutContainerMobile,
shouldReserveUserAreaSpace && styles.guildsLayoutReserveSpace,
showBottomNav && styles.guildsLayoutReserveMobileBottomNav,
)}
>
{(!mobileLayout.enabled || showGuildListOnMobile) && <GuildList />}
<div
className={clsx(
styles.contentContainer,
mobileLayout.enabled && !showGuildListOnMobile && styles.contentContainerMobile,
)}
>
<TopNagbarContext.Provider value={activeNagbars.length > 0}>
<OutlineFrame
className={styles.outlineFrame}
sidebarDivider={shouldShowSidebarDivider}
topBanner={<MobileMentionToast />}
nagbar={
activeNagbars.length > 0 ? (
<div className={styles.nagbarStack}>
<NagbarContainer nagbars={activeNagbars} />
</div>
) : null
}
>
<div className={styles.contentInner}>{children}</div>
</OutlineFrame>
</TopNagbarContext.Provider>
</div>
{!mobileLayout.enabled && user && (
<div className={styles.userAreaWrapper}>
<UserArea user={user} />
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,98 @@
/*
* 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 FocusRingManager from '~/components/uikit/FocusRing/FocusRingManager';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
export const KeyboardModeListener = observer(function KeyboardModeListener() {
const keyboardModeEnabled = KeyboardModeStore.keyboardModeEnabled;
const location = useLocation();
const isAuthRoute = React.useMemo(() => {
const path = location.pathname;
return (
path.startsWith(Routes.LOGIN) ||
path.startsWith(Routes.REGISTER) ||
path.startsWith(Routes.FORGOT_PASSWORD) ||
path.startsWith(Routes.RESET_PASSWORD) ||
path.startsWith(Routes.VERIFY_EMAIL) ||
path.startsWith(Routes.AUTHORIZE_IP) ||
path.startsWith(Routes.PENDING_VERIFICATION) ||
path.startsWith(Routes.OAUTH_AUTHORIZE) ||
path.startsWith('/invite/') ||
path.startsWith('/gift/')
);
}, [location.pathname]);
React.useEffect(() => {
let lastWindowFocusTime = document.hasFocus() ? 0 : -Infinity;
const REFOCUS_THRESHOLD_MS = 100;
const handleWindowFocus = () => {
lastWindowFocusTime = performance.now();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
if (!KeyboardModeStore.keyboardModeEnabled) {
const textarea = document.querySelector<HTMLTextAreaElement>('[data-channel-textarea]');
const hasEnabledTextarea =
textarea && !textarea.disabled && textarea.getAttribute('aria-disabled') !== 'true';
if (hasEnabledTextarea) {
event.preventDefault();
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {enterKeyboardMode: true});
return;
}
}
KeyboardModeStore.enterKeyboardMode(!isAuthRoute);
}
};
const handlePointer = () => {
const timeSinceFocus = performance.now() - lastWindowFocusTime;
if (timeSinceFocus > REFOCUS_THRESHOLD_MS) {
KeyboardModeStore.exitKeyboardMode();
}
};
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('mousedown', handlePointer, true);
window.addEventListener('pointerdown', handlePointer, true);
return () => {
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('mousedown', handlePointer, true);
window.removeEventListener('pointerdown', handlePointer, true);
};
}, [isAuthRoute]);
React.useEffect(() => {
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
}, [keyboardModeEnabled]);
return null;
});

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
height: var(--mobile-bottom-nav-height);
align-items: center;
justify-content: space-around;
border-top: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
}
:global(.theme-light) .container {
background-color: var(--background-primary);
}
.navButton {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
}
.navButtonActive {
color: var(--text-primary);
}
.navButtonInactive {
color: var(--text-primary-muted);
}
.voiceButton {
color: var(--status-online);
}
.icon {
height: 1.5rem;
width: 1.5rem;
}
.label {
font-weight: 600;
font-size: 10px;
}

View File

@@ -0,0 +1,135 @@
/*
* 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 {BellIcon, HouseIcon, SpeakerHighIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {VoiceLobbyBottomSheet} from '~/components/bottomsheets/VoiceLobbyBottomSheet';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {useConnectedVoiceSession} from '~/hooks/useConnectedVoiceSession';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import type {UserRecord} from '~/records/UserRecord';
import * as RouterUtils from '~/utils/RouterUtils';
import styles from './MobileBottomNav.module.css';
interface MobileBottomNavProps {
currentUser: UserRecord;
}
export const MobileBottomNav = observer(({currentUser}: MobileBottomNavProps) => {
const location = useLocation();
const lastChannelPathRef = React.useRef<string | null>(null);
const [voiceLobbyOpen, setVoiceLobbyOpen] = React.useState(false);
const isHomeActive = Routes.isChannelRoute(location.pathname);
const isNotificationsActive = location.pathname === Routes.NOTIFICATIONS;
const isYouActive = location.pathname === Routes.YOU;
const {channel: voiceChannel, guild: voiceGuild, isConnected: isConnectedToVoice} = useConnectedVoiceSession();
React.useEffect(() => {
if (Routes.isChannelRoute(location.pathname)) {
lastChannelPathRef.current = location.pathname;
}
}, [location.pathname]);
const handleHomeNavigation = () => {
if (lastChannelPathRef.current && (isNotificationsActive || isYouActive)) {
RouterUtils.transitionTo(lastChannelPathRef.current);
} else {
RouterUtils.transitionTo(Routes.ME);
}
};
const handleNavigation = (path: string) => {
RouterUtils.transitionTo(path);
};
const handleVoiceIndicatorPress = () => {
setVoiceLobbyOpen(true);
};
const handleCloseVoiceLobby = () => {
setVoiceLobbyOpen(false);
};
return (
<>
<div className={styles.container}>
<button
type="button"
onClick={handleHomeNavigation}
className={clsx(styles.navButton, isHomeActive ? styles.navButtonActive : styles.navButtonInactive)}
>
<HouseIcon weight="fill" className={styles.icon} />
<span className={styles.label}>
<Trans>Home</Trans>
</span>
</button>
{isConnectedToVoice && (
<button
type="button"
onClick={handleVoiceIndicatorPress}
className={clsx(styles.navButton, styles.voiceButton)}
>
<SpeakerHighIcon weight="fill" className={styles.icon} />
<span className={styles.label}>
<Trans>Voice</Trans>
</span>
</button>
)}
<button
type="button"
onClick={() => handleNavigation(Routes.NOTIFICATIONS)}
className={clsx(styles.navButton, isNotificationsActive ? styles.navButtonActive : styles.navButtonInactive)}
>
<BellIcon weight="fill" className={styles.icon} />
<span className={styles.label}>
<Trans>Notifications</Trans>
</span>
</button>
<button
type="button"
onClick={() => handleNavigation(Routes.YOU)}
className={clsx(styles.navButton, isYouActive ? styles.navButtonActive : styles.navButtonInactive)}
>
<StatusAwareAvatar user={currentUser} size={24} showOffline={true} />
<span className={styles.label}>
<Trans>You</Trans>
</span>
</button>
</div>
{isConnectedToVoice && voiceChannel && voiceGuild && (
<VoiceLobbyBottomSheet
isOpen={voiceLobbyOpen}
onClose={handleCloseVoiceLobby}
channel={voiceChannel}
guild={voiceGuild}
/>
)}
</>
);
});

View File

@@ -0,0 +1,116 @@
/*
* 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/>.
*/
.host {
position: sticky;
top: 0;
z-index: var(--z-index-elevated-3);
display: flex;
justify-content: center;
width: 100%;
padding: 8px 16px 0;
pointer-events: none;
}
.toast {
width: min(100%, 640px);
background-color: var(--background-secondary);
border-radius: 16px;
border: 1px solid var(--background-modifier-accent);
box-shadow:
0 10px 20px -4px rgb(0 0 0 / 0.2),
0 4px 8px -6px rgb(0 0 0 / 0.25);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
pointer-events: auto;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--text-primary);
}
.author {
font-weight: 600;
}
.separator {
color: var(--text-primary-muted);
}
.location {
font-size: 0.82rem;
color: var(--text-primary-muted);
text-transform: capitalize;
}
.mentionLabel {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.messageContent {
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.messageContent :global(*) {
margin: 0;
}
.systemLabel,
.attachmentLabel {
font-size: 0.83rem;
color: var(--text-primary-muted);
line-height: 1.2;
}
.progressTrack {
height: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--brand-primary-light) 40%, var(--background-modifier-accent) 60%);
overflow: hidden;
}
.progressFill {
height: 100%;
width: 100%;
transform-origin: left;
background: var(--brand-primary-light);
}
:global(.theme-light) .progressTrack {
background: color-mix(in srgb, var(--brand-primary) 40%, var(--background-modifier-accent) 60%);
}
:global(.theme-light) .progressFill {
background: var(--brand-primary);
}

View File

@@ -0,0 +1,166 @@
/*
* 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 {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {AnimatePresence, motion, type PanInfo} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect} from 'react';
import {MessageTypes} from '~/Constants';
import {SafeMarkdown} from '~/lib/markdown';
import {MarkdownContext} from '~/lib/markdown/renderers';
import type {MessageRecord} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import MobileMentionToastStore from '~/stores/MobileMentionToastStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {SystemMessageUtils} from '~/utils/SystemMessageUtils';
import styles from './MobileMentionToast.module.css';
const DISPLAY_DURATION_MS = 3000;
const getChannelLabel = (channelId: string, i18n: I18n): string => {
const channel = ChannelStore.getChannel(channelId);
if (!channel) {
return i18n._(msg`Unknown channel`);
}
if (channel.isGuildText()) {
const channelName = channel.name?.trim();
const fallback = i18n._(msg`Unknown channel`);
return channelName ? `#${channelName}` : fallback;
}
return ChannelUtils.getDMDisplayName(channel);
};
const getLocationLabel = (message: MessageRecord, i18n: I18n): string => {
const channel = ChannelStore.getChannel(message.channelId);
const channelLabel = getChannelLabel(message.channelId, i18n);
if (channel?.guildId) {
const guild = GuildStore.getGuild(channel.guildId);
if (guild && channel.isGuildText()) {
return `${guild.name}${channelLabel}`;
}
}
return channelLabel;
};
const renderMessageContent = (message: MessageRecord, i18n: I18n): React.ReactNode => {
if (message.type !== MessageTypes.DEFAULT && message.type !== MessageTypes.REPLY) {
const systemText = SystemMessageUtils.stringify(message, i18n);
if (systemText) {
return <span className={styles.systemLabel}>{systemText.replace(/\.$/, '')}</span>;
}
return null;
}
if (message.content) {
return (
<div className={styles.messageContent}>
<SafeMarkdown
content={message.content}
options={{
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
channelId: message.channelId,
messageId: message.id,
disableAnimatedEmoji: true,
}}
/>
</div>
);
}
if (message.attachments.length > 0) {
return <span className={styles.attachmentLabel}>{i18n._(msg`Sent an attachment`)}</span>;
}
return null;
};
export const MobileMentionToast = observer(() => {
const {i18n} = useLingui();
const current = MobileMentionToastStore.current;
const isMobile = isMobileExperienceEnabled();
useEffect(() => {
if (!current || !isMobile) return;
const timer = setTimeout(() => {
MobileMentionToastStore.dequeue(current.id);
}, DISPLAY_DURATION_MS);
return () => clearTimeout(timer);
}, [current?.id, isMobile]);
if (!isMobile || !current) {
return null;
}
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (Math.abs(info.offset.x) > 60) {
MobileMentionToastStore.dequeue(current.id);
}
};
const locationLabel = getLocationLabel(current, i18n);
return (
<div className={styles.host} role="status" aria-live="polite">
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={current.id}
className={styles.toast}
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
transition={{duration: 0.2, ease: 'easeOut'}}
drag="x"
dragConstraints={{left: 0, right: 0}}
dragElastic={0.2}
onDragEnd={handleDragEnd}
>
<div className={styles.header}>
<span className={styles.author}>{current.author.displayName}</span>
<span className={styles.separator} aria-hidden="true">
</span>
<span className={styles.location}>{locationLabel}</span>
<span className={styles.mentionLabel}>{i18n._(msg`Mentioned you`)}</span>
</div>
{renderMessageContent(current, i18n)}
<div className={styles.progressTrack} aria-hidden="true">
<motion.div
key={current.id}
className={styles.progressFill}
initial={{scaleX: 1}}
animate={{scaleX: 0}}
transition={{duration: DISPLAY_DURATION_MS / 1000, ease: 'linear'}}
/>
</div>
</motion.div>
</AnimatePresence>
</div>
);
});

View File

@@ -0,0 +1,67 @@
/*
* 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/>.
*/
.nagbar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.nagbarDismissible {
padding-right: 2.5rem;
}
.nagbarDesktop {
min-height: 36px;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
font-weight: 600;
font-size: 0.875rem;
}
.nagbarMobile {
min-height: 48px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
font-size: 0.75rem;
}
.dismissButton {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: transparent;
cursor: pointer;
padding: 0.25rem;
-webkit-app-region: no-drag;
}
.dismissIcon {
height: 1.25rem;
width: 1.25rem;
display: block;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {NativeDragRegion} from '~/components/layout/NativeDragRegion';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import styles from './Nagbar.module.css';
interface NagbarProps {
isMobile: boolean;
backgroundColor: string;
textColor: string;
children: React.ReactNode;
onDismiss?: () => void;
dismissible?: boolean;
}
export const Nagbar = observer(
({isMobile, backgroundColor, textColor, children, onDismiss, dismissible = false}: NagbarProps) => {
const showDismissButton = dismissible && onDismiss && !isMobile;
return (
<NativeDragRegion
className={clsx(
styles.nagbar,
isMobile ? styles.nagbarMobile : styles.nagbarDesktop,
showDismissButton && styles.nagbarDismissible,
)}
style={
{
backgroundColor,
color: textColor,
'--nagbar-background-color': backgroundColor,
} as React.CSSProperties
}
>
{children}
{showDismissButton && (
<FocusRing>
<button
type="button"
className={styles.dismissButton}
style={{color: textColor}}
aria-label="Close"
onClick={onDismiss}
>
<XIcon weight="regular" className={styles.dismissIcon} />
</button>
</FocusRing>
)}
</NativeDragRegion>
);
},
);

View File

@@ -0,0 +1,23 @@
/*
* 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 {
font-weight: 600;
-webkit-app-region: no-drag;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import type React from 'react';
import {Button} from '~/components/uikit/Button/Button';
import styles from './NagbarButton.module.css';
interface NagbarButtonProps {
children: React.ReactNode;
onClick: () => void;
isMobile: boolean;
className?: string;
disabled?: boolean;
}
export const NagbarButton = ({children, onClick, isMobile, className, disabled = false}: NagbarButtonProps) => {
return (
<Button
variant="inverted-outline"
superCompact={!isMobile}
compact={isMobile}
fitContent
className={clsx(styles.button, className)}
onClick={onClick}
disabled={disabled}
>
{children}
</Button>
);
};

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 0.75rem;
}
.containerMobile {
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.message {
text-align: center;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.actionsMobile {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}

View File

@@ -0,0 +1,37 @@
/*
* 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 React from 'react';
import styles from './NagbarContent.module.css';
interface NagbarContentProps {
message: React.ReactNode;
actions?: React.ReactNode;
isMobile: boolean;
}
export const NagbarContent = ({message, actions, isMobile}: NagbarContentProps) => {
return (
<div className={clsx(styles.container, isMobile && styles.containerMobile)}>
<p className={styles.message}>{message}</p>
{actions && <div className={clsx(styles.actions, isMobile && styles.actionsMobile)}>{actions}</div>}
</div>
);
};

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.nativeDragRegion {
-webkit-app-region: none;
}
:global(html.platform-native.platform-macos) .nativeDragRegion {
-webkit-app-region: drag;
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import type {MotionStyle} from 'framer-motion';
import React from 'react';
import styles from './NativeDragRegion.module.css';
type ElementType = React.ElementType;
type NativeDragRegionProps = Omit<React.HTMLAttributes<HTMLElement>, 'style'> & {
as?: ElementType;
disabled?: boolean;
style?: React.CSSProperties | MotionStyle;
};
export const NativeDragRegion = React.forwardRef<HTMLElement, NativeDragRegionProps>(
function NativeDragRegionInner(props, ref) {
const {as, disabled = false, className, ...rest} = props;
const Component = (as ?? 'div') as ElementType;
return (
<Component
ref={ref as React.Ref<HTMLElement>}
className={clsx(className, !disabled && styles.nativeDragRegion)}
{...rest}
/>
);
},
);
NativeDragRegion.displayName = 'NativeDragRegion';

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.titlebar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--native-titlebar-height);
display: flex;
align-items: center;
padding: 0 calc(var(--spacing-3) + env(safe-area-inset-right)) 0 calc(var(--spacing-3) + env(safe-area-inset-left));
gap: var(--spacing-2);
background: var(--background-secondary);
-webkit-app-region: drag;
z-index: var(--z-index-titlebar);
}
.left {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--text-muted);
opacity: 0.8;
}
.wordmark {
height: 14px;
width: auto;
color: var(--text-muted);
}
.spacer {
flex: 1 1 auto;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
}
.controlButton {
-webkit-app-region: no-drag;
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
width: 28px;
height: 20px;
border-radius: var(--radius-sm);
display: grid;
place-items: center;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease;
cursor: pointer;
}
.controlButton svg {
width: 17px;
height: 17px;
}
.controlButton:hover {
background: var(--background-modifier-hover);
color: var(--text-primary);
border-color: var(--user-area-divider-color);
}
.controlButton:active {
background: var(--background-modifier-active);
}
.closeButton:hover {
background: #e81123;
color: #fff;
border-color: transparent;
}
.closeButton:active {
background: #b50d1a;
}

View File

@@ -0,0 +1,105 @@
/*
* 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 {CopySimple, Minus, Square, X} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import FluxerWordmark from '~/images/fluxer-wordmark.svg?react';
import {getElectronAPI, type NativePlatform} from '~/utils/NativeUtils';
import styles from './NativeTitlebar.module.css';
interface NativeTitlebarProps {
platform: NativePlatform;
}
export const NativeTitlebar: React.FC<NativeTitlebarProps> = ({platform}) => {
const [isMaximized, setIsMaximized] = React.useState(false);
React.useEffect(() => {
const electronApi = getElectronAPI();
if (!electronApi) return;
const unsubscribe = electronApi.onWindowMaximizeChange((maximized: boolean) => {
setIsMaximized(maximized);
});
return () => {
unsubscribe();
};
}, []);
const handleMinimize = () => {
const electronApi = getElectronAPI();
electronApi?.windowMinimize();
};
const handleToggleMaximize = () => {
const electronApi = getElectronAPI();
if (!electronApi) return;
electronApi.windowMaximize();
};
const handleClose = () => {
const electronApi = getElectronAPI();
electronApi?.windowClose();
};
const handleDoubleClick = () => {
handleToggleMaximize();
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Titlebar needs to capture double clicks
<div className={styles.titlebar} onDoubleClick={handleDoubleClick} data-platform={platform}>
<div className={styles.left}>
<FluxerWordmark className={styles.wordmark} />
</div>
<div className={styles.spacer} />
<div className={styles.controls}>
<FocusRing offset={-2}>
<button type="button" className={styles.controlButton} onClick={handleMinimize} aria-label="Minimize window">
<Minus weight="bold" />
</button>
</FocusRing>
<FocusRing offset={-2}>
<button
type="button"
className={styles.controlButton}
onClick={handleToggleMaximize}
aria-label={isMaximized ? 'Restore window' : 'Maximize window'}
>
{isMaximized ? <CopySimple weight="bold" /> : <Square weight="bold" />}
</button>
</FocusRing>
<FocusRing offset={-2}>
<button
type="button"
className={clsx(styles.controlButton, styles.closeButton)}
onClick={handleClose}
aria-label="Close window"
>
<X weight="bold" />
</button>
</FocusRing>
</div>
</div>
);
};

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.backdropBase {
display: none;
position: fixed;
top: 0;
left: 0;
height: var(--native-titlebar-height);
background: var(--background-secondary);
pointer-events: none;
z-index: var(--z-index-elevated-2);
width: var(--traffic-lights-backdrop-width, var(--layout-guild-list-width, 72px));
}
.backdropApp {
right: auto;
}
.backdropAuth {
height: 32px;
width: 76px;
top: 0;
left: 0;
border-bottom-right-radius: var(--radius-xl);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
}
:global(html.platform-native.platform-macos) .backdropBase {
display: block;
}

View File

@@ -0,0 +1,39 @@
/*
* 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 React from 'react';
import styles from './NativeTrafficLightsBackdrop.module.css';
interface NativeTrafficLightsBackdropProps {
variant?: 'app' | 'auth';
className?: string;
}
export const NativeTrafficLightsBackdrop: React.FC<NativeTrafficLightsBackdropProps> = ({
variant = 'app',
className,
}) => {
return (
<div
aria-hidden="true"
className={clsx(styles.backdropBase, variant === 'auth' ? styles.backdropAuth : styles.backdropApp, className)}
/>
);
};

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
position: absolute;
top: 0;
bottom: 0;
left: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
}
.containerDragging {
pointer-events: auto;
}
.containerNotDragging {
pointer-events: none;
}
.indicator {
height: 0.125rem;
width: 100%;
border-radius: 9999px;
background-color: var(--brand-primary);
transition:
transform 150ms,
opacity 150ms;
}
.indicatorVisible {
transform: scaleY(1);
opacity: 1;
}
.indicatorHidden {
transform: scaleY(0);
opacity: 0;
}

View File

@@ -0,0 +1,76 @@
/*
* 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 {ConnectableElement} from 'react-dnd';
import {useDrop} from 'react-dnd';
import styles from './NullSpaceDropIndicator.module.css';
import {DND_TYPES, type DragItem, type DropResult} from './types/dnd';
interface NullSpaceDropIndicatorProps {
isDraggingAnything: boolean;
onChannelDrop?: (item: DragItem, result: DropResult) => void;
variant?: 'top' | 'bottom';
}
export const NullSpaceDropIndicator = observer(
({isDraggingAnything, onChannelDrop, variant = 'top'}: NullSpaceDropIndicatorProps) => {
const [{isOver, canDrop}, dropRef] = useDrop(
() => ({
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY],
drop: (item: DragItem): DropResult => {
const result: DropResult =
variant === 'top'
? {targetId: 'null-space', position: 'before', targetParentId: null}
: {targetId: 'trailing-space', position: 'after', targetParentId: null};
onChannelDrop?.(item, result);
return result;
},
collect: (monitor) => ({
isOver: monitor.isOver({shallow: true}),
canDrop: monitor.canDrop(),
}),
}),
[onChannelDrop, variant],
);
const dropConnectorRef = React.useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
},
[dropRef],
);
return (
<div
ref={dropConnectorRef}
className={clsx(styles.container, isDraggingAnything ? styles.containerDragging : styles.containerNotDragging)}
>
<div
className={clsx(
styles.indicator,
isOver && canDrop && isDraggingAnything ? styles.indicatorVisible : styles.indicatorHidden,
)}
/>
</div>
);
},
);

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.frame {
position: relative;
height: 100%;
width: 100%;
border: 1px solid var(--user-area-divider-color);
border-top: none;
border-top-left-radius: var(--outline-radius, 0px);
background: transparent;
overflow: hidden;
display: flex;
flex-direction: column;
}
.frameShowTop {
border-top: 1px solid var(--user-area-divider-color);
}
.frameHideTop {
border-top: none;
}
.frameHideTop {
border-top: none;
}
.contentWrapper {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.divider {
position: absolute;
top: 0;
bottom: 0;
left: var(--layout-sidebar-width);
width: 1px;
pointer-events: none;
background: var(--user-area-divider-color);
z-index: 2;
}
.body {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
z-index: 1;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import React from 'react';
import {FrameContext, type FrameSides} from './FrameContext';
import styles from './OutlineFrame.module.css';
interface OutlineFrameProps {
sidebarDivider?: boolean;
hideTopBorder?: boolean;
sides?: FrameSides;
nagbar?: React.ReactNode;
topBanner?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export const OutlineFrame: React.FC<OutlineFrameProps> = ({
sidebarDivider = false,
hideTopBorder = false,
sides,
topBanner,
nagbar,
children,
className,
}) => {
const ctxSides = React.useMemo<FrameSides>(() => {
return {
top: !hideTopBorder,
right: true,
bottom: true,
left: true,
...sides,
};
}, [hideTopBorder, sides]);
const showTopBorder = ctxSides.top !== false;
const frameStyle = React.useMemo<React.CSSProperties>(() => {
return {
borderLeft: ctxSides.left === false ? 'none' : undefined,
borderRight: ctxSides.right === false ? 'none' : undefined,
borderBottom: ctxSides.bottom === false ? 'none' : undefined,
};
}, [ctxSides.bottom, ctxSides.left, ctxSides.right]);
return (
<div
className={clsx(
styles.frame,
showTopBorder && styles.frameShowTop,
!showTopBorder && styles.frameHideTop,
className,
)}
style={frameStyle}
>
<FrameContext.Provider value={ctxSides}>
{topBanner}
{nagbar}
<div className={styles.contentWrapper}>
{sidebarDivider && <div className={styles.divider} aria-hidden />}
<div className={styles.body}>{children}</div>
</div>
</FrameContext.Provider>
</div>
);
};

View File

@@ -0,0 +1,77 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.scrollIndicatorLayer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: var(--z-index-elevated-2);
}
.indicatorSlot {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 100%;
display: flex;
justify-content: center;
pointer-events: none;
}
.indicatorSlotTop {
top: 8px;
}
.indicatorSlotBottom {
bottom: 8px;
}
.indicator {
pointer-events: auto;
border: none;
border-radius: 999px;
padding: 0.25rem 0.75rem;
font-weight: 600;
font-size: 0.6875rem;
line-height: 1rem;
letter-spacing: 0.025em;
text-transform: uppercase;
color: white;
cursor: pointer;
box-shadow:
0 10px 20px -12px rgba(0, 0, 0, 0.75),
0 4px 6px -2px rgba(0, 0, 0, 0.45);
transition: transform 0.2s ease;
}
.indicator:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--brand-primary),
0 10px 20px -12px rgba(0, 0, 0, 0.75),
0 4px 6px -2px rgba(0, 0, 0, 0.45);
}
.indicatorBrand {
background-color: hsl(220, 6%, 30%);
}
.indicatorMention {
background-color: var(--status-danger);
}

View File

@@ -0,0 +1,202 @@
/*
* 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 React from 'react';
import styles from './ScrollIndicatorOverlay.module.css';
export type ScrollIndicatorSeverity = 'mention' | 'unread';
export interface ScrollEdgeIndicator {
element: HTMLElement;
severity: ScrollIndicatorSeverity;
id?: string;
}
const severityOrder: Record<ScrollIndicatorSeverity, number> = {
mention: 2,
unread: 1,
};
export const useScrollEdgeIndicators = (
getScrollContainer: () => HTMLElement | null,
dependencies: React.DependencyList = [],
) => {
const [topIndicator, setTopIndicator] = React.useState<ScrollEdgeIndicator | null>(null);
const [bottomIndicator, setBottomIndicator] = React.useState<ScrollEdgeIndicator | null>(null);
const refresh = React.useCallback(() => {
const container = getScrollContainer();
if (!container) {
setTopIndicator(null);
setBottomIndicator(null);
return;
}
const containerRect = container.getBoundingClientRect();
const nodes = container.querySelectorAll<HTMLElement>(
'[data-scroll-indicator="mention"],[data-scroll-indicator="unread"]',
);
let nextTop: ScrollEdgeIndicator | null = null;
let nextBottom: ScrollEdgeIndicator | null = null;
let topDistance = Infinity;
let bottomDistance = Infinity;
for (const node of nodes) {
const datasetValue = node.dataset.scrollIndicator as ScrollIndicatorSeverity | undefined;
if (!datasetValue) continue;
const rect = node.getBoundingClientRect();
const nodeId = node.dataset.scrollId;
if (rect.bottom <= containerRect.top) {
const distance = containerRect.top - rect.bottom;
if (
!nextTop ||
severityOrder[datasetValue] > severityOrder[nextTop.severity] ||
(severityOrder[datasetValue] === severityOrder[nextTop.severity] && distance < topDistance)
) {
nextTop = {element: node, severity: datasetValue, id: nodeId};
topDistance = distance;
}
} else if (rect.top >= containerRect.bottom) {
const distance = rect.top - containerRect.bottom;
if (
!nextBottom ||
severityOrder[datasetValue] > severityOrder[nextBottom.severity] ||
(severityOrder[datasetValue] === severityOrder[nextBottom.severity] && distance < bottomDistance)
) {
nextBottom = {element: node, severity: datasetValue, id: nodeId};
bottomDistance = distance;
}
}
}
setTopIndicator((previous) => {
if (previous && nextTop) {
if (previous.element === nextTop.element && previous.severity === nextTop.severity) {
return previous;
}
}
return nextTop;
});
setBottomIndicator((previous) => {
if (previous && nextBottom) {
if (previous.element === nextBottom.element && previous.severity === nextBottom.severity) {
return previous;
}
}
return nextBottom;
});
}, [getScrollContainer]);
React.useLayoutEffect(() => {
refresh();
}, [refresh, ...dependencies]);
React.useEffect(() => {
const container = getScrollContainer();
if (!container) return;
const handleScroll = () => refresh();
container.addEventListener('scroll', handleScroll, {passive: true});
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, [getScrollContainer, refresh]);
React.useEffect(() => {
const handleResize = () => refresh();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [refresh]);
return {topIndicator, bottomIndicator, refresh};
};
interface FloatingScrollIndicatorProps {
label: React.ReactNode;
severity: ScrollIndicatorSeverity;
direction: 'top' | 'bottom';
onClick: () => void;
}
const FloatingScrollIndicator = ({label, severity, direction, onClick}: FloatingScrollIndicatorProps) => {
const offset = direction === 'top' ? -16 : 16;
return (
<motion.button
type="button"
className={clsx(styles.indicator, severity === 'mention' ? styles.indicatorMention : styles.indicatorBrand)}
onClick={onClick}
initial={{opacity: 0, y: offset}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: offset}}
transition={{type: 'spring', stiffness: 500, damping: 20}}
aria-label={typeof label === 'string' ? label : undefined}
>
{label}
</motion.button>
);
};
interface ScrollIndicatorOverlayProps {
getScrollContainer: () => HTMLElement | null;
dependencies?: React.DependencyList;
label: React.ReactNode;
}
export const ScrollIndicatorOverlay = ({getScrollContainer, dependencies = [], label}: ScrollIndicatorOverlayProps) => {
const {topIndicator, bottomIndicator} = useScrollEdgeIndicators(getScrollContainer, dependencies);
const scrollIndicatorIntoView = (indicator: ScrollEdgeIndicator | null) => {
if (!indicator) return;
indicator.element.scrollIntoView({behavior: 'smooth', block: 'nearest'});
};
return (
<div className={styles.scrollIndicatorLayer}>
<AnimatePresence initial={false}>
{topIndicator && (
<div className={clsx(styles.indicatorSlot, styles.indicatorSlotTop)}>
<FloatingScrollIndicator
direction="top"
severity={topIndicator.severity}
onClick={() => scrollIndicatorIntoView(topIndicator)}
label={label}
/>
</div>
)}
{bottomIndicator && (
<div className={clsx(styles.indicatorSlot, styles.indicatorSlotBottom)}>
<FloatingScrollIndicator
direction="bottom"
severity={bottomIndicator.severity}
onClick={() => scrollIndicatorIntoView(bottomIndicator)}
label={label}
/>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,157 @@
/*
* 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/>.
*/
.splashOverlay {
position: fixed;
inset: 0;
z-index: var(--z-index-overlay);
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-secondary);
padding-left: var(--spacing-6);
padding-right: var(--spacing-6);
}
:global(html.platform-native:not(.platform-macos)) .splashOverlay {
top: var(--native-titlebar-height);
}
.topDragRegion {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--layout-header-height);
z-index: 100;
pointer-events: none;
}
:global(html.platform-native.platform-macos) .topDragRegion {
pointer-events: auto;
}
@media (min-width: 640px) {
.splashOverlay {
padding-left: var(--spacing-8);
padding-right: var(--spacing-8);
}
}
@media (min-width: 768px) {
.splashOverlay {
padding-left: var(--spacing-12);
padding-right: var(--spacing-12);
}
}
.splashContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-4);
width: 100%;
max-width: 28rem;
}
@media (min-width: 640px) {
.splashContent {
max-width: 32rem;
}
}
@media (min-width: 768px) {
.splashContent {
max-width: 36rem;
}
}
@media (min-width: 1024px) {
.splashContent {
max-width: 42rem;
}
}
.iconWrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
}
@media (min-width: 640px) {
.iconWrapper {
width: 6rem;
height: 6rem;
}
}
@media (min-width: 768px) {
.iconWrapper {
width: 7rem;
height: 7rem;
}
}
.iconPulse {
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--brand-primary);
opacity: 0.75;
animation: splashPulse 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.icon {
position: relative;
z-index: var(--z-index-elevated-1);
width: 5rem;
height: 5rem;
}
@media (min-width: 640px) {
.icon {
width: 6rem;
height: 6rem;
}
}
@media (min-width: 768px) {
.icon {
width: 7rem;
height: 7rem;
}
}
@keyframes splashPulse {
0% {
transform: scale(1);
opacity: 0.75;
}
75% {
transform: scale(2);
opacity: 0;
}
100% {
transform: scale(2);
opacity: 0;
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {FluxerIcon} from '~/components/icons/FluxerIcon';
import ConnectionStore from '~/stores/ConnectionStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import InitializationStore from '~/stores/InitializationStore';
import {NativeDragRegion} from './NativeDragRegion';
import styles from './SplashScreen.module.css';
const SPLASH_SCREEN_DELAY = 10000;
export const SplashScreen = observer(() => {
const shouldBypass = DeveloperOptionsStore.bypassSplashScreen;
const connected = ConnectionStore.isConnected;
const isInitialized = InitializationStore.canNavigateToProtectedRoutes;
const [showSplash, setShowSplash] = React.useState(true);
React.useEffect(() => {
if (connected && isInitialized) {
setShowSplash(false);
return;
}
const timer = setTimeout(() => setShowSplash(true), SPLASH_SCREEN_DELAY);
return () => clearTimeout(timer);
}, [connected, isInitialized]);
if (shouldBypass) return null;
return <AnimatePresence initial={false}>{showSplash && <SplashScreenContent />}</AnimatePresence>;
});
const SplashScreenContent = observer(() => {
return (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
transition={{duration: 0.5}}
className={styles.splashOverlay}
>
<NativeDragRegion className={styles.topDragRegion} />
<div className={styles.splashContent}>
<div className={styles.iconWrapper}>
<div className={styles.iconPulse} />
<FluxerIcon className={styles.icon} />
</div>
</div>
</motion.div>
);
});

View File

@@ -0,0 +1,248 @@
/*
* 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/>.
*/
.userAreaInnerWrapper {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
background-color: var(--panel-control-bg);
position: relative;
}
.separator {
height: 1px;
background-color: var(--user-area-divider-color);
}
.userAreaContainer {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
padding: var(--user-area-padding-y) var(--user-area-padding-x);
box-sizing: border-box;
background-color: transparent;
width: 100%;
min-height: var(--layout-user-area-height);
}
.userAreaInnerWrapperHasVoiceConnection {
min-height: var(--layout-user-area-height);
}
.userAreaInnerWrapperHasVoiceConnection .userAreaContainer {
border-top: 0;
}
.voiceConnectionWrapper {
border-bottom: 0;
border-top: 0;
}
.userInfo {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex: 1;
min-width: 0;
cursor: pointer;
padding: 0 var(--spacing-2);
margin: 0;
border-radius: var(--radius-md);
height: var(--user-area-content-height);
position: relative;
transition: color var(--transition-normal);
outline: none;
}
.userInfo::before {
content: '';
position: absolute;
inset: calc(var(--spacing-1) * -1);
border-radius: calc(var(--radius-md) + var(--spacing-1));
background-color: transparent;
z-index: -1;
transition: background-color var(--transition-normal);
}
.userInfo:hover::before,
.userInfo.active::before,
.userInfo:focus-visible::before {
background-color: color-mix(in srgb, var(--text-primary) 3%, transparent);
}
.userInfoText {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
user-select: text;
-webkit-user-select: text;
gap: 0.0625rem;
}
.userName {
font-weight: 500;
font-size: 0.875rem;
line-height: 1.125rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.userStatus {
font-size: 0.6875rem;
line-height: 1rem;
color: var(--text-primary-muted);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
margin-top: -0.0625rem;
opacity: 0.85;
}
.userStatusLabel {
composes: userStatus;
}
.userCustomStatus {
composes: userStatus;
}
.userInfo:hover .userCustomStatus {
--emoji-show-animated: 1;
}
.hoverRoll {
display: inline-block;
vertical-align: top;
position: relative;
width: 100%;
contain: paint;
overflow: hidden;
}
.defaultState,
.hovered {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
transform-style: preserve-3d;
pointer-events: none;
width: 100%;
transition:
transform 0.22s ease,
opacity 0.22s ease;
}
.hovered {
opacity: 0;
transform: translate3d(0, 107%, 0);
position: absolute;
top: 0;
left: 0;
right: 0;
}
.forceHover .defaultState,
.userInfo:hover .hoverRoll .defaultState,
.userInfo:focus-visible .hoverRoll .defaultState {
transform: translate3d(0, 107%, 0);
opacity: 0;
user-select: none;
-webkit-user-select: none;
}
.forceHover .hovered,
.userInfo:hover .hoverRoll .hovered,
.userInfo:focus-visible .hoverRoll .hovered {
transform: translate3d(0, 0, 0);
opacity: 1;
}
.controlsContainer {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-shrink: 0;
padding-left: var(--spacing-3);
}
.controlButton {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
background-color: transparent;
color: var(--control-button-normal-text);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
position: relative;
padding: 0;
}
.controlButton:hover {
background-color: color-mix(in srgb, var(--control-button-normal-text) 10%, transparent);
color: var(--control-button-hover-text);
}
.controlButton:active {
transform: scale(0.95);
}
.controlButton.active {
background-color: color-mix(in srgb, var(--control-button-danger-text) 10%, transparent);
color: var(--control-button-danger-text);
}
.controlButton.active:hover {
background-color: color-mix(in srgb, var(--control-button-danger-text) 20%, transparent);
color: var(--control-button-danger-text);
}
.controlButton.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.controlButton.disabled:hover {
background-color: color-mix(in srgb, var(--control-button-danger-text) 15%, transparent);
color: var(--control-button-danger-text);
}
.controlButton.disabled:active {
transform: none;
}
.controlIcon {
height: 20px;
width: 20px;
}

View File

@@ -0,0 +1,319 @@
/*
* 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 {GearIcon, MicrophoneIcon, MicrophoneSlashIcon, SpeakerHighIcon, SpeakerSlashIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useLayoutEffect, useRef} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
import {getStatusTypeLabel} from '~/Constants';
import {CustomStatusDisplay} from '~/components/common/CustomStatusDisplay/CustomStatusDisplay';
import styles from '~/components/layout/UserArea.module.css';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {UserAreaPopout} from '~/components/popouts/UserAreaPopout';
import {SettingsContextMenu} from '~/components/uikit/ContextMenu/SettingsContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {FocusRingWrapper} from '~/components/uikit/FocusRingWrapper';
import {TooltipWithKeybind} from '~/components/uikit/KeybindHint/KeybindHint';
import {Popout} from '~/components/uikit/Popout/Popout';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {VoiceConnectionStatus} from '~/components/voice/VoiceConnectionStatus';
import {VoiceInputSettingsMenu, VoiceOutputSettingsMenu} from '~/components/voice/VoiceSettingsMenus';
import {useMediaDevices} from '~/hooks/useMediaDevices';
import {usePopout} from '~/hooks/usePopout';
import type {UserRecord} from '~/records/UserRecord';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import KeybindStore from '~/stores/KeybindStore';
import LocalVoiceStateStore from '~/stores/LocalVoiceStateStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PresenceStore from '~/stores/PresenceStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import {formatKeyCombo} from '~/utils/KeybindUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
const VOICE_CONNECTION_HEIGHT_VARIABLE = '--layout-voice-connection-height';
const UserAreaWithRoom = observer(function UserAreaWithRoom({user}: {user: UserRecord}) {
const connectedGuildId = MediaEngineStore.guildId;
const voiceState = MediaEngineStore.getVoiceState(connectedGuildId);
const localSelfMute = LocalVoiceStateStore.selfMute;
const localSelfDeaf = LocalVoiceStateStore.selfDeaf;
const isMuted = voiceState ? voiceState.self_mute : localSelfMute;
const isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf;
const isGuildMuted = voiceState?.mute ?? false;
const isGuildDeafened = voiceState?.deaf ?? false;
const muteReason = MediaEngineStore.getMuteReason(voiceState);
return (
<UserAreaInner
user={user}
isMuted={isMuted}
isDeafened={isDeafened}
isGuildMuted={isGuildMuted}
isGuildDeafened={isGuildDeafened}
muteReason={muteReason}
/>
);
});
const UserAreaInner = observer(
({
user,
isMuted,
isDeafened,
isGuildMuted = false,
isGuildDeafened = false,
muteReason = null,
}: {
user: UserRecord;
isMuted: boolean;
isDeafened: boolean;
isGuildMuted?: boolean;
isGuildDeafened?: boolean;
muteReason?: 'guild' | 'push_to_talk' | 'self' | null;
}) => {
const {t, i18n} = useLingui();
const {isOpen, openProps} = usePopout('user-area');
const status = PresenceStore.getStatus(user.id);
const customStatus = PresenceStore.getCustomStatus(user.id);
const {inputDevices, outputDevices} = useMediaDevices();
const voiceConnectionRef = useRef<HTMLDivElement | null>(null);
const handleMicContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, (props) => (
<VoiceInputSettingsMenu inputDevices={inputDevices} onClose={props.onClose} />
));
};
const handleSpeakerContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, (props) => (
<VoiceOutputSettingsMenu outputDevices={outputDevices} onClose={props.onClose} />
));
};
const handleSettingsClick = () => {
ModalActionCreators.push(modal(() => <UserSettingsModal />));
};
const storeConnectedGuildId = MediaEngineStore.guildId;
const storeConnectedChannelId = MediaEngineStore.channelId;
const forceShowVoiceConnection = DeveloperOptionsStore.forceShowVoiceConnection;
const hasVoiceConnection =
!MobileLayoutStore.enabled &&
(forceShowVoiceConnection || (!!storeConnectedGuildId && !!storeConnectedChannelId));
useLayoutEffect(() => {
const root = document.documentElement;
if (!hasVoiceConnection) {
root.style.removeProperty(VOICE_CONNECTION_HEIGHT_VARIABLE);
return;
}
const height = voiceConnectionRef.current?.getBoundingClientRect().height ?? 0;
if (height > 0) {
root.style.setProperty(VOICE_CONNECTION_HEIGHT_VARIABLE, `${Math.round(height)}px`);
} else {
root.style.removeProperty(VOICE_CONNECTION_HEIGHT_VARIABLE);
}
return () => {
root.style.removeProperty(VOICE_CONNECTION_HEIGHT_VARIABLE);
};
}, [hasVoiceConnection]);
const wrapperClassName = clsx(
styles.userAreaInnerWrapper,
hasVoiceConnection && styles.userAreaInnerWrapperHasVoiceConnection,
);
const pushToTalkCombo = KeybindStore.getByAction('push_to_talk').combo;
const pushToTalkHint = formatKeyCombo(pushToTalkCombo);
const effectiveMuted = muteReason !== null || isMuted;
return (
<div className={wrapperClassName}>
{hasVoiceConnection && (
<>
<div className={styles.separator} aria-hidden />
<div ref={voiceConnectionRef} className={styles.voiceConnectionWrapper}>
<VoiceConnectionStatus />
</div>
<div className={styles.separator} aria-hidden />
</>
)}
{!hasVoiceConnection && <div className={styles.separator} aria-hidden />}
<div className={styles.userAreaContainer}>
<Popout {...openProps} render={() => <UserAreaPopout />} position="top">
<FocusRingWrapper focusRingOffset={-2}>
<div className={clsx(styles.userInfo, isOpen && styles.active)} role="button" tabIndex={0}>
<StatusAwareAvatar user={user} size={36} />
<div className={styles.userInfoText}>
<div className={styles.userName}>{NicknameUtils.getNickname(user)}</div>
<div className={styles.userStatus}>
<div className={clsx(styles.hoverRoll, isOpen && styles.forceHover)}>
<div className={styles.hovered}>{user.tag}</div>
<div className={styles.defaultState}>
{customStatus ? (
<CustomStatusDisplay
customStatus={customStatus}
className={styles.userCustomStatus}
showTooltip
constrained
animateOnParentHover
/>
) : (
<span className={styles.userStatusLabel}>{getStatusTypeLabel(i18n, status)}</span>
)}
</div>
</div>
</div>
</div>
</div>
</FocusRingWrapper>
</Popout>
<div className={styles.controlsContainer}>
<Tooltip
text={() => (
<TooltipWithKeybind
label={
isGuildMuted
? t`Community Muted`
: muteReason === 'push_to_talk'
? t`Push-to-talk enabled — hold ${pushToTalkHint} to speak`
: effectiveMuted
? t`Unmute`
: t`Mute`
}
action={isGuildMuted ? undefined : 'toggle_mute'}
/>
)}
>
<FocusRing offset={-2} enabled={!isGuildMuted}>
<div>
<button
type="button"
aria-label={isGuildMuted ? t`Community Muted` : effectiveMuted ? t`Unmute` : t`Mute`}
className={clsx(
styles.controlButton,
(effectiveMuted || isGuildMuted) && styles.active,
isGuildMuted && styles.disabled,
)}
onClick={isGuildMuted ? undefined : () => VoiceStateActionCreators.toggleSelfMute(null)}
onContextMenu={handleMicContextMenu}
disabled={isGuildMuted}
>
{effectiveMuted || isGuildMuted ? (
<MicrophoneSlashIcon weight="fill" className={styles.controlIcon} />
) : (
<MicrophoneIcon weight="fill" className={styles.controlIcon} />
)}
</button>
</div>
</FocusRing>
</Tooltip>
<Tooltip
text={() => (
<TooltipWithKeybind
label={isGuildDeafened ? t`Community Deafened` : isDeafened ? t`Undeafen` : t`Deafen`}
action={isGuildDeafened ? undefined : 'toggle_deafen'}
/>
)}
>
<FocusRing offset={-2} enabled={!isGuildDeafened}>
<div>
<button
type="button"
aria-label={isGuildDeafened ? t`Community Deafened` : isDeafened ? t`Undeafen` : t`Deafen`}
className={clsx(
styles.controlButton,
(isDeafened || isGuildDeafened) && styles.active,
isGuildDeafened && styles.disabled,
)}
onClick={isGuildDeafened ? undefined : () => VoiceStateActionCreators.toggleSelfDeaf(null)}
onContextMenu={handleSpeakerContextMenu}
disabled={isGuildDeafened}
>
{isDeafened || isGuildDeafened ? (
<SpeakerSlashIcon className={styles.controlIcon} />
) : (
<SpeakerHighIcon className={styles.controlIcon} />
)}
</button>
</div>
</FocusRing>
</Tooltip>
<Tooltip text={() => <TooltipWithKeybind label={t`User Settings`} action="toggle_settings" />}>
<FocusRing offset={-2}>
<button
type="button"
aria-label={t`User Settings`}
className={styles.controlButton}
onClick={handleSettingsClick}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, (props) => (
<SettingsContextMenu onClose={props.onClose} />
));
}}
>
<GearIcon className={styles.controlIcon} />
</button>
</FocusRing>
</Tooltip>
</div>
</div>
</div>
);
},
);
export const UserArea = observer(function UserArea({user}: {user: UserRecord}) {
const room = MediaEngineStore.room;
const connectedGuildId = MediaEngineStore.guildId;
const voiceState = MediaEngineStore.getVoiceState(connectedGuildId);
const localSelfMute = LocalVoiceStateStore.selfMute;
const localSelfDeaf = LocalVoiceStateStore.selfDeaf;
const isMobile = MobileLayoutStore.isMobileLayout();
if (isMobile) {
return null;
}
if (room) {
return <UserAreaWithRoom user={user} />;
}
const isMuted = voiceState ? voiceState.self_mute : localSelfMute;
const isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf;
return <UserAreaInner user={user} isMuted={isMuted} isDeafened={isDeafened} />;
});

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.wrapper {
align-items: stretch;
background-color: var(--background-secondary);
border: 1px solid var(--theme-border);
border-radius: 4px;
box-sizing: border-box;
color: var(--text-tertiary);
display: grid;
flex: 0 0 auto;
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
grid-template-columns: 1fr auto;
height: 16px;
justify-content: center;
line-height: 14px;
margin-right: -4px;
overflow: hidden;
}
.users {
align-items: center;
background-color: var(--background-secondary);
box-sizing: border-box;
display: block;
font-variant-numeric: tabular-nums;
padding: 0 6px;
text-align: center;
width: 28px;
}
.total {
background-color: var(--background-modifier-selected);
box-sizing: border-box;
display: block;
font-variant-numeric: tabular-nums;
padding: 0 4px 0 2px;
position: relative;
text-align: center;
width: 20px;
}
.total::after {
border-bottom: 0 solid transparent;
border-right: 5px solid transparent;
border-right-color: var(--background-modifier-selected);
border-top: 16px solid transparent;
content: '';
height: 0;
left: -5px;
position: absolute;
top: 0;
width: 0;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 styles from './VoiceChannelUserCount.module.css';
interface VoiceChannelUserCountProps {
currentUserCount: number;
userLimit: number;
}
export const VoiceChannelUserCount = observer(function VoiceChannelUserCount({
currentUserCount,
userLimit,
}: VoiceChannelUserCountProps) {
return (
<div className={styles.wrapper}>
<span className={styles.users}>{currentUserCount.toString().padStart(2, '0')}</span>
<span className={styles.total}>{userLimit.toString().padStart(2, '0')}</span>
</div>
);
});

View File

@@ -0,0 +1,104 @@
/*
* 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/>.
*/
.participantRow {
display: flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
transition-property: color, background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
color: var(--text-primary-muted);
cursor: pointer;
}
.participantRowSpeaking {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.participantRow:not(.participantRowDragging):hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.participantRowDragging {
opacity: 0.5;
}
.participantRowCurrentConnection {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.participantRowPopoutOpen {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.deviceIcon {
display: flex;
align-items: center;
justify-content: center;
}
.deviceIconSpeaking {
color: rgb(34 197 94);
}
.deviceIconCurrent {
color: var(--text-primary);
}
.iconContainer {
height: 1.25rem;
width: 1.25rem;
}
.participantName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
max-height: 1.25rem;
}
.participantNameSpeaking {
color: var(--text-primary);
}
.participantNameCurrent {
color: var(--text-primary);
}
.iconsContainer {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.25rem;
}
.flexShrinkZero {
flex-shrink: 0;
}

View File

@@ -0,0 +1,256 @@
/*
* 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 {DesktopIcon, DeviceMobileIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React, {useCallback, useMemo, useState} from 'react';
import type {ConnectableElement} from 'react-dnd';
import {useDrag} from 'react-dnd';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {Permissions} from '~/Constants';
import {VoiceParticipantBottomSheet} from '~/components/bottomsheets/VoiceParticipantBottomSheet';
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
import {LongPressable} from '~/components/LongPressable';
import {AvatarWithPresence} from '~/components/uikit/avatars/AvatarWithPresence';
import {VoiceParticipantContextMenu} from '~/components/uikit/ContextMenu/VoiceParticipantContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip';
import type {UserRecord} from '~/records/UserRecord';
import LocalVoiceStateStore from '~/stores/LocalVoiceStateStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import type {VoiceState} from '~/stores/voice/MediaEngineFacade';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as NicknameUtils from '~/utils/NicknameUtils';
import channelItemSurfaceStyles from './ChannelItemSurface.module.css';
import {DND_TYPES} from './types/dnd';
import styles from './VoiceParticipantItem.module.css';
import {VoiceStateIcons} from './VoiceStateIcons';
export const VoiceParticipantItem = observer(function VoiceParticipantItem({
user,
voiceState,
guildId,
isGroupedItem = false,
isCurrentUserConnection = false,
isCurrentUser = false,
}: {
user: UserRecord;
voiceState: VoiceState | null;
guildId: string;
isGroupedItem?: boolean;
isCurrentUserConnection?: boolean;
isCurrentUser?: boolean;
}) {
const {t} = useLingui();
const connectionId = voiceState?.connection_id ?? '';
const participant = MediaEngineStore.getParticipantByUserIdAndConnectionId(user.id, connectionId);
const connectedChannelId = MediaEngineStore.channelId;
const currentChannelId = voiceState?.channel_id ?? connectedChannelId ?? null;
const canMoveMembers = PermissionStore.can(Permissions.MOVE_MEMBERS, {guildId});
const canDragParticipant = canMoveMembers && currentChannelId !== null;
const isSpeaking = participant?.isSpeaking ?? false;
const isMobileLayout = MobileLayoutStore.isMobileLayout();
const [menuOpen, setMenuOpen] = useState(false);
const [isProfilePopoutOpen, setIsProfilePopoutOpen] = useState(false);
const localSelfVideo = LocalVoiceStateStore.selfVideo;
const localSelfStream = LocalVoiceStateStore.selfStream;
const isLocalParticipant = isCurrentUser || isCurrentUserConnection;
const [{isDragging}, dragRef] = useDrag(
() => ({
type: DND_TYPES.VOICE_PARTICIPANT,
item: {
type: DND_TYPES.VOICE_PARTICIPANT,
id: user.id,
userId: user.id,
guildId,
currentChannelId,
},
canDrag: canDragParticipant,
collect: (monitor) => ({isDragging: monitor.isDragging()}),
}),
[user.id, guildId, currentChannelId, canDragParticipant],
);
const dragConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dragRef(node);
},
[dragRef],
);
const isSelfMuted = voiceState?.self_mute ?? (participant ? !participant.isMicrophoneEnabled : false);
const isSelfDeafened = voiceState?.self_deaf ?? false;
const isGuildMuted = voiceState?.mute ?? false;
const isGuildDeafened = voiceState?.deaf ?? false;
const isActuallySpeaking = isSpeaking && !isSelfMuted && !isGuildMuted;
const remoteCameraOn = voiceState?.self_video ?? (participant ? participant.isCameraEnabled : false);
const remoteLive = voiceState?.self_stream ?? (participant ? participant.isScreenShareEnabled : false);
const displayCameraOn = !!(remoteCameraOn || (isLocalParticipant ? localSelfVideo : false));
const displayLive = !!(remoteLive || (isLocalParticipant ? localSelfStream : false));
const hasVoiceStateIcons =
displayCameraOn || displayLive || isSelfMuted || isSelfDeafened || isGuildMuted || isGuildDeafened;
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const participantName = NicknameUtils.getNickname(user, guildId, currentChannelId) || user.username;
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<VoiceParticipantContextMenu
user={user}
participantName={participantName}
onClose={onClose}
guildId={guildId}
connectionId={connectionId}
isGroupedItem={isGroupedItem}
/>
));
},
[user, guildId, connectionId, currentChannelId],
);
const handleProfilePopoutOpen = React.useCallback(() => {
setIsProfilePopoutOpen(true);
}, []);
const handleProfilePopoutClose = React.useCallback(() => {
setIsProfilePopoutOpen(false);
}, []);
const DeviceIcon = voiceState?.is_mobile ? DeviceMobileIcon : DesktopIcon;
const unknownDeviceFallback = useMemo(() => t`Unknown Device`, []);
const displayName = isGroupedItem
? voiceState?.connection_id || unknownDeviceFallback
: NicknameUtils.getNickname(user, guildId, currentChannelId);
const openProfileAriaLabel = !isGroupedItem ? t`Open profile for ${displayName}` : undefined;
const row = (
<FocusRing offset={-2} ringClassName={channelItemSurfaceStyles.channelItemFocusRing}>
<LongPressable
ref={dragConnectorRef}
className={clsx(
styles.participantRow,
isActuallySpeaking && styles.participantRowSpeaking,
isDragging && styles.participantRowDragging,
isCurrentUserConnection && !isActuallySpeaking && styles.participantRowCurrentConnection,
isProfilePopoutOpen && styles.participantRowPopoutOpen,
)}
onContextMenu={handleContextMenu}
onLongPress={() => {
if (isMobileLayout) setMenuOpen(true);
}}
role={!isGroupedItem ? 'button' : undefined}
tabIndex={!isGroupedItem ? 0 : -1}
aria-label={openProfileAriaLabel}
>
{isGroupedItem ? (
<div
className={clsx(
styles.deviceIcon,
isActuallySpeaking && styles.deviceIconSpeaking,
isCurrentUserConnection && !isActuallySpeaking && styles.deviceIconCurrent,
)}
>
<DeviceIcon className={styles.iconContainer} weight="regular" />
</div>
) : (
<AvatarWithPresence user={user} size={24} speaking={isActuallySpeaking} guildId={guildId} />
)}
{isGroupedItem ? (
<Tooltip text={displayName} position="top">
<span
className={clsx(
styles.participantName,
isActuallySpeaking && styles.participantNameSpeaking,
isCurrentUserConnection && !isActuallySpeaking && styles.participantNameCurrent,
)}
>
{displayName}
</span>
</Tooltip>
) : (
<span
className={clsx(
styles.participantName,
isActuallySpeaking && styles.participantNameSpeaking,
isCurrentUser && !isActuallySpeaking && styles.participantNameCurrent,
)}
>
{displayName}
</span>
)}
{hasVoiceStateIcons && (
<div className={styles.iconsContainer}>
<VoiceStateIcons
isSelfMuted={isSelfMuted}
isSelfDeafened={isSelfDeafened}
isGuildMuted={isGuildMuted}
isGuildDeafened={isGuildDeafened}
isCameraOn={displayCameraOn}
isScreenSharing={displayLive}
className={styles.flexShrinkZero}
/>
</div>
)}
</LongPressable>
</FocusRing>
);
return (
<>
{isGroupedItem ? (
row
) : (
<PreloadableUserPopout
user={user}
isWebhook={false}
guildId={guildId}
channelId={currentChannelId ?? undefined}
position="right-start"
disableContextMenu={true}
onPopoutOpen={handleProfilePopoutOpen}
onPopoutClose={handleProfilePopoutClose}
>
{row}
</PreloadableUserPopout>
)}
{isMobileLayout && (
<VoiceParticipantBottomSheet
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
user={user}
participant={participant}
guildId={guildId}
connectionId={connectionId}
isConnectionItem={isGroupedItem}
/>
)}
</>
);
});

View File

@@ -0,0 +1,27 @@
/*
* 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 {
margin-top: 0.25rem;
margin-right: 0.5rem;
margin-left: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import LocalVoiceStateStore from '~/stores/LocalVoiceStateStore';
import UserStore from '~/stores/UserStore';
import type {VoiceState} from '~/stores/voice/MediaEngineFacade';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import {GroupedVoiceParticipant} from './GroupedVoiceParticipant';
import {VoiceParticipantItem} from './VoiceParticipantItem';
import styles from './VoiceParticipantsList.module.css';
export const VoiceParticipantsList = observer(({guild, channel}: {guild: GuildRecord; channel: ChannelRecord}) => {
const voiceStates = MediaEngineStore.getAllVoiceStatesInChannel(guild.id, channel.id);
const currentUser = UserStore.currentUser;
const localSelfStream = LocalVoiceStateStore.selfStream;
const grouped = React.useMemo(() => {
const byUser = new Map<
string,
{
userId: string;
states: Array<VoiceState>;
isCurrentUser: boolean;
anySpeaking: boolean;
anyLive: boolean;
}
>();
for (const vs of Object.values(voiceStates)) {
const userId = vs.user_id;
let entry = byUser.get(userId);
if (!entry) {
entry = {userId, states: [], isCurrentUser: currentUser?.id === userId, anySpeaking: false, anyLive: false};
byUser.set(userId, entry);
}
entry.states.push(vs);
const connectionId = vs.connection_id ?? '';
const participant = MediaEngineStore.getParticipantByUserIdAndConnectionId(userId, connectionId);
const isSelfMuted = vs.self_mute ?? (participant ? !participant.isMicrophoneEnabled : false);
const isGuildMuted = vs.mute ?? false;
const speaking = !!(participant?.isSpeaking && !isSelfMuted && !isGuildMuted);
const live = vs.self_stream === true || (participant ? participant.isScreenShareEnabled : false);
entry.anySpeaking = entry.anySpeaking || speaking;
entry.anyLive = entry.anyLive || live;
if (entry.isCurrentUser) {
entry.anyLive = entry.anyLive || localSelfStream;
}
}
return Array.from(byUser.values()).sort((a, b) => {
if (a.isCurrentUser !== b.isCurrentUser) return a.isCurrentUser ? -1 : 1;
if (a.anyLive !== b.anyLive) return a.anyLive ? -1 : 1;
if (a.anySpeaking !== b.anySpeaking) return a.anySpeaking ? -1 : 1;
return a.userId.localeCompare(b.userId);
});
}, [voiceStates, currentUser, localSelfStream]);
if (grouped.length === 0) return null;
return (
<div className={styles.container}>
{grouped.map(({userId, states, isCurrentUser, anySpeaking}) => {
const user = UserStore.getUser(userId);
if (!user) return null;
if (states.length === 1) {
return (
<VoiceParticipantItem
key={userId}
user={user}
voiceState={states[0]}
guildId={guild.id}
isCurrentUser={isCurrentUser}
/>
);
}
return (
<GroupedVoiceParticipant
key={userId}
user={user}
voiceStates={states}
guildId={guild.id}
anySpeaking={anySpeaking}
/>
);
})}
</div>
);
});

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
align-items: center;
gap: 0.25rem;
}
.liveBadge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
background-color: rgb(220 38 38);
padding-left: 0.375rem;
padding-right: 0.375rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
font-weight: 600;
font-size: 10px;
color: white;
text-transform: uppercase;
line-height: 1;
}
.icon {
height: 1rem;
width: 1rem;
}
.iconMuted {
color: var(--text-primary-muted);
}
.iconGuildAction {
color: rgb(239 68 68);
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {MicrophoneSlashIcon, SpeakerSlashIcon, VideoCameraIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import styles from './VoiceStateIcons.module.css';
interface Props {
isSelfMuted: boolean;
isSelfDeafened: boolean;
isGuildMuted: boolean;
isGuildDeafened: boolean;
isCameraOn?: boolean;
isScreenSharing?: boolean;
className?: string;
}
export const VoiceStateIcons = observer(
({isSelfMuted, isSelfDeafened, isGuildMuted, isGuildDeafened, isCameraOn, isScreenSharing, className}: Props) => {
const {t} = useLingui();
return (
<div className={clsx(styles.container, className)}>
{isScreenSharing && (
<Tooltip text={t`Screen Sharing`}>
<span className={styles.liveBadge}>{t`Live`}</span>
</Tooltip>
)}
{isCameraOn && (
<Tooltip text={t`Camera On`}>
<VideoCameraIcon weight="fill" className={clsx(styles.icon, styles.iconMuted)} />
</Tooltip>
)}
{(isGuildMuted || isSelfMuted) && (
<Tooltip text={isGuildMuted ? t`Community Muted` : t`Muted`}>
<MicrophoneSlashIcon
weight="fill"
className={clsx(styles.icon, isGuildMuted ? styles.iconGuildAction : styles.iconMuted)}
/>
</Tooltip>
)}
{(isGuildDeafened || isSelfDeafened) && (
<Tooltip text={isGuildDeafened ? t`Community Deafened` : t`Deafened`}>
<SpeakerSlashIcon
weight="fill"
className={clsx(styles.icon, isGuildDeafened ? styles.iconGuildAction : styles.iconMuted)}
/>
</Tooltip>
)}
</div>
);
},
);

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
width: 100%;
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import type React from 'react';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './NagbarContainer.module.css';
import {DesktopDownloadNagbar} from './nagbars/DesktopDownloadNagbar';
import {DesktopNotificationNagbar} from './nagbars/DesktopNotificationNagbar';
import {EmailVerificationNagbar} from './nagbars/EmailVerificationNagbar';
import {GiftInventoryNagbar} from './nagbars/GiftInventoryNagbar';
import {MobileDownloadNagbar} from './nagbars/MobileDownloadNagbar';
import {PendingBulkDeletionNagbar} from './nagbars/PendingBulkDeletionNagbar';
import {PremiumExpiredNagbar} from './nagbars/PremiumExpiredNagbar';
import {PremiumGracePeriodNagbar} from './nagbars/PremiumGracePeriodNagbar';
import {PremiumOnboardingNagbar} from './nagbars/PremiumOnboardingNagbar';
import {UnclaimedAccountNagbar} from './nagbars/UnclaimedAccountNagbar';
import {type NagbarState, NagbarType} from './types';
interface NagbarContainerProps {
nagbars: Array<NagbarState>;
}
export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbars}) => {
const mobileLayout = MobileLayoutStore;
if (nagbars.length === 0) return null;
return (
<div className={styles.container}>
{nagbars.map((nagbar) => {
switch (nagbar.type) {
case NagbarType.UNCLAIMED_ACCOUNT:
return <UnclaimedAccountNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.EMAIL_VERIFICATION:
return <EmailVerificationNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.BULK_DELETE_PENDING:
return <PendingBulkDeletionNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.DESKTOP_NOTIFICATION:
return <DesktopNotificationNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.PREMIUM_GRACE_PERIOD:
return <PremiumGracePeriodNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.PREMIUM_EXPIRED:
return <PremiumExpiredNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.PREMIUM_ONBOARDING:
return <PremiumOnboardingNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.GIFT_INVENTORY:
return <GiftInventoryNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.DESKTOP_DOWNLOAD:
return <DesktopDownloadNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
case NagbarType.MOBILE_DOWNLOAD:
return <MobileDownloadNagbar key={nagbar.type} isMobile={mobileLayout.enabled} />;
default:
return null;
}
})}
</div>
);
});

View File

@@ -0,0 +1,22 @@
/*
* 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 TopNagbarContext = React.createContext<boolean>(false);

View File

@@ -0,0 +1,261 @@
/*
* 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 {clearPendingBulkDeletionNagbarDismissal} from '~/actions/NagbarActionCreators';
import AppStorage from '~/lib/AppStorage';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import NagbarStore from '~/stores/NagbarStore';
import UserStore from '~/stores/UserStore';
import {isDesktop} from '~/utils/NativeUtils';
import * as NotificationUtils from '~/utils/NotificationUtils';
import {isStandalonePwa} from '~/utils/PwaUtils';
import {type AppLayoutState, type NagbarConditions, type NagbarState, NagbarType, UPDATE_DISMISS_KEY} from './types';
export const useAppLayoutState = (): AppLayoutState => {
const [isStandalone, setIsStandalone] = React.useState(isStandalonePwa());
React.useEffect(() => {
const checkStandalone = () => {
setIsStandalone(isStandalonePwa());
};
checkStandalone();
document.documentElement.classList.toggle('is-standalone', isStandalone);
return () => {
document.documentElement.classList.remove('is-standalone');
};
}, [isStandalone]);
return {isStandalone};
};
export const useNagbarConditions = (): NagbarConditions => {
const user = UserStore.currentUser;
const nagbarState = NagbarStore;
const premiumOverrideType = DeveloperOptionsStore.premiumTypeOverride;
const premiumWillCancel = user?.premiumWillCancel ?? false;
const isMockPremium = premiumOverrideType != null && premiumOverrideType > 0;
const previousPendingBulkDeletionKeyRef = React.useRef<string | null>(null);
React.useEffect(() => {
const handleVersionUpdateAvailable = () => {
if (nagbarState.forceUpdateAvailable) {
return;
}
const dismissedUntilStr = AppStorage.getItem(UPDATE_DISMISS_KEY);
if (dismissedUntilStr) {
const dismissedUntil = Number.parseInt(dismissedUntilStr, 10);
if (Date.now() < dismissedUntil) {
return;
}
localStorage.removeItem(UPDATE_DISMISS_KEY);
}
};
window.addEventListener('version-update-available', handleVersionUpdateAvailable as EventListener);
return () => {
window.removeEventListener('version-update-available', handleVersionUpdateAvailable as EventListener);
};
}, [nagbarState.forceUpdateAvailable]);
const shouldShowDesktopNotification = (() => {
if (!NotificationUtils.hasNotification()) return false;
if (typeof Notification !== 'undefined') {
if (Notification.permission === 'granted') return false;
if (Notification.permission === 'denied') return false;
}
return true;
})();
const canShowPremiumGracePeriod = (() => {
if (nagbarState.forceHidePremiumGracePeriod) return false;
if (nagbarState.forcePremiumGracePeriod) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * 24 * 60 * 60 * 1000;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
const isInGracePeriod = now > expiryDate && now <= graceEndDate;
return isInGracePeriod;
})();
const canShowPremiumExpired = (() => {
if (nagbarState.forceHidePremiumExpired) return false;
if (nagbarState.forcePremiumExpired) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
const now = new Date();
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * 24 * 60 * 60 * 1000;
const expiredStateDurationMs = 30 * 24 * 60 * 60 * 1000;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
const expiredStateEndDate = new Date(expiryDate.getTime() + expiredStateDurationMs);
const isExpired = now > graceEndDate;
const showExpiredState = isExpired && now <= expiredStateEndDate;
return showExpiredState;
})();
const canShowGiftInventory = (() => {
if (nagbarState.forceHideGiftInventory) return false;
if (nagbarState.forceGiftInventory) return true;
return Boolean(user?.hasUnreadGiftInventory && !nagbarState.giftInventoryDismissed);
})();
const canShowPremiumOnboarding = (() => {
if (nagbarState.forceHidePremiumOnboarding) return false;
if (nagbarState.forcePremiumOnboarding) return true;
if (isMockPremium) return false;
return Boolean(
user?.isPremium() && !user?.hasDismissedPremiumOnboarding && !nagbarState.premiumOnboardingDismissed,
);
})();
const isNativeDesktop = isDesktop();
const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
const isDesktopBrowser = !isNativeDesktop && !isMobileDevice;
const canShowDesktopDownload = (() => {
if (nagbarState.forceHideDesktopDownload) return false;
if (nagbarState.forceDesktopDownload) return true;
return isDesktopBrowser && !nagbarState.desktopDownloadDismissed;
})();
const canShowMobileDownload = (() => {
if (nagbarState.forceHideMobileDownload) return false;
if (nagbarState.forceMobileDownload) return true;
return Boolean(!isMobileDevice && user?.usedMobileClient === false && !nagbarState.mobileDownloadDismissed);
})();
const pendingBulkDeletion = user?.getPendingBulkMessageDeletion();
const pendingBulkDeletionKey = pendingBulkDeletion?.scheduledAt.toISOString() ?? null;
React.useEffect(() => {
const previousKey = previousPendingBulkDeletionKeyRef.current;
if (previousKey && previousKey !== pendingBulkDeletionKey) {
clearPendingBulkDeletionNagbarDismissal(previousKey);
}
previousPendingBulkDeletionKeyRef.current = pendingBulkDeletionKey;
}, [pendingBulkDeletionKey]);
const hasPendingBulkMessageDeletion = Boolean(
pendingBulkDeletion && !nagbarState.hasPendingBulkDeletionDismissed(pendingBulkDeletionKey),
);
return {
userIsUnclaimed: nagbarState.forceHideUnclaimedAccount
? false
: nagbarState.forceUnclaimedAccount
? true
: Boolean(user && !user.isClaimed()),
userNeedsVerification: nagbarState.forceHideEmailVerification
? false
: nagbarState.forceEmailVerification
? true
: Boolean(user?.isClaimed() && !user.verified),
canShowDesktopNotification: nagbarState.forceHideDesktopNotification
? false
: nagbarState.forceDesktopNotification
? true
: shouldShowDesktopNotification && !nagbarState.desktopNotificationDismissed,
canShowPremiumGracePeriod,
canShowPremiumExpired,
canShowPremiumOnboarding,
canShowGiftInventory,
canShowDesktopDownload,
canShowMobileDownload,
hasPendingBulkMessageDeletion,
};
};
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
return React.useMemo(() => {
const undismissibleTypes = new Set<NagbarType>([
NagbarType.UNCLAIMED_ACCOUNT,
NagbarType.EMAIL_VERIFICATION,
NagbarType.BULK_DELETE_PENDING,
]);
const nagbars: Array<NagbarState> = [
{
type: NagbarType.BULK_DELETE_PENDING,
priority: 0,
visible: conditions.hasPendingBulkMessageDeletion,
},
{
type: NagbarType.UNCLAIMED_ACCOUNT,
priority: 1,
visible: conditions.userIsUnclaimed,
},
{
type: NagbarType.EMAIL_VERIFICATION,
priority: 2,
visible: conditions.userNeedsVerification,
},
{
type: NagbarType.PREMIUM_GRACE_PERIOD,
priority: 3,
visible: conditions.canShowPremiumGracePeriod,
},
{
type: NagbarType.PREMIUM_EXPIRED,
priority: 4,
visible: conditions.canShowPremiumExpired,
},
{
type: NagbarType.PREMIUM_ONBOARDING,
priority: 5,
visible: conditions.canShowPremiumOnboarding,
},
{
type: NagbarType.DESKTOP_NOTIFICATION,
priority: 7,
visible: conditions.canShowDesktopNotification,
},
{
type: NagbarType.GIFT_INVENTORY,
priority: 8,
visible: conditions.canShowGiftInventory,
},
{
type: NagbarType.DESKTOP_DOWNLOAD,
priority: 9,
visible: conditions.canShowDesktopDownload,
},
{
type: NagbarType.MOBILE_DOWNLOAD,
priority: 10,
visible: conditions.canShowMobileDownload,
},
];
const visibleNagbars = nagbars.filter((nagbar) => nagbar.visible).sort((a, b) => a.priority - b.priority);
const undismissibleNagbars = visibleNagbars.filter((nagbar) => undismissibleTypes.has(nagbar.type));
const regularNagbars = visibleNagbars.filter((nagbar) => !undismissibleTypes.has(nagbar.type));
const selectedRegularNagbars = regularNagbars.length > 0 ? [regularNagbars[0]] : [];
return [...undismissibleNagbars, ...selectedRegularNagbars].sort((a, b) => a.priority - b.priority);
}, [conditions]);
};

View File

@@ -0,0 +1,31 @@
/*
* 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/>.
*/
.platformIcons {
display: flex;
align-items: center;
gap: var(--spacing-1);
margin-right: var(--spacing-2);
}
.platformIcon {
width: 1rem;
height: 1rem;
color: white;
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {AndroidLogoIcon, AppleLogoIcon, WindowsLogoIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {openExternalUrl} from '~/utils/NativeUtils';
import styles from './DesktopDownloadNagbar.module.css';
export const DesktopDownloadNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const handleDownload = () => {
openExternalUrl('https://fluxer.app/download');
};
const handleDismiss = () => {
NagbarActionCreators.dismissNagbar('desktopDownloadDismissed');
};
return (
<Nagbar
isMobile={isMobile}
backgroundColor="var(--brand-primary)"
textColor="var(--text-on-brand-primary)"
dismissible
onDismiss={handleDismiss}
>
<NagbarContent
isMobile={isMobile}
message={<Trans>Get the Fluxer desktop app for system-wide push-to-talk and a few other goodies.</Trans>}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<span className={styles.platformIcons}>
<AppleLogoIcon weight="fill" className={styles.platformIcon} />
<AndroidLogoIcon weight="fill" className={styles.platformIcon} />
<WindowsLogoIcon weight="fill" className={styles.platformIcon} />
</span>
<NagbarButton isMobile={isMobile} onClick={handleDownload}>
<Trans>Download</Trans>
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,32 @@
/*
* 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/>.
*/
.description {
margin-top: 1rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-secondary);
}
.status {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
text-align: center;
}

View File

@@ -0,0 +1,140 @@
/*
* 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 * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
import * as PushSubscriptionService from '~/services/push/PushSubscriptionService';
import * as NotificationUtils from '~/utils/NotificationUtils';
import {isPwaOnMobileOrTablet} from '~/utils/PwaUtils';
import styles from './DesktopNotificationNagbar.module.css';
export const DesktopNotificationNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const {i18n, t} = useLingui();
const isPwaMobile = isPwaOnMobileOrTablet();
const {refresh} = usePushSubscriptions(isPwaMobile);
const handleEnable = () => {
if (isPwaMobile) {
void (async () => {
await PushSubscriptionService.registerPushSubscription();
await refresh();
})();
} else if (typeof Notification === 'undefined') {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Notifications Not Supported`}
description={
<p>
<Trans>Your browser does not support desktop notifications.</Trans>
</p>
}
primaryText={t`OK`}
primaryVariant="primary"
secondaryText={false}
onPrimary={() => {
NagbarActionCreators.dismissNagbar('desktopNotificationDismissed');
}}
/>
)),
);
return;
} else {
NotificationUtils.requestPermission(i18n);
}
NagbarActionCreators.dismissNagbar('desktopNotificationDismissed');
};
const handleDismiss = () => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Disable Desktop Notifications?`}
description={
<>
<p>
<Trans>Enable notifications to stay updated on mentions when you're away from the app.</Trans>
</p>
<p className={styles.description}>
<Trans>
If you dismiss this, you can always enable desktop notifications later under User Settings &gt;
Notifications.
</Trans>
</p>
</>
}
primaryText={t`Enable Notifications`}
primaryVariant="primary"
secondaryText={t`Dismiss Anyway`}
onPrimary={() => {
handleEnable();
}}
onSecondary={() => {
NagbarActionCreators.dismissNagbar('desktopNotificationDismissed');
}}
/>
)),
);
};
return (
<Nagbar
isMobile={isMobile}
backgroundColor="var(--brand-primary)"
textColor="var(--text-on-brand-primary)"
dismissible
onDismiss={handleDismiss}
>
<NagbarContent
isMobile={isMobile}
message={
isPwaMobile ? (
<Trans>
Enable push notifications for this installed PWA to keep receiving messages when the browser is
backgrounded.
</Trans>
) : (
<Trans>Enable desktop notifications to stay updated on new messages.</Trans>
)
}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<NagbarButton isMobile={isMobile} onClick={handleEnable}>
<Trans>Enable Notifications</Trans>
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,53 @@
/*
* 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 {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import UserStore from '~/stores/UserStore';
export const EmailVerificationNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const user = UserStore.currentUser;
if (!user) {
return null;
}
const openUserSettings = () => {
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="account_security" />));
};
return (
<Nagbar isMobile={isMobile} backgroundColor="#ea580c" textColor="#ffffff">
<NagbarContent
isMobile={isMobile}
message={<Trans>Hey {user.displayName}, please verify your email address.</Trans>}
actions={
<NagbarButton isMobile={isMobile} onClick={openUserSettings}>
<Trans>Open Settings</Trans>
</NagbarButton>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Plural, Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import UserStore from '~/stores/UserStore';
export const GiftInventoryNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const currentUser = UserStore.currentUser;
const unreadCount = currentUser?.unreadGiftInventoryCount ?? 1;
const handleOpenGiftInventory = () => {
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="gift_inventory" />));
};
return (
<Nagbar isMobile={isMobile} backgroundColor="var(--brand-primary)" textColor="var(--text-on-brand-primary)">
<NagbarContent
isMobile={isMobile}
message={
<Plural
value={unreadCount}
one="You have a new gift code waiting in your Gift Inventory."
other="You have # new gift codes waiting in your Gift Inventory."
/>
}
actions={
<NagbarButton isMobile={isMobile} onClick={handleOpenGiftInventory}>
<Trans>View Gift Inventory</Trans>
</NagbarButton>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,31 @@
/*
* 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/>.
*/
.platformIcons {
display: flex;
align-items: center;
gap: var(--spacing-1);
margin-right: var(--spacing-2);
}
.platformIcon {
width: 1rem;
height: 1rem;
color: white;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {AndroidLogoIcon, AppleLogoIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {openExternalUrl} from '~/utils/NativeUtils';
import styles from './MobileDownloadNagbar.module.css';
export const MobileDownloadNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const handleDownload = () => {
openExternalUrl('https://fluxer.app/download#mobile');
};
const handleDismiss = () => {
NagbarActionCreators.dismissNagbar('mobileDownloadDismissed');
};
return (
<Nagbar
isMobile={isMobile}
backgroundColor="var(--brand-primary)"
textColor="var(--text-on-brand-primary)"
dismissible
onDismiss={handleDismiss}
>
<NagbarContent
isMobile={isMobile}
message={
<Trans>
Get Fluxer on mobile to receive notifications on the go and stay connected with your friends anytime.
</Trans>
}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<span className={styles.platformIcons}>
<AppleLogoIcon weight="fill" className={styles.platformIcon} />
<AndroidLogoIcon weight="fill" className={styles.platformIcon} />
</span>
<NagbarButton isMobile={isMobile} onClick={handleDownload}>
<Trans>Download</Trans>
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} 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 NagbarActionCreators from '~/actions/NagbarActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import UserStore from '~/stores/UserStore';
export const PendingBulkDeletionNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const pending = UserStore.currentUser?.getPendingBulkMessageDeletion();
const countFormatter = React.useMemo(() => new Intl.NumberFormat(), []);
const scheduleKey = pending?.scheduledAt.toISOString();
const handleHideNagbar = React.useCallback(() => {
if (!scheduleKey) {
return;
}
NagbarActionCreators.dismissPendingBulkDeletionNagbar(scheduleKey);
}, [scheduleKey]);
if (!pending) {
return null;
}
const channelCountLabel = countFormatter.format(pending.channelCount);
const messageCountLabel = countFormatter.format(pending.messageCount);
const scheduledLabel = pending.scheduledAt.toLocaleString();
const openDeletionSettings = () => {
ModalActionCreators.push(
modal(() => <UserSettingsModal initialTab="privacy_safety" initialSubtab="data-deletion" />),
);
};
return (
<Nagbar
isMobile={isMobile}
backgroundColor="var(--status-danger)"
textColor="#ffffff"
dismissible
onDismiss={handleHideNagbar}
>
<NagbarContent
isMobile={isMobile}
message={
<Trans>
Deletion of <strong>{messageCountLabel}</strong> messages from <strong>{channelCountLabel}</strong> channels
is scheduled for <strong>{scheduledLabel}</strong>. Cancel it from the Privacy Dashboard.
</Trans>
}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleHideNagbar}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<NagbarButton isMobile={isMobile} onClick={openDeletionSettings}>
<Trans>Review deletion</Trans>
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,84 @@
/*
* 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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import * as PremiumActionCreators from '~/actions/PremiumActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import UserStore from '~/stores/UserStore';
import {openExternalUrl} from '~/utils/NativeUtils';
export const PremiumExpiredNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const user = UserStore.currentUser;
const [loadingPortal, setLoadingPortal] = React.useState(false);
const handleOpenCustomerPortal = async () => {
setLoadingPortal(true);
try {
const url = await PremiumActionCreators.createCustomerPortalSession();
void openExternalUrl(url);
} catch (error) {
console.error('Failed to open customer portal', error);
} finally {
setLoadingPortal(false);
}
};
const handleDismiss = () => {
NagbarActionCreators.dismissNagbar('premiumExpiredDismissed');
};
if (!user?.premiumUntil || user?.premiumWillCancel) return null;
return (
<Nagbar
isMobile={isMobile}
backgroundColor="var(--status-danger)"
textColor="var(--text-on-brand-primary)"
dismissible
onDismiss={handleDismiss}
>
<NagbarContent
isMobile={isMobile}
message={
<Trans>
Your Plutonium subscription has expired. You've lost all Plutonium perks. Reactivate your subscription to
regain access.
</Trans>
}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<NagbarButton isMobile={isMobile} onClick={handleOpenCustomerPortal} disabled={loadingPortal}>
{loadingPortal ? <Trans>Opening...</Trans> : <Trans>Reactivate</Trans>}
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import * as PremiumActionCreators from '~/actions/PremiumActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import UserStore from '~/stores/UserStore';
import * as LocaleUtils from '~/utils/LocaleUtils';
import {openExternalUrl} from '~/utils/NativeUtils';
export const PremiumGracePeriodNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const user = UserStore.currentUser;
const [loadingPortal, setLoadingPortal] = React.useState(false);
const handleOpenCustomerPortal = async () => {
setLoadingPortal(true);
try {
const url = await PremiumActionCreators.createCustomerPortalSession();
void openExternalUrl(url);
} catch (error) {
console.error('Failed to open customer portal', error);
} finally {
setLoadingPortal(false);
}
};
const handleDismiss = () => {
NagbarActionCreators.dismissNagbar('premiumGracePeriodDismissed');
};
if (!user?.premiumUntil || user?.premiumWillCancel) return null;
const expiryDate = new Date(user.premiumUntil);
const gracePeriodMs = 3 * 24 * 60 * 60 * 1000;
const graceEndDate = new Date(expiryDate.getTime() + gracePeriodMs);
const locale = LocaleUtils.getCurrentLocale();
const formattedGraceDate = graceEndDate.toLocaleDateString(locale, {
month: 'long',
day: 'numeric',
year: 'numeric',
});
return (
<Nagbar isMobile={isMobile} backgroundColor="#f97316" textColor="#ffffff" dismissible onDismiss={handleDismiss}>
<NagbarContent
isMobile={isMobile}
message={
<Trans>
Your subscription failed to renew, but you still have access to Plutonium perks until{' '}
<strong>{formattedGraceDate}</strong>. Take action now or you'll lose all perks.
</Trans>
}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<NagbarButton isMobile={isMobile} onClick={handleOpenCustomerPortal} disabled={loadingPortal}>
{loadingPortal ? <Trans>Opening...</Trans> : <Trans>Manage Subscription</Trans>}
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
export const PremiumOnboardingNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const handleOpenPremiumSettings = () => {
void UserActionCreators.update({has_dismissed_premium_onboarding: true});
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="plutonium" />));
};
const handleDismiss = () => {
void UserActionCreators.update({has_dismissed_premium_onboarding: true});
};
return (
<Nagbar
isMobile={isMobile}
backgroundColor="var(--brand-primary)"
textColor="var(--text-on-brand-primary)"
dismissible
onDismiss={handleDismiss}
>
<NagbarContent
isMobile={isMobile}
message={
<Trans>Welcome to Fluxer Plutonium! Explore your premium features and manage your subscription.</Trans>
}
actions={
<>
{isMobile && (
<NagbarButton isMobile={isMobile} onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</NagbarButton>
)}
<NagbarButton isMobile={isMobile} onClick={handleOpenPremiumSettings}>
<Trans>View Premium Features</Trans>
</NagbarButton>
</>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,53 @@
/*
* 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 {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Nagbar} from '~/components/layout/Nagbar';
import {NagbarButton} from '~/components/layout/NagbarButton';
import {NagbarContent} from '~/components/layout/NagbarContent';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import UserStore from '~/stores/UserStore';
export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const user = UserStore.currentUser;
if (!user) {
return null;
}
const handleClaimAccount = () => {
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
};
return (
<Nagbar isMobile={isMobile} backgroundColor="#ea580c" textColor="#ffffff">
<NagbarContent
isMobile={isMobile}
message={<Trans>Hey {user.displayName}, claim your account to prevent losing access.</Trans>}
actions={
<NagbarButton isMobile={isMobile} onClick={handleClaimAccount}>
<Trans>Claim Account</Trans>
</NagbarButton>
}
/>
</Nagbar>
);
});

View File

@@ -0,0 +1,58 @@
/*
* 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 const NagbarType = {
UNCLAIMED_ACCOUNT: 'unclaimed-account',
EMAIL_VERIFICATION: 'email-verification',
DESKTOP_NOTIFICATION: 'desktop-notification',
PREMIUM_GRACE_PERIOD: 'premium-grace-period',
PREMIUM_EXPIRED: 'premium-expired',
PREMIUM_ONBOARDING: 'premium-onboarding',
GIFT_INVENTORY: 'gift-inventory',
BULK_DELETE_PENDING: 'bulk-delete-pending',
DESKTOP_DOWNLOAD: 'desktop-download',
MOBILE_DOWNLOAD: 'mobile-download',
} as const;
export type NagbarType = (typeof NagbarType)[keyof typeof NagbarType];
export interface NagbarState {
type: NagbarType;
priority: number;
visible: boolean;
}
export interface AppLayoutState {
isStandalone: boolean;
}
export interface NagbarConditions {
userIsUnclaimed: boolean;
userNeedsVerification: boolean;
canShowDesktopNotification: boolean;
canShowPremiumGracePeriod: boolean;
canShowPremiumExpired: boolean;
canShowPremiumOnboarding: boolean;
canShowGiftInventory: boolean;
canShowDesktopDownload: boolean;
canShowMobileDownload: boolean;
hasPendingBulkMessageDeletion: boolean;
}
export const UPDATE_DISMISS_KEY = 'fluxer_update_dismissed_until';

View File

@@ -0,0 +1,39 @@
/*
* 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 {
position: relative;
display: flex;
width: 100%;
justify-content: center;
border: none;
background-color: transparent;
padding: 0;
}
.iconText {
height: 1.25rem;
width: 1.25rem;
color: var(--text-primary);
}
.menuIcon {
height: 1rem;
width: 1rem;
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {HouseIcon, LinkIcon, PlusIcon} from '@phosphor-icons/react';
import {motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {AddGuildModal, type AddGuildModalView} from '~/components/modals/AddGuildModal';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {TooltipWithKeybind} from '~/components/uikit/KeybindHint/KeybindHint';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useHover} from '~/hooks/useHover';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import guildStyles from '../GuildsLayout.module.css';
import styles from './AddGuildButton.module.css';
export const AddGuildButton = observer(() => {
const {t} = useLingui();
const [hoverRef, isHovering] = useHover();
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const iconRef = React.useRef<HTMLDivElement | null>(null);
const mergedButtonRef = useMergeRefs([hoverRef, buttonRef]);
const handleAddGuild = (view?: AddGuildModalView) => {
ModalActionCreators.push(modal(() => <AddGuildModal initialView={view} />));
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
ContextMenuActionCreators.openFromEvent(e, ({onClose}) => (
<MenuGroup>
<MenuItem
icon={<HouseIcon className={styles.menuIcon} />}
onClick={() => {
handleAddGuild('create_guild');
onClose();
}}
>
<Trans>Create Community</Trans>
</MenuItem>
<MenuItem
icon={<LinkIcon className={styles.menuIcon} weight="regular" />}
onClick={() => {
handleAddGuild('join_guild');
onClose();
}}
>
<Trans>Join Community</Trans>
</MenuItem>
</MenuGroup>
));
};
return (
<div className={guildStyles.addGuildButton}>
<Tooltip
position="right"
size="large"
text={() => <TooltipWithKeybind label={t`Add a Community`} action="create_or_join_server" />}
>
<FocusRing offset={-2} focusTarget={buttonRef} ringTarget={iconRef}>
<button
type="button"
aria-label={t`Add a Community`}
onClick={() => handleAddGuild()}
onContextMenu={handleContextMenu}
className={styles.button}
ref={mergedButtonRef}
>
<motion.div
ref={iconRef}
className={guildStyles.addGuildButtonIcon}
animate={{borderRadius: isHovering ? '30%' : '50%'}}
initial={{borderRadius: isHovering ? '30%' : '50%'}}
transition={{duration: 0.07, ease: 'easeOut'}}
whileHover={{borderRadius: '30%'}}
>
<PlusIcon weight="bold" className={styles.iconText} />
</motion.div>
</button>
</FocusRing>
</Tooltip>
</div>
);
});

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.button {
position: relative;
display: flex;
width: 100%;
justify-content: center;
border: none;
background-color: transparent;
padding: 0;
}
.iconText {
height: 1.25rem;
width: 1.25rem;
color: var(--text-primary);
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {DownloadSimpleIcon} from '@phosphor-icons/react';
import {motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useHover} from '~/hooks/useHover';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {openExternalUrl} from '~/utils/NativeUtils';
import guildStyles from '../GuildsLayout.module.css';
import styles from './DownloadButton.module.css';
export const DownloadButton = observer(() => {
const {t} = useLingui();
const [hoverRef, isHovering] = useHover();
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const iconRef = React.useRef<HTMLDivElement | null>(null);
const mergedButtonRef = useMergeRefs([hoverRef, buttonRef]);
const handleDownload = () => {
openExternalUrl('https://fluxer.app/download');
};
return (
<div className={guildStyles.addGuildButton}>
<Tooltip position="right" size="large" text={() => t`Download Fluxer`}>
<FocusRing offset={-2} focusTarget={buttonRef} ringTarget={iconRef}>
<button
type="button"
aria-label={t`Download Fluxer`}
onClick={handleDownload}
className={styles.button}
ref={mergedButtonRef}
>
<motion.div
ref={iconRef}
className={guildStyles.addGuildButtonIcon}
animate={{borderRadius: isHovering ? '30%' : '50%'}}
initial={{borderRadius: isHovering ? '30%' : '50%'}}
transition={{duration: 0.07, ease: 'easeOut'}}
whileHover={{borderRadius: '30%'}}
>
<DownloadSimpleIcon weight="bold" className={styles.iconText} />
</motion.div>
</button>
</FocusRing>
</Tooltip>
</div>
);
});

View File

@@ -0,0 +1,124 @@
/*
* 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 {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {FavoritesGuildContextMenu} from '~/components/uikit/ContextMenu/FavoritesGuildContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useHover} from '~/hooks/useHover';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import AccessibilityStore from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import * as RouterUtils from '~/utils/RouterUtils';
import styles from '../GuildsLayout.module.css';
interface FavoritesButtonProps {
className?: string;
}
export const FavoritesButton = observer(({className}: FavoritesButtonProps = {}) => {
const {t} = useLingui();
const [hoverRef, isHovering] = useHover();
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const iconRef = React.useRef<HTMLDivElement | null>(null);
const mergedButtonRef = useMergeRefs([hoverRef, buttonRef]);
const location = useLocation();
const isSelected = location.pathname.startsWith(Routes.FAVORITES);
const handleSelect = () => {
const isMobile = MobileLayoutStore.isMobileLayout();
if (isMobile) {
RouterUtils.transitionTo(Routes.FAVORITES);
return;
}
const validChannelId = SelectedChannelStore.getValidatedFavoritesChannel();
if (validChannelId) {
RouterUtils.transitionTo(Routes.favoritesChannel(validChannelId));
} else {
RouterUtils.transitionTo(Routes.FAVORITES);
}
};
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => <FavoritesGuildContextMenu onClose={onClose} />);
};
const indicatorHeight = isSelected ? 40 : isHovering ? 20 : 8;
const isActive = isHovering || isSelected;
if (!AccessibilityStore.showFavorites) {
return null;
}
return (
<Tooltip position="right" size="large" text={t`Favorites`}>
<FocusRing offset={-2} focusTarget={buttonRef} ringTarget={iconRef}>
<button
type="button"
className={clsx(styles.fluxerButton, className)}
aria-label={t`Favorites`}
aria-pressed={isSelected}
onClick={handleSelect}
onContextMenu={handleContextMenu}
ref={mergedButtonRef}
>
<AnimatePresence>
{(isSelected || isHovering) && (
<div className={styles.guildIndicator}>
<motion.span
className={styles.guildIndicatorBar}
initial={false}
animate={{opacity: 1, scale: 1, height: indicatorHeight}}
exit={{opacity: 0, scale: 0}}
transition={{duration: 0.2, ease: [0.25, 0.1, 0.25, 1]}}
/>
</div>
)}
</AnimatePresence>
<div className={styles.relative}>
<motion.div
ref={iconRef}
className={clsx(styles.fluxerButtonIcon, isSelected && styles.fluxerButtonIconSelected)}
animate={{borderRadius: isActive ? '30%' : '50%'}}
initial={false}
transition={{duration: 0.07, ease: 'easeOut'}}
whileHover={{borderRadius: '30%'}}
>
<StarIcon weight="fill" className={styles.favoritesIcon} />
</motion.div>
</div>
</button>
</FocusRing>
</Tooltip>
);
});

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {ME, RelationshipTypes} from '~/Constants';
import {FluxerSymbol} from '~/components/icons/FluxerSymbol';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useHover} from '~/hooks/useHover';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import * as RouterUtils from '~/utils/RouterUtils';
import styles from '../GuildsLayout.module.css';
export const FluxerButton = observer(() => {
const {t} = useLingui();
const [hoverRef, isHovering] = useHover();
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const iconRef = React.useRef<HTMLDivElement | null>(null);
const mergedButtonRef = useMergeRefs([hoverRef, buttonRef]);
const location = useLocation();
const isSelected = location.pathname.startsWith(Routes.ME) || Routes.isSpecialPage(location.pathname);
const selectedChannel = SelectedChannelStore.selectedChannelIds.get(ME);
const relationships = RelationshipStore.getRelationships();
const pendingRequests = Object.values(relationships).filter(
({type}) => type === RelationshipTypes.INCOMING_REQUEST,
).length;
const handleSelect = () => {
const isMobile = MobileLayoutStore.isMobileLayout();
RouterUtils.transitionTo(isMobile ? Routes.ME : selectedChannel ? Routes.dmChannel(selectedChannel) : Routes.ME);
};
const indicatorHeight = isSelected ? 40 : isHovering ? 20 : 8;
const isActive = isHovering || isSelected;
return (
<Tooltip position="right" size="large" text={t`Direct Messages`}>
<FocusRing offset={-2} focusTarget={buttonRef} ringTarget={iconRef}>
<button
type="button"
className={styles.fluxerButton}
aria-label={t`Direct Messages`}
aria-pressed={isSelected}
onClick={handleSelect}
ref={mergedButtonRef}
>
<AnimatePresence>
{(isSelected || isHovering) && (
<div className={styles.guildIndicator}>
<motion.span
className={styles.guildIndicatorBar}
initial={false}
animate={{opacity: 1, scale: 1, height: indicatorHeight}}
exit={{opacity: 0, scale: 0, height: 0}}
transition={{duration: 0.2, ease: [0.25, 0.1, 0.25, 1]}}
/>
</div>
)}
</AnimatePresence>
<div className={styles.relative}>
<motion.div
ref={iconRef}
className={clsx(styles.fluxerButtonIcon, isSelected && styles.fluxerButtonIconSelected)}
animate={{borderRadius: isActive ? '30%' : '50%'}}
initial={false}
transition={{duration: 0.07, ease: 'easeOut'}}
whileHover={{borderRadius: '30%'}}
>
<FluxerSymbol className={styles.fluxerSymbolIcon} />
</motion.div>
<div className={clsx(styles.guildBadge, pendingRequests > 0 && styles.guildBadgeActive)}>
<MentionBadgeAnimated mentionCount={pendingRequests} size="small" />
</div>
</div>
</button>
</FocusRing>
</Tooltip>
);
});

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