initial commit
This commit is contained in:
42
fluxer_app/src/components/channel/Autocomplete.module.css
Normal file
42
fluxer_app/src/components/channel/Autocomplete.module.css
Normal 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;
|
||||
}
|
||||
366
fluxer_app/src/components/channel/Autocomplete.tsx
Normal file
366
fluxer_app/src/components/channel/Autocomplete.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
70
fluxer_app/src/components/channel/AutocompleteChannel.tsx
Normal file
70
fluxer_app/src/components/channel/AutocompleteChannel.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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
|
||||
}
|
||||
/>
|
||||
));
|
||||
},
|
||||
);
|
||||
99
fluxer_app/src/components/channel/AutocompleteCommand.tsx
Normal file
99
fluxer_app/src/components/channel/AutocompleteCommand.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
200
fluxer_app/src/components/channel/AutocompleteEmoji.tsx
Normal file
200
fluxer_app/src/components/channel/AutocompleteEmoji.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
85
fluxer_app/src/components/channel/AutocompleteGif.module.css
Normal file
85
fluxer_app/src/components/channel/AutocompleteGif.module.css
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
116
fluxer_app/src/components/channel/AutocompleteGif.tsx
Normal file
116
fluxer_app/src/components/channel/AutocompleteGif.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
75
fluxer_app/src/components/channel/AutocompleteItem.tsx
Normal file
75
fluxer_app/src/components/channel/AutocompleteItem.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
79
fluxer_app/src/components/channel/AutocompleteMeme.tsx
Normal file
79
fluxer_app/src/components/channel/AutocompleteMeme.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
/>
|
||||
));
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
175
fluxer_app/src/components/channel/AutocompleteMention.tsx
Normal file
175
fluxer_app/src/components/channel/AutocompleteMention.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
73
fluxer_app/src/components/channel/AutocompleteSticker.tsx
Normal file
73
fluxer_app/src/components/channel/AutocompleteSticker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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
|
||||
}
|
||||
/>
|
||||
));
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
174
fluxer_app/src/components/channel/BlockedMessageGroups.tsx
Normal file
174
fluxer_app/src/components/channel/BlockedMessageGroups.tsx
Normal 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);
|
||||
59
fluxer_app/src/components/channel/CallMessage.module.css
Normal file
59
fluxer_app/src/components/channel/CallMessage.module.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
206
fluxer_app/src/components/channel/CallMessage.tsx
Normal file
206
fluxer_app/src/components/channel/CallMessage.tsx
Normal 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">
|
||||
—
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
482
fluxer_app/src/components/channel/ChannelAttachmentArea.tsx
Normal file
482
fluxer_app/src/components/channel/ChannelAttachmentArea.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
67
fluxer_app/src/components/channel/ChannelChatLayout.tsx
Normal file
67
fluxer_app/src/components/channel/ChannelChatLayout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
546
fluxer_app/src/components/channel/ChannelHeader.module.css
Normal file
546
fluxer_app/src/components/channel/ChannelHeader.module.css
Normal 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;
|
||||
}
|
||||
708
fluxer_app/src/components/channel/ChannelHeader.tsx
Normal file
708
fluxer_app/src/components/channel/ChannelHeader.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
131
fluxer_app/src/components/channel/ChannelHeader/CallButtons.tsx
Normal file
131
fluxer_app/src/components/channel/ChannelHeader/CallButtons.tsx
Normal 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,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
118
fluxer_app/src/components/channel/ChannelIndexPage.module.css
Normal file
118
fluxer_app/src/components/channel/ChannelIndexPage.module.css
Normal 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;
|
||||
}
|
||||
51
fluxer_app/src/components/channel/ChannelIndexPage.tsx
Normal file
51
fluxer_app/src/components/channel/ChannelIndexPage.tsx
Normal 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} />;
|
||||
});
|
||||
66
fluxer_app/src/components/channel/ChannelLayout.module.css
Normal file
66
fluxer_app/src/components/channel/ChannelLayout.module.css
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
51
fluxer_app/src/components/channel/ChannelLayout.tsx
Normal file
51
fluxer_app/src/components/channel/ChannelLayout.tsx
Normal 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>;
|
||||
});
|
||||
108
fluxer_app/src/components/channel/ChannelMembers.module.css
Normal file
108
fluxer_app/src/components/channel/ChannelMembers.module.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
301
fluxer_app/src/components/channel/ChannelMembers.tsx
Normal file
301
fluxer_app/src/components/channel/ChannelMembers.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
24
fluxer_app/src/components/channel/ChannelSearchHighlight.css
Normal file
24
fluxer_app/src/components/channel/ChannelSearchHighlight.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1110
fluxer_app/src/components/channel/ChannelSearchResults.tsx
Normal file
1110
fluxer_app/src/components/channel/ChannelSearchResults.tsx
Normal file
File diff suppressed because it is too large
Load Diff
112
fluxer_app/src/components/channel/ChannelStickersArea.module.css
Normal file
112
fluxer_app/src/components/channel/ChannelStickersArea.module.css
Normal 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;
|
||||
}
|
||||
81
fluxer_app/src/components/channel/ChannelStickersArea.tsx
Normal file
81
fluxer_app/src/components/channel/ChannelStickersArea.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
901
fluxer_app/src/components/channel/ChannelTextarea.tsx
Normal file
901
fluxer_app/src/components/channel/ChannelTextarea.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
60
fluxer_app/src/components/channel/ChannelWelcomeSection.tsx
Normal file
60
fluxer_app/src/components/channel/ChannelWelcomeSection.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
105
fluxer_app/src/components/channel/Divider.module.css
Normal file
105
fluxer_app/src/components/channel/Divider.module.css
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
78
fluxer_app/src/components/channel/Divider.tsx
Normal file
78
fluxer_app/src/components/channel/Divider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
}),
|
||||
);
|
||||
51
fluxer_app/src/components/channel/EditBar.module.css
Normal file
51
fluxer_app/src/components/channel/EditBar.module.css
Normal 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;
|
||||
}
|
||||
69
fluxer_app/src/components/channel/EditBar.tsx
Normal file
69
fluxer_app/src/components/channel/EditBar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
310
fluxer_app/src/components/channel/EditingMessageInput.tsx
Normal file
310
fluxer_app/src/components/channel/EditingMessageInput.tsx
Normal 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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
458
fluxer_app/src/components/channel/EmojiPicker.module.css
Normal file
458
fluxer_app/src/components/channel/EmojiPicker.module.css
Normal 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%;
|
||||
}
|
||||
275
fluxer_app/src/components/channel/EmojiPicker.tsx
Normal file
275
fluxer_app/src/components/channel/EmojiPicker.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
554
fluxer_app/src/components/channel/GifPicker.module.css
Normal file
554
fluxer_app/src/components/channel/GifPicker.module.css
Normal 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;
|
||||
}
|
||||
227
fluxer_app/src/components/channel/GifVideoPool.tsx
Normal file
227
fluxer_app/src/components/channel/GifVideoPool.tsx
Normal 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;
|
||||
};
|
||||
178
fluxer_app/src/components/channel/GiftEmbed.module.css
Normal file
178
fluxer_app/src/components/channel/GiftEmbed.module.css
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
156
fluxer_app/src/components/channel/GiftEmbed.tsx
Normal file
156
fluxer_app/src/components/channel/GiftEmbed.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
52
fluxer_app/src/components/channel/GuildJoinMessage.tsx
Normal file
52
fluxer_app/src/components/channel/GuildJoinMessage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
161
fluxer_app/src/components/channel/InviteEmbed.module.css
Normal file
161
fluxer_app/src/components/channel/InviteEmbed.module.css
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
458
fluxer_app/src/components/channel/InviteEmbed.tsx
Normal file
458
fluxer_app/src/components/channel/InviteEmbed.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
86
fluxer_app/src/components/channel/InviteEmbed/utils.ts
Normal file
86
fluxer_app/src/components/channel/InviteEmbed/utils.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
399
fluxer_app/src/components/channel/MasonryListComputer.ts
Normal file
399
fluxer_app/src/components/channel/MasonryListComputer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
41
fluxer_app/src/components/channel/MemberListContainer.tsx
Normal file
41
fluxer_app/src/components/channel/MemberListContainer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
137
fluxer_app/src/components/channel/MemberListItem.module.css
Normal file
137
fluxer_app/src/components/channel/MemberListItem.module.css
Normal 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;
|
||||
}
|
||||
177
fluxer_app/src/components/channel/MemberListItem.tsx
Normal file
177
fluxer_app/src/components/channel/MemberListItem.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
276
fluxer_app/src/components/channel/MemesPicker.module.css
Normal file
276
fluxer_app/src/components/channel/MemesPicker.module.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
137
fluxer_app/src/components/channel/MentionEveryonePopout.tsx
Normal file
137
fluxer_app/src/components/channel/MentionEveryonePopout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
561
fluxer_app/src/components/channel/Message.tsx
Normal file
561
fluxer_app/src/components/channel/Message.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
845
fluxer_app/src/components/channel/MessageActionBar.tsx
Normal file
845
fluxer_app/src/components/channel/MessageActionBar.tsx
Normal 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};
|
||||
@@ -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;
|
||||
}
|
||||
117
fluxer_app/src/components/channel/MessageActionBottomSheet.tsx
Normal file
117
fluxer_app/src/components/channel/MessageActionBottomSheet.tsx
Normal 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']}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
187
fluxer_app/src/components/channel/MessageAttachments.module.css
Normal file
187
fluxer_app/src/components/channel/MessageAttachments.module.css
Normal 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;
|
||||
}
|
||||
333
fluxer_app/src/components/channel/MessageAttachments.tsx
Normal file
333
fluxer_app/src/components/channel/MessageAttachments.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
241
fluxer_app/src/components/channel/MessageAuthorInfo.tsx
Normal file
241
fluxer_app/src/components/channel/MessageAuthorInfo.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
66
fluxer_app/src/components/channel/MessageAvatar.tsx
Normal file
66
fluxer_app/src/components/channel/MessageAvatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
83
fluxer_app/src/components/channel/MessageGroup.tsx
Normal file
83
fluxer_app/src/components/channel/MessageGroup.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
155
fluxer_app/src/components/channel/MessageReactions.module.css
Normal file
155
fluxer_app/src/components/channel/MessageReactions.module.css
Normal 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);
|
||||
}
|
||||
275
fluxer_app/src/components/channel/MessageReactions.tsx
Normal file
275
fluxer_app/src/components/channel/MessageReactions.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
21
fluxer_app/src/components/channel/MessageSearchBar.tsx
Normal file
21
fluxer_app/src/components/channel/MessageSearchBar.tsx
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
130
fluxer_app/src/components/channel/MessageUploadProgress.tsx
Normal file
130
fluxer_app/src/components/channel/MessageUploadProgress.tsx
Normal 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
Reference in New Issue
Block a user