initial commit
This commit is contained in:
32
fluxer_app/src/components/layout/AppLayout.module.css
Normal file
32
fluxer_app/src/components/layout/AppLayout.module.css
Normal 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;
|
||||
}
|
||||
83
fluxer_app/src/components/layout/AppLayout.tsx
Normal file
83
fluxer_app/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {clsx} from 'clsx';
|
||||
import {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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
259
fluxer_app/src/components/layout/AuthLayout.module.css
Normal file
259
fluxer_app/src/components/layout/AuthLayout.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
196
fluxer_app/src/components/layout/AuthLayout.tsx
Normal file
196
fluxer_app/src/components/layout/AuthLayout.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
363
fluxer_app/src/components/layout/ChannelItem.module.css
Normal file
363
fluxer_app/src/components/layout/ChannelItem.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
665
fluxer_app/src/components/layout/ChannelItem.tsx
Normal file
665
fluxer_app/src/components/layout/ChannelItem.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
59
fluxer_app/src/components/layout/ChannelItemIcon.module.css
Normal file
59
fluxer_app/src/components/layout/ChannelItemIcon.module.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
61
fluxer_app/src/components/layout/ChannelItemIcon.tsx
Normal file
61
fluxer_app/src/components/layout/ChannelItemIcon.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
408
fluxer_app/src/components/layout/ChannelListContent.tsx
Normal file
408
fluxer_app/src/components/layout/ChannelListContent.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
66
fluxer_app/src/components/layout/ChannelListSkeleton.tsx
Normal file
66
fluxer_app/src/components/layout/ChannelListSkeleton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
54
fluxer_app/src/components/layout/DndContext.tsx
Normal file
54
fluxer_app/src/components/layout/DndContext.tsx
Normal 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>;
|
||||
};
|
||||
44
fluxer_app/src/components/layout/DropIndicator.module.css
Normal file
44
fluxer_app/src/components/layout/DropIndicator.module.css
Normal 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;
|
||||
}
|
||||
32
fluxer_app/src/components/layout/DropIndicator.tsx
Normal file
32
fluxer_app/src/components/layout/DropIndicator.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
556
fluxer_app/src/components/layout/FavoritesChannelListContent.tsx
Normal file
556
fluxer_app/src/components/layout/FavoritesChannelListContent.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
82
fluxer_app/src/components/layout/FavoritesGuildHeader.tsx
Normal file
82
fluxer_app/src/components/layout/FavoritesGuildHeader.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
110
fluxer_app/src/components/layout/FavoritesLayout.tsx
Normal file
110
fluxer_app/src/components/layout/FavoritesLayout.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
29
fluxer_app/src/components/layout/FrameContext.tsx
Normal file
29
fluxer_app/src/components/layout/FrameContext.tsx
Normal 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);
|
||||
159
fluxer_app/src/components/layout/GenericChannelItem.tsx
Normal file
159
fluxer_app/src/components/layout/GenericChannelItem.tsx
Normal 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';
|
||||
57
fluxer_app/src/components/layout/GlobalOverlays.tsx
Normal file
57
fluxer_app/src/components/layout/GlobalOverlays.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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;
|
||||
@@ -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;
|
||||
}
|
||||
216
fluxer_app/src/components/layout/GroupedVoiceParticipant.tsx
Normal file
216
fluxer_app/src/components/layout/GroupedVoiceParticipant.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
50
fluxer_app/src/components/layout/GuildDetachedBanner.tsx
Normal file
50
fluxer_app/src/components/layout/GuildDetachedBanner.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
}
|
||||
168
fluxer_app/src/components/layout/GuildHeader.module.css
Normal file
168
fluxer_app/src/components/layout/GuildHeader.module.css
Normal 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));
|
||||
}
|
||||
161
fluxer_app/src/components/layout/GuildHeader.tsx
Normal file
161
fluxer_app/src/components/layout/GuildHeader.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
142
fluxer_app/src/components/layout/GuildHeaderShell.tsx
Normal file
142
fluxer_app/src/components/layout/GuildHeaderShell.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
},
|
||||
);
|
||||
178
fluxer_app/src/components/layout/GuildLayout.module.css
Normal file
178
fluxer_app/src/components/layout/GuildLayout.module.css
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
416
fluxer_app/src/components/layout/GuildLayout.tsx
Normal file
416
fluxer_app/src/components/layout/GuildLayout.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
98
fluxer_app/src/components/layout/GuildNavbar.module.css
Normal file
98
fluxer_app/src/components/layout/GuildNavbar.module.css
Normal 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;
|
||||
}
|
||||
72
fluxer_app/src/components/layout/GuildNavbar.tsx
Normal file
72
fluxer_app/src/components/layout/GuildNavbar.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
176
fluxer_app/src/components/layout/GuildNavbarSkeleton.module.css
Normal file
176
fluxer_app/src/components/layout/GuildNavbarSkeleton.module.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
76
fluxer_app/src/components/layout/GuildNavbarSkeleton.tsx
Normal file
76
fluxer_app/src/components/layout/GuildNavbarSkeleton.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
59
fluxer_app/src/components/layout/GuildSidebar.tsx
Normal file
59
fluxer_app/src/components/layout/GuildSidebar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
632
fluxer_app/src/components/layout/GuildsLayout.module.css
Normal file
632
fluxer_app/src/components/layout/GuildsLayout.module.css
Normal 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);
|
||||
}
|
||||
378
fluxer_app/src/components/layout/GuildsLayout.tsx
Normal file
378
fluxer_app/src/components/layout/GuildsLayout.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
98
fluxer_app/src/components/layout/KeyboardModeListener.tsx
Normal file
98
fluxer_app/src/components/layout/KeyboardModeListener.tsx
Normal 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;
|
||||
});
|
||||
70
fluxer_app/src/components/layout/MobileBottomNav.module.css
Normal file
70
fluxer_app/src/components/layout/MobileBottomNav.module.css
Normal 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;
|
||||
}
|
||||
135
fluxer_app/src/components/layout/MobileBottomNav.tsx
Normal file
135
fluxer_app/src/components/layout/MobileBottomNav.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
116
fluxer_app/src/components/layout/MobileMentionToast.module.css
Normal file
116
fluxer_app/src/components/layout/MobileMentionToast.module.css
Normal 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);
|
||||
}
|
||||
166
fluxer_app/src/components/layout/MobileMentionToast.tsx
Normal file
166
fluxer_app/src/components/layout/MobileMentionToast.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
67
fluxer_app/src/components/layout/Nagbar.module.css
Normal file
67
fluxer_app/src/components/layout/Nagbar.module.css
Normal 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;
|
||||
}
|
||||
73
fluxer_app/src/components/layout/Nagbar.tsx
Normal file
73
fluxer_app/src/components/layout/Nagbar.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
23
fluxer_app/src/components/layout/NagbarButton.module.css
Normal file
23
fluxer_app/src/components/layout/NagbarButton.module.css
Normal 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;
|
||||
}
|
||||
47
fluxer_app/src/components/layout/NagbarButton.tsx
Normal file
47
fluxer_app/src/components/layout/NagbarButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
fluxer_app/src/components/layout/NagbarContent.module.css
Normal file
52
fluxer_app/src/components/layout/NagbarContent.module.css
Normal 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;
|
||||
}
|
||||
37
fluxer_app/src/components/layout/NagbarContent.tsx
Normal file
37
fluxer_app/src/components/layout/NagbarContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
fluxer_app/src/components/layout/NativeDragRegion.module.css
Normal file
26
fluxer_app/src/components/layout/NativeDragRegion.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.nativeDragRegion {
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .nativeDragRegion {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
48
fluxer_app/src/components/layout/NativeDragRegion.tsx
Normal file
48
fluxer_app/src/components/layout/NativeDragRegion.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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';
|
||||
101
fluxer_app/src/components/layout/NativeTitlebar.module.css
Normal file
101
fluxer_app/src/components/layout/NativeTitlebar.module.css
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
105
fluxer_app/src/components/layout/NativeTitlebar.tsx
Normal file
105
fluxer_app/src/components/layout/NativeTitlebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
76
fluxer_app/src/components/layout/NullSpaceDropIndicator.tsx
Normal file
76
fluxer_app/src/components/layout/NullSpaceDropIndicator.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
73
fluxer_app/src/components/layout/OutlineFrame.module.css
Normal file
73
fluxer_app/src/components/layout/OutlineFrame.module.css
Normal 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;
|
||||
}
|
||||
83
fluxer_app/src/components/layout/OutlineFrame.tsx
Normal file
83
fluxer_app/src/components/layout/OutlineFrame.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {clsx} from 'clsx';
|
||||
import 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
202
fluxer_app/src/components/layout/ScrollIndicatorOverlay.tsx
Normal file
202
fluxer_app/src/components/layout/ScrollIndicatorOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
157
fluxer_app/src/components/layout/SplashScreen.module.css
Normal file
157
fluxer_app/src/components/layout/SplashScreen.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
70
fluxer_app/src/components/layout/SplashScreen.tsx
Normal file
70
fluxer_app/src/components/layout/SplashScreen.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
248
fluxer_app/src/components/layout/UserArea.module.css
Normal file
248
fluxer_app/src/components/layout/UserArea.module.css
Normal 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;
|
||||
}
|
||||
319
fluxer_app/src/components/layout/UserArea.tsx
Normal file
319
fluxer_app/src/components/layout/UserArea.tsx
Normal 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} />;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
38
fluxer_app/src/components/layout/VoiceChannelUserCount.tsx
Normal file
38
fluxer_app/src/components/layout/VoiceChannelUserCount.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
104
fluxer_app/src/components/layout/VoiceParticipantItem.module.css
Normal file
104
fluxer_app/src/components/layout/VoiceParticipantItem.module.css
Normal 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;
|
||||
}
|
||||
256
fluxer_app/src/components/layout/VoiceParticipantItem.tsx
Normal file
256
fluxer_app/src/components/layout/VoiceParticipantItem.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
113
fluxer_app/src/components/layout/VoiceParticipantsList.tsx
Normal file
113
fluxer_app/src/components/layout/VoiceParticipantsList.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
53
fluxer_app/src/components/layout/VoiceStateIcons.module.css
Normal file
53
fluxer_app/src/components/layout/VoiceStateIcons.module.css
Normal 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);
|
||||
}
|
||||
71
fluxer_app/src/components/layout/VoiceStateIcons.tsx
Normal file
71
fluxer_app/src/components/layout/VoiceStateIcons.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
261
fluxer_app/src/components/layout/app-layout/hooks.ts
Normal file
261
fluxer_app/src/components/layout/app-layout/hooks.ts
Normal 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]);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 >
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
58
fluxer_app/src/components/layout/app-layout/types.ts
Normal file
58
fluxer_app/src/components/layout/app-layout/types.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
108
fluxer_app/src/components/layout/guild-list/AddGuildButton.tsx
Normal file
108
fluxer_app/src/components/layout/guild-list/AddGuildButton.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
124
fluxer_app/src/components/layout/guild-list/FavoritesButton.tsx
Normal file
124
fluxer_app/src/components/layout/guild-list/FavoritesButton.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
108
fluxer_app/src/components/layout/guild-list/FluxerButton.tsx
Normal file
108
fluxer_app/src/components/layout/guild-list/FluxerButton.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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
Reference in New Issue
Block a user