initial commit

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

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background-color: var(--background-primary);
z-index: 2;
}
.containerAttached {
border-radius: 8px 8px 0 0;
}
.containerDetached {
border-radius: 8px;
}
.scroller {
display: flex;
max-height: 490px;
flex-direction: column;
gap: 4px;
padding: 8px 0;
}

View File

@@ -0,0 +1,366 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {autoUpdate, FloatingPortal, flip, offset, size, useFloating} from '@floating-ui/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {TenorGif} from '~/actions/TenorActionCreators';
import {Permissions} from '~/Constants';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {useListNavigation} from '~/hooks/useListNavigation';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {FavoriteMemeRecord} from '~/records/FavoriteMemeRecord';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRoleRecord} from '~/records/GuildRoleRecord';
import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
import type {UserRecord} from '~/records/UserRecord';
import type {Emoji} from '~/stores/EmojiStore';
import styles from './Autocomplete.module.css';
import {AutocompleteChannel} from './AutocompleteChannel';
import {AutocompleteCommand} from './AutocompleteCommand';
import {AutocompleteEmoji} from './AutocompleteEmoji';
import {AutocompleteGif} from './AutocompleteGif';
import {AutocompleteMeme} from './AutocompleteMeme';
import {AutocompleteMention} from './AutocompleteMention';
import {AutocompleteSticker} from './AutocompleteSticker';
export type AutocompleteType = 'mention' | 'channel' | 'emoji' | 'command' | 'meme' | 'gif' | 'sticker';
interface SimpleCommand {
type: 'simple';
name: string;
content: string;
}
type ScrollerWithScrollableElement = ScrollerHandle & {
getScrollableElement?: () => HTMLElement | null;
};
interface ActionCommand {
type: 'action';
name: string;
permission?: bigint;
requiresGuild?: boolean;
}
export type Command = SimpleCommand | ActionCommand;
export const COMMANDS: Array<Command> = [
{type: 'simple', name: '/shrug', content: '¯\\_(ツ)_/¯'},
{type: 'simple', name: '/tableflip', content: '(╯°□°)╯︵ ┻━┻'},
{type: 'simple', name: '/unflip', content: '┬─┬ ( ゜-゜ノ)'},
{type: 'action', name: '/me'},
{type: 'action', name: '/spoiler'},
{type: 'action', name: '/tts', permission: Permissions.SEND_TTS_MESSAGES},
{type: 'action', name: '/nick', permission: Permissions.CHANGE_NICKNAME, requiresGuild: true},
{type: 'action', name: '/kick', permission: Permissions.KICK_MEMBERS, requiresGuild: true},
{type: 'action', name: '/ban', permission: Permissions.BAN_MEMBERS, requiresGuild: true},
{type: 'action', name: '/msg'},
{type: 'action', name: '/saved'},
{type: 'action', name: '/sticker'},
{type: 'action', name: '/gif'},
{type: 'action', name: '/tenor'},
];
export type AutocompleteOption =
| {type: 'mention'; kind: 'member'; member: GuildMemberRecord}
| {type: 'mention'; kind: 'user'; user: UserRecord}
| {type: 'mention'; kind: 'role'; role: GuildRoleRecord}
| {type: 'mention'; kind: '@everyone' | '@here'}
| {type: 'channel'; channel: ChannelRecord}
| {type: 'emoji'; emoji: Emoji}
| {type: 'command'; command: Command}
| {type: 'meme'; meme: FavoriteMemeRecord}
| {type: 'gif'; gif: TenorGif}
| {type: 'sticker'; sticker: GuildStickerRecord};
export const isMentionMember = (
o: AutocompleteOption,
): o is {type: 'mention'; kind: 'member'; member: GuildMemberRecord} => o.type === 'mention' && o.kind === 'member';
export const isMentionUser = (o: AutocompleteOption): o is {type: 'mention'; kind: 'user'; user: UserRecord} =>
o.type === 'mention' && o.kind === 'user';
export const isMentionRole = (o: AutocompleteOption): o is {type: 'mention'; kind: 'role'; role: GuildRoleRecord} =>
o.type === 'mention' && o.kind === 'role';
export const isSpecialMention = (o: AutocompleteOption): o is {type: 'mention'; kind: '@everyone' | '@here'} =>
o.type === 'mention' && (o.kind === '@everyone' || o.kind === '@here');
export const isChannel = (o: AutocompleteOption): o is {type: 'channel'; channel: ChannelRecord} =>
o.type === 'channel';
export const isEmoji = (o: AutocompleteOption): o is {type: 'emoji'; emoji: Emoji} => o.type === 'emoji';
export const isCommand = (o: AutocompleteOption): o is {type: 'command'; command: Command} => o.type === 'command';
export const isMeme = (o: AutocompleteOption): o is {type: 'meme'; meme: FavoriteMemeRecord} => o.type === 'meme';
export const isGif = (o: AutocompleteOption): o is {type: 'gif'; gif: TenorGif} => o.type === 'gif';
export const isSticker = (o: AutocompleteOption): o is {type: 'sticker'; sticker: GuildStickerRecord} =>
o.type === 'sticker';
export const Autocomplete = observer(
({
type,
onSelect,
selectedIndex: externalSelectedIndex,
options,
setSelectedIndex: externalSetSelectedIndex,
referenceElement,
zIndex,
attached = false,
}: {
type: AutocompleteType;
onSelect: (option: AutocompleteOption) => void;
selectedIndex?: number;
options: Array<AutocompleteOption>;
setSelectedIndex?: React.Dispatch<React.SetStateAction<number>>;
referenceElement?: HTMLElement | null;
zIndex?: number;
query?: string;
attached?: boolean;
}) => {
const {
keyboardFocusIndex: internalKeyboardFocusIndex,
hoverIndexForRender,
handleKeyboardNavigation,
handleMouseEnter,
handleMouseLeave,
reset,
} = useListNavigation({
itemCount: options.length,
initialIndex: 0,
loop: true,
});
const keyboardFocusIndex = externalSelectedIndex ?? internalKeyboardFocusIndex;
const [referenceState, setReferenceState] = React.useState<HTMLElement | null>(referenceElement ?? null);
React.useEffect(() => {
setReferenceState(referenceElement ?? null);
}, [referenceElement]);
const {refs, floatingStyles} = useFloating({
placement: 'top-start',
open: true,
whileElementsMounted: autoUpdate,
elements: {reference: referenceState},
middleware: [
offset(attached ? 0 : 8),
flip({padding: 16}),
size({
apply({rects, elements}) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
});
},
padding: 16,
}),
],
});
const scrollerRef = React.useRef<ScrollerHandle>(null);
const rowRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
if (rowRefs.current.length !== options.length) {
rowRefs.current = Array(options.length).fill(null);
}
React.useEffect(() => {
reset();
}, [options.length, reset]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
handleKeyboardNavigation('down');
if (externalSetSelectedIndex) {
externalSetSelectedIndex((prev) => (prev + 1 >= options.length ? 0 : prev + 1));
}
break;
}
case 'Home': {
event.preventDefault();
handleKeyboardNavigation('home');
if (externalSetSelectedIndex) {
externalSetSelectedIndex(0);
}
break;
}
case 'End': {
event.preventDefault();
handleKeyboardNavigation('end');
if (externalSetSelectedIndex) {
externalSetSelectedIndex(Math.max(0, options.length - 1));
}
break;
}
case 'ArrowUp': {
event.preventDefault();
handleKeyboardNavigation('up');
if (externalSetSelectedIndex) {
externalSetSelectedIndex((prev) => (prev - 1 < 0 ? options.length - 1 : prev - 1));
}
break;
}
case 'Tab':
case 'Enter': {
event.preventDefault();
if (keyboardFocusIndex >= 0 && keyboardFocusIndex < options.length) {
onSelect(options[keyboardFocusIndex]);
}
break;
}
default:
break;
}
},
[externalSetSelectedIndex, handleKeyboardNavigation, keyboardFocusIndex, onSelect, options],
);
function scrollChildIntoView(node: HTMLElement | null, margin = 32) {
if (!node) return;
const scroller = scrollerRef.current as ScrollerWithScrollableElement | null;
if (scroller && typeof scroller.scrollIntoViewNode === 'function') {
scroller.scrollIntoViewNode({node, padding: margin});
return;
}
const scrollerEl =
scroller?.getScrollableElement?.() ||
node.closest('[data-scrollable], .overflow-y-auto, .overflow-y-scroll') ||
node.parentElement;
if (scrollerEl && scrollerEl instanceof HTMLElement) {
const sRect = scrollerEl.getBoundingClientRect();
const nRect = node.getBoundingClientRect();
const outOfViewTop = nRect.top < sRect.top + margin;
const outOfViewBottom = nRect.bottom > sRect.bottom - margin;
if (outOfViewTop) {
scrollerEl.scrollTop -= sRect.top + margin - nRect.top;
} else if (outOfViewBottom) {
scrollerEl.scrollTop += nRect.bottom - (sRect.bottom - margin);
}
return;
}
node.scrollIntoView({block: 'nearest'});
}
React.useLayoutEffect(() => {
const node = rowRefs.current[keyboardFocusIndex] ?? null;
if (!node) return;
const raf = requestAnimationFrame(() => scrollChildIntoView(node, 32));
return () => cancelAnimationFrame(raf);
}, [keyboardFocusIndex, options.length]);
return (
<FloatingPortal>
<div
ref={refs.setFloating}
style={{...floatingStyles, zIndex: zIndex ?? undefined}}
className={`${styles.container} ${attached ? styles.containerAttached : styles.containerDetached}`}
onKeyDown={handleKeyDown}
role="listbox"
>
{type === 'gif' ? (
<AutocompleteGif
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
) : (
<Scroller
ref={scrollerRef}
className={styles.scroller}
key="autocomplete-scroller"
reserveScrollbarTrack={false}
>
{type === 'mention' ? (
<AutocompleteMention
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
) : type === 'channel' ? (
<AutocompleteChannel
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
) : type === 'command' ? (
<AutocompleteCommand
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
) : type === 'meme' ? (
<AutocompleteMeme
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
) : type === 'sticker' ? (
<AutocompleteSticker
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
) : (
<AutocompleteEmoji
onSelect={onSelect}
keyboardFocusIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
options={options}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
rowRefs={rowRefs}
/>
)}
</Scroller>
)}
</div>
</FloatingPortal>
);
},
);

View File

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

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as HighlightActionCreators from '~/actions/HighlightActionCreators';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {type AutocompleteOption, isChannel} from './Autocomplete';
import styles from './AutocompleteChannel.module.css';
import {AutocompleteItem} from './AutocompleteItem';
export const AutocompleteChannel = observer(
({
onSelect,
keyboardFocusIndex,
hoverIndex,
options,
onMouseEnter,
onMouseLeave,
rowRefs,
}: {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
}) => {
const channels = options.filter(isChannel);
return channels.map((option, index) => (
<AutocompleteItem
key={option.channel.id}
icon={ChannelUtils.getIcon(option.channel, {className: styles.channelIcon})}
name={option.channel.name}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => {
HighlightActionCreators.highlightChannel(option.channel.id);
onMouseEnter(index);
}}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[index] = node;
}
: undefined
}
/>
));
},
);

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {MutableRefObject} from 'react';
import {type AutocompleteOption, type Command, isCommand} from './Autocomplete';
import {AutocompleteItem} from './AutocompleteItem';
type Props = {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: MutableRefObject<Array<HTMLButtonElement | null>>;
};
export const AutocompleteCommand = observer(
({onSelect, keyboardFocusIndex, hoverIndex, options, onMouseEnter, onMouseLeave, rowRefs}: Props) => {
const {t} = useLingui();
const getCommandDescription = (command: Command): string | undefined => {
if (command.type === 'simple') {
const content = command.content;
return t`Appends ${content} to your message.`;
}
switch (command.name) {
case '/nick':
return t`Change your nickname in this community.`;
case '/kick':
return t`Kick a member from this community.`;
case '/ban':
return t`Ban a member from this community.`;
case '/msg':
return t`Send a direct message to a user.`;
case '/saved':
return t`Send a saved media item.`;
case '/sticker':
return t`Send a sticker.`;
case '/me':
return t`Send an action message (wraps in italics).`;
case '/spoiler':
return t`Send a spoiler message (wraps in spoiler tags).`;
case '/gif':
return t`Search for and send a GIF.`;
case '/tenor':
return t`Search for and send a GIF from Tenor.`;
default:
return undefined;
}
};
const commands = options.filter(isCommand);
return commands.map((option, index) => {
const description = getCommandDescription(option.command);
return (
<AutocompleteItem
key={option.command.name}
name={option.command.name}
description={description}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node: HTMLButtonElement | null) => {
rowRefs.current[index] = node;
}
: undefined
}
/>
);
});
},
);

View File

@@ -0,0 +1,80 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.sectionHeading {
padding: 4px 12px;
font-weight: 600;
color: var(--text-primary-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.divider {
margin: 4px 0;
height: 1px;
background-color: var(--background-modifier-hover);
}
.emojiIcon {
height: 24px;
width: 24px;
}
.nativeEmojiIcon {
font-size: 24px;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.stickerIconWrapper,
.memeIconWrapper {
height: 32px;
width: 32px;
overflow: hidden;
border-radius: 4px;
}
.stickerIcon,
.memeIcon {
height: 100%;
width: 100%;
object-fit: cover;
}
.memeVideo {
height: 100%;
width: 100%;
object-fit: cover;
}
.audioIconWrapper {
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
background-color: var(--brand-primary);
}
.audioIcon {
height: 16px;
width: 16px;
color: white;
}

View File

@@ -0,0 +1,200 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {MusicNoteIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as EmojiPickerActionCreators from '~/actions/EmojiPickerActionCreators';
import GuildStore from '~/stores/GuildStore';
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import {type AutocompleteOption, isEmoji, isMeme, isSticker} from './Autocomplete';
import styles from './AutocompleteEmoji.module.css';
import {AutocompleteItem} from './AutocompleteItem';
const SectionHeading = observer(({children}: {children: React.ReactNode}) => (
<div className={styles.sectionHeading}>{children}</div>
));
export const AutocompleteEmoji = observer(
({
onSelect,
keyboardFocusIndex,
hoverIndex,
options,
onMouseEnter,
onMouseLeave,
rowRefs,
}: {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
}) => {
const {t} = useLingui();
const emojis = options.filter(isEmoji);
const stickers = options.filter(isSticker);
const memes = options.filter(isMeme);
const handleEmojiSelect = (option: AutocompleteOption) => {
if (isEmoji(option)) EmojiPickerActionCreators.trackEmojiUsage(option.emoji);
onSelect(option);
};
return (
<>
{emojis.length > 0 && (
<>
<SectionHeading>{t`Emojis`}</SectionHeading>
{emojis.map((option, index) => {
const isUnicodeEmoji = !option.emoji.guildId && !option.emoji.id;
const useNativeRendering = shouldUseNativeEmoji && isUnicodeEmoji;
return (
<AutocompleteItem
key={option.emoji.name}
name={`:${option.emoji.name}:`}
description={
option.emoji.guildId ? GuildStore.getGuild(option.emoji.guildId)?.name : t`Default emoji`
}
icon={
useNativeRendering ? (
<span className={styles.nativeEmojiIcon}>{option.emoji.surrogates}</span>
) : (
<img
draggable={false}
className={styles.emojiIcon}
src={option.emoji.url ?? ''}
alt={option.emoji.name}
/>
)
}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndex}
onSelect={() => handleEmojiSelect(option)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[index] = node;
}
: undefined
}
/>
);
})}
{(stickers.length > 0 || memes.length > 0) && <div className={styles.divider} aria-hidden={true} />}
</>
)}
{stickers.length > 0 && (
<>
<SectionHeading>{t`Stickers`}</SectionHeading>
{stickers.map((option, index) => {
const currentIndex = emojis.length + index;
return (
<AutocompleteItem
key={option.sticker.id}
name={option.sticker.name}
description={
option.sticker.tags.length > 0
? option.sticker.tags.join(', ')
: option.sticker.description || undefined
}
icon={
<div className={styles.stickerIconWrapper}>
<img
draggable={false}
className={styles.stickerIcon}
src={option.sticker.url}
alt={option.sticker.name}
/>
</div>
}
isKeyboardSelected={currentIndex === keyboardFocusIndex}
isHovered={currentIndex === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(currentIndex)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[currentIndex] = node;
}
: undefined
}
/>
);
})}
{memes.length > 0 && <div className={styles.divider} aria-hidden={true} />}
</>
)}
{memes.length > 0 && (
<>
<SectionHeading>{t`Media`}</SectionHeading>
{memes.map((option, index) => {
const currentIndex = emojis.length + stickers.length + index;
return (
<AutocompleteItem
key={option.meme.id}
name={option.meme.name}
description={option.meme.tags.length > 0 ? option.meme.tags.join(', ') : undefined}
icon={
<div className={styles.memeIconWrapper}>
{option.meme.contentType.startsWith('video/') || option.meme.contentType.includes('gif') ? (
<video src={option.meme.url} className={styles.memeVideo} muted autoPlay loop playsInline />
) : option.meme.contentType.startsWith('audio/') ? (
<div className={styles.audioIconWrapper}>
<MusicNoteIcon className={styles.audioIcon} weight="fill" />
</div>
) : (
<img
draggable={false}
className={styles.memeIcon}
src={option.meme.url}
alt={option.meme.name}
/>
)}
</div>
}
isKeyboardSelected={currentIndex === keyboardFocusIndex}
isHovered={currentIndex === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(currentIndex)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[currentIndex] = node;
}
: undefined
}
/>
);
})}
</>
)}
</>
);
},
);

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.empty {
display: flex;
height: 128px;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.container {
display: flex;
height: 192px;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.heading {
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.scroller {
display: flex;
flex-direction: row;
gap: 4px;
}
.gifButton {
position: relative;
display: flex;
height: 128px;
width: 192px;
flex-shrink: 0;
cursor: pointer;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
border: 2px solid transparent;
background-color: var(--background-secondary);
transition: border-color 0.15s ease;
}
.gifButton:hover {
border-color: var(--brand-primary);
}
.gifButtonSelected {
border-color: var(--brand-primary);
}
.gifVideoWrapper {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.gifVideo {
display: block;
height: 100%;
width: 100%;
object-fit: cover;
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import * as TenorUtils from '~/utils/TenorUtils';
import {type AutocompleteOption, isGif} from './Autocomplete';
import styles from './AutocompleteGif.module.css';
export const AutocompleteGif = observer(
({
onSelect,
keyboardFocusIndex,
hoverIndex,
options,
onMouseEnter,
onMouseLeave,
rowRefs,
}: {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
}) => {
const {t} = useLingui();
const gifs = options.filter(isGif);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp': {
event.preventDefault();
break;
}
}
}, []);
React.useLayoutEffect(() => {
const selectedElement = rowRefs?.current[keyboardFocusIndex];
if (selectedElement && scrollerRef.current) {
scrollerRef.current.scrollIntoViewNode({
node: selectedElement,
shouldScrollToStart: false,
padding: 0,
});
}
}, [keyboardFocusIndex, rowRefs?.current[keyboardFocusIndex]]);
if (gifs.length === 0) {
return <div className={styles.empty}>{t`No GIFs found`}</div>;
}
return (
<div className={styles.container} onKeyDown={handleKeyDown} role="application">
<div className={styles.heading}>{t`GIFs`}</div>
<Scroller
ref={scrollerRef}
className={styles.scroller}
orientation="horizontal"
fade={false}
key="autocomplete-gif-scroller"
>
{gifs.map((option, index) => {
const gif = option.gif;
const title = gif.title || TenorUtils.parseTitleFromUrl(gif.url);
const isActive = index === keyboardFocusIndex || index === hoverIndex;
return (
<button
type="button"
key={gif.id}
ref={(node) => {
if (rowRefs) {
rowRefs.current[index] = node;
}
}}
className={`${styles.gifButton} ${isActive ? styles.gifButtonSelected : ''}`}
onClick={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
aria-label={`${title} - ${t`From Tenor`}`}
>
<div className={styles.gifVideoWrapper}>
<video src={gif.proxy_src} className={styles.gifVideo} muted autoPlay loop playsInline />
</div>
</button>
);
})}
</Scroller>
</div>
);
},
);

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.button {
cursor: pointer;
border: none;
background-color: transparent;
padding: 0 6px;
text-align: left;
font-weight: 600;
font-size: 14px;
line-height: 16px;
}
.container {
cursor: pointer;
border-radius: 8px;
padding: 8px;
}
.container:hover {
background-color: var(--background-modifier-hover);
}
.selected {
background-color: var(--background-modifier-hover);
}
.content {
display: flex;
min-height: 16px;
align-items: center;
color: var(--text-primary);
}
.icon {
margin-right: 8px;
flex-shrink: 0;
}
.nameWrapper {
min-width: 10ch;
flex-shrink: 1;
flex-grow: 0;
overflow: hidden;
}
.name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
font-size: 16px;
color: var(--text-primary);
line-height: 1.25;
max-height: 1.25em;
}
.description {
margin-left: 16px;
min-width: 10ch;
flex-shrink: 0;
flex-grow: 1;
flex-basis: 10ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-weight: 400;
color: var(--text-primary-muted);
font-size: 12px;
line-height: 1.33;
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import type React from 'react';
import styles from './AutocompleteItem.module.css';
export const AutocompleteItem = observer(
({
icon,
name,
description,
isKeyboardSelected,
isHovered,
onSelect,
onMouseEnter,
onMouseLeave,
innerRef,
...props
}: {
icon?: React.ReactNode;
name: React.ReactNode;
description?: string;
isKeyboardSelected: boolean;
isHovered: boolean;
onSelect: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
innerRef?: React.Ref<HTMLButtonElement>;
} & React.HTMLAttributes<HTMLButtonElement>) => {
const isActive = isKeyboardSelected || isHovered;
return (
<button
type="button"
className={styles.button}
onClick={onSelect}
onPointerEnter={onMouseEnter}
onPointerLeave={onMouseLeave}
ref={innerRef}
{...props}
>
<div className={`${styles.container} ${isActive ? styles.selected : ''}`}>
<div className={styles.content}>
{icon && <div className={styles.icon}>{icon}</div>}
<div className={styles.nameWrapper}>
<div className={styles.name}>{name}</div>
</div>
{description && (
<div className={styles.description}>
<span>{description}</span>
</div>
)}
</div>
</div>
</button>
);
},
);

View File

@@ -0,0 +1,79 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MusicNoteIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {type AutocompleteOption, isMeme} from './Autocomplete';
import styles from './AutocompleteEmoji.module.css';
import {AutocompleteItem} from './AutocompleteItem';
export const AutocompleteMeme = observer(
({
onSelect,
keyboardFocusIndex,
hoverIndex,
options,
onMouseEnter,
onMouseLeave,
rowRefs,
}: {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
}) => {
const memes = options.filter(isMeme);
return memes.map((option, index) => (
<AutocompleteItem
key={option.meme.id}
name={option.meme.name}
description={option.meme.tags.length > 0 ? option.meme.tags.join(', ') : undefined}
icon={
<div className={styles.memeIconWrapper}>
{option.meme.contentType.startsWith('video/') || option.meme.contentType.includes('gif') ? (
<video src={option.meme.url} className={styles.memeVideo} muted autoPlay loop playsInline />
) : option.meme.contentType.startsWith('audio/') ? (
<div className={styles.audioIconWrapper}>
<MusicNoteIcon className={styles.audioIcon} weight="fill" />
</div>
) : (
<img draggable={false} className={styles.memeIcon} src={option.meme.url} alt={option.meme.name} />
)}
</div>
}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[index] = node;
}
: undefined
}
/>
));
},
);

View File

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

View File

@@ -0,0 +1,175 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 React from 'react';
import {useParams} from '~/lib/router';
import GuildStore from '~/stores/GuildStore';
import * as ColorUtils from '~/utils/ColorUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import {StatusAwareAvatar} from '../uikit/StatusAwareAvatar';
import {type AutocompleteOption, isMentionMember, isMentionRole, isMentionUser, isSpecialMention} from './Autocomplete';
import {AutocompleteItem} from './AutocompleteItem';
import styles from './AutocompleteMention.module.css';
export const AutocompleteMention = observer(function AutocompleteMention({
onSelect,
keyboardFocusIndex,
hoverIndex,
options,
onMouseEnter,
onMouseLeave,
rowRefs,
}: {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
}) {
const {t} = useLingui();
const {guildId} = useParams() as {guildId?: string};
const guild = GuildStore.getGuild(guildId ?? '');
const members = options.filter(isMentionMember);
const users = options.filter(isMentionUser);
const roles = options.filter(isMentionRole);
const specialMentions = options.filter(isSpecialMention);
return (
<>
{members.length > 0 && (
<>
{members.map((option, index) => (
<AutocompleteItem
key={option.member.user.id}
icon={<StatusAwareAvatar user={option.member.user} size={24} guildId={guildId} />}
name={NicknameUtils.getNickname(option.member.user, guild?.id)}
description={option.member.user.tag}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[index] = node;
}
: undefined
}
/>
))}
{(users.length > 0 || specialMentions.length > 0 || roles.length > 0) && (
<div className={styles.divider} aria-hidden={true} />
)}
</>
)}
{users.length > 0 && (
<>
{users.map((option, index) => {
const currentIndex = members.length + index;
return (
<AutocompleteItem
key={option.user.id}
icon={<StatusAwareAvatar user={option.user} size={24} />}
name={option.user.username}
description={option.user.tag}
isKeyboardSelected={currentIndex === keyboardFocusIndex}
isHovered={currentIndex === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(currentIndex)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[currentIndex] = node;
}
: undefined
}
/>
);
})}
{(specialMentions.length > 0 || roles.length > 0) && <div className={styles.divider} aria-hidden={true} />}
</>
)}
{specialMentions.length > 0 && (
<>
{specialMentions.map((option, index) => {
const currentIndex = members.length + users.length + index;
return (
<AutocompleteItem
key={option.kind}
name={option.kind}
description={
option.kind === '@everyone'
? t`Notify everyone who has permission to view this channel.`
: t`Notify everyone online who has permission to view this channel.`
}
isKeyboardSelected={currentIndex === keyboardFocusIndex}
isHovered={currentIndex === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(currentIndex)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[currentIndex] = node;
}
: undefined
}
/>
);
})}
{roles.length > 0 && <div className={styles.divider} aria-hidden={true} />}
</>
)}
{roles.length > 0 &&
roles.map((option, index) => {
const currentIndex = members.length + users.length + specialMentions.length + index;
return (
<AutocompleteItem
key={option.role.id}
name={
<span style={{color: option.role.color ? ColorUtils.int2rgb(option.role.color) : undefined}}>
@{option.role.name}
</span>
}
description={t`Notify users with this role who have permission to view this channel.`}
isKeyboardSelected={currentIndex === keyboardFocusIndex}
isHovered={currentIndex === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(currentIndex)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[currentIndex] = node;
}
: undefined
}
/>
);
})}
</>
);
});

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {AutocompleteOption} from './Autocomplete';
import {isSticker} from './Autocomplete';
import styles from './AutocompleteEmoji.module.css';
import {AutocompleteItem} from './AutocompleteItem';
export const AutocompleteSticker = observer(
({
onSelect,
keyboardFocusIndex,
hoverIndex,
options,
onMouseEnter,
onMouseLeave,
rowRefs,
}: {
onSelect: (option: AutocompleteOption) => void;
keyboardFocusIndex: number;
hoverIndex: number;
options: Array<AutocompleteOption>;
onMouseEnter: (index: number) => void;
onMouseLeave: () => void;
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
}) => {
const stickers = options.filter(isSticker);
return stickers.map((option, index) => (
<AutocompleteItem
key={option.sticker.id}
name={option.sticker.name}
description={
option.sticker.tags.length > 0 ? option.sticker.tags.join(', ') : option.sticker.description || undefined
}
icon={
<div className={styles.stickerIconWrapper}>
<img draggable={false} className={styles.stickerIcon} src={option.sticker.url} alt={option.sticker.name} />
</div>
}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(option)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
innerRef={
rowRefs
? (node) => {
rowRefs.current[index] = node;
}
: undefined
}
/>
));
},
);

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
background-color: var(--background-secondary);
border-radius: 4px;
}
.toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
padding: 4px 16px;
background: transparent;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-muted);
transition: color 150ms ease;
}
.toggle:hover {
color: var(--text-secondary);
}
.content {
padding: 8px 0;
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import React from 'react';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import {type ChannelStreamItem, ChannelStreamType} from '~/utils/MessageGroupingUtils';
import styles from './BlockedMessageGroups.module.css';
import {Divider} from './Divider';
import {MessageGroup} from './MessageGroup';
interface BlockedMessageGroupsProps {
channel: ChannelRecord;
messageGroups: Array<ChannelStreamItem>;
onReveal: (messageId: string | null) => void;
revealed: boolean;
compact: boolean;
messageGroupSpacing: number;
}
const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedMessageGroupsProps): boolean => {
if (prevProps.channel.id !== nextProps.channel.id) return false;
if (prevProps.revealed !== nextProps.revealed) return false;
if (prevProps.compact !== nextProps.compact) return false;
if (prevProps.messageGroupSpacing !== nextProps.messageGroupSpacing) return false;
if (prevProps.messageGroups.length !== nextProps.messageGroups.length) return false;
for (let i = 0; i < prevProps.messageGroups.length; i++) {
const prevGroup = prevProps.messageGroups[i];
const nextGroup = nextProps.messageGroups[i];
if (!nextGroup) return false;
if (prevGroup.type !== nextGroup.type) return false;
if (prevGroup.type === ChannelStreamType.MESSAGE) {
const prevMessage = prevGroup.content as MessageRecord;
const nextMessage = nextGroup.content as MessageRecord;
if (prevMessage.id !== nextMessage.id) return false;
if (prevMessage.editedTimestamp !== nextMessage.editedTimestamp) return false;
}
}
return true;
};
export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props) => {
const {t} = useLingui();
const {messageGroups, channel, compact, revealed, messageGroupSpacing, onReveal} = props;
const containerRef = React.useRef<HTMLDivElement>(null);
const handleClick = React.useCallback(() => {
const container = containerRef.current;
const scroller = container?.closest('[data-scroller]') as HTMLElement | null;
if (scroller) {
const wasAtBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 1;
if (revealed) {
onReveal(null);
if (wasAtBottom) {
requestAnimationFrame(() => {
scroller.scrollTop = scroller.scrollHeight;
});
}
} else {
const firstMessage = messageGroups.find((item) => item.type === ChannelStreamType.MESSAGE);
if (firstMessage) {
onReveal((firstMessage.content as MessageRecord).id);
if (wasAtBottom) {
requestAnimationFrame(() => {
scroller.scrollTop = scroller.scrollHeight;
});
}
}
}
} else {
if (revealed) {
onReveal(null);
} else {
const firstMessage = messageGroups.find((item) => item.type === ChannelStreamType.MESSAGE);
if (firstMessage) {
onReveal((firstMessage.content as MessageRecord).id);
}
}
}
}, [messageGroups, onReveal, revealed]);
const totalMessageCount = React.useMemo(() => {
return messageGroups.filter((item) => item.type === ChannelStreamType.MESSAGE).length;
}, [messageGroups]);
const messageNodes = React.useMemo(() => {
if (!revealed) return null;
const nodes: Array<React.ReactNode> = [];
let currentGroupMessages: Array<MessageRecord> = [];
let groupId: string | undefined;
const flushGroup = () => {
if (currentGroupMessages.length > 0) {
nodes.push(
<MessageGroup
key={currentGroupMessages[0].id}
messages={currentGroupMessages}
channel={channel}
messageDisplayCompact={compact}
idPrefix="blocked-messages"
/>,
);
currentGroupMessages = [];
groupId = undefined;
}
};
messageGroups.forEach((item, itemIndex) => {
if (item.type === ChannelStreamType.DIVIDER) {
flushGroup();
nodes.push(
<Divider
key={item.unreadId || item.contentKey || `divider-${itemIndex}`}
spacing={messageGroupSpacing}
red={!!item.unreadId}
id={item.unreadId ? 'new-messages-bar' : undefined}
>
{item.content as string}
</Divider>,
);
} else if (item.type === ChannelStreamType.MESSAGE) {
const message = item.content as MessageRecord;
if (groupId !== item.groupId) {
flushGroup();
groupId = item.groupId;
}
currentGroupMessages.push(message);
}
});
flushGroup();
return nodes;
}, [revealed, messageGroups, messageGroupSpacing, channel, compact]);
return (
<div ref={containerRef} className={styles.container}>
<button type="button" className={styles.toggle} onClick={handleClick}>
{totalMessageCount === 1 ? t`${totalMessageCount} Blocked Message` : t`${totalMessageCount} Blocked Messages`}
</button>
{revealed && (
<div className={styles.content} data-blocked-messages>
{messageNodes}
</div>
)}
</div>
);
}, arePropsEqual);

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--system-message-icon-size);
height: var(--system-message-icon-size);
}
.iconActive {
color: var(--status-online, rgb(34 197 94));
}
.iconEnded {
color: var(--status-online, rgb(34 197 94));
}
.iconMissed {
color: var(--text-tertiary-muted);
transform: scaleX(-1);
}
.callLink {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--text-link);
text-decoration: none;
font-weight: 500;
cursor: pointer;
}
.callLink:hover,
.callLink:focus-visible {
text-decoration: underline;
}
.separator {
color: var(--text-tertiary-muted);
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {PhoneIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators';
import {useCallHeaderState} from '~/components/channel/channel-view/useCallHeaderState';
import {SystemMessage} from '~/components/channel/SystemMessage';
import {SystemMessageUsername} from '~/components/channel/SystemMessageUsername';
import {useSystemMessageData} from '~/hooks/useSystemMessageData';
import i18n from '~/i18n';
import type {MessageRecord} from '~/records/MessageRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import styles from './CallMessage.module.css';
type DurationUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute';
const DURATION_UNITS: Array<{unit: DurationUnit; minutes: number}> = [
{unit: 'year', minutes: 525600},
{unit: 'month', minutes: 43800},
{unit: 'week', minutes: 10080},
{unit: 'day', minutes: 1440},
{unit: 'hour', minutes: 60},
{unit: 'minute', minutes: 1},
];
const LIST_FORMATTER =
typeof Intl !== 'undefined' && typeof Intl.ListFormat !== 'undefined'
? new Intl.ListFormat(undefined, {style: 'long', type: 'conjunction'})
: null;
const formatLocalizedNumber = (value: number): string => {
const locale = i18n.locale ?? 'en-US';
return new Intl.NumberFormat(locale).format(value);
};
const replaceCountPlaceholder = (text: string, count: number): string => {
if (!text.includes('#')) {
return text;
}
return text.replace(/#/g, formatLocalizedNumber(count));
};
const DURATION_UNIT_LABELS: Record<DurationUnit, {singular: MessageDescriptor; plural: MessageDescriptor}> = {
year: {singular: msg`1 year`, plural: msg`# years`},
month: {singular: msg`1 month`, plural: msg`# months`},
week: {singular: msg`1 week`, plural: msg`# weeks`},
day: {singular: msg`1 day`, plural: msg`# days`},
hour: {singular: msg`1 hour`, plural: msg`# hours`},
minute: {singular: msg`a minute`, plural: msg`# minutes`},
};
const formatDurationUnit = (t: (message: MessageDescriptor) => string, value: number, unit: DurationUnit): string => {
const descriptors = DURATION_UNIT_LABELS[unit] ?? DURATION_UNIT_LABELS.minute;
const descriptor = value === 1 ? descriptors.singular : descriptors.plural;
return replaceCountPlaceholder(t(descriptor), value);
};
const FEW_SECONDS_DESCRIPTOR = msg`a few seconds`;
const formatCallDuration = (t: (message: MessageDescriptor) => string, durationSeconds: number): string => {
if (durationSeconds < 60) {
return t(FEW_SECONDS_DESCRIPTOR);
}
const roundedMinutes = Math.max(1, Math.ceil(durationSeconds / 60));
const parts: Array<string> = [];
let remainingMinutes = roundedMinutes;
for (const {unit, minutes} of DURATION_UNITS) {
if (remainingMinutes < minutes) continue;
const count = Math.floor(remainingMinutes / minutes);
remainingMinutes -= count * minutes;
parts.push(formatDurationUnit(t, count, unit));
}
if (parts.length === 0) {
return t(DURATION_UNIT_LABELS.minute.singular);
}
if (!LIST_FORMATTER || parts.length === 1) {
return parts.join(' ');
}
return LIST_FORMATTER.format(parts);
};
export const CallMessage = observer(({message}: {message: MessageRecord}) => {
const {t} = useLingui();
const {author, channel, guild} = useSystemMessageData(message);
const currentUserId = AuthenticationStore.currentUserId;
const callData = message.call;
const isLocalConnected = channel ? MediaEngineStore.connected && MediaEngineStore.channelId === channel.id : false;
const callHeaderState = useCallHeaderState(channel);
const shouldShowJoinLink =
!isLocalConnected &&
!callHeaderState.isDeviceInRoomForChannelCall &&
!callHeaderState.isDeviceConnectingToChannelCall &&
callHeaderState.callExistsAndOngoing &&
callHeaderState.controlsVariant === 'join';
const handleJoinCall = useCallback(() => {
if (!channel) return;
CallActionCreators.joinCall(channel.id);
}, [channel]);
if (!channel || !callData) {
return null;
}
const callEnded = callData.endedTimestamp != null;
const participantIds = callData.participants;
const includesCurrentUser = Boolean(currentUserId && participantIds.includes(currentUserId));
const authorIsCurrentUser = author.id === currentUserId;
const isMissedCall = callEnded && !includesCurrentUser && !authorIsCurrentUser;
const durationText =
callEnded && callData.endedTimestamp
? formatCallDuration(t, Math.max(0, (callData.endedTimestamp.getTime() - message.timestamp.getTime()) / 1000))
: t`a few seconds`;
let messageContent: React.ReactNode;
if (!callEnded) {
messageContent = (
<>
<Trans>
<SystemMessageUsername key={author.id} author={author} guild={guild} message={message} /> started a call.
</Trans>
{shouldShowJoinLink && (
<>
<span className={styles.separator} aria-hidden="true">
&nbsp;&nbsp;
</span>
{/* biome-ignore lint/a11y/useValidAnchor: this is fine */}
<a
className={styles.callLink}
href="#"
onClick={(event) => {
event.preventDefault();
handleJoinCall();
}}
>
{t`Join the call`}
</a>
</>
)}
</>
);
} else if (isMissedCall) {
messageContent = durationText ? (
<Trans>
You missed a call from <SystemMessageUsername key={author.id} author={author} guild={guild} message={message} />{' '}
that lasted {durationText}.
</Trans>
) : (
<Trans>
You missed a call from <SystemMessageUsername key={author.id} author={author} guild={guild} message={message} />
.
</Trans>
);
} else {
messageContent = (
<Trans>
<SystemMessageUsername key={author.id} author={author} guild={guild} message={message} /> started a call that
lasted {durationText}.
</Trans>
);
}
const iconClassname = clsx(
styles.icon,
callEnded ? (isMissedCall ? styles.iconMissed : styles.iconEnded) : styles.iconActive,
);
return (
<SystemMessage
icon={PhoneIcon}
iconWeight="fill"
iconClassname={iconClassname}
message={message}
messageContent={messageContent}
/>
);
});

View File

@@ -0,0 +1,330 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.scroller {
margin: 0 0 2px 6px;
}
.channelAttachmentArea {
display: flex;
gap: 24px;
padding: 20px 10px 10px;
position: relative;
z-index: 1;
}
.upload {
display: inline-flex;
flex-direction: column;
background: var(--background-primary);
border-radius: 4px;
margin: 0;
padding: 8px;
position: relative;
min-width: 200px;
max-width: 200px;
min-height: 200px;
max-height: 200px;
}
.uploadContainer {
display: flex;
position: relative;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.mediaContainer {
position: relative;
flex: 1;
min-height: 0;
background-color: var(--background-tertiary);
border-radius: 4px;
}
.clickableMedia {
cursor: pointer;
width: 100%;
height: 100%;
display: block;
border: none;
background: none;
padding: 0;
margin: 0;
}
.mediaContainer > div:not([aria-expanded='false']),
.mediaContainer > div:not([aria-expanded='false']) > div {
height: 100%;
}
.spoilerContainer {
height: 100%;
position: relative;
filter: blur(0);
border-radius: 4px;
background-color: hsla(0, 0%, 100%, 0.1);
}
.spoilerContainer.hidden {
overflow: hidden;
}
.spoilerContainer.hiddenSpoiler {
cursor: pointer;
}
.spoilerWarning {
text-transform: uppercase;
font-size: 15px;
background-color: hsla(0, 0%, 0%, 0.6);
cursor: pointer;
font-weight: 600;
border-radius: 20px;
transition: background-color 0.2s;
}
.spoilerWarning:hover {
background-color: hsla(0, 0%, 0%, 0.8);
}
.obscureWarning {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
padding: 8px 12px;
user-select: none;
-webkit-user-select: none;
color: hsl(216, 10%, 90%);
}
.spoilerInnerContainer {
width: 100%;
height: 100%;
}
.spoilerWrapper {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
position: relative;
}
.spoiler {
filter: blur(44px);
pointer-events: none;
}
.media {
border-radius: 4px;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
color: transparent;
font-size: 0;
}
.tags {
position: absolute;
left: 3px;
bottom: 6px;
}
.filenameContainer {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 8px;
padding: 0 4px;
min-height: 40px;
flex-shrink: 0;
z-index: 1;
}
.filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
color: var(--text-primary);
}
.fileDetails {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-tertiary);
font-weight: 400;
}
.fileSize {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileExtension {
text-transform: uppercase;
font-weight: 600;
color: var(--brand-primary-light);
flex-shrink: 0;
margin-left: 4px;
}
.actionBarContainer {
position: absolute;
top: 0;
right: 0;
}
.actionBar {
display: grid;
position: relative;
z-index: 1;
transform: translate(25%, -25%);
box-sizing: border-box;
align-items: center;
justify-content: flex-start;
grid-auto-flow: column;
padding: 2px;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
user-select: none;
-webkit-user-select: none;
}
.button {
display: flex;
position: relative;
align-items: center;
justify-content: center;
padding: 4px;
height: 30px;
min-width: 30px;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
}
.button:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.button.danger {
color: var(--status-danger);
}
.actionBarIcon {
width: 20px;
height: 20px;
display: block;
object-fit: contain;
}
.altTag {
color: black;
mix-blend-mode: screen;
background: var(--text-primary);
margin-right: 4px;
text-transform: uppercase;
font-size: 10px;
font-weight: 600;
padding: 4px;
border-radius: 4px;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
background-color: var(--background-tertiary);
border-radius: 4px;
}
.iconImage {
width: 100px;
height: 100px;
color: var(--brand-primary-light);
}
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: hsla(0, 0%, 0%, 0.5);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid hsla(0, 0%, 100%, 0.3);
border-radius: 50%;
border-top-color: hsl(0, 0%, 100%);
animation: spin 1s ease infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.videoModal {
display: flex;
align-items: center;
justify-content: center;
border-radius: 0;
background-color: transparent;
}
.videoContainer {
position: relative;
}
.divider {
height: 1px;
background-color: var(--user-area-divider-color);
margin-left: -16px;
margin-right: -16px;
}

View File

@@ -0,0 +1,482 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors} from '@dnd-kit/core';
import {restrictToHorizontalAxis} from '@dnd-kit/modifiers';
import {arrayMove, horizontalListSortingStrategy, SortableContext, useSortable} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
import {useLingui} from '@lingui/react/macro';
import {
EyeIcon,
EyeSlashIcon,
FileAudioIcon,
FileCodeIcon,
FileIcon,
FilePdfIcon,
FileTextIcon,
FileZipIcon,
type Icon,
PencilIcon,
TrashIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as MediaViewerActionCreators from '~/actions/MediaViewerActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {MessageAttachmentFlags} from '~/Constants';
import styles from '~/components/channel/ChannelAttachmentArea.module.css';
import EmbedVideo from '~/components/channel/embeds/media/EmbedVideo';
import {AttachmentEditModal} from '~/components/modals/AttachmentEditModal';
import * as Modal from '~/components/modals/Modal';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Scroller} from '~/components/uikit/Scroller';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useTextareaAttachments} from '~/hooks/useCloudUpload';
import {type CloudAttachment, CloudUpload} from '~/lib/CloudUpload';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import MessageStore from '~/stores/MessageStore';
import {formatFileSize} from '~/utils/FileUtils';
const getFileExtension = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase() || '';
return ext.length > 0 && ext.length <= 4 ? ext : '';
};
const getFileIcon = (file: File): Icon => {
const mimeType = file.type.toLowerCase();
const extension = file.name.split('.').pop()?.toLowerCase() || '';
if (mimeType.startsWith('audio/')) {
return FileAudioIcon;
}
if (mimeType === 'application/pdf') {
return FilePdfIcon;
}
if (mimeType.startsWith('text/') || ['txt', 'md', 'markdown', 'rtf'].includes(extension)) {
return FileTextIcon;
}
if (
[
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/x-7z-compressed',
].includes(mimeType) ||
['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)
) {
return FileZipIcon;
}
if (
mimeType.startsWith('application/') &&
[
'js',
'ts',
'jsx',
'tsx',
'html',
'css',
'json',
'xml',
'py',
'java',
'cpp',
'c',
'cs',
'php',
'rb',
'go',
'rs',
'swift',
].includes(extension)
) {
return FileCodeIcon;
}
return FileIcon;
};
const VideoPreviewModal = observer(({file, width, height}: {file: File; width: number; height: number}) => {
const {t} = useLingui();
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
React.useEffect(() => {
const url = URL.createObjectURL(file);
setBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!blobUrl) return null;
return (
<Modal.Root className={styles.videoModal}>
<Modal.ScreenReaderLabel text={t`Video`} />
<div className={styles.videoContainer}>
<EmbedVideo src={blobUrl} width={width} height={height} />
</div>
</Modal.Root>
);
});
const SortableAttachmentItem = observer(
({
attachment,
channelId,
isSortingList = false,
}: {
attachment: CloudAttachment;
channelId: string;
isSortingList?: boolean;
}) => {
const {t} = useLingui();
const [spoilerHidden, setSpoilerHidden] = React.useState(true);
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({
id: attachment.id,
});
const isSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0;
React.useEffect(() => {
if (isSpoiler) {
setSpoilerHidden(true);
}
}, [isSpoiler]);
const handleClick = () => {
if (isSpoiler && spoilerHidden) {
setSpoilerHidden(false);
return;
}
if (attachment.file.type.startsWith('image/')) {
if (!attachment.previewURL) return;
MediaViewerActionCreators.openMediaViewer(
[
{
src: attachment.previewURL,
originalSrc: attachment.previewURL,
naturalWidth: attachment.width,
naturalHeight: attachment.height,
type: 'image' as const,
filename: attachment.file.name,
},
],
0,
);
} else if (attachment.file.type.startsWith('video/')) {
ModalActionCreators.push(
modal(() => <VideoPreviewModal file={attachment.file} width={attachment.width} height={attachment.height} />),
);
}
};
const containerStyle: React.CSSProperties = {
width: '200px',
height: '200px',
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: isDragging ? 'grabbing' : 'default',
};
const isMedia =
attachment.previewURL && (attachment.file.type.startsWith('image/') || attachment.file.type.startsWith('video/'));
return (
<li
{...attributes}
{...listeners}
ref={setNodeRef}
style={containerStyle}
className={styles.upload}
tabIndex={-1}
>
<div className={styles.uploadContainer}>
{isMedia ? (
<div className={styles.mediaContainer}>
<button type="button" className={styles.clickableMedia} onClick={handleClick}>
<div
className={clsx(
styles.spoilerContainer,
isSpoiler && spoilerHidden && styles.hidden,
isSpoiler && spoilerHidden && styles.hiddenSpoiler,
)}
>
{isSpoiler && spoilerHidden && (
<div className={clsx(styles.spoilerWarning, styles.obscureWarning)}>{t`Spoiler`}</div>
)}
<div className={styles.spoilerInnerContainer} aria-hidden={spoilerHidden}>
<div className={styles.spoilerWrapper}>
{attachment.file.type.startsWith('image/') ? (
<ImageThumbnail attachment={attachment} spoiler={isSpoiler && spoilerHidden} />
) : attachment.file.type.startsWith('video/') ? (
<VideoThumbnail attachment={attachment} spoiler={isSpoiler && spoilerHidden} />
) : null}
<div className={styles.tags}>
{isSpoiler && !spoilerHidden && <span className={styles.altTag}>{t`Spoiler`}</span>}
</div>
</div>
</div>
</div>
</button>
</div>
) : (
<div className={styles.icon}>
{(() => {
const IconComponent = getFileIcon(attachment.file);
return <IconComponent className={styles.iconImage} weight="fill" aria-label={attachment.filename} />;
})()}
</div>
)}
<div className={styles.filenameContainer}>
<Tooltip text={attachment.filename}>
<div className={styles.filename}>{attachment.filename}</div>
</Tooltip>
<div className={styles.fileDetails}>
<span className={styles.fileSize}>{formatFileSize(attachment.file.size)}</span>
<span className={styles.fileExtension}>{getFileExtension(attachment.filename)}</span>
</div>
</div>
{!isSortingList && (
<div className={styles.actionBarContainer}>
{attachment.status === 'failed' ? (
<div className={styles.actionBar}>
<AttachmentActionBarButton
icon={TrashIcon}
label={t`Remove Attachment`}
danger={true}
onClick={() => CloudUpload.removeAttachment(channelId, attachment.id)}
/>
</div>
) : (
<AttachmentActionBar channelId={channelId} attachment={attachment} />
)}
</div>
)}
</div>
</li>
);
},
);
export const ChannelAttachmentArea = observer(({channelId}: {channelId: string}) => {
const attachments = useTextareaAttachments(channelId);
const prevAttachmentsLength = React.useRef<number | null>(null);
const wasAtBottomBeforeChange = React.useRef<boolean>(true);
const [isDragging, setIsDragging] = React.useState(false);
const sensors = useSensors(useSensor(PointerSensor, {activationConstraint: {distance: 8}}));
const handleDragEnd = (event: DragEndEvent) => {
const {active, over} = event;
if (over && active.id !== over.id) {
const oldIndex = attachments.findIndex((attachment) => attachment.id === active.id);
const newIndex = attachments.findIndex((attachment) => attachment.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newArray = arrayMove(attachments.slice(), oldIndex, newIndex);
CloudUpload.reorderAttachments(channelId, newArray);
}
}
setIsDragging(false);
};
if (attachments.length !== prevAttachmentsLength.current) {
const scrollerElement = document.querySelector('.scroller-base') as HTMLElement | null;
if (scrollerElement) {
const isNearBottom =
scrollerElement.scrollHeight <= scrollerElement.scrollTop + scrollerElement.offsetHeight + 16;
wasAtBottomBeforeChange.current = isNearBottom;
}
}
React.useLayoutEffect(() => {
const currentLength = attachments.length;
const previousLength = prevAttachmentsLength.current;
if (previousLength !== null && previousLength !== currentLength) {
if ((previousLength === 0 && currentLength > 0) || (previousLength > 0 && currentLength === 0)) {
if (wasAtBottomBeforeChange.current) {
const messages = MessageStore.getMessages(channelId);
if (messages.hasMoreAfter) {
ComponentDispatch.dispatch('FORCE_JUMP_TO_PRESENT');
}
}
}
ComponentDispatch.dispatch('LAYOUT_RESIZED');
}
prevAttachmentsLength.current = currentLength;
}, [attachments, channelId]);
if (attachments.length === 0) {
return null;
}
return (
<>
<DndContext
sensors={sensors}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
onDragStart={() => setIsDragging(true)}
>
<SortableContext
items={attachments.map((attachment) => attachment.id)}
strategy={horizontalListSortingStrategy}
>
<Scroller orientation="horizontal" fade={false} className={styles.scroller}>
<ul className={styles.channelAttachmentArea}>
{attachments.map((attachment) => (
<SortableAttachmentItem
key={attachment.id}
attachment={attachment}
channelId={channelId}
isSortingList={isDragging}
/>
))}
</ul>
</Scroller>
</SortableContext>
</DndContext>
<div className={styles.divider} />
</>
);
});
const ImageThumbnail = observer(({attachment, spoiler}: {attachment: CloudAttachment; spoiler: boolean}) => {
const [hasError, setHasError] = React.useState(false);
const src = attachment.previewURL;
if (hasError || !src) return null;
return (
<img
src={src}
className={clsx(styles.media, spoiler && styles.spoiler)}
aria-hidden={true}
alt={attachment.filename}
onError={() => setHasError(true)}
/>
);
});
const VideoThumbnail = observer(({attachment, spoiler}: {attachment: CloudAttachment; spoiler: boolean}) => {
const [hasError, setHasError] = React.useState(false);
const src = attachment.thumbnailURL || attachment.previewURL;
if (hasError || !src) return null;
return (
<img
src={src}
alt={attachment.filename}
className={clsx(styles.media, spoiler && styles.spoiler)}
onError={() => setHasError(true)}
/>
);
});
const AttachmentActionBarButton = observer(
({
label,
icon: Icon,
onClick,
danger = false,
}: {
label: string;
icon: Icon;
onClick: (event: React.MouseEvent | React.KeyboardEvent) => void;
danger?: boolean;
}) => {
const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
onClick(event);
};
return (
<Tooltip text={label}>
<FocusRing offset={-2}>
<button
type="button"
aria-label={label}
onClick={handleClick}
className={clsx(styles.button, danger && styles.danger)}
>
<Icon className={styles.actionBarIcon} />
</button>
</FocusRing>
</Tooltip>
);
},
);
const AttachmentActionBar = observer(({channelId, attachment}: {channelId: string; attachment: CloudAttachment}) => {
const {t} = useLingui();
const isSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0;
const toggleSpoiler = () => {
const nextFlags = isSpoiler
? attachment.flags & ~MessageAttachmentFlags.IS_SPOILER
: attachment.flags | MessageAttachmentFlags.IS_SPOILER;
CloudUpload.updateAttachment(channelId, attachment.id, {
flags: nextFlags,
spoiler: !isSpoiler,
});
};
const editAttachment = () => {
ModalActionCreators.push(modal(() => <AttachmentEditModal channelId={channelId} attachment={attachment} />));
};
const removeAttachment = () => {
CloudUpload.removeAttachment(channelId, attachment.id);
};
return (
<div className={styles.actionBarContainer}>
<div className={styles.actionBar} role="toolbar" aria-label={t`Attachment Actions`}>
<AttachmentActionBarButton
icon={isSpoiler ? EyeSlashIcon : EyeIcon}
label={isSpoiler ? t`Remove Spoiler` : t`Spoiler Attachment`}
onClick={toggleSpoiler}
/>
<AttachmentActionBarButton icon={PencilIcon} label={t`Edit Attachment`} onClick={editAttachment} />
<AttachmentActionBarButton
icon={TrashIcon}
label={t`Remove Attachment`}
danger={true}
onClick={removeAttachment}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,92 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background-color: var(--background-secondary-lighter);
contain: layout style;
}
.messagesArea {
display: flex;
flex: 1 1 0%;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
contain: strict;
}
.typingArea {
flex-shrink: 0;
position: relative;
height: 0;
overflow: visible;
z-index: 1;
--typing-floating-offset: calc(max(var(--typing-indicator-gap), 0px) + var(--textarea-content-offset));
}
.typingContent {
position: absolute;
left: var(--textarea-horizontal-padding);
right: var(--textarea-horizontal-padding);
bottom: var(--typing-floating-offset);
pointer-events: none;
font-size: 12px;
line-height: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.35rem;
}
.typingAreaWithTopBar .typingContent {
bottom: calc(var(--typing-floating-offset) + 12px);
}
.typingLeft {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
order: 1;
}
.typingRight {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
pointer-events: auto;
order: 2;
}
.textareaArea {
flex-shrink: 0;
position: relative;
padding: 0;
overflow: hidden;
background-color: var(--background-secondary-lighter);
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {SlowmodeIndicator} from '~/components/channel/SlowmodeIndicator';
import {useSlowmode} from '~/hooks/useSlowmode';
import type {ChannelRecord} from '~/records/ChannelRecord';
import MessageEditMobileStore from '~/stores/MessageEditMobileStore';
import MessageReplyStore from '~/stores/MessageReplyStore';
import MessageStore from '~/stores/MessageStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './ChannelChatLayout.module.css';
import {TypingUsers} from './TypingUsers';
interface ChannelChatLayoutProps {
channel: ChannelRecord;
messages: React.ReactNode;
textarea: React.ReactNode;
}
export const ChannelChatLayout = observer(({channel, messages, textarea}: ChannelChatLayoutProps) => {
const {isSlowmodeActive, slowmodeRemaining} = useSlowmode(channel);
const hasSlowmodeIndicator = isSlowmodeActive && slowmodeRemaining > 0;
const replyingMessage = MessageReplyStore.getReplyingMessage(channel.id);
const referencedMessage = replyingMessage ? MessageStore.getMessage(channel.id, replyingMessage.messageId) : null;
const editingMobileMessageId = MessageEditMobileStore.getEditingMobileMessageId(channel.id);
const editingMessage = editingMobileMessageId ? MessageStore.getMessage(channel.id, editingMobileMessageId) : null;
const hasTopBar = Boolean(referencedMessage || (editingMessage && MobileLayoutStore.enabled));
return (
<div className={styles.container}>
<div className={styles.messagesArea}>{messages}</div>
<div className={clsx(styles.typingArea, hasTopBar && styles.typingAreaWithTopBar)}>
<div className={styles.typingContent}>
<div className={styles.typingLeft}>
<TypingUsers channel={channel} />
</div>
{hasSlowmodeIndicator && (
<div className={styles.typingRight}>
<SlowmodeIndicator slowmodeRemaining={slowmodeRemaining} />
</div>
)}
</div>
</div>
<div className={styles.textareaArea}>{textarea}</div>
</div>
);
});

View File

@@ -0,0 +1,546 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.headerContainer {
z-index: 3;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--spacing-4);
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-lighter);
padding: 0 var(--spacing-4);
color: var(--text-primary);
}
.headerWrapper {
--channel-header-background: var(--background-secondary-lighter);
background-color: var(--channel-header-background);
display: flex;
flex-direction: column;
}
.headerWrapperCallActive {
--channel-header-background: #000;
background-color: var(--channel-header-background);
}
.headerContainerCallActive {
background-color: transparent;
border-bottom-color: transparent;
}
.callBanner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-4);
border-top: 1px solid color-mix(in srgb, var(--white) 10%, transparent);
background-color: #000;
color: var(--text-primary);
}
.callBannerInfo {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.callBannerIcon {
color: var(--status-online);
height: 1.25rem;
width: 1.25rem;
}
.callBannerTexts {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.callBannerTitle {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25rem;
max-height: 1.25rem;
}
.callBannerSubtitle {
font-size: 0.875rem;
color: var(--text-primary-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25rem;
max-height: 1.25rem;
}
.callBannerConnected {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--spacing-3);
min-width: 0;
}
.callBannerPending {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-2);
min-width: 0;
}
.callBannerAvatarShell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: context-menu;
}
.callBannerAvatar {
border-radius: 9999px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--white) 12%, transparent);
}
.callBannerAvatarPending {
opacity: 0.8;
}
.callBannerAvatarPending::before {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 1px solid color-mix(in srgb, var(--white) 45%, transparent);
animation: callBannerRing 1.6s ease-in-out infinite;
}
.callBannerAvatarPending .callBannerAvatar {
filter: grayscale(0.75);
opacity: 0.75;
}
@keyframes callBannerRing {
0% {
transform: scale(0.9);
opacity: 0.7;
}
70% {
transform: scale(1.15);
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 767px) {
.headerContainer {
height: 4rem;
min-height: 4rem;
}
}
.headerLeftSection {
position: relative;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.backButton {
margin-right: var(--spacing-3);
flex-shrink: 0;
cursor: pointer;
-webkit-app-region: no-drag;
}
.backButtonDesktop {
composes: backButton;
}
@media (min-width: 768px) {
.backButtonDesktop {
display: none;
}
}
.backIcon {
height: 1.5rem;
width: 1.5rem;
}
.backIconBold {
composes: backIcon;
font-weight: bold;
}
.leftContentContainer {
position: relative;
min-width: 0;
flex: 1;
overflow: hidden;
display: flex;
}
.mobileButton {
display: flex;
align-items: center;
border: none;
background-color: transparent;
padding: 0;
text-align: left;
cursor: pointer;
-webkit-app-region: no-drag;
}
.desktopButton {
composes: mobileButton;
cursor: pointer;
}
.desktopButton:hover {
background-color: color-mix(in srgb, var(--white) 6%, transparent);
}
.avatarWrapper {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.groupDMHeaderTrigger {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
cursor: pointer;
-webkit-app-region: no-drag;
}
.groupDMHeaderTrigger:hover {
background-color: color-mix(in srgb, var(--white) 6%, transparent);
}
.groupDMHeaderInner {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
max-width: 100%;
gap: var(--spacing-2);
position: relative;
z-index: 1;
}
.groupDMEditIcon {
opacity: 0;
color: var(--text-primary-muted);
flex-shrink: 0;
position: relative;
margin-left: var(--spacing-2);
}
.groupDMHeaderTrigger:hover .groupDMEditIcon,
.groupDMHeaderTrigger:focus-visible .groupDMEditIcon {
opacity: 1;
}
.channelName {
margin-left: var(--spacing-3);
min-width: 0;
flex-shrink: 1;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5rem;
max-height: 1.5rem;
}
.groupDMChannelName {
margin-left: 0;
}
.dmNameWrapper {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 15rem;
flex-shrink: 1;
}
.userTag {
margin-left: 0.25rem;
}
.channelIcon {
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.caretRight {
margin-left: var(--spacing-1);
height: 1rem;
width: 1rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.channelInfoContainer {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.topicDivider {
margin: 0 var(--spacing-2);
flex-shrink: 0;
color: var(--background-modifier-hover);
}
.topicContainer {
position: relative;
min-width: 0;
flex: 1 1 0%;
overflow: hidden;
max-width: 100%;
isolation: isolate;
}
.topicButton {
display: inline-flex;
align-items: center;
width: 100%;
min-width: 0;
max-width: 100%;
padding-right: var(--spacing-5);
cursor: pointer;
border: none;
background: transparent;
text-align: left;
font-size: 0.8125rem !important;
line-height: 1.125rem !important;
max-height: 1.125rem;
color: var(--text-tertiary) !important;
white-space: nowrap !important;
word-break: normal !important;
overflow-wrap: normal !important;
text-overflow: ellipsis;
-webkit-app-region: no-drag;
}
.topicButtonOverflow {
mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 2.5rem), transparent 100%);
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 2.5rem), transparent 100%);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
}
.topicButton * {
white-space: inherit !important;
word-break: inherit !important;
overflow-wrap: inherit !important;
overflow: hidden;
text-overflow: ellipsis;
color: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
margin: 0;
padding: 0;
}
.topicMarkup :global(.markup),
.topicMarkup :global(.markup.inlineFormat),
.topicMarkup :global(.markup .inlineFormat) {
font-size: inherit !important;
line-height: inherit !important;
color: inherit !important;
margin: 0;
padding: 0;
}
.topicMarkup :global(.markup h1),
.topicMarkup :global(.markup h2),
.topicMarkup :global(.markup h3),
.topicMarkup :global(.markup h4),
.topicMarkup :global(.markup h5),
.topicMarkup :global(.markup h6),
.topicMarkup :global(.markup p),
.topicMarkup :global(.markup ul),
.topicMarkup :global(.markup ol),
.topicMarkup :global(.markup li),
.topicMarkup :global(.markup blockquote) {
font-size: inherit !important;
line-height: inherit !important;
margin: 0 !important;
padding: 0 !important;
color: inherit !important;
}
.headerRightSection {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-2);
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.messageSearchFocusWrapper {
display: flex;
flex-shrink: 1;
min-width: 0;
}
.iconButton {
display: flex;
align-items: center;
justify-content: center;
height: 2rem;
width: 2rem;
flex-shrink: 0;
cursor: pointer;
border-radius: var(--radius-full);
border: none;
background-color: transparent;
transition: color var(--transition-fast);
}
.iconButtonDefault {
composes: iconButton;
color: var(--text-primary-muted);
cursor: pointer;
}
.iconButtonDefault:hover {
color: var(--text-primary);
}
.iconButtonSelected {
composes: iconButton;
color: var(--text-primary);
}
.updateIconButton {
composes: iconButton;
color: #22c55e;
background-color: transparent;
position: relative;
}
.updateIconButtonDisabled {
composes: updateIconButton;
opacity: 0.4;
pointer-events: none;
}
.updateIcon {
height: 1.5rem;
width: 1.5rem;
}
.updateProgress {
position: absolute;
bottom: -0.4rem;
right: -0.6rem;
background: var(--background-tertiary);
color: var(--text-primary);
font-size: 0.65rem;
padding: 0 0.25rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.updateIconError {
color: var(--status-danger);
}
.iconButtonMobile {
display: flex;
align-items: center;
justify-content: center;
height: 2.5rem;
width: 2.5rem;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--background-tertiary);
color: var(--text-primary);
cursor: pointer;
}
.buttonIcon {
height: 1.5rem;
width: 1.5rem;
}
.buttonIconMobile {
height: 1.25rem;
width: 1.25rem;
}
.inlineEditWrapper {
margin-left: var(--spacing-3);
}
.inlineEditButton {
font-weight: 500;
cursor: pointer;
}
.inlineEditInput {
font-weight: 500;
}
.iconButtonWrapper {
position: relative;
display: inline-flex;
}
.unreadPinIndicator {
position: absolute;
bottom: 0.3rem;
right: 0.35rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background-color: var(--status-danger);
box-shadow: 0 0 0 0.08rem var(--channel-header-background);
pointer-events: none;
}

View File

@@ -0,0 +1,708 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {
ArrowLeftIcon,
CaretRightIcon,
EyeSlashIcon,
ListIcon,
MagnifyingGlassIcon,
PencilIcon,
PhoneIcon,
StarIcon,
UserPlusIcon,
UsersIcon,
VideoCameraIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as FavoritesActionCreators from '~/actions/FavoritesActionCreators';
import * as LayoutActionCreators from '~/actions/LayoutActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
import {ChannelTypes, ME, RelationshipTypes} from '~/Constants';
import {ChannelDetailsBottomSheet} from '~/components/bottomsheets/ChannelDetailsBottomSheet';
import {MessageSearchBar} from '~/components/channel/MessageSearchBar';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {NativeDragRegion} from '~/components/layout/NativeDragRegion';
import {AddFriendsToGroupModal} from '~/components/modals/AddFriendsToGroupModal';
import {ChannelTopicModal} from '~/components/modals/ChannelTopicModal';
import {CreateDMModal} from '~/components/modals/CreateDMModal';
import {EditGroupModal} from '~/components/modals/EditGroupModal';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {useCanFitMemberList} from '~/hooks/useMemberListVisible';
import {useTextOverflow} from '~/hooks/useTextOverflow';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {SafeMarkdown} from '~/lib/markdown';
import {MarkdownContext} from '~/lib/markdown/renderers';
import {useLocation} from '~/lib/router/react';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import CallStateStore from '~/stores/CallStateStore';
import FavoritesStore from '~/stores/FavoritesStore';
import MemberListStore from '~/stores/MemberListStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import RelationshipStore from '~/stores/RelationshipStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import markupStyles from '~/styles/Markup.module.css';
import * as CallUtils from '~/utils/CallUtils';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import type {SearchSegment} from '~/utils/SearchSegmentManager';
import {UserTag} from '../channel/UserTag';
import {ChannelContextMenu} from '../uikit/ContextMenu/ChannelContextMenu';
import {MenuGroup} from '../uikit/ContextMenu/MenuGroup';
import {MenuItem} from '../uikit/ContextMenu/MenuItem';
import {Tooltip} from '../uikit/Tooltip/Tooltip';
import {CallButtons} from './ChannelHeader/CallButtons';
import {ChannelHeaderIcon} from './ChannelHeader/ChannelHeaderIcon';
import {ChannelNotificationSettingsButton} from './ChannelHeader/ChannelNotificationSettingsButton';
import {ChannelPinsButton} from './ChannelHeader/ChannelPinsButton';
import {UpdaterIcon} from './ChannelHeader/UpdaterIcon';
import {InboxButton} from './ChannelHeader/UtilityButtons';
import styles from './ChannelHeader.module.css';
import {useChannelHeaderData} from './channel-header/useChannelHeaderData';
const {VoiceCallButton, VideoCallButton} = CallButtons;
interface ChannelHeaderProps {
channel?: ChannelRecord;
leftContent?: React.ReactNode;
showMembersToggle?: boolean;
showPins?: boolean;
onSearchSubmit?: (query: string, segments: Array<SearchSegment>) => void;
onSearchClose?: () => void;
isSearchResultsOpen?: boolean;
forceVoiceCallStyle?: boolean;
}
export const ChannelHeader = observer(
({
channel,
leftContent,
showMembersToggle = false,
showPins = true,
onSearchSubmit,
onSearchClose,
isSearchResultsOpen,
forceVoiceCallStyle = false,
}: ChannelHeaderProps) => {
const {t, i18n} = useLingui();
const location = useLocation();
const {isMembersOpen} = MemberListStore;
const isMobile = MobileLayoutStore.isMobileLayout();
const isCallChannelConnected = Boolean(MediaEngineStore.connected && MediaEngineStore.channelId === channel?.id);
const isVoiceCallActive =
!isMobile &&
Boolean(
channel &&
(channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) &&
isCallChannelConnected &&
CallStateStore.hasActiveCall(channel.id),
);
const isVoiceHeaderActive = isVoiceCallActive || forceVoiceCallStyle;
const canFitMemberList = useCanFitMemberList();
const [channelDetailsOpen, setChannelDetailsOpen] = React.useState(false);
const [openSearchImmediately, setOpenSearchImmediately] = React.useState(false);
const [initialTab, setInitialTab] = React.useState<'members' | 'pins'>('members');
const [searchQuery, setSearchQuery] = React.useState('');
const [searchSegments, setSearchSegments] = React.useState<Array<SearchSegment>>([]);
const latestSearchQueryRef = React.useRef('');
const latestSearchSegmentsRef = React.useRef<Array<SearchSegment>>([]);
const topicButtonRef = React.useRef<HTMLDivElement>(null);
const [isTopicOverflowing, setIsTopicOverflowing] = React.useState(false);
React.useEffect(() => {
latestSearchQueryRef.current = searchQuery;
latestSearchSegmentsRef.current = searchSegments;
}, [searchQuery, searchSegments]);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const dmNameRef = React.useRef<HTMLSpanElement>(null);
const groupDMNameRef = React.useRef<HTMLSpanElement>(null);
const guildChannelNameRef = React.useRef<HTMLSpanElement>(null);
const isDMNameOverflowing = useTextOverflow(dmNameRef);
const isGroupDMNameOverflowing = useTextOverflow(groupDMNameRef);
const isGuildChannelNameOverflowing = useTextOverflow(guildChannelNameRef);
const {
isDM,
isGroupDM,
isPersonalNotes,
isGuildChannel,
isVoiceChannel,
recipient,
directMessageName,
groupDMName,
channelName,
} = useChannelHeaderData(channel);
const isBotDMRecipient = isDM && recipient?.bot;
const isFavorited = channel && !isPersonalNotes ? !!FavoritesStore.getChannel(channel.id) : false;
const handleOpenCreateGroupDM = React.useCallback(() => {
if (!channel) return;
const initialRecipientIds = Array.from(channel.recipientIds);
const excludeChannelId = channel.type === ChannelTypes.GROUP_DM ? channel.id : undefined;
ModalActionCreators.push(
modal(() => (
<CreateDMModal initialSelectedUserIds={initialRecipientIds} duplicateExcludeChannelId={excludeChannelId} />
)),
);
}, [channel]);
const handleOpenEditGroup = React.useCallback(() => {
if (!channel) return;
ModalActionCreators.push(modal(() => <EditGroupModal channelId={channel.id} />));
}, [channel]);
const handleOpenAddFriendsToGroup = React.useCallback(() => {
if (!channel) return;
ModalActionCreators.push(modal(() => <AddFriendsToGroupModal channelId={channel.id} />));
}, [channel]);
const handleToggleMembers = React.useCallback(() => {
if (!canFitMemberList) return;
LayoutActionCreators.toggleMembers(!isMembersOpen);
}, [isMembersOpen, canFitMemberList]);
React.useEffect(() => {
const handleChannelDetailsOpen = (payload?: unknown) => {
const {initialTab} = (payload ?? {}) as {initialTab?: 'members' | 'pins'};
setInitialTab(initialTab || 'members');
setOpenSearchImmediately(false);
setChannelDetailsOpen(true);
};
return ComponentDispatch.subscribe('CHANNEL_DETAILS_OPEN', handleChannelDetailsOpen);
}, []);
React.useEffect(() => {
if (!showMembersToggle) return;
return ComponentDispatch.subscribe('CHANNEL_MEMBER_LIST_TOGGLE', () => {
if (canFitMemberList) {
LayoutActionCreators.toggleMembers(!isMembersOpen);
}
});
}, [showMembersToggle, canFitMemberList, isMembersOpen]);
React.useEffect(() => {
if (!channel?.topic) {
setIsTopicOverflowing(false);
return;
}
const el = topicButtonRef.current;
if (!el) return;
const checkOverflow = () => {
const {scrollWidth, clientWidth} = el;
setIsTopicOverflowing(scrollWidth - clientWidth > 1);
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
};
}, [channel?.topic]);
const handleOpenUserProfile = React.useCallback(() => {
if (!recipient) return;
UserProfileActionCreators.openUserProfile(recipient.id);
}, [recipient]);
const handleBackClick = React.useCallback(() => {
if (isDM || isGroupDM || isPersonalNotes) {
RouterUtils.transitionTo(Routes.ME);
} else if (Routes.isFavoritesRoute(location.pathname)) {
RouterUtils.transitionTo(Routes.FAVORITES);
} else if (isGuildChannel && channel?.guildId) {
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId));
} else {
window.history.back();
}
}, [isDM, isGroupDM, isPersonalNotes, isGuildChannel, channel?.guildId, location.pathname]);
const handleChannelDetailsClick = () => {
setInitialTab('members');
setOpenSearchImmediately(false);
setChannelDetailsOpen(true);
};
const handleSearchClick = () => {
setInitialTab('members');
setOpenSearchImmediately(true);
setChannelDetailsOpen(true);
};
const handleContextMenu = React.useCallback(
(event: React.MouseEvent) => {
if (channel && isGuildChannel) {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<ChannelContextMenu channel={channel} onClose={onClose} />
));
}
},
[channel, isGuildChannel],
);
const handleMobileVoiceCall = React.useCallback(
async (event: React.MouseEvent) => {
if (!channel) return;
const isConnected = MediaEngineStore.connected;
const connectedChannelId = MediaEngineStore.channelId;
const isInCall = isConnected && connectedChannelId === channel.id;
if (isInCall) {
void CallActionCreators.leaveCall(channel.id);
} else if (CallStateStore.hasActiveCall(channel.id)) {
CallActionCreators.joinCall(channel.id);
} else {
const silent = event.shiftKey;
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel],
);
const handleMobileVideoCall = React.useCallback(
async (event: React.MouseEvent) => {
if (!channel) return;
const isConnected = MediaEngineStore.connected;
const connectedChannelId = MediaEngineStore.channelId;
const isInCall = isConnected && connectedChannelId === channel.id;
if (isInCall) {
void CallActionCreators.leaveCall(channel.id);
} else if (CallStateStore.hasActiveCall(channel.id)) {
CallActionCreators.joinCall(channel.id);
} else {
const silent = event.shiftKey;
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel],
);
const handleToggleFavorite = React.useCallback(() => {
if (!channel || isPersonalNotes) return;
if (isFavorited) {
FavoritesStore.removeChannel(channel.id);
ToastActionCreators.createToast({type: 'success', children: t`Channel removed from favorites`});
} else {
FavoritesStore.addChannel(channel.id, channel.guildId ?? ME);
ToastActionCreators.createToast({type: 'success', children: t`Channel added to favorites`});
}
}, [channel, isPersonalNotes, isFavorited]);
const handleFavoriteContextMenu = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<MenuGroup>
<MenuItem
icon={<EyeSlashIcon />}
onClick={() => {
onClose();
FavoritesActionCreators.confirmHideFavorites(undefined, i18n);
}}
danger
>
{t`Hide Favorites`}
</MenuItem>
</MenuGroup>
));
},
[t],
);
const isGroupDMFull = channel ? channel.recipientIds.length + 1 >= MAX_GROUP_DM_RECIPIENTS : false;
const isFriendDM =
isDM &&
recipient &&
!isBotDMRecipient &&
RelationshipStore.getRelationship(recipient.id)?.type === RelationshipTypes.FRIEND;
const shouldShowCreateGroupButton = !!channel && !isMobile && !isPersonalNotes && isFriendDM && !isGroupDM;
const shouldShowAddFriendsButton = !!channel && !isMobile && !isPersonalNotes && isGroupDM && !isGroupDMFull;
return (
<>
<div className={clsx(styles.headerWrapper, isVoiceHeaderActive && styles.headerWrapperCallActive)}>
<NativeDragRegion
className={clsx(styles.headerContainer, isVoiceHeaderActive && styles.headerContainerCallActive)}
>
<div className={styles.headerLeftSection}>
{isMobile ? (
<FocusRing offset={-2}>
<button type="button" className={styles.backButton} onClick={handleBackClick}>
<ArrowLeftIcon className={styles.backIconBold} weight="bold" />
</button>
</FocusRing>
) : (
<FocusRing offset={-2}>
<button type="button" className={styles.backButtonDesktop} onClick={handleBackClick}>
<ListIcon className={styles.backIcon} />
</button>
</FocusRing>
)}
<div className={styles.leftContentContainer}>
{leftContent ? (
leftContent
) : channel ? (
isMobile ? (
<FocusRing offset={-2}>
<button type="button" className={styles.mobileButton} onClick={handleChannelDetailsClick}>
{isDM && recipient ? (
<>
<StatusAwareAvatar user={recipient} size={32} showOffline={true} />
<span className={styles.dmNameWrapper}>
<Tooltip text={isDMNameOverflowing && directMessageName ? directMessageName : ''}>
<span ref={dmNameRef} className={styles.channelName}>
{directMessageName}
</span>
</Tooltip>
{isBotDMRecipient && <UserTag className={styles.userTag} system={recipient.system} />}
</span>
<CaretRightIcon className={styles.caretRight} weight="bold" />
</>
) : isGroupDM ? (
<>
<GroupDMAvatar channel={channel} size={32} />
<Tooltip text={isGroupDMNameOverflowing && groupDMName ? groupDMName : ''}>
<span ref={groupDMNameRef} className={styles.channelName}>
{groupDMName}
</span>
</Tooltip>
<CaretRightIcon className={styles.caretRight} weight="bold" />
</>
) : (
<>
{ChannelUtils.getIcon(channel, {className: styles.channelIcon})}
<Tooltip text={isGuildChannelNameOverflowing && channelName ? channelName : ''}>
<span ref={guildChannelNameRef} className={styles.channelName}>
{channelName}
</span>
</Tooltip>
<CaretRightIcon className={styles.caretRight} weight="bold" />
</>
)}
</button>
</FocusRing>
) : isDM && recipient ? (
<FocusRing offset={-2}>
<button type="button" className={styles.desktopButton} onClick={handleOpenUserProfile}>
<StatusAwareAvatar user={recipient} size={32} showOffline={true} />
<span className={styles.dmNameWrapper}>
<Tooltip text={isDMNameOverflowing ? directMessageName : ''}>
<span ref={dmNameRef} className={styles.channelName}>
{directMessageName}
</span>
</Tooltip>
{isBotDMRecipient && <UserTag className={styles.userTag} system={recipient.system} />}
</span>
</button>
</FocusRing>
) : isGroupDM ? (
isMobile ? (
<div className={styles.avatarWrapper}>
<GroupDMAvatar channel={channel} size={32} />
<Tooltip text={isGroupDMNameOverflowing && groupDMName ? groupDMName : ''}>
<span ref={groupDMNameRef} className={styles.channelName}>
{groupDMName}
</span>
</Tooltip>
</div>
) : (
<FocusRing offset={-2}>
<div
className={styles.groupDMHeaderTrigger}
role="button"
tabIndex={0}
onClick={handleOpenEditGroup}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleOpenEditGroup();
}
}}
>
<div className={styles.groupDMHeaderInner}>
<GroupDMAvatar channel={channel} size={32} />
<div className={styles.dmNameWrapper}>
<Tooltip text={isGroupDMNameOverflowing && groupDMName ? groupDMName : ''}>
<span
ref={groupDMNameRef}
className={clsx(styles.channelName, styles.groupDMChannelName)}
>
{groupDMName}
</span>
</Tooltip>
</div>
</div>
<PencilIcon className={styles.groupDMEditIcon} size={16} weight="bold" />
</div>
</FocusRing>
)
) : isPersonalNotes ? (
<div className={styles.avatarWrapper}>
{ChannelUtils.getIcon(channel, {className: styles.channelIcon})}
<Tooltip text={isGuildChannelNameOverflowing && channelName ? channelName : ''}>
<span ref={guildChannelNameRef} className={styles.channelName}>
{channelName}
</span>
</Tooltip>
</div>
) : (
// biome-ignore lint/a11y/noStaticElementInteractions: Context menu requires onContextMenu handler on this container
<div className={styles.channelInfoContainer} onContextMenu={handleContextMenu}>
{ChannelUtils.getIcon(channel, {className: styles.channelIcon})}
<Tooltip text={isGuildChannelNameOverflowing && channelName ? channelName : ''}>
<span ref={guildChannelNameRef} className={styles.channelName}>
{channelName}
</span>
</Tooltip>
{channel.topic && (
<>
<span className={styles.topicDivider}></span>
<div className={styles.topicContainer}>
<FocusRing offset={-2}>
<div
role="button"
ref={topicButtonRef}
className={clsx(
markupStyles.markup,
styles.topicButton,
isTopicOverflowing && styles.topicButtonOverflow,
)}
onClick={() =>
ModalActionCreators.push(modal(() => <ChannelTopicModal channelId={channel.id} />))
}
onKeyDown={(e) =>
e.key === 'Enter' &&
ModalActionCreators.push(modal(() => <ChannelTopicModal channelId={channel.id} />))
}
tabIndex={0}
>
<SafeMarkdown
content={channel.topic}
options={{
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
channelId: channel.id,
}}
/>
</div>
</FocusRing>
</div>
</>
)}
</div>
)
) : null}
</div>
</div>
<div className={styles.headerRightSection}>
{isMobile && channel && !isPersonalNotes && AccessibilityStore.showFavorites && (
<FocusRing offset={-2}>
<button
type="button"
className={styles.iconButtonMobile}
aria-label={isFavorited ? t`Remove from Favorites` : t`Add to Favorites`}
onClick={handleToggleFavorite}
onContextMenu={handleFavoriteContextMenu}
>
<StarIcon className={styles.buttonIconMobile} weight={isFavorited ? 'fill' : 'bold'} />
</button>
</FocusRing>
)}
{isMobile && (isDM || isGroupDM) && !isPersonalNotes && (
<>
<FocusRing offset={-2}>
<button
type="button"
className={styles.iconButtonMobile}
aria-label={t`Voice Call`}
onClick={handleMobileVoiceCall}
>
<PhoneIcon className={styles.buttonIconMobile} />
</button>
</FocusRing>
<FocusRing offset={-2}>
<button
type="button"
className={styles.iconButtonMobile}
aria-label={t`Video Call`}
onClick={handleMobileVideoCall}
>
<VideoCameraIcon className={styles.buttonIconMobile} />
</button>
</FocusRing>
</>
)}
{isMobile && isGuildChannel && (
<FocusRing offset={-2}>
<button
type="button"
className={styles.iconButtonMobile}
aria-label={t`Search`}
onClick={handleSearchClick}
>
<MagnifyingGlassIcon className={styles.buttonIconMobile} weight="bold" />
</button>
</FocusRing>
)}
{channel && !isMobile && !isPersonalNotes && AccessibilityStore.showFavorites && (
<Tooltip text={isFavorited ? t`Remove from Favorites` : t`Add to Favorites`} position="bottom">
<FocusRing offset={-2}>
<button
type="button"
className={isFavorited ? styles.iconButtonSelected : styles.iconButtonDefault}
aria-label={isFavorited ? t`Remove from Favorites` : t`Add to Favorites`}
onClick={handleToggleFavorite}
onContextMenu={handleFavoriteContextMenu}
>
<StarIcon className={styles.buttonIcon} weight={isFavorited ? 'fill' : 'bold'} />
</button>
</FocusRing>
</Tooltip>
)}
{channel && isGuildChannel && !isMobile && !isVoiceChannel && !isPersonalNotes && (
<ChannelNotificationSettingsButton channel={channel} />
)}
{showPins && channel && !isMobile && <ChannelPinsButton channel={channel} />}
{(isDM || isGroupDM) && channel && !isMobile && !(isDM && isBotDMRecipient) && (
<>
<VoiceCallButton channel={channel} />
<VideoCallButton channel={channel} />
</>
)}
{shouldShowCreateGroupButton && (
<ChannelHeaderIcon icon={UserPlusIcon} label={t`Create Group DM`} onClick={handleOpenCreateGroupDM} />
)}
{shouldShowAddFriendsButton && (
<ChannelHeaderIcon
icon={UserPlusIcon}
label={t`Add Friends to Group`}
onClick={handleOpenAddFriendsToGroup}
/>
)}
{showMembersToggle && !isMobile && (
<ChannelHeaderIcon
icon={UsersIcon}
isSelected={isMembersOpen}
label={
!canFitMemberList
? t`Members list unavailable at this screen width`
: isMembersOpen
? t`Hide Members`
: t`Show Members`
}
onClick={handleToggleMembers}
disabled={!canFitMemberList}
keybindAction="toggle_channel_member_list"
/>
)}
{!isMobile && channel && !isVoiceChannel && (
<FocusRing offset={-2} within>
<div className={styles.messageSearchFocusWrapper}>
<MessageSearchBar
channel={channel}
value={searchQuery}
onChange={(query, segments) => {
setSearchQuery(query);
setSearchSegments(segments);
latestSearchQueryRef.current = query;
latestSearchSegmentsRef.current = segments;
}}
onSearch={() => {
const q = latestSearchQueryRef.current;
if (q.trim()) {
onSearchSubmit?.(q, latestSearchSegmentsRef.current);
}
}}
onClear={() => {
setSearchQuery('');
setSearchSegments([]);
latestSearchQueryRef.current = '';
latestSearchSegmentsRef.current = [];
onSearchClose?.();
}}
isResultsOpen={Boolean(isSearchResultsOpen)}
onCloseResults={() => onSearchClose?.()}
inputRefExternal={searchInputRef}
/>
</div>
</FocusRing>
)}
{!isMobile && <UpdaterIcon />}
{!isMobile && <InboxButton />}
</div>
</NativeDragRegion>
</div>
{channel && (
<ChannelDetailsBottomSheet
isOpen={channelDetailsOpen}
onClose={() => {
setChannelDetailsOpen(false);
setOpenSearchImmediately(false);
setInitialTab('members');
}}
channel={channel}
initialTab={initialTab}
openSearchImmediately={openSearchImmediately}
/>
)}
</>
);
},
);

View File

@@ -0,0 +1,131 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators';
import type {ChannelRecord} from '~/records/ChannelRecord';
import CallStateStore from '~/stores/CallStateStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as CallUtils from '~/utils/CallUtils';
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const {t} = useLingui();
const call = CallStateStore.getCall(channel.id);
const isConnected = MediaEngineStore.connected;
const connectedChannelId = MediaEngineStore.channelId;
const isInCall = isConnected && connectedChannelId === channel.id;
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
const participantCount = participants.length;
const handleClick = React.useCallback(
async (event: React.MouseEvent) => {
if (isInCall) {
void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) {
CallActionCreators.joinCall(channel.id);
} else {
const silent = event.shiftKey;
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel.id, isInCall, hasActiveCall],
);
let label: string;
if (participantCount > 0 && hasActiveCall) {
if (isInCall) {
label =
participantCount === 1
? t`Leave Voice Call (${participantCount} participant)`
: t`Leave Voice Call (${participantCount} participants)`;
} else {
label =
participantCount === 1
? t`Join Voice Call (${participantCount} participant)`
: t`Join Voice Call (${participantCount} participants)`;
}
} else {
label = isInCall ? t`Leave Voice Call` : hasActiveCall ? t`Join Voice Call` : t`Start Voice Call`;
}
return (
<ChannelHeaderIcon
icon={PhoneIcon}
label={label}
isSelected={isInCall}
onClick={handleClick}
keybindAction="start_pm_call"
/>
);
});
const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const {t} = useLingui();
const call = CallStateStore.getCall(channel.id);
const isConnected = MediaEngineStore.connected;
const connectedChannelId = MediaEngineStore.channelId;
const isInCall = isConnected && connectedChannelId === channel.id;
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
const participantCount = participants.length;
const handleClick = React.useCallback(
async (event: React.MouseEvent) => {
if (isInCall) {
void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) {
CallActionCreators.joinCall(channel.id);
} else {
const silent = event.shiftKey;
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel.id, isInCall, hasActiveCall],
);
let label: string;
if (participantCount > 0 && hasActiveCall) {
if (isInCall) {
label =
participantCount === 1
? t`Leave Video Call (${participantCount} participant)`
: t`Leave Video Call (${participantCount} participants)`;
} else {
label =
participantCount === 1
? t`Join Video Call (${participantCount} participant)`
: t`Join Video Call (${participantCount} participants)`;
}
} else {
label = isInCall ? t`Leave Video Call` : hasActiveCall ? t`Join Video Call` : t`Start Video Call`;
}
return <ChannelHeaderIcon icon={VideoCameraIcon} label={label} isSelected={isInCall} onClick={handleClick} />;
});
export const CallButtons = {
VoiceCallButton,
VideoCallButton,
};

View File

@@ -0,0 +1,80 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Icon} from '@phosphor-icons/react';
import React from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {TooltipWithKeybind} from '~/components/uikit/KeybindHint/KeybindHint';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useMergeRefs} from '~/hooks/useMergeRefs';
import type {KeybindAction} from '~/stores/KeybindStore';
import styles from '../ChannelHeader.module.css';
export interface ChannelHeaderIconProps {
icon: Icon;
label: string;
isSelected?: boolean;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
keybindAction?: KeybindAction;
}
export const ChannelHeaderIcon = React.forwardRef<HTMLButtonElement, ChannelHeaderIconProps>((props, ref) => {
const {icon: Icon, label, isSelected = false, onClick, disabled = false, keybindAction, ...rest} = props;
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const mergedRef = useMergeRefs([ref, buttonRef]);
const tooltipText = React.useCallback(
() => <TooltipWithKeybind label={label} action={keybindAction} />,
[label, keybindAction],
);
const button = (
<FocusRing offset={-2} enabled={!disabled}>
<button
{...rest}
ref={mergedRef}
type="button"
className={isSelected ? styles.iconButtonSelected : styles.iconButtonDefault}
aria-label={label}
onClick={disabled ? undefined : onClick}
disabled={disabled}
style={{opacity: disabled ? 0.5 : 1, cursor: disabled ? 'not-allowed' : 'pointer'}}
>
<Icon className={styles.buttonIcon} />
</button>
</FocusRing>
);
if (disabled) {
return (
<Tooltip text={tooltipText} position="bottom">
<div style={{display: 'inline-flex'}}>{button}</div>
</Tooltip>
);
}
return (
<Tooltip text={tooltipText} position="bottom">
{button}
</Tooltip>
);
});
ChannelHeaderIcon.displayName = 'ChannelHeaderIcon';

View File

@@ -0,0 +1,64 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {BellIcon, BellSlashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import type {ChannelRecord} from '~/records/ChannelRecord';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
import {ChannelNotificationSettingsDropdown} from './ChannelNotificationSettingsDropdown';
export const ChannelNotificationSettingsButton = observer(({channel}: {channel: ChannelRecord}) => {
const {t} = useLingui();
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const channelOverride = UserGuildSettingsStore.getChannelOverride(channel.guildId ?? null, channel.id);
const isMuted = channelOverride?.muted ?? false;
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsMenuOpen(true);
ContextMenuActionCreators.openFromElementBottomRight(event, ({onClose}) => (
<ChannelNotificationSettingsDropdown
channel={channel}
onClose={() => {
setIsMenuOpen(false);
onClose();
}}
/>
));
},
[channel],
);
return (
<ChannelHeaderIcon
icon={isMuted ? BellSlashIcon : BellIcon}
label={isMuted ? t`Notification Settings (Muted)` : t`Notification Settings`}
isSelected={isMenuOpen || isMuted}
onClick={handleClick}
/>
);
});

View File

@@ -0,0 +1,223 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {MessageNotifications} from '~/Constants';
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
import {MuteIcon} from '~/components/uikit/ContextMenu/ContextMenuIcons';
import itemStyles from '~/components/uikit/ContextMenu/items/MenuItems.module.css';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import menuItemStyles from '~/components/uikit/ContextMenu/MenuItem.module.css';
import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio';
import {MenuItemSubmenu} from '~/components/uikit/ContextMenu/MenuItemSubmenu';
import type {ChannelRecord} from '~/records/ChannelRecord';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
interface MuteDuration {
label: string;
value: number | null;
}
interface Props {
channel: ChannelRecord;
onClose: () => void;
}
export const ChannelNotificationSettingsDropdown: React.FC<Props> = observer(({channel, onClose}) => {
const {t} = useLingui();
const MUTE_DURATIONS: Array<MuteDuration> = [
{label: t`For 15 Minutes`, value: 15 * 60 * 1000},
{label: t`For 1 Hour`, value: 60 * 60 * 1000},
{label: t`For 3 Hours`, value: 3 * 60 * 60 * 1000},
{label: t`For 8 Hours`, value: 8 * 60 * 60 * 1000},
{label: t`For 24 Hours`, value: 24 * 60 * 60 * 1000},
{label: t`Until I turn it back on`, value: null},
];
const guildId = channel.guildId;
const isGuildChannel = guildId != null;
const channelOverride = UserGuildSettingsStore.getChannelOverride(guildId ?? null, channel.id);
const isMuted = channelOverride?.muted ?? false;
const muteConfig = channelOverride?.mute_config;
const mutedText = getMutedText(isMuted, muteConfig);
const channelNotifications = channelOverride?.message_notifications;
const currentNotificationLevel =
channelNotifications ?? (isGuildChannel ? MessageNotifications.INHERIT : MessageNotifications.ALL_MESSAGES);
const guildNotificationLevel = guildId
? UserGuildSettingsStore.getGuildMessageNotifications(guildId)
: MessageNotifications.ALL_MESSAGES;
const categoryId = channel.parentId;
const categoryOverride = guildId ? UserGuildSettingsStore.getChannelOverride(guildId, categoryId ?? '') : null;
const categoryNotifications = categoryId ? categoryOverride?.message_notifications : undefined;
const resolveEffectiveLevel = (level: number | undefined, fallback: number): number => {
if (level === undefined || level === MessageNotifications.INHERIT) {
return fallback;
}
return level;
};
const effectiveDefaultLevel = resolveEffectiveLevel(categoryNotifications, guildNotificationLevel);
const hasCategory = categoryId != null;
const handleMute = React.useCallback(
(duration: number | null) => {
const muteConfigValue = duration
? {
selected_time_window: duration,
end_time: new Date(Date.now() + duration).toISOString(),
}
: null;
UserGuildSettingsActionCreators.updateChannelOverride(
guildId ?? null,
channel.id,
{
muted: true,
mute_config: muteConfigValue,
},
{persistImmediately: true},
);
onClose();
},
[guildId, channel.id, onClose],
);
const handleUnmute = React.useCallback(() => {
UserGuildSettingsActionCreators.updateChannelOverride(
guildId ?? null,
channel.id,
{
muted: false,
mute_config: null,
},
{persistImmediately: true},
);
onClose();
}, [guildId, channel.id, onClose]);
const handleNotificationLevelChange = React.useCallback(
(level: number) => {
if (level === MessageNotifications.INHERIT) {
UserGuildSettingsActionCreators.updateChannelOverride(
guildId ?? null,
channel.id,
{
message_notifications: MessageNotifications.INHERIT,
},
{persistImmediately: true},
);
} else if (guildId) {
UserGuildSettingsActionCreators.updateMessageNotifications(guildId, level, channel.id, {
persistImmediately: true,
});
} else {
UserGuildSettingsActionCreators.updateChannelOverride(
null,
channel.id,
{
message_notifications: level,
},
{persistImmediately: true},
);
}
},
[guildId, channel.id],
);
const defaultLabelParts = React.useMemo(
() => ({
main: hasCategory ? t`Use Category Default` : t`Use Community Default`,
sub: getNotificationSettingsLabel(effectiveDefaultLevel) ?? null,
}),
[effectiveDefaultLevel, hasCategory],
);
return (
<ContextMenuCloseProvider value={onClose}>
<MenuGroup>
{isMuted ? (
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
{t`Unmute Channel`}
</MenuItem>
) : (
<MenuItemSubmenu
label={t`Mute Channel`}
icon={<MuteIcon />}
onTriggerSelect={() => handleMute(null)}
render={() => (
<MenuGroup>
{MUTE_DURATIONS.map((duration) => (
<MenuItem key={duration.label} onClick={() => handleMute(duration.value)}>
{duration.label}
</MenuItem>
))}
</MenuGroup>
)}
/>
)}
</MenuGroup>
{isGuildChannel && (
<MenuGroup>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.INHERIT}
onSelect={() => handleNotificationLevelChange(MessageNotifications.INHERIT)}
>
<div className={itemStyles.flexColumn}>
<span>{defaultLabelParts.main}</span>
{defaultLabelParts.sub && <div className={menuItemStyles.subtext}>{defaultLabelParts.sub}</div>}
</div>
</MenuItemRadio>
</MenuGroup>
)}
<MenuGroup>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.ALL_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
>
{t`All Messages`}
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.ONLY_MENTIONS}
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
>
{t`Only @mentions`}
</MenuItemRadio>
<MenuItemRadio
selected={currentNotificationLevel === MessageNotifications.NO_MESSAGES}
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
>
{t`Nothing`}
</MenuItemRadio>
</MenuGroup>
</ContextMenuCloseProvider>
);
});

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {PushPinIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {ChannelPinsBottomSheet} from '~/components/bottomsheets/ChannelPinsBottomSheet';
import {ChannelPinsPopout} from '~/components/popouts/ChannelPinsPopout';
import {Popout} from '~/components/uikit/Popout/Popout';
import {usePopout} from '~/hooks/usePopout';
import type {ChannelRecord} from '~/records/ChannelRecord';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import ReadStateStore from '~/stores/ReadStateStore';
import styles from '../ChannelHeader.module.css';
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
export const ChannelPinsButton = observer(({channel}: {channel: ChannelRecord}) => {
const {t} = useLingui();
const {isOpen, openProps} = usePopout('channel-pins');
const isMobile = MobileLayoutStore.isMobileLayout();
const [isBottomSheetOpen, setIsBottomSheetOpen] = React.useState(false);
const hasUnreadPins = ReadStateStore.hasUnreadPins(channel.id);
const handleClick = React.useCallback(() => {
if (isMobile) {
setIsBottomSheetOpen(true);
}
}, [isMobile]);
const indicator = hasUnreadPins ? <div className={styles.unreadPinIndicator} /> : null;
if (isMobile) {
return (
<>
<div className={styles.iconButtonWrapper}>
<ChannelHeaderIcon
icon={PushPinIcon}
label={t`Pinned Messages`}
isSelected={isBottomSheetOpen}
onClick={handleClick}
keybindAction="toggle_pins_popout"
/>
{indicator}
</div>
<ChannelPinsBottomSheet
isOpen={isBottomSheetOpen}
onClose={() => setIsBottomSheetOpen(false)}
channel={channel}
/>
</>
);
}
return (
<Popout
{...openProps}
render={() => <ChannelPinsPopout channel={channel} />}
position="bottom-end"
subscribeTo="CHANNEL_PINS_OPEN"
>
<div className={styles.iconButtonWrapper}>
<ChannelHeaderIcon
icon={PushPinIcon}
label={t`Pinned Messages`}
isSelected={isOpen}
keybindAction="toggle_pins_popout"
/>
{indicator}
</div>
</Popout>
);
});

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {t} from '@lingui/core/macro';
import {DownloadSimpleIcon} from '@phosphor-icons/react';
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 {Platform} from '~/lib/Platform';
import UpdaterStore from '~/stores/UpdaterStore';
import styles from '../ChannelHeader.module.css';
export const UpdaterIcon = observer(() => {
const store = UpdaterStore;
const hasActionableNativeUpdate = Platform.isElectron && store.nativeUpdateReady;
const hasActionableWebUpdate = !!store.updateInfo.web.available && !hasActionableNativeUpdate;
const tooltip = React.useMemo(() => {
const version = store.displayVersion;
if (hasActionableNativeUpdate) {
return version ? t`Click to install update (${version})` : t`Click to install update`;
}
return version ? t`Click to reload and update (${version})` : t`Click to reload and update`;
}, [hasActionableNativeUpdate, store.displayVersion]);
const handleClick = React.useCallback(() => {
void store.applyUpdate();
}, [store]);
if (!hasActionableNativeUpdate && !hasActionableWebUpdate) {
return null;
}
return (
<Tooltip text={tooltip} position="bottom">
<FocusRing offset={-2}>
<button type="button" className={styles.updateIconButton} onClick={handleClick} aria-label={tooltip}>
<DownloadSimpleIcon weight="bold" className={styles.updateIcon} />
</button>
</FocusRing>
</Tooltip>
);
});

View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {InboxIcon} from '~/components/icons/InboxIcon';
import {InboxPopout} from '~/components/popouts/InboxPopout';
import {Popout} from '~/components/uikit/Popout/Popout';
import {usePopout} from '~/hooks/usePopout';
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
export const InboxButton = observer(() => {
const {t} = useLingui();
const {isOpen, openProps} = usePopout('inbox');
return (
<Popout {...openProps} render={() => <InboxPopout />} position="bottom-end" subscribeTo="INBOX_OPEN">
<ChannelHeaderIcon icon={InboxIcon} label={t`Inbox`} isSelected={isOpen} keybindAction="toggle_mentions_popout" />
</Popout>
);
});

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {ImageSquareIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {SystemMessage} from '~/components/channel/SystemMessage';
import {SystemMessageUsername} from '~/components/channel/SystemMessageUsername';
import {useSystemMessageData} from '~/hooks/useSystemMessageData';
import type {MessageRecord} from '~/records/MessageRecord';
export const ChannelIconChangeMessage = observer(({message}: {message: MessageRecord}) => {
const {author, channel, guild} = useSystemMessageData(message);
if (!channel) {
return null;
}
const messageContent = (
<Trans>
<SystemMessageUsername key={author.id} author={author} guild={guild} message={message} /> changed the channel
icon.
</Trans>
);
return <SystemMessage icon={ImageSquareIcon} iconWeight="bold" message={message} messageContent={messageContent} />;
});

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.voiceChannelContainer {
height: 100%;
width: 100%;
}
.channelGrid {
display: grid;
height: 100%;
min-height: 0;
width: 100%;
min-width: 0;
grid-template-rows: 4rem 1fr;
background: var(--background-secondary-lighter);
}
@media (min-width: 768px) {
.channelGrid {
grid-template-rows: 3.5rem 1fr;
}
}
.channelGridVoiceCallActive {
grid-template-rows: auto 1fr;
}
@media (min-width: 768px) {
.channelGridVoiceCallActive {
grid-template-rows: auto 1fr;
}
}
.voiceActiveHeaderWrapper {
background-color: #000;
}
.contentGrid {
display: grid;
height: 100%;
min-height: 0;
width: 100%;
min-width: 0;
grid-template-columns: 1fr auto;
position: relative;
contain: layout style;
}
.memberListDivider {
position: absolute;
top: 0;
bottom: 0;
right: var(--member-list-width, 16.5rem);
width: 1px;
background: var(--user-area-divider-color);
pointer-events: none;
z-index: 5;
}
.searchPanel {
display: flex;
height: 100%;
min-height: 0;
width: 100%;
min-width: 0;
border-left: 1px solid var(--user-area-divider-color);
}
.emptyStateContent {
display: flex;
height: 100%;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
}
.centeredText {
text-align: center;
}
.voiceChannelTitle {
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 1.5rem;
line-height: 2rem;
color: var(--text-primary);
}
.voiceChannelDescription {
color: var(--text-primary-muted);
}
.buttonContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import {DMChannelView} from '~/components/channel/channel-view/DMChannelView';
import {GuildChannelView} from '~/components/channel/channel-view/GuildChannelView';
import {useLocation, useParams} from '~/lib/router';
import ChannelStore from '~/stores/ChannelStore';
export const ChannelIndexPage = observer(() => {
const location = useLocation();
const {
guildId: routeGuildId,
channelId,
messageId,
} = useParams() as {
guildId?: string;
channelId?: string;
messageId?: string;
};
if (!channelId) {
return null;
}
const channel = ChannelStore.getChannel(channelId);
const isInFavorites = location.pathname.startsWith('/channels/@favorites');
const derivedGuildId = isInFavorites ? channel?.guildId : routeGuildId || channel?.guildId;
if (channel?.isPrivate()) {
return <DMChannelView channelId={channelId} />;
}
return <GuildChannelView channelId={channelId} guildId={derivedGuildId} messageId={messageId} />;
});

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.channelLayoutContainer {
display: grid;
grid-template-rows: 1fr;
height: 100%;
min-height: 0;
width: 100%;
min-width: 0;
background-color: var(--background-tertiary);
}
.channelNotFoundContainer {
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);
}
.channelNotFoundContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-1);
text-align: center;
}
.channelNotFoundIcon {
height: 4rem;
width: 4rem;
color: var(--text-tertiary);
}
.channelNotFoundTitle {
font-weight: 600;
font-size: 1.5rem;
line-height: 2rem;
color: var(--text-primary);
}
.channelNotFoundDescription {
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {SmileySadIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useParams} from '~/lib/router';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import styles from './ChannelLayout.module.css';
export const ChannelLayout = observer(({children}: {children: React.ReactNode}) => {
const {guildId: routeGuildId, channelId} = useParams() as {guildId?: string; channelId: string};
const channel = ChannelStore.getChannel(channelId);
const guildId = routeGuildId || channel?.guildId;
const guild = guildId ? GuildStore.getGuild(guildId) : null;
if (guild && !channel) {
return (
<div className={styles.channelNotFoundContainer}>
<div className={styles.channelNotFoundContent}>
<SmileySadIcon className={styles.channelNotFoundIcon} />
<h1 className={styles.channelNotFoundTitle}>
<Trans>This is not the channel you're looking for.</Trans>
</h1>
<p className={styles.channelNotFoundDescription}>
<Trans>The channel you're looking for may have been deleted or you may not have access to it.</Trans>
</p>
</div>
</div>
);
}
return <div className={styles.channelLayoutContainer}>{children}</div>;
});

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.groupContainer {
display: flex;
flex-direction: column;
}
.groupHeader {
padding-top: 1rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.membersList {
display: flex;
flex-direction: column;
}
.groupSpacer {
height: 0.25rem;
}
.skeletonItem {
display: grid;
height: 42px;
min-width: 0;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
margin-top: 1px;
margin-bottom: 1px;
}
.skeletonContent {
display: flex;
min-width: 0;
align-items: center;
gap: 0.625rem;
}
.skeletonAvatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
background: var(--background-modifier-accent);
opacity: 0.45;
}
.skeletonUserInfoContainer {
display: flex;
flex-direction: column;
min-width: 0;
flex-grow: 1;
gap: 2px;
}
.skeletonName {
height: 10px;
width: 60%;
border-radius: 2px;
background: var(--background-modifier-accent);
opacity: 0.45;
}
.skeletonStatus {
height: 8px;
width: 40%;
border-radius: 2px;
background: var(--background-modifier-accent);
opacity: 0.35;
}
.skeletonHeader {
width: 80px;
height: 14px;
border-radius: 4px;
background: var(--background-modifier-accent);
opacity: 0.45;
}
.skeleton {
background: var(--background-modifier-accent);
opacity: 0.45;
border-radius: 4px;
}

View File

@@ -0,0 +1,301 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {observer} from 'mobx-react-lite';
import React from 'react';
import {ChannelTypes} from '~/Constants';
import {MemberListContainer} from '~/components/channel/MemberListContainer';
import {MemberListItem} from '~/components/channel/MemberListItem';
import {OutlineFrame} from '~/components/layout/OutlineFrame';
import {useMemberListSubscription} from '~/hooks/useMemberListSubscription';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import MemberSidebarStore from '~/stores/MemberSidebarStore';
import UserStore from '~/stores/UserStore';
import type {GroupDMMemberGroup, MemberGroup} from '~/utils/MemberListUtils';
import * as MemberListUtils from '~/utils/MemberListUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import styles from './ChannelMembers.module.css';
const MEMBER_ITEM_HEIGHT = 44;
const INITIAL_MEMBER_RANGE: [number, number] = [0, 99];
const SCROLL_BUFFER = 50;
const SkeletonMemberItem = ({index}: {index: number}) => {
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
const baseSeed = (index + 1) * 17;
const nameWidth = 40 + seededRandom(baseSeed) * 40;
const statusWidth = 30 + seededRandom(baseSeed + 1) * 50;
return (
<div className={styles.skeletonItem}>
<div className={styles.skeletonContent}>
<div className={styles.skeletonAvatar} />
<div className={styles.skeletonUserInfoContainer}>
<div className={styles.skeletonName} style={{width: `${Math.min(nameWidth, 95)}%`}} />
<div className={styles.skeletonStatus} style={{width: `${Math.min(statusWidth, 95)}%`}} />
</div>
</div>
</div>
);
};
const _MemberListGroup = observer(
({guild, group, channelId}: {guild: GuildRecord; group: MemberGroup; channelId: string}) => (
<div className={styles.groupContainer}>
<div className={styles.groupHeader}>
{group.displayName} {group.count}
</div>
<div className={styles.membersList}>
{group.members.map((member: GuildMemberRecord) => {
const user = member.user;
const userId = user.id;
return (
<MemberListItem
key={userId}
user={user}
channelId={channelId}
guildId={guild.id}
isOwner={guild.isOwner(userId)}
roleColor={member.getColorString?.() ?? undefined}
displayName={NicknameUtils.getNickname(user, guild.id)}
disableBackdrop={true}
/>
);
})}
</div>
<div className={styles.groupSpacer} />
</div>
),
);
interface GroupDMMemberListGroupProps {
group: GroupDMMemberGroup;
channelId: string;
ownerId: string | null;
}
const GroupDMMemberListGroup = observer(({group, channelId, ownerId}: GroupDMMemberListGroupProps) => (
<div className={styles.groupContainer}>
<div className={styles.groupHeader}>
{group.displayName} {group.count}
</div>
<div className={styles.membersList}>
{group.users.map((user) => (
<MemberListItem
key={user.id}
user={user}
channelId={channelId}
isOwner={user.id === ownerId}
disableBackdrop={true}
/>
))}
</div>
<div className={styles.groupSpacer} />
</div>
));
interface LazyMemberListGroupProps {
guild: GuildRecord;
group: {id: string; count: number};
channelId: string;
members: Array<GuildMemberRecord>;
}
const LazyMemberListGroup = observer(({guild, group, channelId, members}: LazyMemberListGroupProps) => {
const {t} = useLingui();
const getGroupName = () => {
switch (group.id) {
case 'online':
return t`Online`;
case 'offline':
return t`Offline`;
default: {
const role = guild.getRole(group.id);
return role?.name ?? group.id;
}
}
};
const groupName = getGroupName();
return (
<div className={styles.groupContainer}>
<div className={styles.groupHeader}>
{groupName} {group.count}
</div>
<div className={styles.membersList}>
{members.map((member: GuildMemberRecord) => {
const user = member.user;
const userId = user.id;
return (
<MemberListItem
key={userId}
user={user}
channelId={channelId}
guildId={guild.id}
isOwner={guild.isOwner(userId)}
roleColor={member.getColorString?.() ?? undefined}
displayName={NicknameUtils.getNickname(user, guild.id)}
disableBackdrop={true}
/>
);
})}
</div>
<div className={styles.groupSpacer} />
</div>
);
});
interface LazyMemberListProps {
guild: GuildRecord;
channel: ChannelRecord;
}
const LazyMemberList = observer(({guild, channel}: LazyMemberListProps) => {
const [subscribedRange, setSubscribedRange] = React.useState<[number, number]>(INITIAL_MEMBER_RANGE);
const {subscribe} = useMemberListSubscription({
guildId: guild.id,
channelId: channel.id,
enabled: true,
allowInitialUnfocusedLoad: true,
});
const memberListState = MemberSidebarStore.getList(guild.id, channel.id);
const isLoading = !memberListState || memberListState.items.size === 0;
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollTop = target.scrollTop;
const clientHeight = target.clientHeight;
const startIndex = Math.max(0, Math.floor(scrollTop / MEMBER_ITEM_HEIGHT) - SCROLL_BUFFER);
const endIndex = Math.ceil((scrollTop + clientHeight) / MEMBER_ITEM_HEIGHT) + SCROLL_BUFFER;
if (startIndex !== subscribedRange[0] || endIndex !== subscribedRange[1]) {
const newRange: [number, number] = [startIndex, endIndex];
setSubscribedRange(newRange);
subscribe([newRange]);
}
},
[subscribedRange, subscribe],
);
if (isLoading) {
return (
<MemberListContainer channelId={channel.id}>
<div className={styles.groupContainer}>
<div className={clsx(styles.groupHeader, styles.skeletonHeader, styles.skeleton)} />
<div className={styles.membersList}>
{Array.from({length: 10}).map((_, i) => (
<SkeletonMemberItem key={i} index={i} />
))}
</div>
</div>
</MemberListContainer>
);
}
const groupedItems: Map<string, Array<GuildMemberRecord>> = new Map();
const groups = memberListState.groups;
const seenMemberIds = new Set<string>();
for (const group of groups) {
groupedItems.set(group.id, []);
}
let currentGroup: string | null = null;
const sortedItems = Array.from(memberListState.items.entries()).sort(([a], [b]) => a - b);
for (const [, item] of sortedItems) {
if (item.type === 'group') {
currentGroup = (item.data as {id: string}).id;
} else if (item.type === 'member' && currentGroup) {
const member = item.data as GuildMemberRecord;
if (!seenMemberIds.has(member.user.id)) {
seenMemberIds.add(member.user.id);
const members = groupedItems.get(currentGroup);
if (members) {
members.push(member);
}
}
}
}
return (
<MemberListContainer channelId={channel.id} onScroll={handleScroll}>
{groups.map((group) => {
const members = groupedItems.get(group.id) ?? [];
if (members.length === 0) {
return null;
}
return (
<LazyMemberListGroup key={group.id} guild={guild} group={group} channelId={channel.id} members={members} />
);
})}
</MemberListContainer>
);
});
interface ChannelMembersProps {
guild?: GuildRecord | null;
channel: ChannelRecord;
}
export const ChannelMembers = observer(({guild = null, channel}: ChannelMembersProps) => {
const isGroupDM = channel.type === ChannelTypes.GROUP_DM;
if (isGroupDM) {
const currentUserId = AuthenticationStore.currentUserId;
const allUserIds = currentUserId ? [currentUserId, ...channel.recipientIds] : channel.recipientIds;
const users = allUserIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u !== null);
const memberGroups = MemberListUtils.getGroupDMMemberGroups(users);
return (
<OutlineFrame hideTopBorder>
<MemberListContainer channelId={channel.id}>
{memberGroups.map((group) => (
<GroupDMMemberListGroup key={group.id} group={group} channelId={channel.id} ownerId={channel.ownerId} />
))}
</MemberListContainer>
</OutlineFrame>
);
}
if (!guild) {
return null;
}
const frameSides = guild ? {left: false} : undefined;
return (
<OutlineFrame hideTopBorder sides={frameSides}>
<LazyMemberList guild={guild} channel={channel} />
</OutlineFrame>
);
});

View File

@@ -0,0 +1,58 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {PencilSimpleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {SystemMessage} from '~/components/channel/SystemMessage';
import {SystemMessageUsername} from '~/components/channel/SystemMessageUsername';
import {useSystemMessageData} from '~/hooks/useSystemMessageData';
import type {MessageRecord} from '~/records/MessageRecord';
import styles from '~/styles/Message.module.css';
export const ChannelNameChangeMessage = observer(({message}: {message: MessageRecord}) => {
const {author, channel, guild} = useSystemMessageData(message);
if (!channel) {
return null;
}
const newName = message.content;
const nameComponent = channel.isGroupDM() ? (
<span className={styles.systemMessageLink} style={{cursor: 'text', textDecoration: 'none'}}>
{newName}
</span>
) : (
<span className={styles.systemMessageLink}>{newName}</span>
);
const messageContent = newName ? (
<Trans>
<SystemMessageUsername key={author.id} author={author} guild={guild} message={message} /> changed the channel name
to {nameComponent}.
</Trans>
) : (
<Trans>
<SystemMessageUsername key={author.id} author={author} guild={guild} message={message} /> changed the channel
name.
</Trans>
);
return <SystemMessage icon={PencilSimpleIcon} iconWeight="bold" message={message} messageContent={messageContent} />;
});

View File

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

View File

@@ -0,0 +1,559 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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;
height: 100%;
min-height: 0;
width: 420px;
flex-direction: column;
border-left: 1px solid var(--background-modifier-hover);
background-color: var(--background-secondary);
}
.header {
display: flex;
height: 3.5rem;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--background-modifier-hover);
padding-left: 1rem;
padding-right: 1rem;
}
.headerActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.headerLoading {
display: flex;
align-items: center;
gap: 0.75rem;
}
.headerTitle {
font-weight: 600;
color: var(--text-primary);
}
.headerTitleScreenReaderOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.closeButton {
display: flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
color: var(--text-primary-muted);
transition: color 0.2s;
cursor: pointer;
}
.closeButton:hover {
color: var(--text-primary);
}
.closeIcon {
height: 1.25rem;
width: 1.25rem;
}
.loadingState {
display: flex;
flex: 1 1 0%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
}
.loadingIcon {
height: 4rem;
width: 4rem;
color: var(--text-primary-muted);
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loadingContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.loadingHeading {
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
}
.loadingText {
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.errorState {
display: flex;
flex: 1 1 0%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
}
.errorContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.errorHeading {
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
}
.errorText {
max-width: 100%;
overflow-wrap: break-word;
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.errorButton {
margin-top: 1rem;
border-radius: 0.25rem;
background-color: var(--background-modifier-accent);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
transition: background-color 0.2s;
cursor: pointer;
}
.errorButton:hover {
background-color: var(--background-modifier-hover);
}
.emptyState {
display: flex;
flex: 1 1 0%;
align-items: center;
justify-content: center;
}
.emptyStateContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.emptyStateIcon {
height: 5rem;
width: 5rem;
color: var(--text-primary-muted);
}
.emptyStateTextWrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
}
.emptyStateHeading {
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
}
.emptyStateText {
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.resultsScroller {
flex: 1 1 0%;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.5rem;
padding-bottom: 0.75rem;
}
.resultsSpacer {
height: 8px;
width: 100%;
flex-shrink: 0;
}
.channelHeader {
margin-top: 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.channelHeader:first-child {
margin-top: 0;
}
.channelIcon {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.channelIconAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
}
.channelIconAvatarImage {
height: 1.25rem;
width: 1.25rem;
border-radius: 50%;
}
.channelNameButton {
border: none;
background: none;
color: var(--text-primary);
font-weight: 600;
font-size: 0.875rem;
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.channelNameButton:hover,
.channelNameButton:focus-visible {
text-decoration: underline;
}
.channelNameButton:focus-visible {
outline: none;
}
.channelNameText {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.channelNamePrimary {
font-weight: 600;
font-size: 0.875rem;
line-height: 1.2;
}
.channelNameSecondary {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-primary-muted);
font-weight: 400;
}
.channelGuildIcon {
height: 0.75rem;
width: 0.75rem;
}
.channelGuildName {
line-height: 1;
}
.channelScopeRow {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.channelScopeGuildIcon {
display: inline-flex;
align-items: center;
justify-content: center;
--guild-icon-size: 0.75rem;
}
.channelScopeGuildName {
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.channelScopeChevron {
height: 0.75rem;
width: 0.75rem;
color: var(--text-primary-muted);
}
.channelScopeChannelInfo {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.channelScopeChannelIcon {
height: 0.75rem;
width: 0.75rem;
color: var(--text-primary-muted);
}
.channelScopeChannelName {
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.sortButton {
min-width: 0;
}
.scopeButton {
min-width: 0;
}
.messageItem {
position: relative;
margin-bottom: 0.5rem;
cursor: default;
user-select: text;
-webkit-user-select: text;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary-lighter);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
text-align: left;
}
.actionButtons {
display: none;
position: absolute;
top: 12px;
right: 12px;
}
.messageItem:hover .actionButtons {
display: flex;
}
.jumpButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-weight: 600;
line-height: 20px;
padding: 0 8px;
font-size: 11px;
margin-right: 4px;
border-radius: 4px;
text-align: center;
color: var(--text-primary-muted);
background-color: var(--background-primary);
transition: color 0.2s;
}
.jumpButton:hover {
color: var(--text-primary);
}
.paginationBar {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.75rem 0;
border-top: none;
background-color: transparent;
}
.paginationWrapper {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem;
}
.pageButton {
display: flex;
height: 2rem;
width: 2rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
font-weight: 500;
font-size: 0.75rem;
transition:
background-color 0.2s,
color 0.2s;
background-color: var(--background-tertiary);
color: var(--text-primary);
cursor: pointer;
}
.pageButton:hover {
background-color: var(--background-modifier-hover);
}
@media (min-width: 640px) {
.pageButton {
font-size: 0.875rem;
}
}
.pageButtonActive {
background-color: var(--brand-primary);
color: white;
}
.pageButtonActive:hover {
background-color: var(--brand-primary);
}
.ellipsisButton {
display: flex;
height: 2rem;
width: 2rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--background-tertiary);
font-size: 0.875rem;
color: var(--text-primary-muted);
transition: background-color 0.2s;
cursor: pointer;
}
.ellipsisButton:hover {
background-color: var(--background-modifier-hover);
}
.pageInputForm {
display: flex;
height: 2rem;
align-items: center;
}
.pageInputLabel {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.pageInput {
height: 2rem;
width: 3.5rem;
flex-shrink: 0;
appearance: textfield;
border-radius: 9999px;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-tertiary);
text-align: center;
color: var(--text-primary);
font-size: 0.75rem;
transition: border-color 0.2s;
}
@media (min-width: 640px) {
.pageInput {
font-size: 0.875rem;
}
}
.pageInput::placeholder {
color: var(--text-primary-muted);
}
.pageInput:focus {
border-color: var(--background-modifier-accent-focus);
outline: none;
}
.pageInput::-webkit-inner-spin-button,
.pageInput::-webkit-outer-spin-button {
appearance: none;
}
.focusRingTight {
border-radius: 0.5rem;
}
.focusRingCircular {
border-radius: 9999px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {
border-color: var(--background-header-secondary);
background-color: var(--background-secondary-lighter);
padding: 0.5rem 1rem;
}
.withAttachments {
border-top-width: 1px;
border-top-style: solid;
}
.standalone {
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
border-width: 1px;
border-style: solid;
}
.content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stickerPreview {
position: relative;
display: flex;
height: 4rem;
width: 4rem;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background-color: var(--background-tertiary);
}
.stickerImage {
height: 100%;
width: 100%;
border-radius: var(--radius-sm);
object-fit: contain;
padding: 0.25rem;
}
.gifBadge {
position: absolute;
top: 0.25rem;
left: 0.25rem;
border-radius: var(--radius-sm);
background-color: rgba(0, 0, 0, 0.6);
padding: 0.125rem 0.25rem;
font-weight: 600;
font-size: 10px;
color: white;
line-height: 1;
}
.stickerInfo {
flex: 1;
}
.stickerName {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.stickerDescription {
color: var(--text-primary-muted);
font-size: 0.75rem;
}
.removeButton {
display: flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-primary-muted);
transition:
color 0.15s,
background-color 0.15s;
cursor: pointer;
}
.removeButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.icon {
height: 1.25rem;
width: 1.25rem;
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {TrashIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelStickerActionCreators from '~/actions/ChannelStickerActionCreators';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import ChannelStickerStore from '~/stores/ChannelStickerStore';
import styles from './ChannelStickersArea.module.css';
interface ChannelStickersAreaProps {
channelId: string;
hasAttachments: boolean;
}
export const ChannelStickersArea: React.FC<ChannelStickersAreaProps> = observer(({channelId, hasAttachments}) => {
const {t} = useLingui();
const sticker = ChannelStickerStore.getPendingSticker(channelId);
const [previousSticker, setPreviousSticker] = React.useState(sticker);
React.useLayoutEffect(() => {
if (previousSticker && !sticker) {
ComponentDispatch.dispatch('FORCE_JUMP_TO_PRESENT');
} else if (!previousSticker && sticker) {
ComponentDispatch.dispatch('FORCE_JUMP_TO_PRESENT');
}
setPreviousSticker(sticker);
}, [sticker, previousSticker]);
if (!sticker) {
return null;
}
const handleRemove = () => {
ChannelStickerActionCreators.removePendingSticker(channelId);
};
return (
<div className={clsx(styles.container, hasAttachments ? styles.withAttachments : styles.standalone)}>
<div className={styles.content}>
<div className={styles.stickerPreview}>
<img src={sticker.url} alt={sticker.name} className={styles.stickerImage} />
{sticker.isAnimated() && <div className={styles.gifBadge}>GIF</div>}
</div>
<div className={styles.stickerInfo}>
<div className={styles.stickerName}>:{sticker.name}:</div>
{sticker.description && <div className={styles.stickerDescription}>{sticker.description}</div>}
</div>
<Tooltip text={t`Remove sticker`} position="top">
<FocusRing offset={-2}>
<button type="button" onClick={handleRemove} className={styles.removeButton} aria-label={t`Remove sticker`}>
<TrashIcon weight="regular" className={styles.icon} />
</button>
</FocusRing>
</Tooltip>
</div>
</div>
);
});

View File

@@ -0,0 +1,901 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {PlusCircleIcon} 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 * as DraftActionCreators from '~/actions/DraftActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import * as ScheduledMessageActionCreators from '~/actions/ScheduledMessageActionCreators';
import {MAX_MESSAGE_LENGTH_NON_PREMIUM, Permissions} from '~/Constants';
import {TooManyAttachmentsModal} from '~/components/alerts/TooManyAttachmentsModal';
import {Autocomplete} from '~/components/channel/Autocomplete';
import {ChannelAttachmentArea} from '~/components/channel/ChannelAttachmentArea';
import {ChannelStickersArea} from '~/components/channel/ChannelStickersArea';
import {EditBar} from '~/components/channel/EditBar';
import {
getMentionDescription,
getMentionTitle,
MentionEveryonePopout,
} from '~/components/channel/MentionEveryonePopout';
import {MessageCharacterCounter} from '~/components/channel/MessageCharacterCounter';
import {ReplyBar} from '~/components/channel/ReplyBar';
import {ScheduledMessageEditBar} from '~/components/channel/ScheduledMessageEditBar';
import {MessageInputButtonsContextMenu} from '~/components/channel/textarea/MessageInputButtonsContextMenu';
import {TextareaButton} from '~/components/channel/textarea/TextareaButton';
import {TextareaButtons} from '~/components/channel/textarea/TextareaButtons';
import {TextareaInputField} from '~/components/channel/textarea/TextareaInputField';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ExpressionPickerSheet} from '~/components/modals/ExpressionPickerSheet';
import {ScheduleMessageModal} from '~/components/modals/ScheduleMessageModal';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {openPopout} from '~/components/uikit/Popout/Popout';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {useTextareaAttachments} from '~/hooks/useCloudUpload';
import {doesEventMatchShortcut, MARKDOWN_FORMATTING_SHORTCUTS, useMarkdownKeybinds} from '~/hooks/useMarkdownKeybinds';
import {useMessageSubmission} from '~/hooks/useMessageSubmission';
import {useSlowmode} from '~/hooks/useSlowmode';
import {useTextareaAutocomplete} from '~/hooks/useTextareaAutocomplete';
import {useTextareaDraftAndTyping} from '~/hooks/useTextareaDraftAndTyping';
import {useTextareaEditing} from '~/hooks/useTextareaEditing';
import {useTextareaEmojiPicker} from '~/hooks/useTextareaEmojiPicker';
import {useTextareaExpressionHandlers} from '~/hooks/useTextareaExpressionHandlers';
import {useTextareaExpressionPicker} from '~/hooks/useTextareaExpressionPicker';
import {useTextareaKeyboard} from '~/hooks/useTextareaKeyboard';
import {useTextareaPaste} from '~/hooks/useTextareaPaste';
import {useTextareaSegments} from '~/hooks/useTextareaSegments';
import {type MentionConfirmationInfo, useTextareaSubmit} from '~/hooks/useTextareaSubmit';
import {CloudUpload} from '~/lib/CloudUpload';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {safeFocus} from '~/lib/InputFocusManager';
import type {ChannelRecord} from '~/records/ChannelRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import ChannelStickerStore from '~/stores/ChannelStickerStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import DraftStore from '~/stores/DraftStore';
import FeatureFlagStore from '~/stores/FeatureFlagStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import MessageEditMobileStore from '~/stores/MessageEditMobileStore';
import MessageEditStore from '~/stores/MessageEditStore';
import MessageReplyStore from '~/stores/MessageReplyStore';
import MessageStore from '~/stores/MessageStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import ScheduledMessageEditorStore from '~/stores/ScheduledMessageEditorStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {openFilePicker} from '~/utils/FilePickerUtils';
import * as FileUploadUtils from '~/utils/FileUploadUtils';
import {normalizeMessageContent} from '~/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '~/utils/MessageSubmitUtils';
import * as PlaceholderUtils from '~/utils/PlaceholderUtils';
import wrapperStyles from './textarea/InputWrapper.module.css';
import styles from './textarea/TextareaInput.module.css';
const ChannelTextareaContent = observer(
({
channel,
draft,
disabled,
canAttachFiles,
}: {
channel: ChannelRecord;
draft: string | null;
disabled: boolean;
canAttachFiles: boolean;
}) => {
const {t, i18n} = useLingui();
const [isFocused, setIsFocused] = React.useState(false);
const [isInputAreaFocused, setIsInputAreaFocused] = React.useState(false);
const [value, setValue] = React.useState('');
const [showAllButtons, setShowAllButtons] = React.useState(true);
const [_textareaHeight, setTextareaHeight] = React.useState(0);
const [containerWidth, setContainerWidth] = React.useState(0);
const [pendingMentionConfirmation, setPendingMentionConfirmation] = React.useState<MentionConfirmationInfo | null>(
null,
);
const mentionPopoutKey = React.useMemo(() => `mention-everyone-${channel.id}`, [channel.id]);
const mentionModalKey = React.useMemo(() => `mention-everyone-modal-${channel.id}`, [channel.id]);
const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const expressionPickerTriggerRef = React.useRef<HTMLButtonElement>(null);
const invisibleExpressionPickerTriggerRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<ScrollerHandle>(null);
useMarkdownKeybinds(isFocused);
const showGiftButton = AccessibilityStore.showGiftButton;
const showGifButton = AccessibilityStore.showGifButton;
const showMemesButton = AccessibilityStore.showMemesButton;
const showStickersButton = AccessibilityStore.showStickersButton;
const showEmojiButton = AccessibilityStore.showEmojiButton;
const showUploadButton = AccessibilityStore.showUploadButton;
const showMessageSendButton = AccessibilityStore.showMessageSendButton;
const editingMessageId = MessageEditStore.getEditingMessageId(channel.id);
const editingMobileMessageId = MessageEditMobileStore.getEditingMobileMessageId(channel.id);
const mobileLayout = MobileLayoutStore;
const replyingMessage = MessageReplyStore.getReplyingMessage(channel.id);
const referencedMessage = replyingMessage ? MessageStore.getMessage(channel.id, replyingMessage.messageId) : null;
const editingMessage = editingMobileMessageId ? MessageStore.getMessage(channel.id, editingMobileMessageId) : null;
const currentUser = UserStore.getCurrentUser();
const maxMessageLength = currentUser?.maxMessageLength ?? MAX_MESSAGE_LENGTH_NON_PREMIUM;
const uploadAttachments = useTextareaAttachments(channel.id);
const {isSlowmodeActive} = useSlowmode(channel);
const {segmentManagerRef, previousValueRef, displayToActual, insertSegment, handleTextChange, clearSegments} =
useTextareaSegments();
const {handleEmojiSelect} = useTextareaEmojiPicker({
setValue,
textareaRef,
insertSegment,
previousValueRef,
channelId: channel.id,
});
const scheduledMessageEditorState = ScheduledMessageEditorStore.getEditingState();
const isEditingScheduledMessage = ScheduledMessageEditorStore.isEditingChannel(channel.id);
const editingScheduledMessage = isEditingScheduledMessage ? scheduledMessageEditorState : null;
const selectedGuildId = SelectedGuildStore.selectedGuildId;
const hasMessageSchedulingAccess = FeatureFlagStore.isMessageSchedulingEnabled(selectedGuildId ?? undefined);
const {sendMessage, sendOptimisticMessage} = useMessageSubmission({
channel,
referencedMessage: referencedMessage ?? null,
replyingMessage,
clearSegments,
});
const handleCancelScheduledEdit = React.useCallback(() => {
ScheduledMessageEditorStore.stopEditing();
DraftActionCreators.deleteDraft(channel.id);
setValue('');
clearSegments();
}, [channel.id, clearSegments, setValue]);
const handleSendMessage = React.useCallback(
(...args: Parameters<typeof sendMessage>) => {
setValue('');
clearSegments();
sendMessage(...args);
},
[sendMessage, clearSegments],
);
const handleMentionConfirmationNeeded = React.useCallback((info: MentionConfirmationInfo) => {
setPendingMentionConfirmation(info);
}, []);
const handleMentionConfirm = React.useCallback(() => {
if (pendingMentionConfirmation) {
handleSendMessage(pendingMentionConfirmation.content, false, pendingMentionConfirmation.tts);
setPendingMentionConfirmation(null);
}
}, [pendingMentionConfirmation, handleSendMessage]);
const handleMentionCancel = React.useCallback(() => {
setPendingMentionConfirmation(null);
textareaRef.current?.focus();
}, []);
React.useEffect(() => {
if (!pendingMentionConfirmation) {
PopoutActionCreators.close(mentionPopoutKey);
ModalActionCreators.popWithKey(mentionModalKey);
return;
}
if (mobileLayout.enabled) {
const index = pendingMentionConfirmation.mentionType;
const title = getMentionTitle(index, pendingMentionConfirmation.roleName);
const description = getMentionDescription(
index,
pendingMentionConfirmation.memberCount,
pendingMentionConfirmation.roleName,
);
ModalActionCreators.pushWithKey(
modal(() => (
<ConfirmModal
title={title}
description={description}
primaryText={t`Continue`}
secondaryText={t`Cancel`}
onPrimary={() => {
handleMentionConfirm();
}}
onSecondary={() => {
handleMentionCancel();
}}
/>
)),
mentionModalKey,
);
return () => {
ModalActionCreators.popWithKey(mentionModalKey);
};
}
const containerElement = containerRef.current;
if (!containerElement) {
return;
}
openPopout(
containerElement,
{
render: ({onClose}) => (
<MentionEveryonePopout
mentionType={pendingMentionConfirmation.mentionType}
memberCount={pendingMentionConfirmation.memberCount}
roleName={pendingMentionConfirmation.roleName}
onConfirm={() => {
handleMentionConfirm();
onClose();
}}
onCancel={() => {
handleMentionCancel();
onClose();
}}
/>
),
position: 'top-start',
offsetMainAxis: 8,
shouldAutoUpdate: true,
returnFocusRef: textareaRef,
onCloseRequest: () => {
handleMentionCancel();
return true;
},
},
mentionPopoutKey,
);
return () => {
PopoutActionCreators.close(mentionPopoutKey);
};
}, [
pendingMentionConfirmation,
mentionPopoutKey,
mentionModalKey,
handleMentionConfirm,
handleMentionCancel,
textareaRef,
mobileLayout.enabled,
]);
const {
autocompleteQuery,
autocompleteOptions,
autocompleteType,
selectedIndex,
isAutocompleteAttached,
setSelectedIndex,
onCursorMove,
handleSelect,
} = useTextareaAutocomplete({
channel,
value,
setValue,
textareaRef,
segmentManagerRef,
previousValueRef,
});
React.useEffect(() => {
ComponentDispatch.safeDispatch('TEXTAREA_AUTOCOMPLETE_CHANGED', {
channelId: channel.id,
open: isAutocompleteAttached,
});
}, [channel.id, isAutocompleteAttached]);
const trimmedMessageContent = displayToActual(value).trim();
const hasScheduleContent = trimmedMessageContent.length > 0 || uploadAttachments.length > 0;
const canScheduleMessage = hasMessageSchedulingAccess && !disabled && hasScheduleContent;
useTextareaPaste({
channel,
textareaRef,
segmentManagerRef,
setValue,
previousValueRef,
});
const handleOpenScheduleModal = React.useCallback(() => {
if (!hasMessageSchedulingAccess) {
return;
}
setIsScheduleModalOpen(true);
}, [hasMessageSchedulingAccess]);
const handleScheduleSubmit = React.useCallback(
async (scheduledLocalAt: string, timezone: string) => {
const actualContent = displayToActual(value).trim();
if (!actualContent && uploadAttachments.length === 0) {
return;
}
const normalized = normalizeMessageContent(actualContent, undefined);
if (editingScheduledMessage) {
await ScheduledMessageActionCreators.updateScheduledMessage(i18n, {
channelId: channel.id,
scheduledMessageId: editingScheduledMessage.scheduledMessageId,
scheduledLocalAt,
timezone,
normalized,
payload: editingScheduledMessage.payload,
replyMentioning: replyingMessage?.mentioning,
});
ScheduledMessageEditorStore.stopEditing();
} else {
await ScheduledMessageActionCreators.scheduleMessage(i18n, {
channelId: channel.id,
content: actualContent,
scheduledLocalAt,
timezone,
messageReference: MessageSubmitUtils.prepareMessageReference(channel.id, referencedMessage),
replyMentioning: replyingMessage?.mentioning,
favoriteMemeId: undefined,
stickers: undefined,
tts: false,
hasAttachments: uploadAttachments.length > 0,
});
}
setValue('');
clearSegments();
setIsScheduleModalOpen(false);
},
[
channel.id,
clearSegments,
displayToActual,
editingScheduledMessage,
referencedMessage,
replyingMessage?.mentioning,
setIsScheduleModalOpen,
setValue,
uploadAttachments.length,
value,
],
);
const handleFileButtonClick = async () => {
const files = await openFilePicker({multiple: true});
const result = await FileUploadUtils.handleFileUpload(channel.id, files, uploadAttachments.length);
if (!result.success && result.error === 'too_many_attachments') {
ModalActionCreators.push(modal(() => <TooManyAttachmentsModal />));
}
};
useTextareaExpressionHandlers({
setValue,
textareaRef,
insertSegment,
previousValueRef,
sendOptimisticMessage,
});
const {expressionPickerOpen, setExpressionPickerOpen, handleExpressionPickerTabToggle, selectedTab} =
useTextareaExpressionPicker({
channelId: channel.id,
onEmojiSelect: handleEmojiSelect,
expressionPickerTriggerRef,
invisibleExpressionPickerTriggerRef,
textareaRef,
});
useTextareaEditing({
channelId: channel.id,
editingMessageId: editingMessageId ?? null,
editingMessage: editingMessage ?? null,
isMobileEditMode: mobileLayout.enabled,
replyingMessage,
value,
setValue,
textareaRef,
previousValueRef,
});
const hasPendingSticker = ChannelStickerStore.getPendingSticker(channel.id) !== null;
const hasAttachments = uploadAttachments.length > 0;
const showAttachments = hasAttachments;
const showStickers = hasPendingSticker;
const isComposing = !!value.trim() || hasAttachments || hasPendingSticker;
const isOverCharacterLimit = value.length > maxMessageLength;
const shouldShowMobileGiftButton = mobileLayout.enabled && showGiftButton && containerWidth > 540;
const {onSubmit} = useTextareaSubmit({
channelId: channel.id,
guildId: channel.guildId ?? null,
editingMessage: editingMessage ?? null,
isMobileEditMode: mobileLayout.enabled,
uploadAttachmentsLength: uploadAttachments.length,
hasPendingSticker,
value,
setValue,
displayToActual,
clearSegments,
isSlowmodeActive,
handleSendMessage,
onMentionConfirmationNeeded: handleMentionConfirmationNeeded,
i18n: i18n,
});
const handleEscapeKey = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Escape') return;
if (hasAttachments || hasPendingSticker || replyingMessage) {
event.preventDefault();
if (hasAttachments) {
CloudUpload.clearTextarea(channel.id);
}
if (hasPendingSticker) {
ChannelStickerStore.removePendingSticker(channel.id);
}
if (replyingMessage) {
MessageActionCreators.stopReply(channel.id);
}
return;
}
if (isInputAreaFocused && KeyboardModeStore.keyboardModeEnabled) {
event.preventDefault();
KeyboardModeStore.exitKeyboardMode();
return;
}
if (AccessibilityStore.escapeExitsKeyboardMode) {
KeyboardModeStore.exitKeyboardMode();
}
},
[
channel.id,
hasAttachments,
hasPendingSticker,
replyingMessage,
isInputAreaFocused,
KeyboardModeStore.keyboardModeEnabled,
AccessibilityStore.escapeExitsKeyboardMode,
],
);
const handleFormattingShortcut = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
for (const {combo: shortcutCombo, wrapper} of MARKDOWN_FORMATTING_SHORTCUTS) {
if (!doesEventMatchShortcut(event, shortcutCombo)) {
continue;
}
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const selectionStart = textarea.selectionStart ?? 0;
const selectionEnd = textarea.selectionEnd ?? 0;
if (selectionStart === selectionEnd) {
return;
}
const selectedText = value.slice(selectionStart, selectionEnd);
const wrapperLength = wrapper.length;
const alreadyWrappedInside =
selectedText.length >= wrapperLength * 2 &&
selectedText.startsWith(wrapper) &&
selectedText.endsWith(wrapper);
const hasPrefixWrapper =
wrapperLength > 0 &&
selectionStart >= wrapperLength &&
value.slice(selectionStart - wrapperLength, selectionStart) === wrapper;
const hasSuffixWrapper =
wrapperLength > 0 &&
selectionEnd + wrapperLength <= value.length &&
value.slice(selectionEnd, selectionEnd + wrapperLength) === wrapper;
let newValue: string;
let newSelectionStart: number;
let newSelectionEnd: number;
if (alreadyWrappedInside) {
const unwrappedText = selectedText.slice(wrapperLength, selectedText.length - wrapperLength);
newValue = value.slice(0, selectionStart) + unwrappedText + value.slice(selectionEnd);
newSelectionStart = selectionStart;
newSelectionEnd = selectionStart + unwrappedText.length;
} else if (hasPrefixWrapper && hasSuffixWrapper) {
newValue =
value.slice(0, selectionStart - wrapperLength) + selectedText + value.slice(selectionEnd + wrapperLength);
newSelectionStart = selectionStart - wrapperLength;
newSelectionEnd = selectionEnd - wrapperLength;
} else {
const wrappedText = `${wrapper}${selectedText}${wrapper}`;
newValue = value.slice(0, selectionStart) + wrappedText + value.slice(selectionEnd);
newSelectionStart = selectionStart + wrapperLength;
newSelectionEnd = selectionEnd + wrapperLength;
}
handleTextChange(newValue, previousValueRef.current);
setValue(newValue);
const updateSelection = () => {
textarea.setSelectionRange(newSelectionStart, newSelectionEnd);
};
window.requestAnimationFrame(updateSelection);
event.preventDefault();
event.stopPropagation();
return;
}
},
[handleTextChange, previousValueRef, setValue, textareaRef, value],
);
const handleTextareaKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
handleFormattingShortcut(event);
handleEscapeKey(event);
},
[handleFormattingShortcut, handleEscapeKey],
);
const handleSubmit = React.useCallback(() => {
if (isOverCharacterLimit || isEditingScheduledMessage) {
return;
}
onSubmit();
}, [isOverCharacterLimit, onSubmit, isEditingScheduledMessage]);
useTextareaDraftAndTyping({
channelId: channel.id,
value,
setValue,
draft,
previousValueRef,
isAutocompleteAttached,
enabled: !disabled,
});
const {handleArrowUp} = useTextareaKeyboard({
channelId: channel.id,
isFocused,
textareaRef,
value,
setValue,
handleTextChange,
previousValueRef,
clearSegments,
replyingMessage,
editingMessage: editingMessage || null,
getLastEditableMessage: () => MessageStore.getLastEditableMessage(channel.id) || null,
enabled: !disabled,
});
const placeholderText = disabled
? t`You do not have permission to send messages in this channel.`
: channel.guildId != null
? PlaceholderUtils.getChannelPlaceholder(channel.name || t`channel`, t`Message #`, Number.MAX_SAFE_INTEGER)
: PlaceholderUtils.getDMPlaceholder(
ChannelUtils.getDMDisplayName(channel),
channel.isDM() ? t`Message @` : t`Message `,
Number.MAX_SAFE_INTEGER,
);
React.useEffect(() => {
const unsubscribe = ComponentDispatch.subscribe('FOCUS_TEXTAREA', (payload?: unknown) => {
const {channelId, enterKeyboardMode} = (payload ?? {}) as {channelId?: string; enterKeyboardMode?: boolean};
if (channelId && channelId !== channel.id) return;
if (disabled) return;
const textarea = textareaRef.current;
if (textarea) {
if (enterKeyboardMode) {
KeyboardModeStore.enterKeyboardMode(true);
} else {
KeyboardModeStore.exitKeyboardMode();
}
safeFocus(textarea, true);
}
});
return unsubscribe;
}, [channel.id]);
React.useEffect(() => {
if (!canAttachFiles) return;
const unsubscribe = ComponentDispatch.subscribe('TEXTAREA_UPLOAD_FILE', (payload?: unknown) => {
const {channelId} = (payload ?? {}) as {channelId?: string};
if (channelId && channelId !== channel.id) return;
handleFileButtonClick();
});
return unsubscribe;
}, [channel.id, canAttachFiles]);
React.useLayoutEffect(() => {
if (!containerRef.current) return;
const checkButtonVisibility = () => {
if (!containerRef.current) return;
const containerWidthLocal = containerRef.current.offsetWidth;
const shouldShowAll = containerWidthLocal > 500;
setShowAllButtons(shouldShowAll);
setContainerWidth(containerWidthLocal);
};
const resizeObserver = new ResizeObserver(checkButtonVisibility);
resizeObserver.observe(containerRef.current);
checkButtonVisibility();
return () => {
resizeObserver.disconnect();
};
}, [mobileLayout.enabled]);
const handleCancelEdit = React.useCallback(() => {
setValue('');
clearSegments();
}, [clearSegments]);
const handleMessageInputButtonContextMenu = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, () => (
<MessageInputButtonsContextMenu canSchedule={canScheduleMessage} onSchedule={handleOpenScheduleModal} />
));
},
[canScheduleMessage, handleOpenScheduleModal],
);
const hasStackedSections = Boolean(
referencedMessage ||
(editingMessage && mobileLayout.enabled) ||
uploadAttachments.length > 0 ||
hasPendingSticker,
);
const topBarContent =
editingMessage && mobileLayout.enabled ? (
<EditBar channel={channel} onCancel={handleCancelEdit} />
) : (
referencedMessage && (
<ReplyBar
replyingMessageObject={referencedMessage}
shouldReplyMention={replyingMessage?.mentioning ?? false}
setShouldReplyMention={(mentioning) => MessageActionCreators.setReplyMentioning(channel.id, mentioning)}
channel={channel}
/>
)
);
const renderSection = (content: React.ReactNode) => <div className={wrapperStyles.stackSection}>{content}</div>;
return (
<>
{topBarContent && renderSection(<div className={wrapperStyles.topBarContainer}>{topBarContent}</div>)}
{hasMessageSchedulingAccess &&
editingScheduledMessage &&
renderSection(
<ScheduledMessageEditBar
scheduledLocalAt={editingScheduledMessage.scheduledLocalAt}
timezone={editingScheduledMessage.timezone}
onCancel={handleCancelScheduledEdit}
/>,
)}
<FocusRing
focusTarget={textareaRef}
ringTarget={containerRef}
offset={0}
enabled={!disabled && AccessibilityStore.showTextareaFocusRing}
ringClassName={styles.textareaFocusRing}
>
<div
ref={containerRef}
className={clsx(
wrapperStyles.box,
wrapperStyles.wrapperSides,
styles.textareaOuter,
hasStackedSections ? wrapperStyles.roundedBottom : wrapperStyles.roundedAll,
wrapperStyles.bottomSpacing,
disabled && wrapperStyles.disabled,
)}
style={{minHeight: 'var(--input-container-min-height)'}}
>
{showAttachments && renderSection(<ChannelAttachmentArea channelId={channel.id} />)}
{showStickers &&
renderSection(<ChannelStickersArea channelId={channel.id} hasAttachments={hasAttachments} />)}
{renderSection(
<div className={clsx(styles.mainWrapperDense, disabled && wrapperStyles.disabled)}>
{!disabled && showUploadButton && canAttachFiles && (
<div className={clsx(styles.uploadButtonColumn, styles.sideButtonPadding)}>
<TextareaButton
icon={PlusCircleIcon}
label={t`Upload file`}
onClick={handleFileButtonClick}
onContextMenu={handleMessageInputButtonContextMenu}
keybindAction="upload_file"
/>
</div>
)}
<div className={styles.contentAreaDense}>
<Scroller ref={scrollerRef} fade={true} className={styles.scroller} key="channel-textarea-scroller">
<div style={{display: 'flex', flexDirection: 'column'}}>
<TextareaInputField
disabled={disabled}
isMobile={mobileLayout.enabled}
value={value}
placeholder={placeholderText}
textareaRef={textareaRef}
scrollerRef={scrollerRef}
isFocused={isFocused}
isAutocompleteAttached={isAutocompleteAttached}
autocompleteOptions={autocompleteOptions}
selectedIndex={selectedIndex}
onFocus={() => {
setIsFocused(true);
setIsInputAreaFocused(true);
}}
onBlur={() => {
setIsFocused(false);
setIsInputAreaFocused(false);
}}
onChange={(newValue) => {
handleTextChange(newValue, previousValueRef.current);
setValue(newValue);
}}
onHeightChange={setTextareaHeight}
onCursorMove={onCursorMove}
onArrowUp={handleArrowUp}
onEnter={handleSubmit}
onAutocompleteSelect={handleSelect}
setSelectedIndex={setSelectedIndex}
onKeyDown={handleTextareaKeyDown}
/>
</div>
</Scroller>
</div>
<TextareaButtons
disabled={disabled}
showAllButtons={showAllButtons}
showUploadButton={showUploadButton}
showGiftButton={showGiftButton}
showGifButton={showGifButton}
showMemesButton={showMemesButton}
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
shouldShowMobileGiftButton={shouldShowMobileGiftButton}
isComposing={isComposing}
isSlowmodeActive={isSlowmodeActive}
isOverLimit={isOverCharacterLimit}
hasContent={!!value.trim()}
hasAttachments={uploadAttachments.length > 0}
expressionPickerTriggerRef={expressionPickerTriggerRef}
invisibleExpressionPickerTriggerRef={invisibleExpressionPickerTriggerRef}
onExpressionPickerToggle={handleExpressionPickerTabToggle}
onSubmit={handleSubmit}
disableSendButton={isEditingScheduledMessage}
onContextMenu={handleMessageInputButtonContextMenu}
/>
{isScheduleModalOpen && hasMessageSchedulingAccess && (
<ScheduleMessageModal
onClose={() => setIsScheduleModalOpen(false)}
onSubmit={handleScheduleSubmit}
initialScheduledLocalAt={editingScheduledMessage?.scheduledLocalAt}
initialTimezone={editingScheduledMessage?.timezone}
title={isEditingScheduledMessage ? t`Reschedule Message` : undefined}
submitLabel={isEditingScheduledMessage ? t`Update` : undefined}
helpText={
isEditingScheduledMessage
? t`This will modify the existing scheduled message rather than sending immediately.`
: undefined
}
/>
)}
</div>,
)}
<MessageCharacterCounter
currentLength={value.length}
maxLength={maxMessageLength}
isPremium={currentUser?.isPremium() ?? false}
/>
{isAutocompleteAttached && (
<Autocomplete
type={autocompleteType}
onSelect={handleSelect}
selectedIndex={selectedIndex}
options={autocompleteOptions}
setSelectedIndex={setSelectedIndex}
referenceElement={containerRef.current}
query={autocompleteQuery}
attached={true}
/>
)}
</div>
</FocusRing>
{mobileLayout.enabled && (
<ExpressionPickerSheet
isOpen={expressionPickerOpen}
onClose={() => setExpressionPickerOpen(false)}
channelId={channel.id}
onEmojiSelect={handleEmojiSelect}
/>
)}
</>
);
},
);
export const ChannelTextarea = observer(({channel}: {channel: ChannelRecord}) => {
const draft = DraftStore.getDraft(channel.id);
const forceNoSendMessages = DeveloperOptionsStore.forceNoSendMessages;
const forceNoAttachFiles = DeveloperOptionsStore.forceNoAttachFiles;
const disabled = channel.isPrivate()
? forceNoSendMessages
: forceNoSendMessages || !PermissionStore.can(Permissions.SEND_MESSAGES, channel);
const canAttachFiles = channel.isPrivate()
? !forceNoAttachFiles
: !forceNoAttachFiles && PermissionStore.can(Permissions.ATTACH_FILES, channel);
return (
<ChannelTextareaContent
key={channel.id}
channel={channel}
disabled={disabled}
canAttachFiles={canAttachFiles}
draft={draft}
/>
);
});

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.channelIcon {
display: flex;
align-items: center;
justify-content: center;
height: 5rem;
width: 5rem;
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--channel-welcome-icon-bg, var(--guild-list-foreground));
background-size: cover;
background-position: center;
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
container-type: size;
}
.channelIconInitials {
overflow: hidden;
white-space: nowrap;
font-size: clamp(0.5rem, 40cqi, 1.25rem);
line-height: 1;
color: inherit;
}
:global(.theme-light) .channelIcon {
--channel-welcome-icon-bg: color-mix(in srgb, var(--guild-list-foreground) 55%, var(--background-primary) 45%);
}
.container {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 1rem;
margin-top: auto;
margin-bottom: 2rem;
min-width: 0;
padding-top: 120px;
color: var(--text-primary);
}
.heading {
margin-top: 0.75rem;
font-size: 1.875rem;
font-weight: 600;
word-break: break-word;
overflow-wrap: break-word;
}
.description {
min-width: 0;
font-size: 1.125rem;
color: var(--text-primary-muted);
}
.iconSize {
height: 3rem;
width: 3rem;
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {ChannelTypes} from '~/Constants';
import styles from '~/components/channel/ChannelWelcomeSection.module.css';
import {DMWelcomeSection} from '~/components/channel/dm/DMWelcomeSection';
import {GroupDMWelcomeSection} from '~/components/channel/dm/GroupDMWelcomeSection';
import {PersonalNotesWelcomeSection} from '~/components/channel/dm/PersonalNotesWelcomeSection';
import type {ChannelRecord} from '~/records/ChannelRecord';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
export const ChannelWelcomeSection = observer(({channel}: {channel: ChannelRecord}) => {
const recipient = UserStore.getUser(channel.recipientIds[0]);
if (channel.type === ChannelTypes.DM && recipient) {
return <DMWelcomeSection userId={recipient.id} />;
}
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES && recipient) {
return <PersonalNotesWelcomeSection userId={recipient.id} />;
}
if (channel.type === ChannelTypes.GROUP_DM) {
return <GroupDMWelcomeSection channel={channel} />;
}
return (
<div className={styles.container}>
<div className={clsx('pointer-events-none', styles.channelIcon)}>
{ChannelUtils.getIcon(channel, {className: styles.iconSize})}
</div>
<h1 className={styles.heading}>
<Trans>Welcome to #{channel.name ?? ''}</Trans>
</h1>
<p className={styles.description}>
<Trans>In the beginning, there was nothing. Then, there was #{channel.name ?? ''}. And it was good.</Trans>
</p>
</div>
);
});

View File

@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container,
.unreadContainer {
position: relative;
display: flex;
align-items: center;
width: 100%;
contain: layout style;
}
.container {
padding: 0 20px;
height: 20px;
min-height: 20px;
max-height: 20px;
}
.unreadContainer {
--divider-size: var(--message-group-spacing, 16px);
padding: 0 var(--divider-size);
height: var(--divider-size);
min-height: var(--divider-size);
max-height: var(--divider-size);
}
.unreadDate {
--divider-size: 20px;
padding: 0 20px;
}
.line {
flex: 1;
height: 2px;
background-color: var(--background-modifier-accent);
opacity: 0.3;
}
.text {
padding: 0 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
background-color: var(--background-secondary-lighter);
position: relative;
z-index: 1;
white-space: nowrap;
flex-shrink: 0;
}
.unreadLine {
flex: 1;
height: 2px;
background-color: var(--status-danger);
opacity: 0.4;
}
.dateWithUnreadText {
position: absolute;
left: 50%;
transform: translateX(-50%);
padding: 0 12px;
font-size: 12px;
font-weight: 500;
color: var(--status-danger);
background-color: var(--background-secondary-lighter);
z-index: 2;
white-space: nowrap;
}
.unreadBadge {
position: relative;
background-color: var(--status-danger);
color: white;
font-size: 10px;
font-weight: 700;
padding: 8px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
opacity: 0.9;
flex-shrink: 0;
height: var(--divider-size);
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import React from 'react';
import styles from './Divider.module.css';
export const Divider = React.memo(
React.forwardRef<
HTMLDivElement,
{
red?: boolean;
children?: React.ReactNode;
spacing?: number;
isDate?: boolean;
style?: React.CSSProperties;
className?: string;
id?: string;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
}
>(({red = false, children, spacing = 8, isDate = false, style, ...rest}, ref) => {
if (red) {
if (isDate && children) {
return (
<div
ref={ref}
className={`${styles.unreadContainer} ${styles.unreadDate}`}
style={{marginTop: `${spacing}px`, marginBottom: `${spacing}px`, ...style}}
{...rest}
>
<div className={styles.unreadLine} />
<span className={styles.dateWithUnreadText}>{children}</span>
<div className={styles.unreadLine} />
<span className={styles.unreadBadge}>
<Trans>New</Trans>
</span>
</div>
);
}
return (
<div ref={ref} className={styles.unreadContainer} style={{...style}} {...rest}>
<div className={styles.unreadLine} />
<span className={styles.unreadBadge}>{children || <Trans>New</Trans>}</span>
</div>
);
}
return (
<div
ref={ref}
className={styles.container}
style={{marginTop: `${spacing}px`, marginBottom: `${spacing}px`, ...style}}
{...rest}
>
<div className={styles.line} />
{children && <span className={styles.text}>{children}</span>}
<div className={styles.line} />
</div>
);
}),
);

View File

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

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans} from '@lingui/react/macro';
import {XCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import type {ChannelRecord} from '~/records/ChannelRecord';
import styles from './EditBar.module.css';
import wrapperStyles from './textarea/InputWrapper.module.css';
interface EditBarProps {
channel: ChannelRecord;
onCancel: () => void;
}
export const EditBar = observer(({channel, onCancel}: EditBarProps) => {
const handleStopEdit = () => {
MessageActionCreators.stopEditMobile(channel.id);
onCancel();
};
const handleKeyDown = (handler: () => void) => (event: React.KeyboardEvent) => {
if (event.key === 'Enter') handler();
};
return (
<div
className={`${wrapperStyles.box} ${wrapperStyles.wrapperSides} ${wrapperStyles.roundedTop} ${wrapperStyles.noBottomBorder}`}
>
<div className={wrapperStyles.barInner} style={{gridTemplateColumns: '1fr auto'}}>
<div className={styles.text}>
<Trans>Editing message</Trans>
</div>
<div className={styles.controls}>
<FocusRing offset={-2}>
<button
type="button"
className={styles.button}
onClick={handleStopEdit}
onKeyDown={handleKeyDown(handleStopEdit)}
>
<XCircleIcon className={styles.icon} />
</button>
</FocusRing>
</div>
</div>
<div className={wrapperStyles.separator} />
</div>
);
});

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.scroller {
max-height: 50svh;
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--text-chat);
font-size: 0.75rem;
}
.footerLink {
color: var(--text-link);
cursor: pointer;
}
.footerLink:hover {
text-decoration: underline;
}
.separator {
display: inline-block;
margin-left: 0.25rem;
margin-right: 0.25rem;
height: 0.25rem;
width: 0.25rem;
border-radius: 50%;
background-color: var(--text-chat-muted);
vertical-align: middle;
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {SmileyIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import {Autocomplete} from '~/components/channel/Autocomplete';
import {MessageCharacterCounter} from '~/components/channel/MessageCharacterCounter';
import {TextareaButton} from '~/components/channel/textarea/TextareaButton';
import {TextareaInputField} from '~/components/channel/textarea/TextareaInputField';
import {ExpressionPickerSheet} from '~/components/modals/ExpressionPickerSheet';
import {ExpressionPickerPopout} from '~/components/popouts/ExpressionPickerPopout';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {openPopout} from '~/components/uikit/Popout/Popout';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {useMarkdownKeybinds} from '~/hooks/useMarkdownKeybinds';
import {useTextareaAutocomplete} from '~/hooks/useTextareaAutocomplete';
import {useTextareaEmojiPicker} from '~/hooks/useTextareaEmojiPicker';
import {useTextareaPaste} from '~/hooks/useTextareaPaste';
import {useTextareaSegments} from '~/hooks/useTextareaSegments';
import type {ChannelRecord} from '~/records/ChannelRecord';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import {applyMarkdownSegments} from '~/utils/MarkdownToSegmentUtils';
import editingStyles from './EditingMessageInput.module.css';
import styles from './textarea/TextareaInput.module.css';
export const EditingMessageInput = observer(
({
channel,
onCancel,
onSubmit,
textareaRef,
value,
setValue,
}: {
channel: ChannelRecord;
onCancel: () => void;
onSubmit: (actualContent?: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
}) => {
const currentUser = UserStore.getCurrentUser()!;
const maxMessageLength = currentUser.maxMessageLength;
const [expressionPickerOpen, setExpressionPickerOpen] = React.useState(false);
const hasInitializedRef = React.useRef(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const mobileLayout = MobileLayoutStore;
const expressionPickerTriggerRef = React.useRef<HTMLButtonElement>(null);
const [isFocused, setIsFocused] = React.useState(false);
useMarkdownKeybinds(isFocused);
const [textareaHeight, setTextareaHeight] = React.useState(0);
const hasScrolledInitiallyRef = React.useRef(false);
const shouldStickToBottomRef = React.useRef(true);
const handleScroll = React.useCallback(() => {
const distance = scrollerRef.current?.getDistanceFromBottom?.();
if (distance == null) return;
shouldStickToBottomRef.current = distance <= 8;
}, []);
const handleTextareaKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel();
}
},
[onCancel],
);
const {segmentManagerRef, previousValueRef, displayToActual, insertSegment, handleTextChange} =
useTextareaSegments();
const {handleEmojiSelect} = useTextareaEmojiPicker({setValue, textareaRef, insertSegment, previousValueRef});
const {
autocompleteQuery,
autocompleteOptions,
autocompleteType,
selectedIndex,
isAutocompleteAttached,
setSelectedIndex,
onCursorMove,
handleSelect,
} = useTextareaAutocomplete({
channel,
value,
setValue,
textareaRef,
segmentManagerRef,
previousValueRef,
allowedTriggers: ['emoji'],
});
useTextareaPaste({
channel,
textareaRef,
segmentManagerRef,
setValue,
previousValueRef,
});
React.useLayoutEffect(() => {
if (!hasInitializedRef.current && value) {
hasInitializedRef.current = true;
const displayText = applyMarkdownSegments(value, channel.guildId, segmentManagerRef.current);
setValue(displayText);
previousValueRef.current = displayText;
requestAnimationFrame(() => {
if (textareaRef.current) {
const length = displayText.length;
textareaRef.current.setSelectionRange(length, length);
}
});
}
}, [value, channel.guildId, setValue, segmentManagerRef, previousValueRef]);
React.useLayoutEffect(() => {
if (hasScrolledInitiallyRef.current) return;
if (!scrollerRef.current) return;
if (textareaHeight <= 0) return;
scrollerRef.current.scrollToBottom({animate: false});
hasScrolledInitiallyRef.current = true;
shouldStickToBottomRef.current = true;
}, [textareaHeight]);
const handleSubmit = React.useCallback(() => {
if (value.length > maxMessageLength) {
return;
}
const actualContent = displayToActual(value);
onSubmit(actualContent);
}, [value, displayToActual, onSubmit, maxMessageLength]);
const handleExpressionPickerToggle = React.useCallback(() => {
const triggerElement = expressionPickerTriggerRef.current;
if (!triggerElement) return;
const popoutKey = `editing-expression-picker-${channel.id}`;
const isOpen = expressionPickerOpen;
if (isOpen) {
PopoutActionCreators.close(popoutKey);
setExpressionPickerOpen(false);
} else {
openPopout(
triggerElement,
{
render: ({onClose}) => (
<ExpressionPickerPopout
channelId={channel.id}
onEmojiSelect={handleEmojiSelect}
onClose={onClose}
visibleTabs={['emojis']}
/>
),
position: 'top-end',
animationType: 'none',
offsetCrossAxis: 16,
onOpen: () => setExpressionPickerOpen(true),
onClose: () => setExpressionPickerOpen(false),
returnFocusRef: textareaRef,
},
popoutKey,
);
}
}, [channel.id, expressionPickerOpen, handleEmojiSelect, textareaRef]);
return (
<>
{isAutocompleteAttached && (
<Autocomplete
type={autocompleteType}
onSelect={handleSelect}
selectedIndex={selectedIndex}
options={autocompleteOptions}
setSelectedIndex={setSelectedIndex}
referenceElement={containerRef.current}
query={autocompleteQuery}
/>
)}
<FocusRing within={true} offset={-2}>
<div ref={containerRef} className={styles.textareaContainer}>
<div className={styles.mainWrapperEditing}>
<div className={styles.contentAreaEditing}>
<Scroller
ref={scrollerRef}
fade={true}
className={editingStyles.scroller}
key="editing-message-input-scroller"
onScroll={handleScroll}
>
<div style={{display: 'flex', flexDirection: 'column'}}>
<span
key={textareaHeight}
style={{position: 'absolute', visibility: 'hidden', pointerEvents: 'none'}}
/>
<TextareaInputField
disabled={false}
isMobile={mobileLayout.enabled}
value={value}
placeholder=""
textareaRef={textareaRef}
scrollerRef={scrollerRef}
shouldStickToBottomRef={shouldStickToBottomRef}
isFocused={isFocused}
isAutocompleteAttached={isAutocompleteAttached}
autocompleteOptions={autocompleteOptions}
selectedIndex={selectedIndex}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={(newValue) => {
handleTextChange(newValue, previousValueRef.current);
setValue(newValue);
}}
onHeightChange={setTextareaHeight}
onCursorMove={onCursorMove}
onArrowUp={() => {}}
onEnter={handleSubmit}
onAutocompleteSelect={handleSelect}
setSelectedIndex={setSelectedIndex}
onKeyDown={handleTextareaKeyDown}
/>
</div>
</Scroller>
</div>
<div className={styles.buttonContainerEditing}>
<TextareaButton
ref={mobileLayout.enabled ? undefined : expressionPickerTriggerRef}
icon={SmileyIcon}
iconProps={{weight: 'fill'}}
label="Emojis"
isSelected={expressionPickerOpen}
onClick={mobileLayout.enabled ? () => setExpressionPickerOpen(true) : handleExpressionPickerToggle}
data-expression-picker-tab="emojis"
compact={true}
/>
</div>
</div>
<MessageCharacterCounter
currentLength={value.length}
maxLength={maxMessageLength}
isPremium={currentUser.isPremium()}
/>
</div>
</FocusRing>
<div className={editingStyles.footer}>
<div>
<Trans>
escape to{' '}
<FocusRing offset={-2}>
<button type="button" className={editingStyles.footerLink} onClick={onCancel} key="cancel">
cancel
</button>
</FocusRing>
</Trans>
<div aria-hidden={true} className={editingStyles.separator} />
<Trans>
enter to{' '}
<FocusRing offset={-2}>
<button type="button" className={editingStyles.footerLink} onClick={handleSubmit} key="save">
save
</button>
</FocusRing>
</Trans>
</div>
</div>
{mobileLayout.enabled && (
<ExpressionPickerSheet
isOpen={expressionPickerOpen}
onClose={() => setExpressionPickerOpen(false)}
channelId={channel.id}
onEmojiSelect={handleEmojiSelect}
visibleTabs={['emojis']}
selectedTab="emojis"
/>
)}
</>
);
},
);

View File

@@ -0,0 +1,458 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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;
height: 100%;
}
.skinToneSelectorContainer {
position: relative;
display: flex;
align-items: center;
}
.skinTonePickerButton {
width: 24px;
height: 24px;
cursor: pointer;
}
.cursorPointer {
cursor: pointer;
}
.cursorText {
cursor: text;
}
.skinTonePickerOptions {
position: absolute;
top: -4px;
right: -4px;
display: flex;
flex-direction: column;
width: auto;
height: 192px;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
z-index: 10;
}
.skinTonePickerItem {
width: 32px;
height: 32px;
padding: 4px;
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
}
.skinTonePickerItemImage {
width: 24px;
height: 24px;
}
.skinToneNativeEmoji {
font-size: 24px;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.emojiPicker {
position: relative;
display: grid;
grid-template-columns: 46px auto;
grid-template-rows: 1fr auto;
width: auto;
height: 100%;
overflow: hidden;
}
.bodyWrapper {
display: grid;
position: relative;
grid-column: 2 / 3;
grid-row: 1 / 2;
grid-template-rows: 1fr;
}
.emojiPickerListWrapper {
position: relative;
grid-row: 1 / 2;
overflow: visible;
display: flex;
flex-direction: column;
min-height: 0;
}
.listWrapper {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.list {
height: 100%;
padding: var(--spacing-3) var(--spacing-2) 0;
}
.header {
display: flex;
align-items: center;
position: relative;
grid-column: 1 / 3;
padding: var(--spacing-3);
z-index: 100;
}
.headerMobile {
grid-column: 1;
}
.searchBar {
display: flex;
flex: 1;
margin-right: var(--spacing-3);
box-sizing: border-box;
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--background-primary);
}
:global(.theme-light) .searchBar {
background-color: var(--background-secondary);
box-shadow: inset 0 0 0 1px var(--background-modifier-accent);
}
.searchBarInner {
display: flex;
position: relative;
flex: 1 1 auto;
flex-direction: row;
flex-wrap: wrap;
padding: 1px;
box-sizing: border-box;
}
.searchBarInput {
flex: 1;
min-width: 48px;
height: 30px;
margin: 1px;
padding: 0 8px;
box-sizing: border-box;
font-size: 1rem;
line-height: 32px;
background: transparent;
border: none;
resize: none;
appearance: none;
color: var(--text-tertiary);
}
.searchBarInput::placeholder {
color: var(--text-primary-muted);
}
.iconLayout {
display: flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
box-sizing: border-box;
cursor: text;
}
.iconContainer {
position: relative;
width: 20px;
height: 20px;
box-sizing: border-box;
}
.icon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transform: rotate(90deg);
z-index: 2;
transition:
transform 0.1s ease-out,
opacity 0.1s ease-out;
color: var(--text-tertiary-muted);
}
.icon.visible {
transform: rotate(0);
opacity: 1;
}
.iconSize {
width: 24px;
height: 24px;
}
.categoryIcon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.headerIcon {
height: 1rem;
width: 1rem;
}
.caretIcon {
height: 0.75rem;
width: 0.75rem;
flex-shrink: 0;
color: var(--text-primary-muted);
transition: transform 0.2s;
}
.inspector {
display: flex;
align-items: center;
position: relative;
grid-column: 2 / 3;
grid-row: 3 / 4;
height: 48px;
padding: 0 var(--spacing-4);
box-sizing: border-box;
background-color: var(--background-primary);
overflow: hidden;
border-top: 1px solid var(--background-modifier-accent);
}
.inspectorEmoji {
width: 32px;
height: 32px;
}
.inspectorEmojiSprite {
width: 32px;
height: 32px;
background-repeat: no-repeat;
}
.inspectorNativeEmoji {
font-size: 32px;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.inspectorText {
margin-left: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
line-height: 1.2;
max-height: 1.2em;
}
.categoryList {
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 46px;
padding: var(--spacing-2) 0;
box-sizing: border-box;
overflow: hidden;
background: var(--background-primary);
border-radius: 0 0 0 8px;
box-shadow: inset -1px 0 0 var(--background-modifier-accent);
}
.categoryListScroll {
flex: 1 1 auto;
width: 100%;
min-height: 0;
overflow-y: auto;
padding: 0 var(--spacing-2);
-ms-overflow-style: none;
scrollbar-width: none;
}
.categoryListScroll::-webkit-scrollbar {
width: 0;
height: 0;
}
.listItems {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
gap: var(--spacing-2);
contain: layout;
}
.categoryListIcon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-bottom: 0;
cursor: pointer;
border-radius: 0.375rem;
transition:
background-color 0.2s,
color 0.2s;
}
.categoryListIcon:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.categoryListIconActive {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.textPrimaryMuted {
color: var(--text-primary-muted);
}
.emojiRowContainer {
height: 48px;
display: flex;
align-items: center;
padding: 0 var(--spacing-3);
}
.emojiGrid {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
justify-items: center;
width: 100%;
}
.emojiGridCategory {
margin-bottom: 16px;
}
.categoryTitle {
margin: 0;
font-size: 0.875rem;
font-weight: bold;
color: var(--text-primary-muted);
line-height: 1.2;
max-height: 1.05rem;
}
.emojiRenderer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
max-width: 48px;
border-radius: 0.375rem;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
background: none;
border: none;
padding: 0;
outline: none;
}
.emojiRenderer:hover,
.emojiRenderer.selectedEmojiRenderer {
background-color: var(--background-modifier-selected) !important;
}
.emojiRenderer:focus {
outline: none;
}
.emojiRenderer:focus-visible {
outline: 2px solid var(--brand-primary-light);
outline-offset: -2px;
}
.focusedEmojiRenderer {
background-color: var(--background-modifier-hover);
outline: 2px solid var(--brand-primary-light);
outline-offset: -2px;
}
.emojiImage {
width: 83.33%;
height: 83.33%;
max-width: 40px;
max-height: 40px;
object-fit: contain;
}
.spriteEmoji {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
background-repeat: no-repeat;
flex-shrink: 0;
}
.nativeEmoji {
font-size: 32px;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.emojiLocked {
opacity: 0.3;
filter: blur(1px);
}
.iconSmall {
height: 1rem;
width: 1rem;
}
.horizontalCategories {
display: flex;
width: 100%;
justify-content: space-around;
}
.container {
position: relative;
height: 100%;
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as EmojiPickerActionCreators from '~/actions/EmojiPickerActionCreators';
import styles from '~/components/channel/EmojiPicker.module.css';
import {EmojiPickerCategoryList} from '~/components/channel/emoji-picker/EmojiPickerCategoryList';
import {EMOJI_SPRITE_SIZE} from '~/components/channel/emoji-picker/EmojiPickerConstants';
import {EmojiPickerInspector} from '~/components/channel/emoji-picker/EmojiPickerInspector';
import {EmojiPickerSearchBar} from '~/components/channel/emoji-picker/EmojiPickerSearchBar';
import {useEmojiCategories} from '~/components/channel/emoji-picker/hooks/useEmojiCategories';
import {useVirtualRows} from '~/components/channel/emoji-picker/hooks/useVirtualRows';
import {VirtualizedRow} from '~/components/channel/emoji-picker/VirtualRow';
import {PremiumUpsellBanner} from '~/components/channel/PremiumUpsellBanner';
import {ExpressionPickerHeaderContext, ExpressionPickerHeaderPortal} from '~/components/popouts/ExpressionPickerPopout';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {useForceUpdate} from '~/hooks/useForceUpdate';
import {useSearchInputAutofocus} from '~/hooks/useSearchInputAutofocus';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import UnicodeEmojis, {EMOJI_SPRITES} from '~/lib/UnicodeEmojis';
import ChannelStore from '~/stores/ChannelStore';
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
import {checkEmojiAvailability, shouldShowEmojiPremiumUpsell} from '~/utils/ExpressionPermissionUtils';
import {shouldShowPremiumFeatures} from '~/utils/PremiumUtils';
export const EmojiPicker = observer(
({channelId, handleSelect}: {channelId?: string; handleSelect: (emoji: Emoji, shiftKey?: boolean) => void}) => {
const headerContext = React.useContext(ExpressionPickerHeaderContext);
if (!headerContext) {
throw new Error(
'EmojiPicker must be rendered inside ExpressionPickerPopout so that the header portal is available.',
);
}
const [searchTerm, setSearchTerm] = React.useState('');
const [hoveredEmoji, setHoveredEmoji] = React.useState<Emoji | null>(null);
const [renderedEmojis, setRenderedEmojis] = React.useState<Array<Emoji>>([]);
const [selectedRow, setSelectedRow] = React.useState(-1);
const [selectedColumn, setSelectedColumn] = React.useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = React.useState(false);
const scrollerRef = React.useRef<ScrollerHandle>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const emojiRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
const {i18n} = useLingui();
const channel = channelId ? (ChannelStore.getChannel(channelId) ?? null) : null;
const categoryRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const forceUpdate = useForceUpdate();
const skinTone = EmojiStore.skinTone;
const spriteSheetSizes = React.useMemo(() => {
const nonDiversitySize = [
`${EMOJI_SPRITE_SIZE * EMOJI_SPRITES.NonDiversityPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(UnicodeEmojis.numNonDiversitySprites / EMOJI_SPRITES.NonDiversityPerRow)}px`,
].join(' ');
const diversitySize = [
`${EMOJI_SPRITE_SIZE * EMOJI_SPRITES.DiversityPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(UnicodeEmojis.numDiversitySprites / EMOJI_SPRITES.DiversityPerRow)}px`,
].join(' ');
return {nonDiversitySize, diversitySize};
}, []);
React.useEffect(() => {
const emojis = EmojiStore.search(channel, searchTerm).slice();
setRenderedEmojis(emojis);
if (emojis.length > 0) {
setSelectedRow(0);
setSelectedColumn(0);
} else {
setSelectedRow(-1);
setSelectedColumn(-1);
setHoveredEmoji(null);
}
}, [channel, searchTerm]);
React.useEffect(() => {
return ComponentDispatch.subscribe('EMOJI_PICKER_RERENDER', forceUpdate);
});
useSearchInputAutofocus(searchInputRef);
const {favoriteEmojis, frequentlyUsedEmojis, customEmojisByGuildId, unicodeEmojisByCategory} =
useEmojiCategories(renderedEmojis);
const virtualRows = useVirtualRows(
searchTerm,
renderedEmojis,
favoriteEmojis,
frequentlyUsedEmojis,
customEmojisByGuildId,
unicodeEmojisByCategory,
);
const showPremiumUpsell = shouldShowPremiumFeatures() && shouldShowEmojiPremiumUpsell(channel);
const sections = React.useMemo(() => {
const result: Array<number> = [];
for (const row of virtualRows) {
if (row.type === 'emoji-row') {
result.push(row.emojis.length);
}
}
return result;
}, [virtualRows]);
const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category);
if (element) {
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true});
}
};
const handleHover = (emoji: Emoji | null, row?: number, column?: number) => {
setHoveredEmoji(emoji);
if (emoji && row !== undefined && column !== undefined) {
handleSelectionChange(row, column, false);
}
};
const handleEmojiSelect = React.useCallback(
(emoji: Emoji, shiftKey?: boolean) => {
const availability = checkEmojiAvailability(i18n, emoji, channel);
if (!availability.canUse) {
return;
}
EmojiPickerActionCreators.trackEmojiUsage(emoji);
handleSelect(emoji, shiftKey);
},
[channel, handleSelect, i18n],
);
const handleSelectionChange = React.useCallback(
(row: number, column: number, shouldScroll = false) => {
if (row < 0 || column < 0) {
return;
}
setSelectedRow(row);
setSelectedColumn(column);
setShouldScrollOnSelection(shouldScroll);
let currentRow = 0;
for (const virtualRow of virtualRows) {
if (virtualRow.type === 'emoji-row') {
if (currentRow === row && column < virtualRow.emojis.length) {
const emoji = virtualRow.emojis[column];
setHoveredEmoji(emoji);
break;
}
currentRow++;
}
}
},
[virtualRows],
);
React.useEffect(() => {
if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) {
handleSelectionChange(0, 0, false);
}
}, [renderedEmojis, selectedRow, selectedColumn, hoveredEmoji, handleSelectionChange]);
const handleSelectEmoji = React.useCallback(
(row: number | null, column: number | null, event?: React.KeyboardEvent) => {
if (row === null || column === null) {
return;
}
let currentRow = 0;
for (const virtualRow of virtualRows) {
if (virtualRow.type === 'emoji-row') {
if (currentRow === row && column < virtualRow.emojis.length) {
const emoji = virtualRow.emojis[column];
handleEmojiSelect(emoji, event?.shiftKey);
return;
}
currentRow++;
}
}
},
[virtualRows, handleEmojiSelect],
);
return (
<div className={styles.container}>
<ExpressionPickerHeaderPortal>
<EmojiPickerSearchBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
hoveredEmoji={hoveredEmoji}
inputRef={searchInputRef}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
sections={sections}
onSelect={handleSelectEmoji}
onSelectionChange={handleSelectionChange}
/>
</ExpressionPickerHeaderPortal>
<div className={styles.emojiPicker}>
<div className={styles.bodyWrapper}>
<div className={styles.emojiPickerListWrapper} role="presentation">
<Scroller
ref={scrollerRef}
className={`${styles.list} ${styles.listWrapper}`}
fade={false}
key="emoji-picker-scroller"
reserveScrollbarTrack={false}
>
{showPremiumUpsell && <PremiumUpsellBanner />}
{virtualRows.map((row, index) => {
const emojiRowIndex = virtualRows.slice(0, index).filter((r) => r.type === 'emoji-row').length;
return (
<div
key={`${row.type}-${row.index}`}
ref={
row.type === 'header'
? (el) => {
if (el && 'category' in row) {
categoryRefs.current.set(row.category, el);
}
}
: undefined
}
>
<VirtualizedRow
row={row}
handleHover={handleHover}
handleSelect={handleEmojiSelect}
skinTone={skinTone}
spriteSheetSizes={spriteSheetSizes}
channel={channel}
hoveredEmoji={hoveredEmoji}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
emojiRowIndex={emojiRowIndex}
shouldScrollOnSelection={shouldScrollOnSelection}
emojiRefs={emojiRefs}
/>
</div>
);
})}
</Scroller>
</div>
</div>
<EmojiPickerInspector hoveredEmoji={hoveredEmoji} />
</div>
<EmojiPickerCategoryList
customEmojisByGuildId={customEmojisByGuildId}
unicodeEmojisByCategory={unicodeEmojisByCategory}
handleCategoryClick={handleCategoryClick}
/>
</div>
);
},
);

View File

@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.emptyState {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-6);
text-align: center;
height: 100%;
}
.emptyStateContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
}
.emptyStateIcon {
height: 3.5rem;
width: 3.5rem;
color: var(--text-primary-muted);
}
.emptyStateTextContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
}
.emptyStateTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.emptyStateDescription {
font-size: 1rem;
color: var(--text-primary-muted);
max-width: 280px;
margin: 0;
}
.pickerHeader {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
padding: var(--spacing-3);
gap: var(--spacing-3);
background-color: var(--background-tertiary);
border-bottom: 1px solid var(--background-modifier-accent);
}
.searchBarWrapper {
display: flex;
flex: 1;
overflow: hidden;
border-radius: var(--radius-md);
background-color: var(--background-primary);
}
:global(.theme-light) .searchBarWrapper {
background-color: var(--background-secondary);
box-shadow: inset 0 0 0 1px var(--background-modifier-accent);
}
.pickerContent {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background-color: var(--background-primary);
}
.pickerGrid {
display: flex;
flex-wrap: nowrap;
gap: var(--spacing-3);
padding: 0 var(--spacing-4);
justify-content: flex-start;
}
.pickerColumn {
display: flex;
flex-direction: column;
flex: 1;
gap: var(--spacing-3);
min-width: 227px;
}
@media (max-width: 768px) {
.pickerGrid {
gap: var(--spacing-2);
padding: 0 var(--spacing-3);
}
.pickerColumn {
gap: var(--spacing-2);
min-width: calc(50svw - 20px);
}
.pickerHeader {
padding: var(--spacing-3);
}
}

View File

@@ -0,0 +1,554 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.gifPickerContainer {
position: relative;
display: grid;
height: 100%;
grid-template-rows: 1fr;
overflow: hidden;
background-color: var(--background-primary);
--gif-picker-overlay-bg: oklab(0 0 0 / 0.75);
--gif-picker-overlay-hover-bg: oklab(0 0 0 / 0.8);
}
.gifPickerMain {
position: relative;
min-height: 0;
overflow: hidden;
background-color: var(--background-primary);
}
.autoSizerWrapper {
height: 100%;
width: 100%;
}
.autoSizerWrapper > div {
height: 100% !important;
width: 100% !important;
}
.virtualList {
scrollbar-color: var(--scrollbar-thumb-bg) transparent;
scrollbar-width: thin;
overflow-anchor: none;
background-color: var(--background-primary);
}
@media (max-width: 768px) {
.virtualList {
scrollbar-width: none;
}
.virtualList::-webkit-scrollbar {
display: none;
}
}
.virtualList::-webkit-scrollbar {
width: 8px;
}
.virtualList::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-bg);
background-clip: padding-box;
border: 2px solid transparent;
border-radius: 4px;
min-height: 48px;
}
.virtualList::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-bg-hover);
}
.virtualList::-webkit-scrollbar-track {
background-color: transparent;
}
.virtualRow {
display: flex;
gap: var(--spacing-3);
}
@media (max-width: 768px) {
.virtualRow {
gap: var(--spacing-2);
}
}
.searchBarContainer {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.mobileHeaderWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding-block: var(--spacing-2);
padding-inline: var(--spacing-4);
}
.searchBarTitleWrapper {
display: flex;
align-items: center;
gap: 8px;
}
.searchBarBackButton {
cursor: pointer;
width: 24px;
height: 24px;
color: var(--text-primary-muted);
transition: color 0.1s ease-out;
}
.searchBarBackButton:hover {
color: var(--text-primary);
}
.searchBarTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-tertiary);
}
.grid {
display: flex;
flex-wrap: nowrap;
gap: var(--spacing-3);
padding: 0 10px 0 10px;
justify-content: flex-start;
}
.column {
display: flex;
flex-direction: column;
flex: 1;
gap: var(--spacing-3);
min-width: 227px;
}
@media (max-width: 768px) {
.grid {
gap: var(--spacing-2);
padding: 0 10px 0 10px;
}
.column {
gap: var(--spacing-2);
min-width: calc(50svw - 20px);
}
.gridItem {
border-radius: var(--radius-sm);
}
}
.gridItem {
position: relative;
border-radius: 0.375rem;
background-color: var(--background-secondary);
cursor: pointer;
width: 100%;
box-sizing: border-box;
border: 1px solid transparent;
transition: border-color 0.1s ease-out;
outline: none;
}
.gridItemFocused {
border-color: var(--brand-primary-light);
box-shadow: inset 0 0 0 2px var(--brand-primary-light);
}
.gridItemBackdrop {
position: absolute;
inset: 1px;
transition:
background-color 0.1s ease-out,
backdrop-filter 0.1s ease-out;
z-index: 2;
background-color: var(--gif-picker-overlay-bg);
border-radius: calc(0.375rem - 1px);
}
.gridItemCategory:hover {
border-color: var(--brand-primary-light);
box-shadow: inset 0 0 0 1px var(--brand-primary-light);
}
.gridItemCategory:hover .gridItemBackdrop {
backdrop-filter: blur(2px);
background-color: var(--gif-picker-overlay-hover-bg);
}
.gridItemGif .gridItemBackdrop {
display: none;
}
.gifMediaContainer {
position: absolute;
inset: 1px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: calc(0.375rem - 1px);
transition: filter 0.1s ease-out;
}
.gridItemGif:hover {
border-color: var(--brand-primary-light);
box-shadow: inset 0 0 0 1px var(--brand-primary-light);
}
.gridItemGif:hover .gifMediaContainer {
filter: brightness(1.2);
}
.gridItemFavorites .gridItemBackdrop {
background-color: hsla(242, 67%, 55%, 0.6);
}
.gridItemFavorites:hover .gridItemBackdrop {
background-color: hsla(242, 67%, 55%, 0.8);
}
.gridItemCategoryTitle {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
pointer-events: none;
gap: 4px;
}
.gridItemIcon {
width: 20px;
height: 20px;
color: white;
}
.gridItemCategoryTitleText {
font-size: 1rem;
line-height: 16px;
font-weight: 600;
color: white;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.6);
}
.gif {
width: 100%;
height: 100%;
object-fit: cover;
background-color: transparent;
}
.gifVideoContainer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.header {
display: flex;
align-items: center;
padding: 16px;
background-color: var(--background-primary);
}
.backButton {
margin-right: 16px;
cursor: pointer;
width: 24px;
height: 24px;
color: var(--text-primary-muted);
transition: color 0.1s ease-out;
}
.backButton:hover {
color: var(--text-primary);
}
.header h2 {
flex: 1;
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.skeletonContainer {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 0 16px;
}
.skeletonItem {
background: linear-gradient(
90deg,
var(--background-secondary) 0%,
var(--background-tertiary) 50%,
var(--background-secondary) 100%
);
background-size: 200% 100%;
border-radius: 0.375rem;
animation: shimmer 2s ease-in-out infinite;
will-change: background-position;
}
@keyframes shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@media (max-width: 768px) {
.skeletonItem {
border-radius: 0.25rem;
}
}
.suggestionsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 16px 16px 0;
justify-content: center;
}
.suggestionTag {
padding: 8px 16px;
border: none;
border-radius: 16px;
background-color: var(--background-tertiary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.suggestionTag:hover {
background-color: var(--background-modifier-hover);
}
.suggestionTag:active {
background-color: var(--background-modifier-selected);
}
.hoverActionButtons {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 10;
display: flex;
gap: 0.25rem;
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
}
.favoriteButton {
display: flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-primary);
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
transition:
transform 0.15s,
border-color 0.15s,
background-color 0.15s;
cursor: pointer;
}
.favoriteButton:hover {
transform: scale(1.05);
}
.favoriteButton:hover:has([style*='status-danger']) {
background-color: var(--status-danger-hover);
}
.favoriteButton:active {
transform: scale(0.95);
}
.favoriteButtonActive {
border-color: var(--brand-primary);
background-color: var(--brand-primary);
}
.favoriteButtonIcon {
color: var(--text-primary);
height: 1rem;
width: 1rem;
}
.favoriteButtonActiveIcon {
color: white;
}
.centeredContent {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 64px;
}
.slate {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
justify-content: center;
padding: 0 1rem;
gap: 0.5rem;
}
.slateContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.slateIcon {
height: 3.5rem;
width: 3.5rem;
color: var(--text-primary-muted);
}
.slateTextContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.slateTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.slateDescription {
font-size: 1rem;
color: var(--text-primary-muted);
}
:global(.theme-light) .gifPickerContainer {
background-color: var(--background-primary);
--gif-picker-overlay-bg: color-mix(in srgb, var(--background-primary) 80%, transparent);
--gif-picker-overlay-hover-bg: color-mix(in srgb, var(--background-primary) 90%, transparent);
}
:global(.theme-light) .gifPickerMain,
:global(.theme-light) .scrollArea {
background-color: var(--background-primary);
}
:global(.theme-light) .gridItem {
background-color: color-mix(in srgb, var(--background-primary) 90%, var(--background-secondary) 10%);
border-color: color-mix(in srgb, var(--background-modifier-accent) 60%, transparent);
}
:global(.theme-light) .gridItemCategoryTitleText {
color: var(--text-primary);
text-shadow: none;
}
:global(.theme-light) .gridItemIcon {
color: var(--text-primary);
}
:global(.theme-light) .gridItemFavorites .gridItemCategoryTitleText,
:global(.theme-light) .gridItemFavorites .gridItemIcon {
color: var(--text-on-brand-primary);
}
:global(.theme-light) .gridItemFavorites .gridItemCategoryTitleText {
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
}
:global(.theme-light) .suggestionTag {
background-color: var(--background-secondary);
}
:global(.theme-light) .gridItemFocused {
border-color: var(--brand-primary);
box-shadow: inset 0 0 0 2px var(--brand-primary);
}
.searchBarContent {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--spacing-3);
}
.favoriteButtonSpinner {
width: 18px;
height: 18px;
border-radius: 999px;
box-sizing: border-box;
border: 2px solid color-mix(in srgb, var(--brand-primary-light) 20%, transparent);
border-top-color: var(--brand-primary-light);
border-right-color: var(--brand-primary-light);
animation: gifFavoriteSpinner 600ms linear infinite;
}
@keyframes gifFavoriteSpinner {
to {
transform: rotate(360deg);
}
}
.gridItemGif:hover .hoverActionButtons,
.gridItemGif:focus-visible .hoverActionButtons,
.gridItemGif:focus-within .hoverActionButtons,
.gridItemFavoritePending .hoverActionButtons,
.gridItemFocused .hoverActionButtons {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}

View File

@@ -0,0 +1,227 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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';
class ElementPool<T> {
private _elements: Array<T>;
private _createElement: () => T;
private _cleanElement: (element: T) => void;
constructor(createElement: () => T, cleanElement: (element: T) => void) {
this._elements = [];
this._createElement = createElement;
this._cleanElement = cleanElement;
}
getElement(): T {
return this._elements.length === 0 ? this._createElement() : this._elements.pop()!;
}
poolElement(element: T): void {
this._cleanElement(element);
this._elements.push(element);
}
clearPool(): void {
this._elements.length = 0;
}
}
interface PooledVideo {
getElement: (src?: string) => HTMLVideoElement;
poolElement: (element: HTMLVideoElement, src?: string) => void;
clearPool: () => void;
getBlobUrl: (src: string) => Promise<string>;
clearBlobCache: () => void;
registerActive: (element: HTMLVideoElement) => void;
unregisterActive: (element: HTMLVideoElement) => void;
pauseAll: () => void;
resumeAll: () => void;
}
const GifVideoPoolContext = React.createContext<PooledVideo | null>(null);
export const GifVideoPoolProvider = ({children}: {children: React.ReactNode}) => {
const [videoPool] = React.useState<PooledVideo>(() => {
const basePool = new ElementPool<HTMLVideoElement>(
() => {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.controls = false;
video.style.width = '100%';
video.style.height = '100%';
video.style.objectFit = 'cover';
video.style.display = 'block';
return video;
},
(video) => {
video.src = '';
video.oncanplay = null;
video.currentTime = 0;
const {parentNode} = video;
if (parentNode != null) {
parentNode.removeChild(video);
}
},
);
const elementCache = new Map<string, HTMLVideoElement>();
const MAX_ELEMENTS = 16;
const blobCache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_BLOBS = 32;
const activeElements = new Set<HTMLVideoElement>();
const evictOldestBlob = () => {
const oldest = blobCache.keys().next();
if (!oldest.done) {
const key = oldest.value;
const url = blobCache.get(key);
if (url) {
URL.revokeObjectURL(url);
}
blobCache.delete(key);
}
};
const getBlobUrl = async (src: string): Promise<string> => {
if (blobCache.has(src)) {
return blobCache.get(src)!;
}
if (inflight.has(src)) {
return inflight.get(src)!;
}
const promise = (async () => {
const response = await fetch(src, {cache: 'force-cache'});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (blobCache.size >= MAX_BLOBS) {
evictOldestBlob();
}
blobCache.set(src, url);
return url;
})().finally(() => {
inflight.delete(src);
});
inflight.set(src, promise);
return promise;
};
return {
getElement(src?: string): HTMLVideoElement {
if (src && elementCache.has(src)) {
const el = elementCache.get(src)!;
elementCache.delete(src);
return el;
}
return basePool.getElement();
},
poolElement(element: HTMLVideoElement, src?: string): void {
activeElements.delete(element);
const {parentNode} = element;
if (parentNode != null) {
parentNode.removeChild(element);
}
if (src) {
element.oncanplay = null;
element.pause();
element.currentTime = 0;
element.src = '';
if (elementCache.size >= MAX_ELEMENTS) {
const oldestKey = elementCache.keys().next().value as string | undefined;
if (oldestKey) {
const oldest = elementCache.get(oldestKey);
if (oldest) {
basePool.poolElement(oldest);
}
elementCache.delete(oldestKey);
}
}
elementCache.set(src, element);
return;
}
basePool.poolElement(element);
},
clearPool(): void {
activeElements.clear();
elementCache.forEach((el) => {
el.src = '';
el.oncanplay = null;
});
elementCache.clear();
basePool.clearPool();
blobCache.forEach((url) => URL.revokeObjectURL(url));
blobCache.clear();
inflight.clear();
},
registerActive(element: HTMLVideoElement) {
activeElements.add(element);
},
unregisterActive(element: HTMLVideoElement) {
activeElements.delete(element);
},
pauseAll() {
activeElements.forEach((el) => {
try {
el.pause();
} catch {}
});
},
resumeAll() {
activeElements.forEach((el) => {
try {
const playPromise = el.play();
void playPromise?.catch(() => {});
} catch {}
});
},
getBlobUrl,
clearBlobCache(): void {
blobCache.forEach((url) => URL.revokeObjectURL(url));
blobCache.clear();
},
};
});
React.useEffect(() => {
return () => {
videoPool.clearPool();
};
}, [videoPool]);
return <GifVideoPoolContext.Provider value={videoPool}>{children}</GifVideoPoolContext.Provider>;
};
export const useGifVideoPool = (): PooledVideo => {
const pool = React.useContext(GifVideoPoolContext);
if (!pool) {
throw new Error('useGifVideoPool must be used within GifVideoPoolProvider');
}
return pool;
};

View File

@@ -0,0 +1,178 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.iconCircle {
display: flex;
height: 2.75rem;
width: 2.75rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
@media (min-width: 640px) {
.iconCircle {
height: 3rem;
width: 3rem;
}
}
.iconCircleActive {
background: linear-gradient(to bottom right, rgb(168 85 247 / 1), rgb(236 72 153 / 1));
}
.iconCircleInactive {
background: linear-gradient(to bottom right, rgb(168 85 247 / 0.5), rgb(236 72 153 / 0.5));
}
.iconCircleDisabled {
background-color: var(--background-tertiary);
}
.icon {
height: 1.25rem;
width: 1.25rem;
color: white;
}
@media (min-width: 640px) {
.icon {
height: 1.5rem;
width: 1.5rem;
}
}
.iconError {
color: var(--text-tertiary);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 0.95rem;
}
@media (min-width: 640px) {
.title {
font-size: 1rem;
}
}
.titlePrimary {
color: var(--text-primary);
}
.titleTertiary {
color: var(--text-tertiary);
}
.titleDanger {
color: var(--status-danger);
}
.subRow {
margin-bottom: 0.25rem;
color: var(--text-secondary);
font-size: 0.7rem;
line-height: 1.25;
min-height: 0.9rem;
}
@media (min-width: 640px) {
.subRow {
font-size: 0.8rem;
line-height: 1.25;
min-height: 1rem;
}
}
.helpRow {
color: var(--text-tertiary);
font-size: 0.7rem;
min-height: 0.9rem;
}
@media (min-width: 640px) {
.helpRow {
font-size: 0.8rem;
min-height: 1rem;
}
}
.skeleton {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-radius: 9999px;
background-color: var(--background-tertiary);
}
.skeletonCircle {
height: 2.75rem;
width: 2.75rem;
}
@media (min-width: 640px) {
.skeletonCircle {
height: 3rem;
width: 3rem;
}
}
.skeletonTitle {
height: 1.1rem;
width: 7rem;
border-radius: 0.25rem;
}
@media (min-width: 640px) {
.skeletonTitle {
height: 1.25rem;
width: 7rem;
}
}
.skeletonHelp {
height: 0.9rem;
width: 6.5rem;
border-radius: 0.25rem;
}
@media (min-width: 640px) {
.skeletonHelp {
height: 1rem;
width: 7.5rem;
}
}
.skeletonButton {
height: 2.25rem;
width: 100%;
border-radius: 0.375rem;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {GiftIcon, QuestionIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
import {
EmbedCard,
EmbedSkeletonButton,
EmbedSkeletonCircle,
EmbedSkeletonSubtitle,
EmbedSkeletonTitle,
} from '~/components/embeds/EmbedCard/EmbedCard';
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
import {Button} from '~/components/uikit/Button/Button';
import i18n from '~/i18n';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import GiftStore from '~/stores/GiftStore';
import UserStore from '~/stores/UserStore';
import {getGiftDurationText} from '~/utils/giftUtils';
import styles from './GiftEmbed.module.css';
interface GiftEmbedProps {
code: string;
}
export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
const {t} = useLingui();
const giftState = GiftStore.gifts.get(code) ?? null;
const gift = giftState?.data;
const creator = UserStore.getUser(gift?.created_by?.id ?? '');
const shouldForceSkeleton = useEmbedSkeletonOverride();
React.useEffect(() => {
if (!giftState) {
void GiftActionCreators.fetchWithCoalescing(code).catch(() => {});
}
}, [code, giftState]);
const prevLoadingRef = React.useRef<boolean>(true);
React.useEffect(() => {
const isLoading = !!giftState?.loading;
if (prevLoadingRef.current && !isLoading && giftState) {
ComponentDispatch.dispatch('LAYOUT_RESIZED');
}
prevLoadingRef.current = isLoading;
}, [giftState?.loading]);
if (shouldForceSkeleton || !giftState || giftState.loading) {
return <GiftLoadingState />;
}
if (giftState.invalid || giftState.error || !gift) {
return <GiftNotFoundError />;
}
const durationText = getGiftDurationText(i18n, gift);
const handleRedeem = async () => {
try {
await GiftActionCreators.redeem(i18n, code);
} catch (error) {
console.error('Failed to redeem gift', error);
}
};
const subtitleNode = creator ? (
<span className={styles.subRow}>{t`From ${creator.username}#${creator.discriminator}`}</span>
) : undefined;
const helpText = gift.redeemed ? t`Already redeemed` : t`Click to claim your gift!`;
const footer = gift.redeemed ? (
<Button variant="primary" matchSkeletonHeight disabled>
{t`Gift Claimed`}
</Button>
) : (
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem}>
{t`Claim Gift`}
</Button>
);
return (
<EmbedCard
splashURL={null}
icon={
<div className={`${styles.iconCircle} ${gift.redeemed ? styles.iconCircleInactive : styles.iconCircleActive}`}>
<GiftIcon className={styles.icon} />
</div>
}
title={
<h3
className={`${styles.title} ${cardStyles.title} ${gift.redeemed ? styles.titleTertiary : styles.titlePrimary}`}
>
{durationText}
</h3>
}
subtitle={subtitleNode}
body={<div className={styles.helpRow}>{helpText}</div>}
footer={footer}
/>
);
});
const GiftLoadingState = observer(function GiftLoadingState() {
return (
<EmbedCard
splashURL={null}
icon={<EmbedSkeletonCircle className={styles.skeletonCircle} />}
title={<EmbedSkeletonTitle className={styles.skeletonTitle} />}
body={<EmbedSkeletonSubtitle className={styles.skeletonHelp} />}
footer={<EmbedSkeletonButton className={styles.skeletonButton} />}
/>
);
});
const GiftNotFoundError = observer(function GiftNotFoundError() {
const {t} = useLingui();
return (
<EmbedCard
splashURL={null}
icon={
<div className={`${styles.iconCircle} ${styles.iconCircleDisabled}`}>
<QuestionIcon className={`${styles.icon} ${styles.iconError}`} />
</div>
}
title={<h3 className={`${styles.title} ${styles.titleDanger}`}>{t`Unknown Gift`}</h3>}
body={<span className={styles.helpRow}>{t`This gift code is invalid or already claimed.`}</span>}
footer={
<Button variant="primary" matchSkeletonHeight disabled>
{t`Gift Unavailable`}
</Button>
}
/>
);
});

View File

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

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {ArrowRightIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {SystemMessage} from '~/components/channel/SystemMessage';
import {SystemMessageUsername} from '~/components/channel/SystemMessageUsername';
import {useSystemMessageData} from '~/hooks/useSystemMessageData';
import type {MessageRecord} from '~/records/MessageRecord';
import {SystemMessageUtils} from '~/utils/SystemMessageUtils';
import styles from './GuildJoinMessage.module.css';
export const GuildJoinMessage = observer(({message}: {message: MessageRecord}) => {
const {i18n} = useLingui();
const {author, channel, guild} = useSystemMessageData(message);
if (!channel) {
return null;
}
const messageContent = SystemMessageUtils.getGuildJoinMessage(
message.id,
<SystemMessageUsername author={author} guild={guild} message={message} key={author.id} />,
i18n,
);
return (
<SystemMessage
icon={ArrowRightIcon}
iconWeight="bold"
iconClassname={styles.icon}
message={message}
messageContent={messageContent}
/>
);
});

View File

@@ -0,0 +1,161 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.icon {
--guild-icon-size: 2.75rem;
height: var(--guild-icon-size);
width: var(--guild-icon-size);
border-radius: 9999px;
flex: 0 0 auto;
}
@media (min-width: 640px) {
.icon {
--guild-icon-size: 3rem;
}
}
.iconFallback {
--guild-icon-size: 2.75rem;
height: var(--guild-icon-size);
width: var(--guild-icon-size);
border-radius: 9999px;
background-color: var(--background-tertiary);
}
@media (min-width: 640px) {
.iconFallback {
--guild-icon-size: 3rem;
}
}
.titleRowWithIcon {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
max-width: 100%;
}
.titleContainer {
display: grid;
align-items: center;
width: 100%;
}
.titleText {
font-size: 1rem;
line-height: 1.25;
}
.headerInvite {
display: grid;
grid-auto-flow: row;
justify-content: flex-start;
align-items: flex-start;
gap: 0.25rem;
min-height: unset;
width: 100%;
text-align: start;
}
.verifiedIcon {
height: 1.15rem;
width: 1.15rem;
flex: 0 0 auto;
color: var(--text-primary);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, max-content));
gap: 0.5rem 0.6rem;
align-items: center;
min-height: 1rem;
}
.stat {
display: inline-flex;
align-items: center;
min-width: 0;
}
.statDot {
margin-right: 0.3rem;
height: 0.5rem;
width: 0.5rem;
border-radius: 9999px;
flex: 0 0 auto;
}
.statDotOnline {
background-color: var(--status-online);
}
.statDotMembers {
background-color: var(--text-tertiary-secondary);
}
.statText {
color: var(--text-tertiary);
font-size: clamp(0.68rem, 1.6vw, 0.82rem);
line-height: 1.2;
white-space: nowrap;
}
.packTitleRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.packBadge {
background: var(--background-modifier-accent);
color: var(--text-primary);
font-size: 0.75rem;
line-height: 1.25;
padding: 0.15rem 0.5rem;
border-radius: 0.75rem;
}
.packBody {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.packDescription {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.4;
margin: 0;
}
.packMeta {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--text-tertiary);
}
.packNote {
margin: 0;
font-size: 0.75rem;
color: var(--text-tertiary-secondary);
}

View File

@@ -0,0 +1,458 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {QuestionIcon, SealCheckIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import {GuildFeatures} from '~/Constants';
import {
EmbedCard,
EmbedSkeletonButton,
EmbedSkeletonCircle,
EmbedSkeletonDot,
EmbedSkeletonIcon,
EmbedSkeletonStatLong,
EmbedSkeletonStatShort,
EmbedSkeletonTitle,
} from '~/components/embeds/EmbedCard/EmbedCard';
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Avatar} from '~/components/uikit/Avatar';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {Routes} from '~/Routes';
import {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import InviteStore from '~/stores/InviteStore';
import PresenceStore from '~/stores/PresenceStore';
import UserStore from '~/stores/UserStore';
import {isGroupDmInvite, isGuildInvite, isPackInvite as isPackInviteGuard} from '~/types/InviteTypes';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {
GuildInvitePrimaryAction,
getGuildInviteActionState,
getGuildInvitePrimaryAction,
isGuildInviteActionDisabled,
} from '~/utils/invite/GuildInviteActionState';
import * as RouterUtils from '~/utils/RouterUtils';
import {getGroupDMTitle, getGuildEmbedSplashAspectRatio, getImageAspectRatioFromBase64} from './InviteEmbed/utils';
import styles from './InviteEmbed.module.css';
const createTitleKeyDownHandler = (callback: () => void) => (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback();
}
};
interface InviteEmbedProps {
code: string;
}
export const InviteEmbed = observer(function InviteEmbed({code}: InviteEmbedProps) {
const {t, i18n} = useLingui();
const inviteState = InviteStore.invites.get(code) ?? null;
const shouldForceSkeleton = useEmbedSkeletonOverride();
const invite = inviteState?.data ?? null;
const isGroupDM = invite != null && isGroupDmInvite(invite);
const isPackInvite = invite != null && isPackInviteGuard(invite);
const isGuildInviteType = invite != null && isGuildInvite(invite);
const packCreatorRecord = React.useMemo(() => {
if (!isPackInvite || !invite) return null;
return new UserRecord(invite.pack.creator);
}, [invite, isPackInvite]);
const guildFromInvite = isGuildInviteType ? invite!.guild : null;
const guild = GuildStore.getGuild(guildFromInvite?.id ?? '') || guildFromInvite;
const embedSplash = guild != null ? ('embedSplash' in guild ? guild.embedSplash : guild.embed_splash) : undefined;
const splashURL =
guild != null ? AvatarUtils.getGuildEmbedSplashURL({id: guild.id, embedSplash: embedSplash || null}) : null;
const channelFromInvite = (isGuildInviteType || isGroupDM) && invite ? invite.channel : null;
const channelId = channelFromInvite?.id ?? undefined;
const splashLayoutRef = React.useRef(false);
const splashChannelRef = React.useRef<string | null>(null);
React.useLayoutEffect(() => {
if (isGroupDM || !channelId) return;
if (splashChannelRef.current !== channelId) {
splashChannelRef.current = channelId;
splashLayoutRef.current = false;
}
const hasSplash = Boolean(splashURL);
if (hasSplash && !splashLayoutRef.current) {
ComponentDispatch.dispatch('LAYOUT_RESIZED', {channelId});
}
splashLayoutRef.current = hasSplash;
}, [channelId, isGroupDM, splashURL]);
const isLoading = shouldForceSkeleton || !inviteState || inviteState.loading;
const prevLoadingRef = React.useRef(true);
const prevCodeRef = React.useRef(code);
React.useLayoutEffect(() => {
if (prevCodeRef.current !== code) {
prevLoadingRef.current = true;
prevCodeRef.current = code;
}
}, [code]);
React.useLayoutEffect(() => {
if (prevLoadingRef.current && !isLoading && channelId) {
ComponentDispatch.dispatch('LAYOUT_RESIZED', {channelId});
}
prevLoadingRef.current = isLoading;
}, [isLoading, channelId]);
React.useEffect(() => {
if (!inviteState) {
void InviteActionCreators.fetchWithCoalescing(code).catch(() => {});
}
}, [code, inviteState]);
if (shouldForceSkeleton || !inviteState || inviteState.loading) {
return <InviteLoadingState />;
}
if (inviteState.error || !invite) {
return <InviteNotFoundError />;
}
if (isGroupDmInvite(invite)) {
const inviter = UserStore.getUser(invite.inviter?.id ?? '');
const groupDMTitle = getGroupDMTitle(invite.channel);
const groupDMPath = Routes.dmChannel(invite.channel.id);
const handleAcceptInvite = () => InviteActionCreators.acceptAndTransitionToChannel(invite.code, i18n);
const handleNavigateToGroup = () => RouterUtils.transitionTo(groupDMPath);
const isAlreadyInGroupDM = !!ChannelStore.getChannel(invite.channel.id);
const memberCount = invite.member_count ?? 0;
return (
<EmbedCard
splashURL={null}
headerClassName={styles.headerInvite}
icon={
inviter ? (
<Avatar user={inviter} size={48} className={styles.icon} />
) : (
<div className={styles.iconFallback} />
)
}
title={
<div className={styles.titleContainer}>
<h3 className={`${cardStyles.title} ${cardStyles.titlePrimary} ${styles.titleText}`}>
<button
type="button"
className={cardStyles.titleButton}
onClick={handleNavigateToGroup}
onKeyDown={createTitleKeyDownHandler(handleNavigateToGroup)}
>
{groupDMTitle}
</button>
</h3>
</div>
}
body={
<div className={styles.stats}>
<div className={styles.stat}>
<div className={`${styles.statDot} ${styles.statDotMembers}`} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
</span>
</div>
</div>
}
footer={
<Button variant="primary" matchSkeletonHeight onClick={handleAcceptInvite} disabled={isAlreadyInGroupDM}>
{isAlreadyInGroupDM ? t`Already joined` : t`Join Group`}
</Button>
}
/>
);
}
if (isPackInviteGuard(invite)) {
const pack = invite.pack;
const packCreator = packCreatorRecord ?? new UserRecord(pack.creator);
const packKindLabel = pack.type === 'emoji' ? t`Emoji pack` : t`Sticker pack`;
const packActionLabel = pack.type === 'emoji' ? t`Install Emoji Pack` : t`Install Sticker Pack`;
const inviterTag = invite.inviter ? `${invite.inviter.username}#${invite.inviter.discriminator}` : null;
const handleAcceptInvite = () => InviteActionCreators.acceptAndTransitionToChannel(invite.code, i18n);
return (
<EmbedCard
splashURL={null}
headerClassName={styles.headerInvite}
icon={<Avatar user={packCreator} size={48} className={styles.icon} />}
title={
<div className={`${styles.titleContainer} ${styles.packTitleRow}`}>
<h3 className={`${cardStyles.title} ${cardStyles.titlePrimary} ${styles.titleText}`}>{pack.name}</h3>
<span className={styles.packBadge}>{packKindLabel}</span>
</div>
}
body={
<div className={styles.packBody}>
<p className={styles.packDescription}>{pack.description || t`No description provided.`}</p>
<div className={styles.packMeta}>
<span>
<Trans>Created by {pack.creator.username}</Trans>
</span>
{inviterTag ? (
<span>
<Trans>Invited by {inviterTag}</Trans>
</span>
) : null}
</div>
<p className={styles.packNote}>{t`Accepting this invite installs the pack automatically.`}</p>
</div>
}
footer={
<Button variant="primary" matchSkeletonHeight onClick={handleAcceptInvite}>
{packActionLabel}
</Button>
}
/>
);
}
if (!guild || !isGuildInvite(invite)) return <InviteNotFoundError />;
const guildActionState = getGuildInviteActionState({invite, guild});
const {features, presenceCount, memberCount} = guildActionState;
const isVerified = features.includes(GuildFeatures.VERIFIED);
const splashAspectRatio = getGuildEmbedSplashAspectRatio(guild);
const renderedPresenceCount = presenceCount;
const renderedMemberCount = memberCount;
const handleAcceptInvite = () => InviteActionCreators.acceptAndTransitionToChannel(invite.code, i18n);
const guildPath = Routes.guildChannel(guild.id, invite.channel.id);
const handleNavigateToGuild = () => RouterUtils.transitionTo(guildPath);
const actionType = getGuildInvitePrimaryAction(guildActionState);
const isButtonDisabled = isGuildInviteActionDisabled(guildActionState);
const getButtonLabel = () => {
switch (actionType) {
case GuildInvitePrimaryAction.InvitesDisabled:
return t`Invites Disabled`;
case GuildInvitePrimaryAction.GoToCommunity:
return t`Go to Community`;
default:
return t`Join Community`;
}
};
return (
<EmbedCard
splashURL={splashURL}
splashAspectRatio={splashAspectRatio}
headerClassName={styles.headerInvite}
icon={<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={styles.icon} />}
title={
<div className={styles.titleContainer}>
<div className={styles.titleRowWithIcon}>
<h3 className={`${cardStyles.title} ${cardStyles.titlePrimary} ${styles.titleText}`}>
<button
type="button"
className={cardStyles.titleButton}
onClick={handleNavigateToGuild}
onKeyDown={createTitleKeyDownHandler(handleNavigateToGuild)}
>
{guild.name}
</button>
</h3>
{isVerified ? (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
) : null}
</div>
</div>
}
body={
<div className={styles.stats}>
<div className={styles.stat}>
<div className={`${styles.statDot} ${styles.statDotOnline}`} />
<span className={styles.statText}>{t`${renderedPresenceCount} Online`}</span>
</div>
<div className={styles.stat}>
<div className={`${styles.statDot} ${styles.statDotMembers}`} />
<span className={styles.statText}>
{renderedMemberCount === 1 ? t`${renderedMemberCount} Member` : t`${renderedMemberCount} Members`}
</span>
</div>
</div>
}
footer={
<Button variant="primary" matchSkeletonHeight onClick={handleAcceptInvite} disabled={isButtonDisabled}>
{getButtonLabel()}
</Button>
}
/>
);
});
const InviteLoadingState = observer(() => {
return (
<EmbedCard
splashURL={null}
headerClassName={styles.headerInvite}
icon={<EmbedSkeletonCircle />}
title={
<div className={styles.titleContainer}>
<div className={styles.titleRowWithIcon}>
<EmbedSkeletonTitle />
<EmbedSkeletonIcon />
</div>
</div>
}
body={
<div className={styles.stats}>
<div className={styles.stat}>
<EmbedSkeletonDot />
<EmbedSkeletonStatShort />
</div>
<div className={styles.stat}>
<EmbedSkeletonDot />
<EmbedSkeletonStatLong />
</div>
</div>
}
footer={<EmbedSkeletonButton />}
/>
);
});
const InviteNotFoundError = observer(() => {
const {t} = useLingui();
return (
<EmbedCard
splashURL={null}
icon={
<div className={cardStyles.iconCircleDisabled}>
<QuestionIcon className={cardStyles.iconError} />
</div>
}
title={
<h3 className={`${cardStyles.title} ${cardStyles.titleDanger} ${styles.titleText}`}>{t`Unknown Invite`}</h3>
}
subtitle={<span className={cardStyles.helpText}>{t`Try asking for a new invite.`}</span>}
footer={
<Button variant="primary" matchSkeletonHeight disabled>
{t`Invite Unavailable`}
</Button>
}
/>
);
});
interface GuildInviteEmbedPreviewProps {
guildId: string;
splashURLOverride?: string | null;
}
export const GuildInviteEmbedPreview = observer(function GuildInviteEmbedPreview({
guildId,
splashURLOverride,
}: GuildInviteEmbedPreviewProps) {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const [base64AspectRatio, setBase64AspectRatio] = React.useState<number | undefined>();
const splashAspectRatio = React.useMemo(() => {
if (!guild) return undefined;
if (splashURLOverride) {
return base64AspectRatio;
}
return getGuildEmbedSplashAspectRatio(guild);
}, [guild, splashURLOverride, base64AspectRatio]);
React.useEffect(() => {
if (splashURLOverride) {
getImageAspectRatioFromBase64(splashURLOverride)
.then(setBase64AspectRatio)
.catch(() => {
setBase64AspectRatio(undefined);
});
} else {
setBase64AspectRatio(undefined);
}
}, [splashURLOverride]);
if (!guild) return null;
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
const splashURL =
splashURLOverride !== undefined
? splashURLOverride
: AvatarUtils.getGuildEmbedSplashURL({id: guild.id, embedSplash: guild.embedSplash || null});
const presenceCount = PresenceStore.getPresenceCount(guild.id);
const memberCount = GuildMemberStore.getMemberCount(guild.id);
return (
<EmbedCard
splashURL={splashURL}
splashAspectRatio={splashAspectRatio}
headerClassName={styles.headerInvite}
icon={<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={styles.icon} />}
title={
<div className={styles.titleContainer}>
<div className={styles.titleRowWithIcon}>
<h3 className={`${cardStyles.title} ${cardStyles.titlePrimary} ${styles.titleText}`}>{guild.name}</h3>
{isVerified ? (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
) : null}
</div>
</div>
}
body={
<div className={styles.stats}>
<div className={styles.stat}>
<div className={`${styles.statDot} ${styles.statDotOnline}`} />
<span className={styles.statText}>{t`${presenceCount} Online`}</span>
</div>
<div className={styles.stat}>
<div className={`${styles.statDot} ${styles.statDotMembers}`} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
</span>
</div>
</div>
}
footer={
<Button variant="primary" matchSkeletonHeight disabled>
{t`Join Community`}
</Button>
}
/>
);
});

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 type {Channel} from '~/records/ChannelRecord';
import type {Guild, GuildRecord} from '~/records/GuildRecord';
export const getGroupDMTitle = (channel: Channel): string => {
const {t} = useLingui();
const channelName = channel.name?.trim();
if (channelName && channelName.length > 0) {
return channelName;
}
const recipients = channel.recipients ?? [];
const names = recipients
.map((recipient) => recipient.username)
.filter((name): name is string => name !== undefined && name.length > 0);
if (names.length === 0) {
return t`Unnamed Group`;
}
return names.join(', ');
};
type InviteGuild = Guild | GuildRecord;
export const getGuildSplashAspectRatio = (guild: InviteGuild): number | undefined => {
const width = 'splashWidth' in guild ? guild.splashWidth : guild.splash_width;
const height = 'splashHeight' in guild ? guild.splashHeight : guild.splash_height;
if (width != null && height != null && width > 0 && height > 0) {
return width / height;
}
return undefined;
};
export const getGuildEmbedSplashAspectRatio = (guild: InviteGuild): number | undefined => {
const width = 'embedSplashWidth' in guild ? guild.embedSplashWidth : guild.embed_splash_width;
const height = 'embedSplashHeight' in guild ? guild.embedSplashHeight : guild.embed_splash_height;
if (width != null && height != null && width > 0 && height > 0) {
return width / height;
}
return undefined;
};
export const getImageAspectRatioFromBase64 = (base64Url: string): Promise<number> => {
if (typeof Image === 'undefined') {
return Promise.resolve(16 / 9);
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
resolve(img.naturalWidth / img.naturalHeight);
} else {
reject(new Error('Invalid image dimensions'));
}
img.onload = null;
img.onerror = null;
};
img.onerror = () => {
reject(new Error('Failed to load image'));
img.onload = null;
img.onerror = null;
};
img.src = base64Url;
});
};

View File

@@ -0,0 +1,399 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
interface CoordsStyle {
position: 'absolute' | 'sticky';
left?: number;
right?: number;
width: number;
top: number;
height: number;
}
interface GridCoordinates {
section: number;
row: number;
column: number;
}
interface GridData {
boundaries: Array<number>;
coordinates: Record<string, GridCoordinates>;
}
type VisibleSection = Array<[string, number, number]>;
type Padding = number | {top?: number; bottom?: number; left?: number; right?: number};
function findMinColumnIndex(columnHeights: Array<number>): [number, number] {
return columnHeights.reduce((acc, height, index) => (height < acc[0] ? [height, index] : acc), [columnHeights[0], 0]);
}
const defaultGetSectionHeight = () => 0;
const getSectionKey = (sectionIndex: number) => `__section__${sectionIndex}`;
const getSectionHeaderKey = (sectionIndex: number) => `__section_header__${sectionIndex}`;
export class MasonryListComputer {
public visibleSections: Record<string, VisibleSection> = {};
public gridData: GridData = {coordinates: {}, boundaries: []};
public coordsMap: Record<string, CoordsStyle> = {};
public itemGrid: Array<Array<string>> = [];
public totalHeight: number = 0;
private columnHeights: Array<number> = [];
private columnWidth: number = 0;
private currentRow: number = 0;
private lastColumnIndex: number = 0;
private needsFullCompute: boolean = true;
private bufferWidth: number = 0;
private sections: Array<number> = [];
private columns: number = 0;
private itemGutter: number = 0;
private removeEdgeItemGutters: boolean = false;
private sectionGutter: number | null = null;
private padding: Padding | null = null;
private paddingVertical: number | null = null;
private paddingHorizontal: number | null = null;
private marginLeft: number | null = null;
private dir: 'ltr' | 'rtl' = 'ltr';
private version: number | string | null = null;
private getItemKey: (section: number, item: number) => string | null = () => {
throw new Error('MasonryListComputer: getItemKey has not been implemented');
};
private getItemHeight: (section: number, item: number, width: number) => number = () => {
throw new Error('MasonryListComputer: getItemHeight has not been implemented');
};
private getSectionHeight: (section: number) => number = defaultGetSectionHeight;
private getPadding(key: 'top' | 'bottom' | 'left' | 'right'): number {
if (this.padding == null) {
return this.itemGutter;
}
if (typeof this.padding === 'number') {
return this.padding;
}
return this.padding[key] ?? this.itemGutter;
}
private getPaddingLeft(): number {
return this.paddingHorizontal != null ? this.paddingHorizontal : this.getPadding('left');
}
private getPaddingRight(): number {
return this.paddingHorizontal != null ? this.paddingHorizontal : this.getPadding('right');
}
private getPaddingTop(): number {
return this.paddingVertical != null ? this.paddingVertical : this.getPadding('top');
}
private getPaddingBottom(): number {
return this.paddingVertical != null ? this.paddingVertical : this.getPadding('bottom');
}
private getSectionGutter(): number {
return this.sectionGutter != null ? this.sectionGutter : this.itemGutter;
}
mergeProps(props: {
sections?: Array<number>;
columns?: number;
itemGutter?: number;
removeEdgeItemGutters?: boolean;
getItemKey?: (section: number, item: number) => string | null;
getItemHeight?: (section: number, item: number, width: number) => number;
getSectionHeight?: (section: number) => number;
bufferWidth?: number;
padding?: Padding;
paddingVertical?: number;
paddingHorizontal?: number;
marginLeft?: number;
sectionGutter?: number;
dir?: 'ltr' | 'rtl';
version?: number | string | null;
}): void {
const {
sections = this.sections,
columns = this.columns,
itemGutter = this.itemGutter,
removeEdgeItemGutters = this.removeEdgeItemGutters,
getItemKey = this.getItemKey,
getItemHeight = this.getItemHeight,
getSectionHeight = this.getSectionHeight,
bufferWidth = this.bufferWidth,
padding = this.padding,
paddingVertical = this.paddingVertical,
paddingHorizontal = this.paddingHorizontal,
marginLeft = this.marginLeft,
sectionGutter = this.sectionGutter,
dir = this.dir,
version = this.version,
} = props;
if (
this.sections !== sections ||
this.columns !== columns ||
this.itemGutter !== itemGutter ||
this.removeEdgeItemGutters !== removeEdgeItemGutters ||
this.getItemKey !== getItemKey ||
this.getSectionHeight !== getSectionHeight ||
this.getItemHeight !== getItemHeight ||
this.bufferWidth !== bufferWidth ||
this.padding !== padding ||
this.paddingVertical !== paddingVertical ||
this.paddingHorizontal !== paddingHorizontal ||
this.marginLeft !== marginLeft ||
this.sectionGutter !== sectionGutter ||
this.dir !== dir ||
this.version !== version
) {
this.needsFullCompute = true;
this.sections = sections;
this.columns = columns;
this.itemGutter = itemGutter;
this.removeEdgeItemGutters = removeEdgeItemGutters;
this.getItemKey = getItemKey;
this.getSectionHeight = getSectionHeight;
this.getItemHeight = getItemHeight;
this.bufferWidth = bufferWidth;
this.padding = padding;
this.paddingVertical = paddingVertical;
this.paddingHorizontal = paddingHorizontal;
this.marginLeft = marginLeft;
this.sectionGutter = sectionGutter;
this.dir = dir;
this.version = version;
}
}
private computeFullCoords(): void {
if (!this.needsFullCompute) return;
const {columns, getItemKey, getItemHeight, itemGutter, getSectionHeight, bufferWidth, removeEdgeItemGutters} = this;
const horizontalKey = this.dir === 'rtl' ? 'right' : 'left';
this.coordsMap = {};
this.gridData = {boundaries: [], coordinates: {}};
this.currentRow = 0;
this.lastColumnIndex = 0;
const paddingTop = this.getPaddingTop();
const paddingBottom = this.getPaddingBottom();
const paddingLeft = this.getPaddingLeft();
const paddingRight = this.getPaddingRight();
const marginLeft = this.marginLeft ?? 0;
this.columnHeights = Array(columns).fill(paddingTop);
this.columnWidth =
(bufferWidth -
paddingRight -
paddingLeft -
itemGutter * (columns - 1) -
(removeEdgeItemGutters ? itemGutter : 0)) /
columns;
this.itemGrid = [];
let sectionIndex = 0;
while (sectionIndex < this.sections.length) {
this.gridData.boundaries[sectionIndex] = this.currentRow;
this.currentRow = 0;
this.lastColumnIndex = 0;
const sectionLength = this.sections[sectionIndex];
let itemIndex = 0;
let minItemTop = Number.POSITIVE_INFINITY;
let maxItemBottom = Number.NEGATIVE_INFINITY;
const sectionHeight = getSectionHeight(sectionIndex);
let maxColumnHeight = this.getMaxColumnHeight(this.columnHeights);
if (sectionIndex > 0) {
maxColumnHeight = maxColumnHeight - itemGutter + this.getSectionGutter();
}
const sectionHeaderHeight = sectionHeight > 0 ? sectionHeight + itemGutter : 0;
for (let col = 0; col < this.columnHeights.length; col++) {
this.columnHeights[col] = maxColumnHeight + sectionHeaderHeight;
}
while (itemIndex < sectionLength) {
const itemKey = getItemKey(sectionIndex, itemIndex);
if (itemKey == null) {
itemIndex++;
continue;
}
const [minHeight, minColumnIndex] = findMinColumnIndex(this.columnHeights);
if (minColumnIndex < this.lastColumnIndex) {
this.currentRow++;
}
this.lastColumnIndex = minColumnIndex;
const itemHeight = getItemHeight(sectionIndex, itemIndex, this.columnWidth);
const coords: CoordsStyle = {
position: 'absolute',
[horizontalKey]:
this.columnWidth * minColumnIndex + itemGutter * (minColumnIndex + 1) - itemGutter + paddingLeft,
width: this.columnWidth,
top: minHeight - maxColumnHeight,
height: itemHeight,
};
minItemTop = Math.min(minItemTop, coords.top);
maxItemBottom = Math.max(maxItemBottom, coords.top + coords.height);
const gridCoords: GridCoordinates = {
section: sectionIndex,
row: this.currentRow,
column: minColumnIndex,
};
this.coordsMap[itemKey] = coords;
this.gridData.coordinates[itemKey] = gridCoords;
this.columnHeights[minColumnIndex] = minHeight + itemHeight + itemGutter;
this.itemGrid[minColumnIndex] = this.itemGrid[minColumnIndex] ?? [];
this.itemGrid[minColumnIndex].push(itemKey);
itemIndex++;
}
if (sectionHeight > 0) {
this.coordsMap[getSectionHeaderKey(sectionIndex)] = {
position: 'sticky',
[horizontalKey]: paddingLeft,
width: this.columnWidth * columns + itemGutter * columns,
top: 0,
height: sectionHeight,
};
this.coordsMap[getSectionKey(sectionIndex)] = {
position: 'absolute',
[horizontalKey]: marginLeft,
width: this.columnWidth * columns + itemGutter * (columns - 1) + paddingLeft + paddingRight,
top: maxColumnHeight,
height: this.getMaxColumnHeight(this.columnHeights) - maxColumnHeight,
};
} else if (Number.isFinite(minItemTop) && Number.isFinite(maxItemBottom)) {
this.coordsMap[getSectionKey(sectionIndex)] = {
position: 'absolute',
[horizontalKey]: marginLeft,
width: this.columnWidth * columns + itemGutter * (columns - 1) + paddingLeft + paddingRight,
top: minItemTop,
height: maxItemBottom - minItemTop,
};
}
sectionIndex++;
}
this.columnHeights = this.columnHeights.map((height) => height - itemGutter + paddingBottom);
this.totalHeight = this.getMaxColumnHeight();
this.visibleSections = {};
this.needsFullCompute = false;
}
computeVisibleSections(start: number, end: number): void {
this.computeFullCoords();
const {getItemKey, coordsMap} = this;
this.visibleSections = {};
let sectionIndex = 0;
while (sectionIndex < this.sections.length) {
const sectionLength = this.sections[sectionIndex];
const sectionKey = getSectionKey(sectionIndex);
const sectionCoords = coordsMap[sectionKey];
if (sectionCoords == null) {
sectionIndex++;
continue;
}
const {top} = sectionCoords;
const bottom = top + sectionCoords.height;
if (top > end) break;
if (bottom < start) {
sectionIndex++;
continue;
}
let itemIndex = 0;
let direction = 1;
if (bottom < end && bottom > start) {
itemIndex = sectionLength - 1;
direction = -1;
}
this.visibleSections[sectionKey] = [];
while (itemIndex >= 0 && itemIndex < sectionLength) {
const itemKey = getItemKey(sectionIndex, itemIndex);
const itemCoords = itemKey != null ? coordsMap[itemKey] : null;
if (itemKey == null || itemCoords == null) {
itemIndex += direction;
continue;
}
const {top: itemTop, height: itemHeight} = itemCoords;
const itemAbsoluteTop = itemTop + top;
if (itemAbsoluteTop > start - itemHeight && itemAbsoluteTop < end) {
if (direction === -1) {
this.visibleSections[sectionKey].unshift([itemKey, sectionIndex, itemIndex]);
} else {
this.visibleSections[sectionKey].push([itemKey, sectionIndex, itemIndex]);
}
}
itemIndex += direction;
}
if (top < start && bottom > end) break;
sectionIndex++;
}
}
private getMaxColumnHeight(columnHeights: Array<number> = this.columnHeights): number {
return columnHeights.reduce((max, height) => Math.max(max, height), 0);
}
getState(): {
coordsMap: Record<string, CoordsStyle>;
gridData: GridData;
visibleSections: Record<string, VisibleSection>;
totalHeight: number;
} {
return {
coordsMap: this.coordsMap,
gridData: this.gridData,
visibleSections: this.visibleSections,
totalHeight: this.totalHeight,
};
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.memberListContainer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
width: 100svw;
min-width: 0;
overflow: hidden;
background-color: var(--background-secondary-lighter);
--member-list-width: 100svw;
}
@media (min-width: 768px) {
.memberListContainer {
--member-list-width: 16.5rem;
width: var(--member-list-width);
}
}
.memberListScroller {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: var(--spacing-4);
background-color: var(--background-secondary-lighter);
padding-left: var(--spacing-2);
padding-right: 0;
padding-bottom: var(--spacing-4);
}
.scrollerSpacer {
height: 0.625rem;
flex-shrink: 0;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 type {UIEvent} from 'react';
import {Scroller} from '~/components/uikit/Scroller';
import styles from './MemberListContainer.module.css';
interface MemberListContainerProps {
channelId: string;
children: React.ReactNode;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
}
export const MemberListContainer: React.FC<MemberListContainerProps> = observer(({channelId, children, onScroll}) => {
return (
<div className={styles.memberListContainer}>
<Scroller className={styles.memberListScroller} key={`member-list-scroller-${channelId}`} onScroll={onScroll}>
<div className={styles.scrollerSpacer} />
{children}
</Scroller>
</div>
);
});

View File

@@ -0,0 +1,137 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.button {
position: relative;
margin-top: 1px;
margin-bottom: 1px;
cursor: pointer;
border-radius: 0.375rem;
color: var(--text-chat);
display: block;
width: 100%;
text-align: left;
}
.button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
opacity: 1;
}
.button[aria-expanded='true'] {
background-color: var(--background-modifier-selected);
color: var(--text-primary);
opacity: 1;
}
.buttonOffline {
opacity: 0.3;
}
.buttonContextMenuOpen {
background-color: var(--background-modifier-selected);
color: var(--text-primary);
opacity: 1;
}
.memberFocusRing {
border-radius: 0.375rem;
}
.grid {
display: grid;
height: 42px;
min-width: 0;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.content {
display: flex;
min-width: 0;
align-items: center;
gap: 0.625rem;
font-weight: 500;
}
.avatarContainer {
flex-shrink: 0;
}
.userInfoContainer {
display: flex;
flex-direction: column;
min-width: 0;
flex-grow: 1;
}
.nameContainer {
display: flex;
min-width: 0;
align-items: center;
overflow: hidden;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25rem;
max-height: 1.25rem;
}
.memberCustomStatus {
max-width: 100%;
color: var(--text-primary-muted);
font-size: 0.6875rem;
line-height: 0.875rem;
font-weight: 500;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.button:hover .memberCustomStatus {
--emoji-show-animated: 1;
}
.buttonContextMenuOpen .memberCustomStatus {
--emoji-show-animated: 1;
}
.ownerIcon {
margin-top: 0.1em;
margin-left: 4px;
flex-shrink: 0;
}
.crownIcon {
height: 14px;
width: 14px;
color: hsl(39, 57%, 64%);
}
.userTag {
margin-left: 0.25rem;
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {CrownIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {isOfflineStatus} from '~/Constants';
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
import {UserTag} from '~/components/channel/UserTag';
import {CustomStatusDisplay} from '~/components/common/CustomStatusDisplay/CustomStatusDisplay';
import {GroupDMMemberContextMenu} from '~/components/uikit/ContextMenu/GroupDMContextMenu';
import {GuildMemberContextMenu} from '~/components/uikit/ContextMenu/GuildMemberContextMenu';
import {FocusRingWrapper} from '~/components/uikit/FocusRingWrapper';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useMemberListCustomStatus} from '~/hooks/useMemberListCustomStatus';
import {useMemberListPresence} from '~/hooks/useMemberListPresence';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ContextMenuStore from '~/stores/ContextMenuStore';
import TypingStore from '~/stores/TypingStore';
import * as NicknameUtils from '~/utils/NicknameUtils';
import styles from './MemberListItem.module.css';
interface MemberListItemProps {
user: UserRecord;
channelId: string;
guildId?: string;
isOwner?: boolean;
roleColor?: string;
displayName?: string;
disableBackdrop?: boolean;
}
export const MemberListItem: React.FC<MemberListItemProps> = observer((props) => {
const {t} = useLingui();
const {user, channelId, guildId, isOwner = false, roleColor, displayName, disableBackdrop = false} = props;
const itemRef = React.useRef<HTMLButtonElement>(null);
const status = useMemberListPresence({
guildId: guildId ?? '',
channelId,
userId: user.id,
enabled: guildId !== undefined,
});
const memberListCustomStatus = useMemberListCustomStatus({
guildId: guildId ?? '',
channelId,
userId: user.id,
enabled: guildId !== undefined,
});
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const isTyping = TypingStore.isTyping(channelId, user.id);
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
React.useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenuStore.contextMenu;
const targetElement = contextMenu?.target.target;
const isNodeTarget = typeof Node !== 'undefined' && targetElement instanceof Node;
const isOpen = Boolean(contextMenu && isNodeTarget && itemRef.current?.contains(targetElement));
setContextMenuOpen(isOpen);
});
return () => {
disposer();
};
}, []);
const handleContextMenu = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<>
{guildId ? (
<GuildMemberContextMenu user={user} onClose={onClose} guildId={guildId} channelId={channelId} />
) : (
<GroupDMMemberContextMenu userId={user.id} channelId={channelId} onClose={onClose} />
)}
</>
));
},
[user, guildId, channelId],
);
const ownerTitle = guildId ? t`Community Owner` : t`Group Owner`;
const nickname = displayName || NicknameUtils.getNickname(user, guildId, channelId);
const content = (
<FocusRingWrapper focusRingClassName={styles.memberFocusRing}>
<button
type="button"
className={clsx(
styles.button,
!isCurrentUser && isOfflineStatus(status) && !contextMenuOpen && styles.buttonOffline,
contextMenuOpen && styles.buttonContextMenuOpen,
)}
onContextMenu={handleContextMenu}
>
<div className={styles.grid}>
<span className={styles.content}>
<div className={styles.avatarContainer}>
<StatusAwareAvatar
user={user}
size={32}
isTyping={isTyping}
showOffline={isCurrentUser || isTyping}
guildId={guildId}
status={guildId ? status : undefined}
/>
</div>
<div className={styles.userInfoContainer}>
<div className={styles.nameContainer}>
<span className={styles.name} style={roleColor ? {color: roleColor} : undefined}>
{nickname}
</span>
{isOwner && (
<div className={styles.ownerIcon}>
<Tooltip text={ownerTitle}>
<CrownIcon className={styles.crownIcon} />
</Tooltip>
</div>
)}
{user.bot && <UserTag className={styles.userTag} system={user.system} />}
</div>
<CustomStatusDisplay
customStatus={memberListCustomStatus}
userId={user.id}
className={styles.memberCustomStatus}
showTooltip
constrained
animateOnParentHover
/>
</div>
</span>
</div>
</button>
</FocusRingWrapper>
);
return (
<PreloadableUserPopout
ref={itemRef}
user={user}
isWebhook={false}
guildId={guildId}
channelId={channelId}
key={user.id}
disableBackdrop={disableBackdrop}
>
{content}
</PreloadableUserPopout>
);
});

View File

@@ -0,0 +1,276 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.headerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.scroller {
padding-top: var(--spacing-2);
}
.filterList {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.filterPill {
display: flex;
align-items: center;
gap: 4px;
flex: none;
border-radius: var(--radius-md);
padding: var(--spacing-1) var(--spacing-2);
font-weight: 600;
font-size: 0.75rem;
line-height: 1.25rem;
background-color: transparent;
color: var(--text-primary-muted);
cursor: pointer;
transition-property: color, background-color;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.filterPill:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.filterPillActive {
background-color: var(--background-modifier-selected);
color: var(--text-primary);
}
.filterPillIcon {
width: 14px;
height: 14px;
}
.mobileHeaderContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.mobileHeaderContainerStandalone {
composes: mobileHeaderContainer;
padding-block: var(--spacing-2);
padding-inline: var(--spacing-4);
}
.columnContainerOverflow .grid {
padding: 0;
}
.fullHeightRelative {
position: relative;
height: 100%;
}
.columnContainer {
display: flex;
height: 100%;
flex-direction: column;
}
.columnContainerOverflow {
composes: columnContainer;
overflow: hidden;
}
.bodyWrapper {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.scrollerFull {
height: 100%;
width: 100%;
}
@media (max-width: 768px) {
.scrollerFull {
scrollbar-width: none;
}
.scrollerFull::-webkit-scrollbar {
display: none;
}
}
.centeredContent {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.slate {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.slateIcon {
height: 3.5rem;
width: 3.5rem;
color: var(--text-primary-muted);
}
.slateTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.slateDescription {
font-size: 1rem;
color: var(--text-primary-muted);
}
.gifBadge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 10;
padding: 0.125rem 0.25rem;
border-radius: 0.375rem;
background-color: rgba(0, 0, 0, 0.6);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
color: white;
}
.fullSize {
height: 100%;
width: 100%;
}
.audioCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
height: 100%;
width: 100%;
padding: 1rem;
border-radius: 0.5rem;
background-color: var(--brand-primary);
}
.audioIcon {
height: 3rem;
width: 3rem;
color: white;
flex-shrink: 0;
}
.audioMeta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.audioDuration {
font-family: var(--font-mono);
font-size: 1.125rem;
color: white;
}
.audioFilename {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-weight: 600;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.9);
}
.audioBadge {
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
background-color: rgba(0, 0, 0, 0.2);
font-weight: 700;
font-size: 0.75rem;
color: white;
}
.actionBar {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.5rem;
z-index: 10;
opacity: 0;
transform: translateY(-0.25rem);
pointer-events: none;
}
.actionButton {
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
width: 2.25rem;
border: none;
border-radius: 9999px;
background-color: var(--background-primary);
color: var(--text-primary);
backdrop-filter: blur(6px);
opacity: 0.8;
transition:
opacity 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
cursor: pointer;
}
.actionButton:hover {
opacity: 1;
}
.actionButtonDanger {
composes: actionButton;
}
.actionButtonDanger:hover {
background-color: var(--status-danger);
color: white;
}
.actionButtonIcon {
height: 1rem;
width: 1rem;
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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;
gap: 12px;
padding: 16px;
background-color: var(--background-primary);
border-radius: 8px;
box-shadow: var(--elevation-high);
max-width: 340px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
}
.warningIcon {
color: var(--status-warning);
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.description {
margin: 0;
font-size: 14px;
line-height: 1.4;
color: var(--text-secondary);
}
.description strong {
color: var(--text-primary);
font-weight: 600;
}
.roleName {
color: var(--text-primary);
font-weight: 600;
}
.keybindHint {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 4px;
margin-left: 6px;
border-radius: 3px;
background-color: rgba(255, 255, 255, 0.15);
color: inherit;
font-family: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.keybinds {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.keybind {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
text-transform: capitalize;
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {WarningIcon} from '@phosphor-icons/react';
import React from 'react';
import styles from './MentionEveryonePopout.module.css';
interface MentionEveryonePopoutProps {
mentionType: '@everyone' | '@here' | 'role';
memberCount: number;
onConfirm: () => void;
onCancel: () => void;
roleName?: string;
}
const isMac = () => /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const getMentionTitle = (
mentionType: MentionEveryonePopoutProps['mentionType'],
roleName?: string,
t?: (msg: import('@lingui/core').MessageDescriptor) => string,
) => {
if (mentionType === 'role') {
return <Trans>Mention {roleName ?? t?.(msg`this role`) ?? 'this role'}?</Trans>;
}
if (mentionType === '@everyone') {
return <Trans>Mention @everyone?</Trans>;
}
return <Trans>Mention @here?</Trans>;
};
export const getMentionDescription = (
mentionType: MentionEveryonePopoutProps['mentionType'],
memberCount: number,
roleName?: string,
t?: (msg: import('@lingui/core').MessageDescriptor) => string,
) => {
if (mentionType === 'role') {
return (
<Trans>
This will notify <strong>{memberCount.toLocaleString()}</strong> members with the{' '}
<span className={styles.roleName}>{roleName ?? t?.(msg`mentioned role`) ?? 'mentioned role'}</span> in this
channel. Are you sure you want to do this?
</Trans>
);
}
if (mentionType === '@everyone') {
return (
<Trans>
This will notify <strong>{memberCount.toLocaleString()}</strong> members in this channel. Are you sure you want
to do this?
</Trans>
);
}
return (
<Trans>
This will notify up to <strong>{memberCount.toLocaleString()}</strong> online members in this channel. Are you
sure you want to do this?
</Trans>
);
};
export const MentionEveryonePopout = ({
mentionType,
memberCount,
onConfirm,
onCancel,
roleName,
}: MentionEveryonePopoutProps) => {
const {t} = useLingui();
const handleKeyDown = React.useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onConfirm();
}
},
[onCancel, onConfirm],
);
React.useEffect(() => {
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [handleKeyDown]);
const enterKeySymbol = isMac() ? '↵' : 'Enter';
return (
<div className={styles.container} role="dialog" aria-modal="true">
<div className={styles.header}>
<WarningIcon size={20} weight="fill" className={styles.warningIcon} />
<span className={styles.title}>{getMentionTitle(mentionType, roleName, t)}</span>
</div>
<p className={styles.description}>{getMentionDescription(mentionType, memberCount, roleName, t)}</p>
<div className={styles.keybinds}>
<div className={styles.keybind}>
<kbd className={styles.keybindHint}>Esc</kbd>
<span>
<Trans>Cancel</Trans>
</span>
</div>
<div className={styles.keybind}>
<kbd className={styles.keybindHint}>{enterKeySymbol}</kbd>
<span>
<Trans>Confirm</Trans>
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,561 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
import {FLUXERBOT_ID, MessageEmbedTypes, MessagePreviewContext, MessageStates, MessageTypes} from '~/Constants';
import {MessageActionBar, MessageActionBarCore} from '~/components/channel/MessageActionBar';
import {MessageActionBottomSheet} from '~/components/channel/MessageActionBottomSheet';
import {MessageContextMenu} from '~/components/uikit/ContextMenu/MessageContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {NodeType} from '~/lib/markdown/parser/types/enums';
import {MarkdownContext, parse} from '~/lib/markdown/renderers';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import ContextMenuStore, {isContextMenuNodeTarget} from '~/stores/ContextMenuStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import MessageEditStore from '~/stores/MessageEditStore';
import MessageReplyStore from '~/stores/MessageReplyStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import styles from '~/styles/Message.module.css';
import {getMessageComponent} from '~/utils/MessageComponentUtils';
import {MessageViewContextProvider} from './MessageViewContext';
const shouldApplyGroupedLayout = (message: MessageRecord, _prevMessage?: MessageRecord) => {
if (message.type !== MessageTypes.DEFAULT && message.type !== MessageTypes.REPLY) {
return false;
}
return true;
};
const isActivationKey = (key: string) => key === 'Enter' || key === ' ' || key === 'Spacebar' || key === 'Space';
const handleAltClickEvent = (event: React.MouseEvent, message: MessageRecord) => {
if (!event.altKey) return;
ReadStateActionCreators.markAsUnread(message.channelId, message.id);
};
const handleAltKeyboardEvent = (event: React.KeyboardEvent, message: MessageRecord) => {
if (!event.altKey || !isActivationKey(event.key)) {
return;
}
event.preventDefault();
ReadStateActionCreators.markAsUnread(message.channelId, message.id);
};
const handleDeleteMessage = (i18n: any, bypassConfirm: boolean, message: MessageRecord) => {
if (bypassConfirm) {
MessageActionCreators.remove(message.channelId, message.id);
return;
}
MessageActionCreators.showDeleteConfirmation(i18n, {message});
};
export type MessageBehaviorOverrides = Partial<{
mobileLayoutEnabled: boolean;
messageGroupSpacing: number;
messageDisplayCompact: boolean;
prefersReducedMotion: boolean;
isEditing: boolean;
isReplying: boolean;
isHighlight: boolean;
forceUnknownMessageType: boolean;
contextMenuOpen: boolean;
disableContextMenu: boolean;
disableContextMenuTracking: boolean;
}>;
interface MessageProps {
channel: ChannelRecord;
message: MessageRecord;
prevMessage?: MessageRecord;
onEdit?: (targetNode: HTMLElement) => void;
previewContext?: keyof typeof MessagePreviewContext;
shouldGroup?: boolean;
previewOverrides?: {
usernameColor?: string;
displayName?: string;
};
removeTopSpacing?: boolean;
isJumpTarget?: boolean;
previewMode?: boolean;
behaviorOverrides?: MessageBehaviorOverrides;
compact?: boolean;
idPrefix?: string;
}
export const Message: React.FC<MessageProps> = observer((props) => {
const {
channel,
message,
prevMessage,
onEdit,
previewContext,
shouldGroup = false,
previewOverrides,
removeTopSpacing = false,
isJumpTarget = false,
previewMode,
behaviorOverrides,
compact,
idPrefix = 'message',
} = props;
const {i18n} = useLingui();
const [showActionBar, setShowActionBar] = useState(false);
const [isLongPressing, setIsLongPressing] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(behaviorOverrides?.contextMenuOpen ?? false);
const [isHoveringDesktop, setIsHoveringDesktop] = useState(false);
const [isFocusedWithin, setIsFocusedWithin] = useState(false);
const [isPopoutOpen, setIsPopoutOpen] = useState(false);
const messageRef = useRef<HTMLDivElement | null>(null);
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
const wasEditingInPreviousUpdateRef = useRef(false);
const mobileLayoutEnabled = behaviorOverrides?.mobileLayoutEnabled ?? MobileLayoutStore.isEnabled();
const messageDisplayCompact =
compact ?? behaviorOverrides?.messageDisplayCompact ?? UserSettingsStore.getMessageDisplayCompact();
const prefersReducedMotion = behaviorOverrides?.prefersReducedMotion ?? AccessibilityStore.useReducedMotion;
const isEditing = behaviorOverrides?.isEditing ?? MessageEditStore.isEditing(message.channelId, message.id);
const isReplying = behaviorOverrides?.isReplying ?? MessageReplyStore.isReplying(message.channelId, message.id);
const isHighlight = behaviorOverrides?.isHighlight ?? MessageReplyStore.isHighlight(message.id);
const forceUnknownMessageType =
behaviorOverrides?.forceUnknownMessageType ?? DeveloperOptionsStore.forceUnknownMessageType;
const messageGroupSpacing = behaviorOverrides?.messageGroupSpacing ?? AccessibilityStore.messageGroupSpacingValue;
const handleContextMenuUpdate = useCallback(() => {
const contextMenu = ContextMenuStore.contextMenu;
const contextMenuTarget = contextMenu?.target?.target ?? null;
const messageElement = messageRef.current;
const isOpen =
Boolean(contextMenu) &&
isContextMenuNodeTarget(contextMenuTarget) &&
Boolean(messageElement?.contains(contextMenuTarget));
setContextMenuOpen(!!isOpen);
}, []);
const handleAltClick = useCallback(
(event: React.MouseEvent) => {
handleAltClickEvent(event, message);
},
[message],
);
const handleAltKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
handleAltKeyboardEvent(event, message);
},
[message],
);
const handleDelete = useCallback(
(bypassConfirm = false) => {
handleDeleteMessage(i18n, bypassConfirm, message);
},
[i18n, message],
);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
if (behaviorOverrides?.disableContextMenu) {
event.preventDefault();
return;
}
if (
(previewContext && previewContext !== MessagePreviewContext.LIST_POPOUT) ||
message.state === MessageStates.SENDING ||
isEditing
) {
return;
}
event.preventDefault();
if (mobileLayoutEnabled) {
return;
}
let linkUrl: string | undefined;
const target = event.target as HTMLElement;
const anchor = target.closest('a');
if (anchor?.href) {
linkUrl = anchor.href;
}
ContextMenuActionCreators.openFromEvent(event, (props) => (
<MessageContextMenu message={message} onClose={props.onClose} onDelete={handleDelete} linkUrl={linkUrl} />
));
},
[previewContext, message, isEditing, mobileLayoutEnabled, handleDelete, behaviorOverrides?.disableContextMenu],
);
const LONG_PRESS_DELAY = 500;
const MOVEMENT_THRESHOLD = 10;
const SWIPE_VELOCITY_THRESHOLD = 0.4;
const HIGHLIGHT_DELAY = 100;
const touchStartPos = useRef<{x: number; y: number} | null>(null);
const velocitySamples = useRef<Array<{x: number; y: number; timestamp: number}>>([]);
const highlightTimerRef = useRef<NodeJS.Timeout | null>(null);
const clearLongPressState = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
if (highlightTimerRef.current) {
clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = null;
}
touchStartPos.current = null;
velocitySamples.current = [];
setIsLongPressing(false);
}, []);
const calculateVelocity = useCallback((): number => {
const samples = velocitySamples.current;
if (samples.length < 2) return 0;
const now = performance.now();
const recentSamples = samples.filter((s) => now - s.timestamp < 100);
if (recentSamples.length < 2) return 0;
const first = recentSamples[0];
const last = recentSamples[recentSamples.length - 1];
const dt = last.timestamp - first.timestamp;
if (dt === 0) return 0;
const dx = last.x - first.x;
const dy = last.y - first.y;
return Math.sqrt(dx * dx + dy * dy) / dt;
}, []);
const handleLongPressStart = useCallback(
(event: React.TouchEvent) => {
if (!mobileLayoutEnabled || previewContext) {
return;
}
const touch = event.touches[0];
if (!touch) return;
touchStartPos.current = {x: touch.clientX, y: touch.clientY};
velocitySamples.current = [{x: touch.clientX, y: touch.clientY, timestamp: performance.now()}];
highlightTimerRef.current = setTimeout(() => {
if (touchStartPos.current) {
setIsLongPressing(true);
}
highlightTimerRef.current = null;
}, HIGHLIGHT_DELAY);
longPressTimerRef.current = setTimeout(() => {
if (touchStartPos.current) {
setShowActionBar(true);
setIsLongPressing(false);
}
clearLongPressState();
}, LONG_PRESS_DELAY);
},
[mobileLayoutEnabled, previewContext, clearLongPressState],
);
const handleLongPressEnd = useCallback(() => {
clearLongPressState();
}, [clearLongPressState]);
const handleLongPressMove = useCallback(
(event: React.TouchEvent) => {
if (!touchStartPos.current) return;
const touch = event.touches[0];
if (!touch) return;
velocitySamples.current.push({x: touch.clientX, y: touch.clientY, timestamp: performance.now()});
if (velocitySamples.current.length > 10) {
velocitySamples.current = velocitySamples.current.slice(-10);
}
const deltaX = Math.abs(touch.clientX - touchStartPos.current.x);
const deltaY = Math.abs(touch.clientY - touchStartPos.current.y);
if (deltaX > MOVEMENT_THRESHOLD || deltaY > MOVEMENT_THRESHOLD) {
clearLongPressState();
return;
}
const velocity = calculateVelocity();
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
clearLongPressState();
}
},
[clearLongPressState, calculateVelocity],
);
useEffect(() => {
if (behaviorOverrides?.disableContextMenuTracking) {
return;
}
const disposer = autorun(() => {
handleContextMenuUpdate();
});
return () => {
disposer();
};
}, [handleContextMenuUpdate, behaviorOverrides?.disableContextMenuTracking]);
useEffect(() => {
if (!behaviorOverrides?.disableContextMenuTracking) {
return;
}
if (behaviorOverrides.contextMenuOpen !== undefined) {
setContextMenuOpen(behaviorOverrides.contextMenuOpen);
}
}, [behaviorOverrides?.contextMenuOpen, behaviorOverrides?.disableContextMenuTracking]);
const keyboardModeEnabled = KeyboardModeStore.keyboardModeEnabled;
const handleFocusWithin = useCallback(() => {
if (!keyboardModeEnabled) {
return;
}
setIsFocusedWithin(true);
}, [keyboardModeEnabled]);
const handleBlurWithin = useCallback(() => {
setIsFocusedWithin(false);
}, []);
useEffect(() => {
if (mobileLayoutEnabled || !messageRef.current) return;
const element = messageRef.current;
const syncHoverState = () => {
setIsHoveringDesktop(element.matches(':hover'));
};
const handleMouseEnter = () => {
setIsHoveringDesktop(true);
};
const handleMouseLeave = () => {
setIsHoveringDesktop(false);
};
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
window.addEventListener('focus', syncHoverState);
const rafId = requestAnimationFrame(syncHoverState);
return () => {
cancelAnimationFrame(rafId);
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('focus', syncHoverState);
};
}, [mobileLayoutEnabled, keyboardModeEnabled]);
useLayoutEffect(() => {
const wasEditing = wasEditingInPreviousUpdateRef.current;
const justStartedEditing = !wasEditing && isEditing;
if (justStartedEditing && onEdit && messageRef.current) {
onEdit(messageRef.current);
}
wasEditingInPreviousUpdateRef.current = isEditing;
}, [isEditing, onEdit]);
useEffect(() => {
if (!mobileLayoutEnabled) return;
const handleScroll = () => {
if (touchStartPos.current) {
clearLongPressState();
}
};
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
return () => {
window.removeEventListener('scroll', handleScroll, {capture: true});
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
if (highlightTimerRef.current) {
clearTimeout(highlightTimerRef.current);
}
};
}, [mobileLayoutEnabled, clearLongPressState]);
const isHovering = mobileLayoutEnabled ? false : isHoveringDesktop;
useEffect(() => {
if (!keyboardModeEnabled) {
setIsFocusedWithin(false);
return;
}
const activeElement = messageRef.current?.ownerDocument?.activeElement ?? document.activeElement;
if (messageRef.current && activeElement && messageRef.current.contains(activeElement)) {
setIsFocusedWithin(true);
}
}, [keyboardModeEnabled]);
const actionBarHoverState = previewMode
? true
: isHovering || (keyboardModeEnabled && isFocusedWithin) || isPopoutOpen;
const messageContextValue = useMemo(
() => ({
channel,
message,
handleDelete,
shouldGroup,
isHovering,
previewContext,
previewOverrides,
onPopoutToggle: setIsPopoutOpen,
}),
[channel, message, handleDelete, shouldGroup, isHovering, previewContext, previewOverrides, setIsPopoutOpen],
);
const messageComponent = (
<MessageViewContextProvider value={messageContextValue}>
{getMessageComponent(channel, message, forceUnknownMessageType)}
</MessageViewContextProvider>
);
const {nodes: astNodes} = parse({
content: message.content,
context: MarkdownContext.STANDARD_WITH_JUMBO,
});
const shouldHideContent =
UserSettingsStore.getRenderEmbeds() &&
message.embeds.length > 0 &&
message.embeds.every((embed) => embed.type === MessageEmbedTypes.IMAGE || embed.type === MessageEmbedTypes.GIFV) &&
astNodes.length === 1 &&
astNodes[0].type === NodeType.Link &&
!message.suppressEmbeds;
const shouldDisableHoverBackground = prefersReducedMotion && !isEditing;
const isKeyboardFocused = keyboardModeEnabled && isFocusedWithin;
const shouldApplySpacing = !shouldGroup && !removeTopSpacing && previewContext !== MessagePreviewContext.LIST_POPOUT;
const messageClasses = clsx(
messageDisplayCompact ? styles.messageCompact : styles.message,
shouldDisableHoverBackground && styles.messageNoHover,
isEditing && styles.messageEditing,
!messageDisplayCompact && shouldGroup && shouldApplyGroupedLayout(message, prevMessage) && styles.messageGrouped,
!previewContext && message.isMentioned() && styles.messageMentioned,
!previewContext &&
(isReplying || isHighlight || isJumpTarget) &&
(isReplying ? styles.messageReplying : styles.messageHighlight),
message.type === MessageTypes.CLIENT_SYSTEM && message.author.id === FLUXERBOT_ID && styles.messageClientSystem,
isLongPressing && styles.messageLongPress,
!previewContext && (contextMenuOpen || isPopoutOpen) && styles.contextMenuActive,
previewContext && styles.messagePreview,
MobileLayoutStore.isEnabled() && styles.mobileLayout,
!messageDisplayCompact &&
(!message.content || shouldHideContent) &&
!isEditing &&
message.isUserMessage() &&
styles.messageNoText,
isKeyboardFocused && styles.keyboardFocused,
isKeyboardFocused && 'keyboard-focus-active',
shouldApplySpacing && previewContext && styles.messagePreviewSpacing,
);
const shouldShowActionBar =
!previewContext && message.state !== MessageStates.SENDING && !isEditing && !MobileLayoutStore.isEnabled();
const shouldShowBottomSheet =
MobileLayoutStore.isEnabled() &&
showActionBar &&
!previewContext &&
message.state !== MessageStates.SENDING &&
!isEditing;
return (
<>
<FocusRing>
<div
role="article"
id={`${idPrefix}-${channel.id}-${message.id}`}
data-message-id={message.id}
data-channel-id={channel.id}
tabIndex={keyboardModeEnabled ? 0 : undefined}
className={messageClasses}
ref={messageRef}
onClick={handleAltClick}
onKeyDown={handleAltKeyDown}
onFocus={handleFocusWithin}
onBlur={handleBlurWithin}
onContextMenu={handleContextMenu}
onTouchStart={handleLongPressStart}
onTouchEnd={handleLongPressEnd}
onTouchMove={handleLongPressMove}
style={{
touchAction: 'pan-y',
WebkitUserSelect: 'text',
userSelect: 'text',
marginTop: shouldApplySpacing && previewContext ? `${messageGroupSpacing}px` : undefined,
}}
>
{messageComponent}
{shouldShowActionBar &&
(previewMode ? (
<MessageActionBarCore
message={message}
handleDelete={handleDelete}
permissions={{
canSendMessages: true,
canAddReactions: true,
canEditMessage: true,
canDeleteMessage: true,
canPinMessage: true,
shouldRenderSuppressEmbeds: true,
}}
isSaved={false}
developerMode={false}
isHovering={actionBarHoverState}
onPopoutToggle={setIsPopoutOpen}
/>
) : (
<MessageActionBar
message={message}
handleDelete={handleDelete}
isHovering={actionBarHoverState}
onPopoutToggle={setIsPopoutOpen}
/>
))}
</div>
</FocusRing>
{shouldShowBottomSheet && (
<MessageActionBottomSheet
isOpen={shouldShowBottomSheet}
onClose={() => setShowActionBar(false)}
message={message}
handleDelete={handleDelete}
/>
)}
</>
);
});

View File

@@ -0,0 +1,94 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.actionBarContainer {
position: absolute;
top: -16px;
right: 0;
z-index: var(--z-index-elevated-1);
padding: 0 14px 0 32px;
}
.actionBar {
display: grid;
position: relative;
box-sizing: border-box;
align-items: center;
justify-content: flex-start;
grid-auto-flow: column;
padding: 2px;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
user-select: none;
-webkit-user-select: none;
}
.button {
display: flex;
position: relative;
align-items: center;
justify-content: center;
padding: 4px;
height: 30px;
min-width: 30px;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
}
.button:hover,
.button.active {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.button.danger {
color: var(--status-danger);
}
.actionBarIcon {
width: 20px;
height: 20px;
display: block;
object-fit: contain;
}
.tooltipContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.tooltipHint {
color: var(--text-primary-muted);
font-size: 0.75rem;
}
.emojiImage {
height: 1.25rem;
width: 1.25rem;
}
span.emojiImage {
font-size: 1.25rem;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}

View File

@@ -0,0 +1,845 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {ArrowsClockwiseIcon, DotsThreeIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import React, {useSyncExternalStore} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import {MessageStates} from '~/Constants';
import styles from '~/components/channel/MessageActionBar.module.css';
import {
createMessageActionHandlers,
isEmbedsSuppressed,
triggerAddReaction,
useMessagePermissions,
} from '~/components/channel/messageActionUtils';
import {MessageDebugModal} from '~/components/debug/MessageDebugModal';
import {EmojiPickerPopout} from '~/components/popouts/EmojiPickerPopout';
import {
AddReactionIcon,
BookmarkIcon,
CopyIdIcon,
CopyLinkIcon,
CopyTextIcon,
DebugIcon,
DeleteIcon,
EditIcon,
ForwardIcon,
MarkAsUnreadIcon,
PinIcon,
ReplyIcon,
SuppressEmbedsIcon,
} from '~/components/uikit/ContextMenu/ContextMenuIcons';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Popout} from '~/components/uikit/Popout/Popout';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import type {MessageRecord} from '~/records/MessageRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import ContextMenuStore, {isContextMenuNodeTarget} from '~/stores/ContextMenuStore';
import EmojiPickerStore from '~/stores/EmojiPickerStore';
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import SavedMessagesStore from '~/stores/SavedMessagesStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import messageStyles from '~/styles/Message.module.css';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
const shiftKeyManager = (() => {
let isShiftPressed = false;
const listeners = new Set<() => void>();
let notifyTimeout: NodeJS.Timeout | null = null;
const scheduleNotify = () => {
if (notifyTimeout) return;
notifyTimeout = setTimeout(() => {
notifyTimeout = null;
listeners.forEach((listener) => listener());
}, 0);
};
const setShiftPressed = (pressed: boolean) => {
if (isShiftPressed !== pressed) {
isShiftPressed = pressed;
scheduleNotify();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
setShiftPressed(true);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
setShiftPressed(false);
}
};
const handleWindowBlur = () => {
setShiftPressed(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleWindowBlur);
return {
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot: () => isShiftPressed,
getServerSnapshot: () => false,
};
})();
const quickReactionManager = (() => {
let cache: Array<Emoji> | null = null;
const listeners = new Set<() => void>();
const recompute = () => {
const allEmojis = EmojiStore.getAllEmojis(null);
cache = EmojiPickerStore.getQuickReactionEmojis(allEmojis, 3);
listeners.forEach((listener) => listener());
};
autorun(() => {
const usage = EmojiPickerStore.emojiUsage;
for (const key in usage) {
const entry = usage[key];
entry?.count;
entry?.lastUsed;
}
recompute();
});
return {
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot(): Array<Emoji> {
if (!cache) {
recompute();
}
return cache ?? [];
},
getServerSnapshot(): Array<Emoji> {
return [];
},
};
})();
const useShiftKey = (enabled: boolean) => {
const subscribe = React.useCallback(
(listener: () => void) => {
if (!enabled) {
return () => undefined;
}
return shiftKeyManager.subscribe(listener);
},
[enabled],
);
const getSnapshot = React.useCallback(() => {
return enabled ? shiftKeyManager.getSnapshot() : false;
}, [enabled]);
return useSyncExternalStore(subscribe, getSnapshot, shiftKeyManager.getServerSnapshot);
};
interface MessageActionBarButtonProps {
label: string;
icon: React.ReactNode;
onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void;
onPointerDownCapture?: (event: React.PointerEvent) => void;
danger?: boolean;
isActive?: boolean;
dataAction?: string;
}
const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageActionBarButtonProps>(
({label, icon, onClick, onPointerDownCapture, danger, isActive, dataAction}, ref) => {
const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
onClick?.(event);
};
return (
<Tooltip text={label}>
<FocusRing>
<button
type="button"
ref={ref}
aria-label={label}
onClick={handleClick}
onPointerDownCapture={onPointerDownCapture}
className={clsx(styles.button, danger && styles.danger, isActive && styles.active)}
data-action={dataAction}
>
<div className={styles.actionBarIcon}>{icon}</div>
</button>
</FocusRing>
</Tooltip>
);
},
);
MessageActionBarButton.displayName = 'MessageActionBarButton';
interface QuickReactionButtonProps {
emoji: Emoji;
onReact: (emoji: Emoji) => void;
}
const QuickReactionButton = React.forwardRef<HTMLButtonElement, QuickReactionButtonProps>(({emoji, onReact}, ref) => {
const {t} = useLingui();
const [isHovered, setIsHovered] = React.useState(false);
const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
onReact(emoji);
};
const emojiNameWithColons = `:${emoji.name}:`;
const isUnicodeEmoji = !emoji.guildId && !emoji.id;
const useNativeRendering = shouldUseNativeEmoji && isUnicodeEmoji;
const shouldShowAnimated = emoji.animated && isHovered;
const emojiSrc =
emoji.animated && emoji.id && !useNativeRendering
? AvatarUtils.getEmojiURL({id: emoji.id, animated: shouldShowAnimated})
: (emoji.url ?? '');
return (
<Tooltip
text={() => (
<div className={styles.tooltipContent}>
<span>{emojiNameWithColons}</span>
<span className={styles.tooltipHint}>{t`Click to react`}</span>
</div>
)}
>
<FocusRing>
<button
type="button"
ref={ref}
aria-label={`React with ${emojiNameWithColons}`}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={styles.button}
>
{useNativeRendering ? (
<span className={styles.emojiImage}>{emoji.surrogates}</span>
) : (
<img src={emojiSrc} alt={emoji.name} className={styles.emojiImage} />
)}
</button>
</FocusRing>
</Tooltip>
);
});
QuickReactionButton.displayName = 'QuickReactionButton';
interface MessageActionBarCoreProps {
message: MessageRecord;
handleDelete: (bypassConfirm?: boolean) => void;
permissions: {
canSendMessages: boolean;
canAddReactions: boolean;
canEditMessage: boolean;
canDeleteMessage: boolean;
canPinMessage: boolean;
shouldRenderSuppressEmbeds: boolean;
};
isSaved: boolean;
developerMode: boolean;
isHovering?: boolean;
onPopoutToggle?: (isOpen: boolean) => void;
}
const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observer(
({message, handleDelete, permissions, isSaved, developerMode, isHovering = false, onPopoutToggle}) => {
const {t} = useLingui();
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const moreOptionsButtonRef = React.useRef<HTMLButtonElement>(null);
const emojiPickerButtonRef = React.useRef<HTMLButtonElement>(null);
const actionBarRef = React.useRef<HTMLDivElement>(null);
const showMessageActionBar = AccessibilityStore.showMessageActionBar;
const showQuickReactions = AccessibilityStore.showMessageActionBarQuickReactions;
const showShiftExpand = AccessibilityStore.showMessageActionBarShiftExpand;
const onlyMoreButton = AccessibilityStore.showMessageActionBarOnlyMoreButton;
const keyboardModeEnabled = KeyboardModeStore.keyboardModeEnabled;
const isActionBarActive = isHovering || contextMenuOpen || emojiPickerOpen || moreMenuOpen;
const shouldListenForShift =
showShiftExpand && showMessageActionBar && !onlyMoreButton && isActionBarActive && !keyboardModeEnabled;
const shiftPressed = useShiftKey(shouldListenForShift);
const showFullActions = showShiftExpand && shiftPressed;
const {
canSendMessages,
canAddReactions,
canEditMessage,
canDeleteMessage,
canPinMessage,
shouldRenderSuppressEmbeds,
} = permissions;
const handlers = createMessageActionHandlers(message);
const quickReactionEmojis = useSyncExternalStore(
quickReactionManager.subscribe,
quickReactionManager.getSnapshot,
quickReactionManager.getServerSnapshot,
);
const blurEmojiPickerTrigger = React.useCallback(() => {
if (keyboardModeEnabled) {
return;
}
requestAnimationFrame(() => emojiPickerButtonRef.current?.blur());
}, [keyboardModeEnabled]);
const handleEmojiPickerToggle = React.useCallback(
(open: boolean) => {
setEmojiPickerOpen(open);
onPopoutToggle?.(open);
if (!open) {
blurEmojiPickerTrigger();
}
},
[onPopoutToggle, blurEmojiPickerTrigger],
);
const handleEmojiPickerOpen = React.useCallback(() => handleEmojiPickerToggle(true), [handleEmojiPickerToggle]);
const handleEmojiPickerClose = React.useCallback(() => handleEmojiPickerToggle(false), [handleEmojiPickerToggle]);
React.useEffect(() => {
return () => {
if (emojiPickerOpen) {
onPopoutToggle?.(false);
}
};
}, [emojiPickerOpen, onPopoutToggle]);
const handleDebugClick = React.useCallback(() => {
ModalActionCreators.push(modal(() => <MessageDebugModal title={t`Message Debug`} message={message} />));
}, [message]);
React.useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenuStore.contextMenu;
const contextMenuTarget = contextMenu?.target?.target ?? null;
const actionBarElement = actionBarRef.current;
const isOpen =
Boolean(contextMenu) &&
isContextMenuNodeTarget(contextMenuTarget) &&
Boolean(actionBarElement?.contains(contextMenuTarget));
setContextMenuOpen(!!isOpen);
const isMoreOpen =
!!contextMenu && !!moreOptionsButtonRef.current && contextMenu.target.target === moreOptionsButtonRef.current;
setMoreMenuOpen(isMoreOpen);
});
return () => disposer();
}, []);
React.useEffect(() => {
const unsubscribe = ComponentDispatch.subscribe('EMOJI_PICKER_OPEN', (payload?: unknown) => {
const data = (payload ?? {}) as {messageId?: string};
if (data.messageId === message.id && emojiPickerButtonRef.current) {
PopoutActionCreators.open({
key: `emoji-picker-${message.id}`,
position: 'left-start',
render: ({onClose}) => (
<EmojiPickerPopout
channelId={message.channelId}
handleSelect={handlers.handleEmojiSelect}
onClose={onClose}
/>
),
target: emojiPickerButtonRef.current,
animationType: 'none',
onOpen: () => handleEmojiPickerToggle(true),
onClose: () => handleEmojiPickerToggle(false),
});
}
});
return () => unsubscribe();
}, [message.id, message.channelId, handlers.handleEmojiSelect, handleEmojiPickerToggle]);
const handleMoreOptionsPointerDown = React.useCallback((event: React.PointerEvent) => {
const contextMenu = ContextMenuStore.contextMenu;
const isOpen = !!contextMenu && contextMenu.target.target === moreOptionsButtonRef.current;
if (isOpen) {
event.stopPropagation();
event.preventDefault();
ContextMenuActionCreators.close();
}
}, []);
const openMoreOptionsMenu = React.useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (!showMessageActionBar) {
return;
}
const contextMenu = ContextMenuStore.contextMenu;
const isOpen = !!contextMenu && contextMenu.target.target === event.currentTarget;
if (isOpen) {
return;
}
if (!(event.nativeEvent instanceof MouseEvent)) {
return;
}
ContextMenuActionCreators.openFromEvent(event as React.MouseEvent, (props) => (
<>
<MenuGroup>
{canAddReactions && (
<MenuItem
icon={<AddReactionIcon />}
onClick={() => {
triggerAddReaction(message.id);
props.onClose();
}}
shortcut="+"
>
{t`Add Reaction`}
</MenuItem>
)}
{message.isUserMessage() && !message.messageSnapshots && canEditMessage && (
<MenuItem
icon={<EditIcon />}
onClick={() => {
handlers.handleEditMessage();
props.onClose();
}}
shortcut="e"
>
{t`Edit Message`}
</MenuItem>
)}
{message.isUserMessage() && canSendMessages && (
<MenuItem
icon={<ReplyIcon />}
onClick={() => {
handlers.handleReply();
props.onClose();
}}
shortcut="r"
>
{t`Reply`}
</MenuItem>
)}
{message.isUserMessage() && (
<MenuItem
icon={<ForwardIcon />}
onClick={() => {
handlers.handleForward();
props.onClose();
}}
shortcut="f"
>
{t`Forward`}
</MenuItem>
)}
</MenuGroup>
{(message.isUserMessage() || shouldRenderSuppressEmbeds || message.content) && (
<MenuGroup>
{message.isUserMessage() && canPinMessage && (
<MenuItem
icon={<PinIcon />}
onClick={() => {
handlers.handlePinMessage();
props.onClose();
}}
shortcut="p"
>
{message.pinned ? t`Unpin Message` : t`Pin Message`}
</MenuItem>
)}
{message.isUserMessage() && (
<MenuItem
icon={<BookmarkIcon filled={isSaved} />}
onClick={() => {
handlers.handleSaveMessage(isSaved)();
props.onClose();
}}
shortcut="b"
>
{isSaved ? t`Remove Bookmark` : t`Bookmark Message`}
</MenuItem>
)}
<MenuItem
icon={<MarkAsUnreadIcon />}
onClick={() => {
handlers.handleMarkAsUnread();
props.onClose();
}}
shortcut="u"
>
{t`Mark as Unread`}
</MenuItem>
{shouldRenderSuppressEmbeds && (
<MenuItem
icon={<SuppressEmbedsIcon />}
onClick={() => {
handlers.handleToggleSuppressEmbeds();
props.onClose();
}}
shortcut="s"
>
{isEmbedsSuppressed(message) ? t`Unsuppress Embeds` : t`Suppress Embeds`}
</MenuItem>
)}
{message.content && (
<MenuItem
icon={<CopyTextIcon />}
onClick={() => {
handlers.handleCopyMessage();
props.onClose();
}}
shortcut="c"
>
{t`Copy Text`}
</MenuItem>
)}
</MenuGroup>
)}
<MenuGroup>
<MenuItem
icon={<CopyLinkIcon />}
onClick={() => {
handlers.handleCopyMessageLink();
props.onClose();
}}
shortcut="l"
>
{t`Copy Message Link`}
</MenuItem>
<MenuItem
icon={<CopyIdIcon />}
onClick={() => {
handlers.handleCopyMessageId();
props.onClose();
}}
>
{t`Copy Message ID`}
</MenuItem>
{developerMode && (
<MenuItem
icon={<DebugIcon />}
onClick={() => {
handleDebugClick();
props.onClose();
}}
>
{t`Debug Message`}
</MenuItem>
)}
</MenuGroup>
{canDeleteMessage && (
<MenuGroup>
<MenuItem
icon={<DeleteIcon />}
onClick={(event) => {
const shiftKey = Boolean((event as {shiftKey?: boolean} | undefined)?.shiftKey);
handleDelete(shiftKey);
props.onClose();
}}
danger
shortcut="d"
>
{t`Delete Message`}
</MenuItem>
</MenuGroup>
)}
</>
));
},
[
canAddReactions,
canSendMessages,
canEditMessage,
canPinMessage,
shouldRenderSuppressEmbeds,
developerMode,
canDeleteMessage,
message,
handlers,
handleDelete,
isSaved,
handleDebugClick,
showMessageActionBar,
],
);
return (
<div
ref={actionBarRef}
className={clsx(
styles.actionBarContainer,
messageStyles.buttons,
(emojiPickerOpen || contextMenuOpen) && messageStyles.emojiPickerOpen,
)}
>
<div className={styles.actionBar}>
{message.state === MessageStates.SENT &&
(onlyMoreButton ? (
<MessageActionBarButton
ref={moreOptionsButtonRef}
icon={<DotsThreeIcon size={20} weight="bold" />}
label={t`More`}
onPointerDownCapture={handleMoreOptionsPointerDown}
onClick={openMoreOptionsMenu}
isActive={moreMenuOpen}
/>
) : (
<>
{!showFullActions &&
canAddReactions &&
showQuickReactions &&
quickReactionEmojis.map((emoji) => (
<QuickReactionButton key={emoji.name} emoji={emoji} onReact={handlers.handleEmojiSelect} />
))}
{showFullActions && (
<>
{developerMode && (
<MessageActionBarButton
icon={<DebugIcon size={20} />}
label={t`Debug Message`}
onClick={handleDebugClick}
/>
)}
<MessageActionBarButton
icon={<CopyIdIcon size={20} />}
label={t`Copy Message ID`}
onClick={handlers.handleCopyMessageId}
/>
<MessageActionBarButton
icon={<CopyLinkIcon size={20} />}
label={t`Copy Message Link`}
onClick={handlers.handleCopyMessageLink}
/>
{message.content && (
<MessageActionBarButton
icon={<CopyTextIcon size={20} />}
label={t`Copy Text`}
onClick={handlers.handleCopyMessage}
/>
)}
{shouldRenderSuppressEmbeds && (
<MessageActionBarButton
icon={<SuppressEmbedsIcon size={20} />}
label={isEmbedsSuppressed(message) ? t`Unsuppress Embeds` : t`Suppress Embeds`}
onClick={handlers.handleToggleSuppressEmbeds}
/>
)}
<MessageActionBarButton
icon={<MarkAsUnreadIcon size={20} />}
label={t`Mark as Unread`}
onClick={handlers.handleMarkAsUnread}
/>
{message.isUserMessage() && (
<MessageActionBarButton
icon={<BookmarkIcon size={20} filled={isSaved} />}
label={isSaved ? t`Remove Bookmark` : t`Bookmark Message`}
onClick={handlers.handleSaveMessage(isSaved)}
/>
)}
{message.isUserMessage() && canPinMessage && (
<MessageActionBarButton
icon={<PinIcon size={20} />}
label={message.pinned ? t`Unpin Message` : t`Pin Message`}
onClick={handlers.handlePinMessage}
/>
)}
</>
)}
{canAddReactions && (
<Popout
render={({onClose}) => (
<EmojiPickerPopout
channelId={message.channelId}
handleSelect={handlers.handleEmojiSelect}
onClose={onClose}
/>
)}
position="left-start"
uniqueId={`emoji-picker-actionbar-${message.id}`}
animationType="none"
onOpen={handleEmojiPickerOpen}
onClose={handleEmojiPickerClose}
>
<MessageActionBarButton
ref={emojiPickerButtonRef}
icon={<AddReactionIcon size={20} />}
label={t`Add Reaction`}
isActive={emojiPickerOpen}
dataAction="message-add-reaction-button"
/>
</Popout>
)}
{message.isUserMessage() && !message.messageSnapshots && canEditMessage && (
<MessageActionBarButton
icon={<EditIcon size={20} />}
label={t`Edit Message`}
onClick={handlers.handleEditMessage}
/>
)}
{message.isUserMessage() && canSendMessages && (
<MessageActionBarButton
icon={<ReplyIcon size={20} />}
label={t`Reply`}
onClick={handlers.handleReply}
/>
)}
{message.isUserMessage() && (
<MessageActionBarButton
icon={<ForwardIcon size={20} />}
label={t`Forward`}
onClick={handlers.handleForward}
/>
)}
{(!showFullActions || !canDeleteMessage) && (
<MessageActionBarButton
ref={moreOptionsButtonRef}
icon={<DotsThreeIcon size={20} weight="bold" />}
label={t`More`}
onPointerDownCapture={handleMoreOptionsPointerDown}
onClick={openMoreOptionsMenu}
isActive={moreMenuOpen}
/>
)}
{showFullActions && canDeleteMessage && (
<MessageActionBarButton
danger={true}
icon={<DeleteIcon size={20} />}
label={t`Delete Message`}
onClick={(event) => handleDelete(event.shiftKey)}
/>
)}
</>
))}
{message.state === MessageStates.FAILED && (
<>
<MessageActionBarButton
icon={<ArrowsClockwiseIcon size={20} weight="fill" />}
label={t`Retry`}
onClick={handlers.handleRetryMessage}
/>
<MessageActionBarButton
danger={true}
icon={<DeleteIcon size={20} />}
label={t`Delete Message`}
onClick={handlers.handleFailedMessageDelete}
/>
</>
)}
</div>
</div>
);
},
);
export const MessageActionBar = observer(
({
message,
handleDelete,
isHovering = false,
onPopoutToggle,
}: {
message: MessageRecord;
handleDelete: (bypassConfirm?: boolean) => void;
isHovering?: boolean;
onPopoutToggle?: (isOpen: boolean) => void;
}) => {
const isSaved = SavedMessagesStore.isSaved(message.id);
const developerMode = UserSettingsStore.developerMode;
const permissions = useMessagePermissions(message);
return (
<MessageActionBarCore
message={message}
handleDelete={handleDelete}
permissions={permissions}
isSaved={isSaved}
developerMode={developerMode}
isHovering={isHovering}
onPopoutToggle={onPopoutToggle}
/>
);
},
);
export {MessageActionBarCore};

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.quickReactionWrapper {
padding: 1rem;
padding-bottom: 0;
}
.quickReactionRow {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding-bottom: 0.75rem;
}
.quickReactionButton {
display: flex;
align-items: center;
justify-content: center;
height: 3rem;
width: 3rem;
border-radius: 9999px;
background-color: var(--background-modifier-hover);
transition: background-color 0.15s ease;
cursor: pointer;
}
.quickReactionButton:hover {
background-color: var(--background-modifier-selected);
}
.quickReactionEmoji {
height: 2rem;
width: 2rem;
}
span.quickReactionEmoji {
font-size: 2rem;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.addReactionIcon {
height: 1.5rem;
width: 1.5rem;
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useMessageActionMenuData} from '~/components/channel/messageActionMenu';
import {ExpressionPickerSheet} from '~/components/modals/ExpressionPickerSheet';
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
import type {MessageRecord} from '~/records/MessageRecord';
import EmojiPickerStore from '~/stores/EmojiPickerStore';
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import styles from './MessageActionBottomSheet.module.css';
interface MessageActionBottomSheetProps {
isOpen: boolean;
onClose: () => void;
message: MessageRecord;
handleDelete: (bypassConfirm?: boolean) => void;
}
export const MessageActionBottomSheet: React.FC<MessageActionBottomSheetProps> = observer(
({isOpen, onClose, message, handleDelete}) => {
const {t} = useLingui();
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const handleAddReaction = React.useCallback(() => {
setIsEmojiPickerOpen(true);
}, []);
const handleEmojiPickerClose = React.useCallback(() => {
setIsEmojiPickerOpen(false);
onClose();
}, [onClose]);
const {groups, handlers, quickReactionEmojis, quickReactionRowVisible} = useMessageActionMenuData(message, {
onClose,
onDelete: () => handleDelete(),
onOpenEmojiPicker: handleAddReaction,
quickReactionCount: 4,
});
const quickReactionRow = quickReactionRowVisible ? (
<div className={styles.quickReactionWrapper}>
<div className={styles.quickReactionRow}>
{quickReactionEmojis.map((emoji) => {
const isUnicodeEmoji = !emoji.guildId && !emoji.id;
const useNativeRendering = shouldUseNativeEmoji && isUnicodeEmoji;
return (
<button
key={emoji.name}
type="button"
onClick={() => {
EmojiPickerStore.trackEmoji(emoji);
handlers.handleEmojiSelect(emoji);
onClose();
}}
aria-label={t`React with :${emoji.name}:`}
className={styles.quickReactionButton}
>
{useNativeRendering ? (
<span className={styles.quickReactionEmoji}>{emoji.surrogates}</span>
) : (
<img src={emoji.url ?? ''} alt={emoji.name} className={styles.quickReactionEmoji} />
)}
</button>
);
})}
<button
type="button"
onClick={handleAddReaction}
aria-label={t`Add another reaction`}
className={styles.quickReactionButton}
>
<PlusIcon className={styles.addReactionIcon} weight="bold" />
</button>
</div>
</div>
) : null;
return (
<>
<MenuBottomSheet
isOpen={isOpen && !isEmojiPickerOpen}
onClose={onClose}
groups={groups}
headerContent={quickReactionRow}
/>
<ExpressionPickerSheet
isOpen={isEmojiPickerOpen}
onClose={handleEmojiPickerClose}
channelId={message.channelId}
onEmojiSelect={handlers.handleEmojiSelect}
visibleTabs={['emojis']}
/>
</>
);
},
);

View File

@@ -0,0 +1,187 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.forwardedContainer {
display: flex;
width: 100%;
}
.forwardedBar {
width: 0.25rem;
flex-shrink: 0;
border-radius: 0.25rem;
background-color: var(--interactive-muted);
margin-right: 0.75rem;
}
.forwardedContent {
flex: 1;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.forwardedHeader {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-chat-muted);
}
.forwardedIcon {
height: 0.75rem;
width: 0.75rem;
}
.forwardedLabel {
font-style: italic;
}
.attachmentsContainer {
margin-top: 0.5rem;
}
.forwardedSourceButton {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background-color: var(--background-secondary);
font-size: 0.75rem;
line-height: 1rem;
transition: background-color 0.15s ease;
cursor: pointer;
border: 1px solid var(--background-modifier-accent);
box-sizing: border-box;
align-self: flex-start;
justify-content: flex-start;
width: fit-content;
max-width: 100%;
}
.forwardedSourceButton:hover {
background-color: var(--background-secondary-alt);
}
.forwardedSourceLabel {
color: var(--text-chat-muted);
flex: 0 0 auto;
white-space: nowrap;
}
.forwardedSourceInfo {
display: flex;
align-items: center;
gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.forwardedSourceIcon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: var(--text-secondary);
}
.forwardedSourceAvatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.forwardedSourceGuildIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.25rem;
--guild-icon-size: 1rem;
}
.forwardedSourceChevron {
color: var(--text-muted);
flex-shrink: 0;
}
.forwardedSourceName {
color: var(--text-primary);
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: normal;
word-break: normal;
}
.stickersContainer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
.stickerWrapper {
position: relative;
height: 10rem;
width: 10rem;
}
.stickerImage {
height: 100%;
width: 100%;
object-fit: contain;
}
.stickerTooltip {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stickerName {
font-weight: 500;
}
.stickerGuildInfo {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-tertiary);
}
.stickerGuildIcon {
display: inline-flex;
align-items: center;
justify-content: center;
--guild-icon-size: 1rem;
}
.stickerGuildName {
font-size: 0.75rem;
line-height: 1rem;
}

View File

@@ -0,0 +1,333 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {ArrowBendUpRightIcon, CaretRightIcon, HashIcon, NotePencilIcon, SpeakerHighIcon} 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 {ChannelTypes, StickerFormatTypes} from '~/Constants';
import {Attachment} from '~/components/channel/embeds/attachments/Attachment';
import {AttachmentMosaic} from '~/components/channel/embeds/attachments/AttachmentMosaic';
import {Embed} from '~/components/channel/embeds/Embed';
import {GiftEmbed} from '~/components/channel/GiftEmbed';
import {InviteEmbed} from '~/components/channel/InviteEmbed';
import {MessageReactions} from '~/components/channel/MessageReactions';
import {getAttachmentRenderingState} from '~/components/channel/messageAttachmentStateUtils';
import {ThemeEmbed} from '~/components/channel/ThemeEmbed';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Avatar} from '~/components/uikit/Avatar';
import {MediaContextMenu} from '~/components/uikit/ContextMenu/MediaContextMenu';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {SafeMarkdown} from '~/lib/markdown';
import {MarkdownContext} from '~/lib/markdown/renderers';
import type {
MessageAttachment,
MessageEmbed,
MessageRecord,
MessageSnapshot,
MessageStickerItem,
} from '~/records/MessageRecord';
import GuildStore from '~/stores/GuildStore';
import StickerStore from '~/stores/StickerStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import markupStyles from '~/styles/Markup.module.css';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {useForwardedMessageContext} from '~/utils/forwardedMessageUtils';
import {goToMessage} from '~/utils/MessageNavigator';
import styles from './MessageAttachments.module.css';
import {useMessageViewContext} from './MessageViewContext';
const ForwardedFromSource = observer(({message}: {message: MessageRecord}) => {
const {sourceChannel, sourceGuild, sourceUser, hasAccessToSource, displayName} = useForwardedMessageContext(message);
const handleJumpToOriginal = React.useCallback(() => {
if (message.messageReference && sourceChannel) {
goToMessage(message.messageReference.channel_id, message.messageReference.message_id);
}
}, [message.messageReference, sourceChannel]);
if (!hasAccessToSource || !sourceChannel || !displayName || !message.messageReference) {
return null;
}
const renderChannelIcon = () => {
const iconSize = 16;
if (sourceChannel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return <NotePencilIcon className={styles.forwardedSourceIcon} weight="fill" size={iconSize} />;
}
if (sourceChannel.type === ChannelTypes.DM && sourceUser) {
return (
<div className={styles.forwardedSourceAvatar}>
<Avatar user={sourceUser} size={iconSize} status={null} />
</div>
);
}
if (sourceChannel.type === ChannelTypes.GROUP_DM) {
return (
<div className={styles.forwardedSourceAvatar}>
<GroupDMAvatar channel={sourceChannel} size={iconSize} />
</div>
);
}
if (sourceChannel.type === ChannelTypes.GUILD_VOICE) {
return <SpeakerHighIcon className={styles.forwardedSourceIcon} weight="fill" size={iconSize} />;
}
return <HashIcon className={styles.forwardedSourceIcon} weight="bold" size={iconSize} />;
};
if (
sourceChannel.type === ChannelTypes.DM ||
sourceChannel.type === ChannelTypes.GROUP_DM ||
sourceChannel.type === ChannelTypes.DM_PERSONAL_NOTES
) {
return (
<FocusRing>
<button type="button" onClick={handleJumpToOriginal} className={styles.forwardedSourceButton}>
<span className={styles.forwardedSourceLabel}>
<Trans>Forwarded from</Trans>
</span>
<span className={styles.forwardedSourceInfo}>
{renderChannelIcon()}
<span className={styles.forwardedSourceName}>{displayName}</span>
</span>
</button>
</FocusRing>
);
}
if (sourceGuild) {
return (
<FocusRing>
<button type="button" onClick={handleJumpToOriginal} className={styles.forwardedSourceButton}>
<span className={styles.forwardedSourceLabel}>
<Trans>Forwarded from</Trans>
</span>
<span className={styles.forwardedSourceInfo}>
<GuildIcon
id={sourceGuild.id}
name={sourceGuild.name}
icon={sourceGuild.icon}
className={styles.forwardedSourceGuildIcon}
sizePx={16}
/>
<span className={styles.forwardedSourceName}>{sourceGuild.name}</span>
<CaretRightIcon className={styles.forwardedSourceChevron} weight="bold" size={12} />
{renderChannelIcon()}
<span className={styles.forwardedSourceName}>{displayName}</span>
</span>
</button>
</FocusRing>
);
}
return null;
});
const ForwardedMessageContent = observer(({message, snapshot}: {message: MessageRecord; snapshot: MessageSnapshot}) => {
return (
<div className={styles.forwardedContainer}>
<div className={styles.forwardedBar} />
<div className={styles.forwardedContent}>
<div className={styles.forwardedHeader}>
<ArrowBendUpRightIcon className={styles.forwardedIcon} weight="bold" />
<span className={styles.forwardedLabel}>
<Trans>Forwarded</Trans>
</span>
</div>
{snapshot.content && (
<div className={clsx(markupStyles.markup)}>
<SafeMarkdown
content={snapshot.content}
options={{
context: MarkdownContext.STANDARD_WITH_JUMBO,
messageId: message.id,
channelId: message.channelId,
}}
/>
</div>
)}
{snapshot.attachments && snapshot.attachments.length > 0 && (
<div className={styles.attachmentsContainer}>
{(() => {
const {enrichedAttachments, mediaAttachments, shouldUseMosaic} = getAttachmentRenderingState(
snapshot.attachments,
);
return (
<>
{shouldUseMosaic && <AttachmentMosaic attachments={mediaAttachments} message={message} />}
{enrichedAttachments.map((attachment: MessageAttachment) => (
<Attachment
key={attachment.id}
attachment={attachment}
isPreview={false}
message={message}
renderInMosaic={shouldUseMosaic}
/>
))}
</>
);
})()}
</div>
)}
{snapshot.embeds && snapshot.embeds.length > 0 && UserSettingsStore.getRenderEmbeds() && (
<div className={styles.attachmentsContainer}>
{snapshot.embeds.map((embed: MessageEmbed, index: number) => (
<Embed embed={embed} key={embed.id} message={message} embedIndex={index} onDelete={() => {}} />
))}
</div>
)}
<ForwardedFromSource message={message} />
</div>
</div>
);
});
export const MessageAttachments = observer(() => {
const {message, handleDelete, previewContext, onPopoutToggle} = useMessageViewContext();
const isPreview = Boolean(previewContext);
return (
<>
{message.messageSnapshots && message.messageSnapshots.length > 0 && (
<ForwardedMessageContent message={message} snapshot={message.messageSnapshots[0]} />
)}
{message.invites.map((code) => (
<FocusRing key={code}>
<InviteEmbed code={code} />
</FocusRing>
))}
{message.themes.map((themeId) => (
<FocusRing key={themeId}>
<ThemeEmbed themeId={themeId} />
</FocusRing>
))}
{message.gifts.map((code) => (
<FocusRing key={code}>
<GiftEmbed code={code} />
</FocusRing>
))}
{message.stickers && message.stickers.length > 0 && (
<div className={styles.stickersContainer}>
{message.stickers.map((sticker: MessageStickerItem) => {
const stickerUrl = AvatarUtils.getStickerURL({
id: sticker.id,
animated: sticker.format_type === StickerFormatTypes.GIF,
size: 320,
});
const stickerRecord = StickerStore.getStickerById(sticker.id);
const guild = stickerRecord?.guildId ? GuildStore.getGuild(stickerRecord.guildId) : null;
const tooltipContent = () => (
<div className={styles.stickerTooltip}>
<span className={styles.stickerName}>{sticker.name}</span>
{guild && (
<div className={styles.stickerGuildInfo}>
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
className={styles.stickerGuildIcon}
sizePx={16}
/>
<span className={styles.stickerGuildName}>{guild.name}</span>
</div>
)}
</div>
);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
ContextMenuActionCreators.openFromEvent(e, ({onClose}) => (
<MediaContextMenu
message={message}
originalSrc={stickerUrl}
type="image"
defaultName={sticker.name}
onClose={onClose}
onDelete={handleDelete}
/>
));
};
return (
<Tooltip key={sticker.id} text={tooltipContent}>
<FocusRing>
<div role="img" className={styles.stickerWrapper} onContextMenu={handleContextMenu}>
<img
src={stickerUrl}
alt={stickerRecord?.description || sticker.name}
className={styles.stickerImage}
width="160"
height="160"
/>
</div>
</FocusRing>
</Tooltip>
);
})}
</div>
)}
{(() => {
const {enrichedAttachments, mediaAttachments} = getAttachmentRenderingState(message.attachments);
const inlineMedia = UserSettingsStore.getInlineAttachmentMedia();
const shouldWrapInMosaic = inlineMedia && mediaAttachments.length > 0;
return (
<>
{shouldWrapInMosaic && <AttachmentMosaic attachments={mediaAttachments} message={message} />}
{enrichedAttachments.map((attachment) => (
<Attachment
key={attachment.id}
attachment={attachment}
isPreview={isPreview}
message={message}
renderInMosaic={shouldWrapInMosaic}
/>
))}
</>
);
})()}
{UserSettingsStore.getRenderEmbeds() &&
!message.suppressEmbeds &&
message.embeds.map((embed, index) => (
<Embed embed={embed} key={embed.id} message={message} embedIndex={index} onDelete={handleDelete} />
))}
{UserSettingsStore.getRenderReactions() && message.reactions.length > 0 && (
<MessageReactions message={message} isPreview={isPreview} onPopoutToggle={onPopoutToggle} />
)}
</>
);
});

View File

@@ -0,0 +1,241 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {ClockIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type {MessagePreviewContext} from '~/Constants';
import {MessageAvatar} from '~/components/channel/MessageAvatar';
import {MessageUsername} from '~/components/channel/MessageUsername';
import {TimestampWithTooltip} from '~/components/channel/TimestampWithTooltip';
import {UserTag} from '~/components/channel/UserTag';
import {Tooltip} from '~/components/uikit/Tooltip';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import type {UserRecord} from '~/records/UserRecord';
import styles from '~/styles/Message.module.css';
import * as DateUtils from '~/utils/DateUtils';
export const MessageAuthorInfo = observer(
({
message,
author,
guild,
member,
shouldGroup,
shouldAppearAuthorless,
messageDisplayCompact,
showUserAvatarsInCompactMode,
mobileLayoutEnabled,
isHovering,
formattedDate,
previewContext,
previewOverrides,
}: {
message: MessageRecord;
author: UserRecord;
guild?: GuildRecord;
member?: GuildMemberRecord;
shouldGroup: boolean;
shouldAppearAuthorless: boolean;
messageDisplayCompact: boolean;
showUserAvatarsInCompactMode: boolean;
mobileLayoutEnabled: boolean;
isHovering: boolean;
formattedDate: string;
previewContext?: keyof typeof MessagePreviewContext;
previewOverrides?: {
usernameColor?: string;
displayName?: string;
};
}) => {
if (shouldAppearAuthorless) return null;
const isPreview = !!previewContext;
const timeoutUntil = member?.communicationDisabledUntil ?? null;
const isMemberTimedOut = Boolean(member?.isTimedOut());
const timeoutIndicator =
timeoutUntil && isMemberTimedOut ? (
<Tooltip
text={() => (
<Trans>
Timeout ends {DateUtils.getShortRelativeDateString(timeoutUntil)} (
{DateUtils.getFormattedDateTime(timeoutUntil)})
</Trans>
)}
position="top"
maxWidth="none"
>
<span className={styles.messageTimeoutIndicator}>
<ClockIcon size={16} weight="bold" />
</span>
</Tooltip>
) : null;
if (messageDisplayCompact && !shouldGroup) {
return (
<h3 className={styles.messageAuthorInfoCompact}>
<TimestampWithTooltip date={message.timestamp} className={styles.messageTimestampCompact}>
<span className={styles.messageAssistiveText} aria-hidden="true">
{'['}
</span>
{DateUtils.getFormattedTime(message.timestamp)}
<span className={styles.messageAssistiveText} aria-hidden="true">
{'] '}
</span>
</TimestampWithTooltip>
{author.bot && <UserTag className={styles.userTagCompact} system={author.system} />}
{showUserAvatarsInCompactMode && (
<MessageAvatar
user={author}
message={message}
guildId={guild?.id}
size={16}
className={styles.messageAvatarCompact}
isHovering={isHovering}
isPreview={isPreview}
/>
)}
<span className={styles.authorContainer}>
{timeoutIndicator}
<MessageUsername
user={author}
message={message}
guild={guild}
member={member}
className={styles.messageUsername}
isPreview={isPreview}
previewColor={previewOverrides?.usernameColor}
previewName={previewOverrides?.displayName}
/>
<span className={styles.messageAssistiveText} aria-hidden="true">
{':'}
</span>
</span>
</h3>
);
}
if (messageDisplayCompact && shouldGroup) {
if (mobileLayoutEnabled) return null;
return (
<h3 className={styles.messageAuthorInfoCompact}>
<TimestampWithTooltip date={message.timestamp} className={styles.messageTimestampCompactHover}>
<span className={styles.messageAssistiveText} aria-hidden="true">
{'['}
</span>
{DateUtils.getFormattedTime(message.timestamp)}
<span className={styles.messageAssistiveText} aria-hidden="true">
{'] '}
</span>
</TimestampWithTooltip>
{author.bot && <UserTag className={styles.userTagCompact} system={author.system} />}
{showUserAvatarsInCompactMode && (
<MessageAvatar
user={author}
message={message}
guildId={guild?.id}
size={16}
className={styles.messageAvatarCompact}
isHovering={isHovering}
isPreview={isPreview}
/>
)}
<span className={styles.authorContainer}>
{timeoutIndicator}
<MessageUsername
user={author}
message={message}
guild={guild}
member={member}
className={styles.messageUsername}
isPreview={isPreview}
previewColor={previewOverrides?.usernameColor}
previewName={previewOverrides?.displayName}
/>
<span className={styles.messageAssistiveText} aria-hidden="true">
{':'}
</span>
</span>
</h3>
);
}
if (!shouldGroup) {
return (
<>
<div className={styles.messageGutterLeft} />
<MessageAvatar
user={author}
message={message}
guildId={guild?.id}
size={40}
className={styles.messageAvatar}
isHovering={isHovering}
isPreview={isPreview}
/>
<div className={styles.messageGutterRight} />
<h3 className={styles.messageAuthorInfo}>
<span className={styles.authorContainer}>
{timeoutIndicator}
<MessageUsername
user={author}
message={message}
guild={guild}
member={member}
className={styles.messageUsername}
isPreview={isPreview}
previewColor={previewOverrides?.usernameColor}
previewName={previewOverrides?.displayName}
/>
{author.bot && <UserTag className={styles.userTagOffset} system={author.system} />}
</span>
<TimestampWithTooltip date={message.timestamp} className={styles.messageTimestamp}>
<span className={styles.messageAssistiveText} aria-hidden="true">
{' — '}
</span>
{formattedDate}
</TimestampWithTooltip>
</h3>
</>
);
}
if (mobileLayoutEnabled) return null;
return (
<>
<div className={styles.messageGutterLeft} />
<TimestampWithTooltip date={message.timestamp} className={styles.messageTimestampHover}>
<span className={styles.messageAssistiveText} aria-hidden="true">
{'['}
</span>
{DateUtils.getFormattedTime(message.timestamp)}
<span className={styles.messageAssistiveText} aria-hidden="true">
{']'}
</span>
</TimestampWithTooltip>
<div className={styles.messageGutterRight} />
</>
);
},
);

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
import {Avatar} from '~/components/uikit/Avatar';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import type {MessageRecord} from '~/records/MessageRecord';
import type {UserRecord} from '~/records/UserRecord';
export const MessageAvatar = observer(
({
user,
message,
guildId,
size,
className,
isHovering,
}: {
user: UserRecord;
message: MessageRecord;
guildId?: string;
size: 16 | 24 | 32 | 40 | 48 | 80 | 120;
className: string;
isHovering: boolean;
isPreview: boolean;
}) => {
return (
<PreloadableUserPopout
user={user}
isWebhook={message.webhookId != null}
guildId={guildId}
channelId={message.channelId}
enableLongPressActions={false}
>
<FocusRing>
<Avatar
user={user}
size={size}
className={className}
forceAnimate={isHovering}
guildId={guildId}
data-user-id={user.id}
data-guild-id={guildId}
/>
</FocusRing>
</PreloadableUserPopout>
);
},
);

View File

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

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import * as PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
import {MAX_MESSAGE_LENGTH_PREMIUM} from '~/Constants';
import styles from '~/components/channel/MessageCharacterCounter.module.css';
import {CharacterCounter} from '~/components/uikit/CharacterCounter/CharacterCounter';
interface MessageCharacterCounterProps {
currentLength: number;
maxLength: number;
isPremium: boolean;
threshold?: number;
}
export const MessageCharacterCounter = observer(
({currentLength, maxLength, isPremium, threshold = 0.8}: MessageCharacterCounterProps) => {
if (currentLength <= maxLength * threshold) {
return null;
}
return (
<div className={styles.container}>
<CharacterCounter
currentLength={currentLength}
maxLength={maxLength}
isPremium={isPremium}
premiumMaxLength={MAX_MESSAGE_LENGTH_PREMIUM}
onUpgradeClick={() => PremiumModalActionCreators.open()}
/>
</div>
);
},
);

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {Fragment} from 'react';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import {Message} from './Message';
import {UnreadDividerSlot} from './UnreadDividerSlot';
interface MessageGroupProps {
messages: Array<MessageRecord>;
channel: ChannelRecord;
onEdit?: (targetNode: HTMLElement) => void;
jumpSequenceId?: number;
highlightedMessageId?: string | null;
messageDisplayCompact?: boolean;
flashKey?: number;
getUnreadDividerVisibility?: (messageId: string, position: 'before' | 'after') => boolean;
idPrefix?: string;
}
export const MessageGroup: React.FC<MessageGroupProps> = observer((props) => {
const {
messages,
channel,
onEdit,
jumpSequenceId,
highlightedMessageId,
messageDisplayCompact = false,
getUnreadDividerVisibility,
idPrefix,
} = props;
const groupId = messages[0]?.id;
return (
<div data-jump-sequence-id={jumpSequenceId} data-group-id={groupId} role="group" aria-label="Message group">
{messages.map((message, index) => {
const prevMessage = messages[index - 1];
const isGroupStart = index === 0;
return (
<Fragment key={message.id}>
{getUnreadDividerVisibility && (
<UnreadDividerSlot beforeId={message.id} visible={getUnreadDividerVisibility(message.id, 'before')} />
)}
<div data-message-index={index} data-message-id={message.id} data-is-group-start={isGroupStart}>
<Message
channel={channel}
message={message}
prevMessage={prevMessage}
onEdit={onEdit}
shouldGroup={!isGroupStart}
isJumpTarget={highlightedMessageId === message.id}
compact={messageDisplayCompact}
idPrefix={idPrefix}
/>
</div>
</Fragment>
);
})}
</div>
);
});

View File

@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.reactionsGrid {
display: flex;
flex-wrap: wrap;
align-items: center;
padding-top: 0.25rem;
user-select: none;
-webkit-user-select: none;
}
.reactionContainer {
position: relative;
border-radius: 0.5rem;
margin-bottom: 0;
margin-inline-end: 0.25rem;
}
.reactionButton {
appearance: none;
background: var(--background-secondary);
border: 1px solid transparent;
border-radius: 0.5rem;
cursor: pointer;
padding: 0;
transition:
background-color 0.1s ease,
border-color 0.1s ease;
color: var(--text-tertiary);
}
.reactionButton:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-accent);
}
.reactionMe .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border-color: var(--brand-primary);
color: var(--text-on-brand-primary);
}
.reactionMe .reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%);
border-color: var(--brand-primary);
}
.reactionInner {
display: flex;
align-items: center;
padding: 0.125rem 0.375rem;
}
.emoji {
height: 1.25rem;
width: 1.25rem;
margin: 0.125rem 0;
min-height: auto;
min-width: auto;
object-fit: contain;
display: block;
flex-shrink: 0;
}
span.emoji {
font-size: 1.25rem;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
.countWrapper {
color: var(--text-tertiary);
font-weight: 600;
min-width: 12px;
line-height: 1;
position: relative;
overflow: hidden;
height: 1rem;
margin-inline-start: 0.375rem;
text-align: center;
user-select: none;
-webkit-user-select: none;
}
.reactionMe .countWrapper {
color: var(--text-on-brand-primary);
}
:global(.theme-light) .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary-light) 6%, var(--background-secondary) 94%);
border-color: color-mix(in srgb, var(--brand-primary-light) 10%, var(--background-secondary) 90%);
color: var(--text-primary);
}
:global(.theme-light) .reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary-light) 8%, var(--background-secondary) 92%);
border-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-secondary) 88%);
}
:global(.theme-light) .reactionMe .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
color: var(--brand-primary);
}
:global(.theme-light) .reactionMe .reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary) 18%, var(--background-primary) 82%);
border-color: var(--brand-primary);
}
:global(.theme-light) .reactionMe .countWrapper {
color: var(--brand-primary);
}
.addReactionButton {
display: flex;
align-items: center;
justify-content: center;
height: auto;
padding: 0.25rem 0.375rem;
border-radius: 0.5rem;
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
transition:
background-color 0.1s ease,
color 0.1s ease;
}
.addReactionButton:hover,
.addReactionButtonActive {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import React, {useEffect} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ReactionActionCreators from '~/actions/ReactionActionCreators';
import {ReactionInteractionDisabledModal} from '~/components/alerts/ReactionInteractionDisabledModal';
import {EmojiInfoBottomSheet} from '~/components/bottomsheets/EmojiInfoBottomSheet';
import styles from '~/components/channel/MessageReactions.module.css';
import {createMessageActionHandlers, useMessagePermissions} from '~/components/channel/messageActionUtils';
import {LongPressable} from '~/components/LongPressable';
import {EmojiPickerPopout} from '~/components/popouts/EmojiPickerPopout';
import {ReactionTooltip} from '~/components/popouts/ReactionTooltip';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Popout} from '~/components/uikit/Popout/Popout';
import {useHover} from '~/hooks/useHover';
import type {MessageReaction, MessageRecord} from '~/records/MessageRecord';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {getEmojiName, getReactionKey, useEmojiURL} from '~/utils/ReactionUtils';
interface EmojiInfoData {
id?: string;
name: string;
animated?: boolean;
}
const MessageReactionItem = observer(
({
message,
reaction,
isPreview = false,
}: {
message: MessageRecord;
reaction: MessageReaction;
isPreview?: boolean;
}) => {
const {t, i18n} = useLingui();
const [hoverRef, isHovering] = useHover();
const [prevCount, setPrevCount] = React.useState(reaction.count);
const [animationSyncKey, setAnimationSyncKey] = React.useState(0);
const [emojiInfoOpen, setEmojiInfoOpen] = React.useState(false);
const [selectedEmoji, setSelectedEmoji] = React.useState<EmojiInfoData | null>(null);
const isMobile = MobileLayoutStore.isMobileLayout();
const handleTooltipAnimationSync = React.useCallback(() => {
setAnimationSyncKey((prev) => prev + 1);
}, []);
React.useEffect(() => {
if (prevCount !== reaction.count) {
setPrevCount(reaction.count);
}
}, [reaction.count, prevCount]);
const handleClick = () => {
if (isPreview) {
ModalActionCreators.push(modal(() => <ReactionInteractionDisabledModal />));
return;
}
if (reaction.me) {
ReactionActionCreators.removeReaction(i18n, message.channelId, message.id, reaction.emoji);
} else {
ReactionActionCreators.addReaction(i18n, message.channelId, message.id, reaction.emoji);
}
};
const handleLongPress = () => {
if (isPreview) {
return;
}
setSelectedEmoji({
id: reaction.emoji.id ?? undefined,
name: reaction.emoji.name,
animated: reaction.emoji.animated,
});
setEmojiInfoOpen(true);
};
const handleCloseEmojiInfo = React.useCallback(() => {
setEmojiInfoOpen(false);
setSelectedEmoji(null);
}, []);
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
const _emojiIdentifier = reaction.emoji.id ?? reaction.emoji.name;
const isUnicodeEmoji = reaction.emoji.id == null;
const variants = {
up: {y: -20, opacity: 0},
down: {y: 20, opacity: 0},
center: {y: 0, opacity: 1},
};
const reactionCountText = reaction.count === 1 ? t`${reaction.count} reaction` : t`${reaction.count} reactions`;
const actionText = reaction.me ? t`press to remove reaction` : t`press to add reaction`;
const ariaLabel = t`${emojiName}: ${reactionCountText}, ${actionText}`;
const buttonContent = (
<FocusRing offset={-2}>
<button
type="button"
className={styles.reactionButton}
aria-label={ariaLabel}
aria-pressed={reaction.me}
onClick={handleClick}
>
<div className={styles.reactionInner}>
{emojiUrl ? (
<img src={emojiUrl} alt={emojiName} draggable={false} className={clsx('emoji', styles.emoji)} />
) : isUnicodeEmoji ? (
<span className={clsx('emoji', styles.emoji)}>{reaction.emoji.name}</span>
) : null}
<div className={styles.countWrapper}>
<AnimatePresence initial={false}>
<motion.div
key={reaction.count}
initial={reaction.count > prevCount ? 'up' : 'down'}
animate="center"
exit={reaction.count > prevCount ? 'down' : 'up'}
variants={variants}
transition={{duration: 0.2}}
>
{reaction.count}
</motion.div>
</AnimatePresence>
</div>
</div>
</button>
</FocusRing>
);
if (isMobile) {
return (
<LongPressable
className={clsx(styles.reactionContainer, reaction.me && styles.reactionMe)}
onLongPress={handleLongPress}
>
{buttonContent}
<EmojiInfoBottomSheet isOpen={emojiInfoOpen} onClose={handleCloseEmojiInfo} emoji={selectedEmoji} />
</LongPressable>
);
}
return (
<ReactionTooltip
message={message}
reaction={reaction}
hoveredEmojiUrl={emojiUrl}
animationSyncKey={animationSyncKey}
onRequestAnimationSync={handleTooltipAnimationSync}
>
<div className={clsx(styles.reactionContainer, reaction.me && styles.reactionMe)} ref={hoverRef}>
{buttonContent}
</div>
</ReactionTooltip>
);
},
);
export const MessageReactions = observer(
({
message,
isPreview = false,
onPopoutToggle,
}: {
message: MessageRecord;
isPreview?: boolean;
onPopoutToggle?: (isOpen: boolean) => void;
}) => {
const {t} = useLingui();
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
const addReactionButtonRef = React.useRef<HTMLButtonElement>(null);
const permissions = useMessagePermissions(message);
const handlers = createMessageActionHandlers(message);
const keyboardModeEnabled = KeyboardModeStore.keyboardModeEnabled;
const blurReactionTrigger = React.useCallback(() => {
if (keyboardModeEnabled) {
return;
}
requestAnimationFrame(() => addReactionButtonRef.current?.blur());
}, [keyboardModeEnabled]);
const handleEmojiPickerToggle = React.useCallback(
(open: boolean) => {
setEmojiPickerOpen(open);
onPopoutToggle?.(open);
if (!open) {
blurReactionTrigger();
}
},
[onPopoutToggle, blurReactionTrigger],
);
const handleEmojiPickerOpen = React.useCallback(() => handleEmojiPickerToggle(true), [handleEmojiPickerToggle]);
const handleEmojiPickerClose = React.useCallback(() => handleEmojiPickerToggle(false), [handleEmojiPickerToggle]);
useEffect(() => {
return () => {
if (emojiPickerOpen) {
onPopoutToggle?.(false);
}
};
}, [emojiPickerOpen, onPopoutToggle]);
const hasReactions = message.reactions.length > 0;
return (
<div className={styles.reactionsGrid}>
{message.reactions.map((reaction) => (
<MessageReactionItem
key={getReactionKey(message.id, reaction.emoji)}
message={message}
reaction={reaction}
isPreview={isPreview}
/>
))}
{hasReactions && permissions.canAddReactions && !isPreview && (
<Popout
render={({onClose}) => (
<EmojiPickerPopout
channelId={message.channelId}
handleSelect={handlers.handleEmojiSelect}
onClose={onClose}
/>
)}
position="right-start"
uniqueId={`emoji-picker-reactions-${message.id}`}
animationType="none"
onOpen={handleEmojiPickerOpen}
onClose={handleEmojiPickerClose}
>
<FocusRing offset={-2}>
<button
ref={addReactionButtonRef}
type="button"
className={clsx(styles.addReactionButton, emojiPickerOpen && styles.addReactionButtonActive)}
aria-label={t`Add Reaction`}
data-action="message-add-reaction-button"
>
<SmileyIcon size={20} weight="fill" />
</button>
</FocusRing>
</Popout>
)}
</div>
);
},
);

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 type {SearchBarProps} from './MessageSearchBar/MessageSearchBar';
export {MessageSearchBar} from './MessageSearchBar/MessageSearchBar';

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import styles from './MessageSearchBar.module.css';
interface AutocompleteOptionProps {
index: number;
isSelected: boolean;
isHovered: boolean;
onSelect: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
children: React.ReactNode;
listboxId: string;
}
export const AutocompleteOption: React.FC<AutocompleteOptionProps> = observer(
({index, isSelected, isHovered, onSelect, onMouseEnter, onMouseLeave, children, listboxId}) => {
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
},
[onSelect],
);
const isActive = isSelected || isHovered;
const showIcon = isSelected || isHovered;
return (
<div
role="option"
id={`${listboxId}-opt-${index}`}
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={(ev) => ev.preventDefault()}
onClick={onSelect}
onKeyDown={handleKeyDown}
className={`${styles.option} ${isActive ? styles.optionActive : ''} ${isSelected ? styles.optionKeyboardFocus : ''}`}
>
{children}
<PlusIcon
weight="bold"
className={`${styles.optionMetaIcon} ${showIcon ? '' : styles.optionMetaIconInactive}`}
/>
</div>
);
},
);

View File

@@ -0,0 +1,77 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {ChannelRecord} from '~/records/ChannelRecord';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {AutocompleteOption} from './AutocompleteOption';
import styles from './MessageSearchBar.module.css';
interface ChannelsSectionProps {
options: Array<ChannelRecord>;
selectedIndex: number;
hoverIndex: number;
onSelect: (channel: ChannelRecord) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
}
export const ChannelsSection: React.FC<ChannelsSectionProps> = observer(
({options, selectedIndex, hoverIndex, onSelect, onMouseEnter, onMouseLeave, listboxId}) => {
const {t} = useLingui();
if (options.length === 0) return null;
return (
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<MagnifyingGlassIcon weight="regular" size={14} />
{t`Channels`}
</span>
</div>
{options.map((channelOption: ChannelRecord, index) => (
<AutocompleteOption
key={channelOption.id}
index={index}
isSelected={index === selectedIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(channelOption)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
>
<div className={styles.optionLabel}>
<div className={styles.optionContent}>
<div className={styles.channelRow}>
{ChannelUtils.getIcon(channelOption, {className: styles.channelIcon})}
<span className={styles.channelName}>{channelOption.name || 'Unnamed Channel'}</span>
</div>
</div>
</div>
</AutocompleteOption>
))}
</div>
);
},
);

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {DateTime} from 'luxon';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {AutocompleteOption} from './AutocompleteOption';
import styles from './MessageSearchBar.module.css';
interface DateSectionProps {
selectedIndex: number;
hoverIndex: number;
onSelect: (dateValue: string) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
}
export const DateSection: React.FC<DateSectionProps> = observer(
({selectedIndex, hoverIndex, onSelect, onMouseEnter, onMouseLeave, listboxId}) => {
const {t} = useLingui();
const now = DateTime.local();
const fmt = (dt: DateTime) => dt.toFormat('yyyy-MM-dd');
const fmtTime = (dt: DateTime) => dt.toFormat("yyyy-MM-dd'T'HH:mm");
const options = [
{label: 'Today', value: fmt(now)},
{label: 'Yesterday', value: fmt(now.minus({days: 1}))},
{label: 'Now', value: fmtTime(now)},
];
return (
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<MagnifyingGlassIcon weight="regular" size={14} />
{t`Date Options`}
</span>
</div>
{options.map((opt: {label: string; value: string}, index) => (
<AutocompleteOption
key={opt.label}
index={index}
isSelected={index === selectedIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(opt.value)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
>
<div className={styles.optionLabel}>
<div className={styles.optionContent}>
<div className={styles.optionText}>
<div className={styles.optionTitle}>{opt.label}</div>
<div className={styles.optionDescription}>{opt.value}</div>
</div>
</div>
</div>
</AutocompleteOption>
))}
</div>
);
},
);

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {FunnelIcon, PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {SearchFilterOption} from '~/utils/SearchUtils';
import styles from './MessageSearchBar.module.css';
interface FilterOptionProps {
option: SearchFilterOption;
index: number;
isSelected: boolean;
isHovered: boolean;
onSelect: () => void;
onMouseEnter: () => void;
onMouseLeave?: () => void;
listboxId: string;
}
export const FilterOption: React.FC<FilterOptionProps> = observer(
({option, index, isSelected, isHovered, onSelect, onMouseEnter, onMouseLeave, listboxId}) => {
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
},
[onSelect],
);
const isActive = isSelected || isHovered;
const showIcon = isSelected || isHovered;
return (
<div
role="option"
id={`${listboxId}-opt-${index}`}
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={(ev) => ev.preventDefault()}
onClick={onSelect}
onKeyDown={handleKeyDown}
className={`${styles.option} ${isActive ? styles.optionActive : ''} ${isSelected ? styles.optionKeyboardFocus : ''}`}
>
<div className={styles.optionLabel}>
<div className={styles.optionContent}>
<div className={styles.optionText}>
<span className={styles.optionTitle}>
<span className={styles.searchFilter}>{option.label}</span>
<span className={styles.optionDescription}> {option.description}</span>
</span>
</div>
</div>
</div>
<PlusIcon
weight="bold"
className={`${styles.optionMetaIcon} ${showIcon ? '' : styles.optionMetaIconInactive}`}
/>
</div>
);
},
);
interface FiltersSectionProps {
options: Array<SearchFilterOption>;
selectedIndex: number;
hoverIndex: number;
onSelect: (option: SearchFilterOption) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
title?: string;
}
export const FiltersSection: React.FC<FiltersSectionProps> = observer(
({options, selectedIndex, hoverIndex, onSelect, onMouseEnter, onMouseLeave, listboxId, title}) => {
const {t} = useLingui();
if (options.length === 0) return null;
return (
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<FunnelIcon weight="regular" size={12} />
{title || t`Search Filters`}
</span>
</div>
{options.map((option: SearchFilterOption, index) => (
<FilterOption
key={option.key}
option={option}
index={index}
isSelected={index === selectedIndex}
onSelect={() => onSelect(option)}
isHovered={index === hoverIndex}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
/>
))}
</div>
);
},
);

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {ClockIcon, FunnelIcon, TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {SearchHistoryEntry} from '~/stores/SearchHistoryStore';
import SearchHistoryStore from '~/stores/SearchHistoryStore';
import type {SearchFilterOption} from '~/utils/SearchUtils';
import {AutocompleteOption} from './AutocompleteOption';
import {FilterOption} from './FilterOption';
import styles from './MessageSearchBar.module.css';
interface HistorySectionProps {
selectedIndex: number;
hoverIndex: number;
onSelect: (entry: SearchHistoryEntry) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
isInGuild: boolean;
channelId?: string;
onHistoryClear: () => void;
onFilterSelect: (filter: SearchFilterOption, index: number) => void;
onFilterMouseEnter: (index: number) => void;
onFilterMouseLeave?: () => void;
filterOptions: Array<SearchFilterOption>;
}
export const HistorySection: React.FC<HistorySectionProps> = observer(
({
selectedIndex,
hoverIndex,
onSelect,
onMouseEnter,
onMouseLeave,
listboxId,
isInGuild,
channelId,
onHistoryClear,
onFilterSelect,
onFilterMouseEnter,
onFilterMouseLeave,
filterOptions,
}) => {
const {t} = useLingui();
const historyOptions = SearchHistoryStore.search('', channelId).slice(0, 5);
const commonFilters = filterOptions
.filter((opt) => !opt.requiresGuild || isInGuild)
.filter((opt) => !opt.key.startsWith('-'));
return (
<>
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<FunnelIcon weight="regular" size={12} />
{t`Search Filters`}
</span>
</div>
{commonFilters.map((option: SearchFilterOption, index) => (
<FilterOption
key={option.key}
option={option}
index={index}
isSelected={selectedIndex === index}
isHovered={index === hoverIndex}
onSelect={() => onFilterSelect(option, index)}
onMouseEnter={() => onFilterMouseEnter(index)}
onMouseLeave={onFilterMouseLeave}
listboxId={listboxId}
/>
))}
</div>
{historyOptions.length > 0 && (
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<ClockIcon weight="regular" size={12} />
{t`Recent Searches`}
</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onHistoryClear();
}}
className={`${styles.flex} ${styles.itemsCenter} ${styles.gap1}`}
>
<TrashIcon weight="regular" size={10} />
{t`Clear`}
</button>
</div>
{historyOptions.map((entry: SearchHistoryEntry, index) => (
<AutocompleteOption
key={`${entry.query}:${entry.ts}`}
index={commonFilters.length + index}
isSelected={selectedIndex === commonFilters.length + index}
isHovered={commonFilters.length + index === hoverIndex}
onSelect={() => onSelect(entry)}
onMouseEnter={() => onMouseEnter(commonFilters.length + index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
>
<div className={styles.optionLabel}>
<div className={styles.optionContent}>
<div className={styles.optionText}>
<span className={`${styles.optionTitle} ${styles.historyOptionTitle}`}>{entry.query}</span>
</div>
</div>
</div>
</AutocompleteOption>
))}
</div>
)}
</>
);
},
);

View File

@@ -0,0 +1,485 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.anchor {
position: relative;
display: flex;
align-items: center;
width: 244px;
}
.inputContainer {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding-left: var(--input-container-padding);
padding-right: var(--input-container-padding);
min-height: 36px;
border-radius: var(--radius-xl);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-textarea);
}
.inputContainer:focus-within {
border-color: var(--background-modifier-accent-focus);
}
.searchIcon {
height: 16px;
width: 16px;
flex-shrink: 0;
color: var(--text-tertiary);
transition: color 0.1s ease;
}
.scopeBadge {
position: absolute;
bottom: -4px;
left: -4px;
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: var(--radius-sm);
background-color: var(--background-secondary);
color: var(--text-primary-muted);
border: 1px solid var(--background-modifier-accent);
}
.scopeButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.scopeButton:hover .searchIcon {
color: var(--text-primary);
}
.scopeButton:hover .scopeBadge {
color: var(--text-primary);
background-color: var(--background-secondary-alt);
}
.input {
height: 36px;
min-height: 36px;
flex: 1;
border: none;
background: transparent;
outline: none;
color: var(--text-primary);
font-size: 0.875rem;
}
.input::placeholder {
color: var(--text-primary-muted);
}
.clearButton {
margin-left: 8px;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.clearButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.popoutContainer {
border-radius: var(--radius-xl);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-textarea);
box-shadow: var(--shadow-popover, 0 8px 24px rgba(0, 0, 0, 0.28));
display: flex;
flex-direction: column;
z-index: 1000;
}
.popoutInner {
display: flex;
flex-direction: column;
}
.list {
display: flex;
flex-direction: column;
gap: 0;
padding: var(--spacing-1);
}
.option {
display: flex;
width: 100%;
align-items: center;
gap: var(--spacing-2);
border: none;
background: transparent;
text-align: left;
border-radius: var(--radius-md);
padding: 3px var(--spacing-2);
cursor: pointer;
color: var(--text-primary);
}
.customDateInput {
display: flex;
width: 100%;
align-items: flex-start;
gap: var(--spacing-2);
border: none;
background: transparent;
text-align: left;
border-radius: var(--radius-md);
padding: 3px var(--spacing-2);
color: var(--text-primary);
}
.option:hover {
background-color: var(--surface-interactive-hover-bg);
}
.optionActive {
background-color: var(--surface-interactive-hover-bg);
}
.optionKeyboardFocus {
background-color: var(--surface-interactive-selected-bg);
color: var(--surface-interactive-selected-color);
}
.optionKeyboardFocus:hover {
background-color: var(--surface-interactive-selected-bg);
}
.optionLabel {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex: 1;
gap: var(--spacing-2);
}
.optionContent {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex: 1;
min-width: 0;
}
.valueOptionContent {
align-items: flex-start;
}
.valueOptionText {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
width: 100%;
min-width: 0;
}
.valueOptionTitle {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-wrap: wrap;
}
.valueOptionDefault {
font-size: 0.6875rem;
line-height: 1;
color: var(--text-primary-muted);
background-color: var(--background-secondary-alt);
border: 1px solid var(--background-modifier-accent);
border-radius: var(--radius-md);
padding: 2px 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.optionText {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex: 1;
min-width: 0;
}
.optionTitle {
font-weight: 500;
font-size: 0.9375rem;
line-height: 1.25rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.historyOptionTitle {
font-size: 0.8125rem;
line-height: 1.125rem;
color: var(--text-primary-muted);
}
.optionDescription {
font-size: 0.8125rem;
line-height: 1rem;
color: var(--text-primary-muted);
opacity: 0.7;
}
.optionMetaIcon {
flex-shrink: 0;
height: 14px;
width: 14px;
color: var(--text-primary);
}
.optionMetaIconInactive {
flex-shrink: 0;
height: 14px;
width: 14px;
color: var(--text-tertiary);
}
.divider {
margin: 4px 8px;
border-top: 1px solid var(--background-modifier-accent);
opacity: 0.5;
}
.userRow {
min-width: 0;
display: flex;
align-items: center;
gap: var(--spacing-2);
overflow: hidden;
}
.channelRow {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.userName,
.channelName {
font-size: 0.9375rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.userTag {
font-size: 0.8125rem;
color: var(--text-primary-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
margin-left: 2px;
}
.channelIcon {
height: 20px;
width: 20px;
color: var(--text-primary-muted);
flex-shrink: 0;
}
.kbdKey {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 8px;
min-width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary-alt);
color: var(--text-primary);
font-size: 0.75rem;
font-weight: 600;
text-align: center;
line-height: 1;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.2),
0 1px 0 rgba(255, 255, 255, 0.1) inset,
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.searchFilter {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
background-color: var(--background-secondary-alt);
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.2;
border: 1px solid var(--background-modifier-accent);
}
.popoutSection {
margin-bottom: var(--spacing-1);
}
.popoutSection:last-child {
margin-bottom: 0;
}
.popoutSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px var(--spacing-2);
margin-bottom: 2px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary-muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.popoutSectionHeader button {
padding: 2px 6px;
border: none;
border-radius: 3px;
background-color: transparent;
color: var(--text-primary-muted);
font-size: 12px;
cursor: pointer;
}
.popoutSectionHeader button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.helpRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2);
border-top: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
font-size: 0.8125rem;
color: var(--text-primary-muted);
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
}
.helpShortcuts {
display: flex;
align-items: center;
gap: 8px;
}
.helpShortcut {
display: flex;
align-items: center;
gap: 4px;
}
.helpActionButton {
display: flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-2);
border: none;
border-radius: var(--radius-md);
background-color: transparent;
color: var(--text-primary-muted);
font-size: 0.8125rem;
cursor: pointer;
}
.helpActionButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.helpActionButton .kbdKey {
min-width: 24px;
height: 24px;
font-size: 0.7rem;
}
.flex {
display: flex;
}
.flexCol {
flex-direction: column;
}
.itemsCenter {
align-items: center;
}
.justifyBetween {
justify-content: space-between;
}
.gap1 {
gap: var(--spacing-1);
}
.gap2 {
gap: var(--spacing-2);
}
.minW0 {
min-width: 0;
}
.flex1 {
flex: 1;
}
.overflowHidden {
overflow: hidden;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import type {UserRecord} from '~/records/UserRecord';
import GuildStore from '~/stores/GuildStore';
import * as NicknameUtils from '~/utils/NicknameUtils';
import {AutocompleteOption} from './AutocompleteOption';
import styles from './MessageSearchBar.module.css';
interface UsersSectionProps {
options: Array<UserRecord>;
selectedIndex: number;
hoverIndex: number;
onSelect: (user: UserRecord) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
guildId?: string;
isInGuild: boolean;
}
export const UsersSection: React.FC<UsersSectionProps> = observer(
({options, selectedIndex, hoverIndex, onSelect, onMouseEnter, onMouseLeave, listboxId, guildId, isInGuild}) => {
const {t} = useLingui();
if (options.length === 0) return null;
return (
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<MagnifyingGlassIcon weight="regular" size={14} />
{t`Users`}
</span>
</div>
{options.map((user: UserRecord, index) => {
const guild = isInGuild && guildId ? GuildStore.getGuild(guildId) : null;
const nickname = NicknameUtils.getNickname(user, guild?.id);
return (
<AutocompleteOption
key={user.id}
index={index}
isSelected={index === selectedIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(user)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
>
<div className={styles.optionLabel}>
<div className={styles.optionContent}>
<div className={styles.optionText}>
<div className={styles.optionTitle}>
<span className={`${styles.userRow} ${styles.gap1}`}>
<span className={`${styles.userRow} ${styles.gap2}`}>
<StatusAwareAvatar user={user} size={16} />
<span className={`${styles.minW0} ${styles.overflowHidden}`}>{nickname}</span>
</span>
<span className={styles.userTag}>{user.tag}</span>
</span>
</div>
</div>
</div>
</div>
</AutocompleteOption>
);
})}
</div>
);
},
);

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {FunnelIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {SearchValueOption} from '~/utils/SearchUtils';
import {AutocompleteOption} from './AutocompleteOption';
import styles from './MessageSearchBar.module.css';
interface ValuesSectionProps {
options: Array<SearchValueOption>;
selectedIndex: number;
hoverIndex: number;
onSelect: (value: SearchValueOption) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
}
export const ValuesSection: React.FC<ValuesSectionProps> = observer(
({options, selectedIndex, hoverIndex, onSelect, onMouseEnter, onMouseLeave, listboxId}) => {
const {t} = useLingui();
if (options.length === 0) return null;
return (
<div className={styles.popoutSection}>
<div className={styles.popoutSectionHeader}>
<span className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}>
<FunnelIcon weight="regular" size={14} />
{t`Values`}
</span>
</div>
{options.map((valueOption, index) => (
<AutocompleteOption
key={valueOption.value}
index={index}
isSelected={index === selectedIndex}
isHovered={index === hoverIndex}
onSelect={() => onSelect(valueOption)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
>
<div className={styles.optionLabel}>
<div className={`${styles.optionContent} ${styles.valueOptionContent}`}>
<div className={styles.valueOptionText}>
<div className={styles.valueOptionTitle}>
<span className={styles.searchFilter}>{valueOption.label}</span>
{valueOption.isDefault && <span className={styles.valueOptionDefault}>{t`Default`}</span>}
</div>
{valueOption.description && (
<span className={styles.optionDescription}>{valueOption.description}</span>
)}
</div>
</div>
</div>
</AutocompleteOption>
))}
</div>
);
},
);

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 0.75rem;
overflow: hidden;
border-radius: 0.5rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
padding: 0.75rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.iconContainer {
display: flex;
height: 3rem;
width: 3rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: var(--background-tertiary);
color: var(--text-tertiary);
}
.content {
min-width: 0;
flex: 1;
overflow: hidden;
}
.fileName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.fileSize {
color: var(--text-tertiary);
font-size: 0.75rem;
}
.progressContainer {
margin-top: 0.375rem;
height: 0.25rem;
width: 100%;
overflow: hidden;
border-radius: 9999px;
background-color: var(--background-tertiary);
}
.progressBarIndeterminate {
height: 100%;
width: 100%;
border-radius: 9999px;
background-color: var(--brand-primary);
opacity: 0.5;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.progressBar {
height: 100%;
border-radius: 9999px;
transition:
width 150ms,
background-color 150ms;
}
.progressBarNormal {
background-color: var(--brand-primary);
}
.progressBarFailed {
background-color: rgb(239 68 68);
}
.cancelButton {
display: flex;
height: 2.5rem;
width: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: var(--background-tertiary);
color: var(--text-tertiary);
cursor: pointer;
}
.cancelButton:hover {
background-color: rgb(239 68 68);
color: white;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {FileIcon, XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {useMessageUpload} from '~/hooks/useCloudUpload';
import {CloudUpload} from '~/lib/CloudUpload';
import type {MessageAttachment, MessageRecord} from '~/records/MessageRecord';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {formatFileSize} from '~/utils/FileUtils';
import styles from './MessageUploadProgress.module.css';
interface MessageUploadProgressProps {
attachment: MessageAttachment;
message: MessageRecord;
}
export const MessageUploadProgress = observer(({attachment, message}: MessageUploadProgressProps) => {
const {t} = useLingui();
const {enabled: isMobile} = MobileLayoutStore;
const messageUpload = useMessageUpload(message.nonce || '');
const resolveProgress = (): number | null => {
if (!messageUpload) return null;
if (typeof messageUpload.sendingProgress === 'number') {
return Math.round(messageUpload.sendingProgress);
}
if (!messageUpload.attachments.length) return null;
const withProgress = messageUpload.attachments.filter(
(att) => att.uploadProgress !== undefined && att.status !== 'failed',
);
if (!withProgress.length) {
return null;
}
const total = withProgress.reduce((sum, att) => sum + (att.uploadProgress ?? 0), 0);
return Math.round(total / withProgress.length);
};
const hasFailedUploads = (): boolean => {
if (!messageUpload) return false;
return messageUpload.attachments.some((att) => att.status === 'failed');
};
const handleCancel = async () => {
if (!message.nonce || !messageUpload) return;
try {
await Promise.all(messageUpload.attachments.map((att) => CloudUpload.cancelUpload(att.id)));
} catch (error) {
console.error('Failed to cancel some uploads:', error);
}
CloudUpload.removeMessageUpload(message.nonce);
MessageActionCreators.deleteOptimistic(message.channelId, message.id);
};
const progress = resolveProgress();
const failed = hasFailedUploads();
const fileName = attachment.filename;
const fileSize = formatFileSize(attachment.size);
const isIndeterminate = progress === null;
const progressValue = progress ?? 0;
const containerStyles: React.CSSProperties = isMobile
? {
display: 'grid',
width: '100%',
maxWidth: '100%',
minWidth: 0,
}
: {
display: 'grid',
width: '400px',
maxWidth: '400px',
};
return (
<div style={containerStyles}>
<div className={styles.container}>
<div className={styles.iconContainer}>
<FileIcon size={32} />
</div>
<div className={styles.content}>
<p className={styles.fileName}>{fileName}</p>
<p className={styles.fileSize}>{fileSize}</p>
<div className={styles.progressContainer}>
{isIndeterminate ? (
<div className={styles.progressBarIndeterminate} />
) : (
<div
className={`${styles.progressBar} ${failed ? styles.progressBarFailed : styles.progressBarNormal}`}
style={{width: `${progressValue}%`}}
/>
)}
</div>
</div>
<FocusRing offset={-2}>
<button type="button" onClick={handleCancel} className={styles.cancelButton} aria-label={t`Cancel upload`}>
<XIcon size={20} weight="bold" />
</button>
</FocusRing>
</div>
</div>
);
});

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