fix(app): rework composer layout and footer alignment (#1591)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-14 19:28:22 +02:00
1 parent d56c1e5674
commit 01432bc682
76 files changed
+8772 -8759

No files matched your search

@@ -164,6 +164,9 @@ export {
isSpecialMention,
isSticker,
} from '@app/features/channel/components/AutocompleteTypes';
const ATTACHED_AUTOCOMPLETE_GAP = 4;
export const Autocomplete = observer(
({
type,
@@ -214,11 +217,11 @@ export const Autocomplete = observer(
setReferenceState(resolveReferenceElement(referenceElement));
}, [referenceElement]);
const portalHost = usePortalHost();
let resolvedMainAxisOffset = attached ? 0 : 8;
let resolvedMainAxisOffset = attached ? ATTACHED_AUTOCOMPLETE_GAP : 8;
if (mainAxisOffset != null) {
resolvedMainAxisOffset = mainAxisOffset;
}
const resolvedCrossAxisOffset = attached ? 8 : 0;
const resolvedCrossAxisOffset = 0;
const heading = resolveAutocompleteHeading(type, options, i18n);
const {refs, floatingStyles} = useFloating({
placement: 'top-start',
@@ -230,7 +233,7 @@ export const Autocomplete = observer(
flip({padding: 16}),
size({
apply({rects, elements}) {
const width = attached ? Math.max(0, rects.reference.width - 16) : rects.reference.width;
const width = rects.reference.width;
Object.assign(elements.floating.style, {
width: `${width}px`,
});
@@ -1,6 +1,15 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
--composer-surface-color: var(--background-secondary-lighter);
--composer-status-line-height: 1.125rem;
--composer-status-safe-gap: 0.5rem;
--composer-status-safe-area: calc(
var(--composer-status-line-height) +
var(--composer-status-safe-gap) +
var(--composer-status-safe-gap)
);
--messages-bottom-clearance: max(0px, calc(var(--composer-status-safe-area) - var(--composer-box-inset, 0px)));
display: flex;
flex-direction: column;
height: 100%;
@@ -9,7 +18,7 @@
max-width: 100%;
min-width: 0;
overflow: hidden;
background-color: var(--background-secondary-lighter);
background-color: var(--composer-surface-color);
contain: layout style;
}
@@ -33,7 +42,6 @@
z-index: 1;
padding: 0;
overflow: visible;
background-color: var(--background-secondary-lighter);
width: 100%;
max-width: 100%;
box-sizing: border-box;
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
export interface ChannelComposerDismissalRequest {
channelId: string;
}
export function requestChannelComposerAffordanceDismissal(channelId: string): boolean {
const result = ComponentDispatch.dispatchToFirstResult(
'TEXTAREA_DISMISS_AFFORDANCE',
{channelId},
(candidate) => candidate === true,
);
return result === true;
}
@@ -7,7 +7,6 @@ import {TypingUsers, usePresentableTypingUsers} from '@app/features/channel/comp
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -24,7 +23,6 @@ interface ChannelComposerStatusRailProps {
slowmodeRemaining: number;
slowmodeImmune: boolean;
mobile: boolean;
leadingContentInFlow: boolean;
onCancelEdit: () => void;
}
@@ -41,7 +39,6 @@ export const ChannelComposerStatusRail = observer(function ChannelComposerStatus
slowmodeRemaining,
slowmodeImmune,
mobile,
leadingContentInFlow,
onCancelEdit,
}: ChannelComposerStatusRailProps) {
const presentableTypingUsers = usePresentableTypingUsers(channel);
@@ -50,9 +47,6 @@ export const ChannelComposerStatusRail = observer(function ChannelComposerStatus
const leadingContentVisible = mobileEditVisible || replyVisible;
const typingVisible = showTypingStatus && !autocompleteVisible && presentableTypingUsers.length > 0;
const slowmodeVisible = showSlowmodeStatus && slowmodeEnabled;
if (!leadingContentVisible && !typingVisible && !slowmodeVisible) {
return null;
}
let topBar: React.ReactNode = null;
if (mobileEditVisible) {
topBar = <EditBar channel={channel} onCancel={onCancelEdit} data-flx="channel.composer-status-rail.edit-bar" />;
@@ -68,35 +62,33 @@ export const ChannelComposerStatusRail = observer(function ChannelComposerStatus
);
}
return (
<div
className={clsx(wrapperStyles.statusRail, leadingContentInFlow && wrapperStyles.statusRailInFlow)}
data-flx="channel.composer-status-rail.container"
>
<div
className={clsx(wrapperStyles.statusRailLeft, leadingContentVisible && wrapperStyles.statusRailLeftWithLeading)}
data-flx="channel.composer-status-rail.left"
>
{topBar !== null && (
<div className={wrapperStyles.topBarContainer} data-flx="channel.composer-status-rail.top-bar">
{topBar}
</div>
)}
{typingVisible && (
<div className={wrapperStyles.statusTypingSlot} data-flx="channel.composer-status-rail.typing-slot">
<TypingUsers channel={channel} data-flx="channel.composer-status-rail.typing-users" />
<>
<div className={wrapperStyles.statusRail} data-flx="channel.composer-status-rail.container">
<div className={wrapperStyles.statusRailLeft} data-flx="channel.composer-status-rail.left">
{typingVisible && (
<div className={wrapperStyles.statusTypingSlot} data-flx="channel.composer-status-rail.typing-slot">
<TypingUsers channel={channel} showAvatars={true} data-flx="channel.composer-status-rail.typing-users" />
</div>
)}
</div>
{slowmodeVisible && (
<div className={wrapperStyles.statusSlowmodeSlot} data-flx="channel.composer-status-rail.slowmode-slot">
<SlowmodeIndicator
slowmodeRemaining={slowmodeRemaining}
slowmodeDuration={channel.rateLimitPerUser * 1000}
isImmune={slowmodeImmune}
data-flx="channel.composer-status-rail.slowmode-indicator"
/>
</div>
)}
</div>
{slowmodeVisible && (
<div className={wrapperStyles.statusSlowmodeSlot} data-flx="channel.composer-status-rail.slowmode-slot">
<SlowmodeIndicator
slowmodeRemaining={slowmodeRemaining}
slowmodeDuration={channel.rateLimitPerUser * 1000}
isImmune={slowmodeImmune}
data-flx="channel.composer-status-rail.slowmode-indicator"
/>
{leadingContentVisible && (
<div className={wrapperStyles.composerActionStack} data-flx="channel.composer-status-rail.action-stack">
<div className={wrapperStyles.composerActionRow} data-flx="channel.composer-status-rail.action-row">
{topBar}
</div>
</div>
)}
</div>
</>
);
});
@@ -23,20 +23,16 @@
flex-shrink: 0;
border: none;
background-color: transparent;
width: var(--composer-status-pill-height, 1.5rem);
height: var(--composer-status-pill-height, 1.5rem);
width: var(--composer-action-button-size, 2rem);
height: var(--composer-action-button-size, 2rem);
padding: 0;
border-radius: var(--radius-full);
color: var(--text-primary-muted);
line-height: 0;
transition:
color 200ms,
background-color 200ms;
transition: color 200ms;
}
.button:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.icon {
@@ -34,7 +34,7 @@
.scrollerSpacer {
display: block;
height: var(--scroller-spacer-height);
height: var(--messages-bottom-clearance, var(--scroller-spacer-height));
pointer-events: none;
width: 100%;
flex-shrink: 0;
@@ -147,7 +147,7 @@
.messageBottomPill {
top: auto;
bottom: 0.625rem;
bottom: calc(0.625rem + var(--composer-status-safe-area, 0px));
}
.jumpToPresentBar {
@@ -324,12 +324,17 @@ export const Messages = observer(function Messages({
ReadStateCommands.ack(channel.id, true, false);
}
}, [channel.id, state.messages?.hasMoreAfter, state.visualUnreadMessageId, scrollManager]);
const onEscapePressed = useCallback(() => {
if (scrollManager.jumpReturnToOrigin()) {
return;
}
onScrollToPresentAndAck();
}, [onScrollToPresentAndAck, scrollManager]);
const onEscapePressed = useCallback(
(payload?: unknown) => {
const data = payload as {channelId?: string} | undefined;
if (data?.channelId && data.channelId !== channel.id) return;
if (scrollManager.jumpReturnToOrigin()) {
return;
}
onScrollToPresentAndAck();
},
[channel.id, onScrollToPresentAndAck, scrollManager],
);
const onRetryLoadMessages = useCallback(() => {
void MessageCommands.fetchMessages(channel.id, null, null, MAX_MESSAGES_PER_CHANNEL);
}, [channel.id]);
@@ -11,8 +11,9 @@
.replyTargetButton {
display: block;
justify-self: start;
min-width: 0;
width: 100%;
max-width: 100%;
cursor: pointer;
border: 0;
background: transparent;
@@ -35,7 +36,7 @@
align-items: center;
flex: 0 0 auto;
gap: 0.125rem;
min-height: var(--composer-status-pill-height, 1.5rem);
min-height: var(--composer-action-button-size, 2rem);
}
.mentionToggle {
@@ -43,7 +44,7 @@
cursor: pointer;
align-items: center;
justify-content: center;
height: calc(var(--composer-status-pill-height, 1.5rem) - 0.25rem);
height: calc(var(--composer-action-button-size, 2rem) - 0.25rem);
inline-size: 3.5rem;
flex: 0 0 3.5rem;
padding: 0 0.5rem;
@@ -91,20 +92,16 @@
flex-shrink: 0;
border: none;
background-color: transparent;
width: var(--composer-status-pill-height, 1.5rem);
height: var(--composer-status-pill-height, 1.5rem);
width: var(--composer-action-button-size, 2rem);
height: var(--composer-action-button-size, 2rem);
padding: 0;
border-radius: var(--radius-full);
color: var(--text-primary-muted);
line-height: 0;
transition:
color 200ms,
background-color 200ms;
transition: color 200ms;
}
.closeButton:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.closeIcon {
@@ -1,8 +1,7 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
border-color: var(--background-header-secondary);
background-color: var(--background-secondary-lighter);
border-color: var(--composer-divider-color, var(--background-modifier-accent));
padding: 0.5rem 1rem;
width: fit-content;
}
@@ -13,7 +12,7 @@
}
.standalone {
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
border-radius: var(--radius-lg);
border-width: 0.0625rem;
border-style: solid;
}
@@ -165,14 +165,13 @@ export const EditingMessageInput = observer(
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
if (event.key === 'Escape' && !event.shiftKey) {
if (event.key === 'Escape' && !event.shiftKey && !event.defaultPrevented && !event.nativeEvent.isComposing) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('ESCAPE_PRESSED', {channelId: channel.id});
onCancel();
}
},
[actualContent, channel.id, onCancel],
[actualContent, onCancel],
);
useEffect(() => {
if (editingDisabled || hasFocusedInitiallyRef.current) {
@@ -7,6 +7,10 @@ import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {Limits} from '@app/features/app/utils/UserLimits';
import {fetchSlowmodeState} from '@app/features/channel/commands/ChannelCommands';
import {ChannelAttachmentArea} from '@app/features/channel/components/ChannelAttachmentArea';
import {
type ChannelComposerDismissalRequest,
requestChannelComposerAffordanceDismissal,
} from '@app/features/channel/components/ChannelComposerDismissal';
import {EditBar} from '@app/features/channel/components/ChannelEditBar';
import {ReplyBar} from '@app/features/channel/components/ChannelReplyBar';
import {ChannelStickersArea} from '@app/features/channel/components/ChannelStickersArea';
@@ -99,7 +103,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
import {SlashCommandIcon} from '@app/features/ui/components/icons/SlashCommandIcon';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {openPopout} from '@app/features/ui/popover/PopoverPopout';
import ContextMenuState from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
@@ -110,7 +113,7 @@ import {openVoiceMessageComposerModal} from '@app/features/voice/components/Voic
import {flxElementClassName} from '@app/lib/react';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {PlusCircleIcon} from '@phosphor-icons/react';
import {PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react';
@@ -128,6 +131,8 @@ const CLEAR_COMMAND_DESCRIPTOR = msg({
comment: 'Accessible label for the composer button that clears the slash command currently being composed.',
});
const PLUS_ICON_PROPS = {weight: 'bold'} as const;
export const LexicalChannelTextareaContent = observer(
({
channel,
@@ -672,21 +677,88 @@ export const LexicalChannelTextareaContent = observer(
onMentionConfirmationNeeded: handleMentionConfirmationNeeded,
i18n: i18n,
});
const handleClearSlashCommand = useCallback(() => {
const handle = handleRef.current;
if (handle !== null) {
handle.clear();
}
setValue('');
clearSegments();
DraftCommands.deleteDraft(channel.id);
if (handle !== null) {
handle.focus();
}
}, [channel.id, clearSegments]);
const handleCancelEdit = useCallback(() => {
setValue('');
clearSegments();
}, [clearSegments]);
const focusComposer = useCallback(() => {
const handle = handleRef.current;
if (handle !== null) {
handle.focus();
}
}, []);
const dismissTopmostComposerAffordance = useCallback((): boolean => {
const editingInline = MessageEdit.getEditingMessageId(channel.id) !== null;
if (editingInline) {
MessageCommands.stopEdit(channel.id);
return true;
}
if (hasPendingSticker) {
ChannelSticker.removePendingSticker(channel.id);
focusComposer();
return true;
}
if (hasAttachments) {
CloudUpload.clearTextarea(channel.id);
focusComposer();
return true;
}
const slashCommandResolution = LexicalMessageCommandResolver.resolve(handleRef.current);
if (slashCommandResolution.status !== LexicalMessageCommandResolutionStatus.NO_COMMAND) {
handleClearSlashCommand();
return true;
}
if (editingMessageForComposer !== null && mobileLayout.enabled) {
MessageCommands.stopEditMobile(channel.id);
handleCancelEdit();
focusComposer();
return true;
}
if (replyingMessage !== null) {
MessageCommands.stopReply(channel.id);
focusComposer();
return true;
}
return false;
}, [
channel.id,
editingMessageForComposer,
focusComposer,
handleCancelEdit,
handleClearSlashCommand,
hasAttachments,
hasPendingSticker,
mobileLayout.enabled,
replyingMessage,
]);
useEffect(() => {
return ComponentDispatch.subscribe('TEXTAREA_DISMISS_AFFORDANCE', (request?: unknown) => {
const dismissalRequest = request as ChannelComposerDismissalRequest | undefined;
if (dismissalRequest?.channelId !== channel.id) {
return false;
}
return dismissTopmostComposerAffordance();
});
}, [channel.id, dismissTopmostComposerAffordance]);
const handleEscapeKey = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Escape') return;
if (event.shiftKey) return;
if (hasAttachments || hasPendingSticker || replyingMessage) {
if (event.defaultPrevented || event.nativeEvent.isComposing) return;
if (event.key !== 'Escape' || event.shiftKey) return;
if (requestChannelComposerAffordanceDismissal(channel.id)) {
event.preventDefault();
if (hasAttachments) {
CloudUpload.clearTextarea(channel.id);
}
if (hasPendingSticker) {
ChannelSticker.removePendingSticker(channel.id);
}
if (replyingMessage) {
MessageCommands.stopReply(channel.id);
}
event.stopPropagation();
return;
}
if (isInputAreaFocused && KeyboardMode.keyboardModeEnabled) {
@@ -698,15 +770,7 @@ export const LexicalChannelTextareaContent = observer(
KeyboardMode.exitKeyboardMode();
}
},
[
channel.id,
hasAttachments,
hasPendingSticker,
replyingMessage,
isInputAreaFocused,
KeyboardMode.keyboardModeEnabled,
Accessibility.escapeExitsKeyboardMode,
],
[channel.id, isInputAreaFocused, KeyboardMode.keyboardModeEnabled, Accessibility.escapeExitsKeyboardMode],
);
const slotResolvers = useMemo<SlashSlotResolvers>(() => {
const guildId = channel.guildId;
@@ -776,18 +840,6 @@ export const LexicalChannelTextareaContent = observer(
}
MessageCommands.startEdit(channel.id, message.id, message.content);
}, [channel.id]);
const handleClearSlashCommand = useCallback(() => {
const handle = handleRef.current;
if (handle !== null) {
handle.clear();
}
setValue('');
clearSegments();
DraftCommands.deleteDraft(channel.id);
if (handle !== null) {
handle.focus();
}
}, [channel.id, clearSegments]);
useTextareaDraftAndTyping({
channelId: channel.id,
value,
@@ -808,11 +860,6 @@ export const LexicalChannelTextareaContent = observer(
textareaInputDisabled,
isFocused,
handleArrowUpEmpty,
editingMessage,
replyingMessage,
mobileLayout,
setValue,
clearSegments,
});
const messageLabel = i18n._(MESSAGE_DESCRIPTOR);
const messagePrefix = `${messageLabel} `;
@@ -933,10 +980,6 @@ export const LexicalChannelTextareaContent = observer(
resizeObserver.disconnect();
};
}, [mobileLayout.enabled]);
const handleCancelEdit = useCallback(() => {
setValue('');
clearSegments();
}, [clearSegments]);
const isPlusContextMenuOpen = useCallback(() => {
const plusButton = plusButtonRef.current;
const contextMenu = ContextMenuState.contextMenu;
@@ -1093,36 +1136,29 @@ export const LexicalChannelTextareaContent = observer(
],
);
const isSlashParamBarVisible = slashCommandState.activeSlot != null;
const hasStackedSections = Boolean(
referencedMessage ||
(editingMessage && mobileLayout.enabled) ||
uploadAttachments.length > 0 ||
hasPendingSticker ||
isSlashParamBarVisible,
);
const isMobileEditBarVisible = editingMessage && mobileLayout.enabled;
const isReplyBarVisible = !isMobileEditBarVisible && referencedMessage != null;
const presentableTypingUsers = usePresentableTypingUsers(channel);
const isTypingStatusVisible = !isAutocompleteVisible && presentableTypingUsers.length > 0;
const isSlowmodeIndicatorVisible = isSlowmodeEnabled;
const hasLeadingStatusContent = isMobileEditBarVisible || isReplyBarVisible || isSlashParamBarVisible;
const hasComposerStatusRail = isTypingStatusVisible || hasLeadingStatusContent || isSlowmodeIndicatorVisible;
let shouldReplyMention = false;
if (replyingMessage !== null && replyingMessage !== undefined) {
shouldReplyMention = replyingMessage.mentioning;
}
const topBarContent = isMobileEditBarVisible ? (
<EditBar channel={channel} onCancel={handleCancelEdit} />
) : (
referencedMessage && (
let topBarContent: React.ReactNode = null;
if (isMobileEditBarVisible) {
topBarContent = <EditBar channel={channel} onCancel={handleCancelEdit} />;
} else if (referencedMessage !== null) {
topBarContent = (
<ReplyBar
replyingMessageObject={referencedMessage}
shouldReplyMention={shouldReplyMention}
setShouldReplyMention={(mentioning) => MessageCommands.setReplyMentioning(channel.id, mentioning)}
channel={channel}
/>
)
);
);
}
const renderSection = (content: React.ReactNode, sectionClassName?: string) => (
<flx-channel-textarea-section className={flxElementClassName(wrapperStyles.stackSection, sectionClassName)}>
{content}
@@ -1130,171 +1166,155 @@ export const LexicalChannelTextareaContent = observer(
);
return (
<>
<FocusRing
focusTarget={editableRef}
ringTarget={containerRef}
offset={0}
enabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
ringClassName={styles.textareaFocusRing}
<flx-channel-textarea
ref={containerRef}
className={flxElementClassName(
wrapperStyles.box,
wrapperStyles.composerRoot,
wrapperStyles.wrapperSides,
styles.textareaOuter,
mobileLayout.enabled && styles.textareaOuterMobile,
wrapperStyles.roundedAll,
textareaInputDisabled && wrapperStyles.disabled,
!mobileLayout.enabled && styles.textareaOuterRow,
)}
>
<flx-channel-textarea
ref={containerRef}
className={flxElementClassName(
wrapperStyles.box,
wrapperStyles.wrapperSides,
styles.textareaOuter,
mobileLayout.enabled && styles.textareaOuterMobile,
hasStackedSections ? wrapperStyles.roundedBottom : wrapperStyles.roundedAll,
wrapperStyles.bottomSpacing,
textareaInputDisabled && wrapperStyles.disabled,
!mobileLayout.enabled && styles.textareaOuterMinHeight,
)}
>
{hasComposerStatusRail && (
<flx-channel-textarea-status-rail className={flxElementClassName(wrapperStyles.statusRail)}>
<flx-channel-textarea-status-rail-left
className={flxElementClassName(
wrapperStyles.statusRailLeft,
hasLeadingStatusContent && wrapperStyles.statusRailLeftWithLeading,
)}
>
{topBarContent && (
<flx-channel-textarea-top-bar className={flxElementClassName(wrapperStyles.topBarContainer)}>
{topBarContent}
</flx-channel-textarea-top-bar>
)}
{slashCommandState.activeSlot != null && (
<flx-channel-textarea-slash-command-bar
className={flxElementClassName(wrapperStyles.topBarContainer)}
>
<SlashCommandParamBar
activeSlot={slashCommandState.activeSlot}
onClear={handleClearSlashCommand}
/>
</flx-channel-textarea-slash-command-bar>
)}
{isTypingStatusVisible && (
<flx-channel-textarea-typing-slot className={flxElementClassName(wrapperStyles.statusTypingSlot)}>
<TypingUsers channel={channel} withText={true} showAvatars={true} />
</flx-channel-textarea-typing-slot>
)}
</flx-channel-textarea-status-rail-left>
{isSlowmodeIndicatorVisible && (
<flx-channel-textarea-slowmode-slot className={flxElementClassName(wrapperStyles.statusSlowmodeSlot)}>
<SlowmodeIndicator
slowmodeRemaining={slowmodeRemaining}
slowmodeDuration={channel.rateLimitPerUser * 1000}
isImmune={isSlowmodeImmune}
/>
</flx-channel-textarea-slowmode-slot>
)}
</flx-channel-textarea-status-rail>
)}
{showAttachments &&
renderSection(<ChannelAttachmentArea channelId={channel.id} />, styles.collapsibleSection)}
{showStickers &&
renderSection(
<ChannelStickersArea channelId={channel.id} hasAttachments={hasAttachments} />,
styles.collapsibleSection,
<flx-channel-textarea-status-rail className={flxElementClassName(wrapperStyles.statusRail)}>
<flx-channel-textarea-status-rail-left className={flxElementClassName(wrapperStyles.statusRailLeft)}>
{isTypingStatusVisible && (
<flx-channel-textarea-typing-slot className={flxElementClassName(wrapperStyles.statusTypingSlot)}>
<TypingUsers channel={channel} withText={true} showAvatars={true} />
</flx-channel-textarea-typing-slot>
)}
{renderSection(
<flx-channel-textarea-box
className={flxElementClassName(
styles.mainWrapperDense,
textareaInputDisabled && wrapperStyles.disabled,
)}
>
<flx-channel-textarea-upload-column
className={flxElementClassName(styles.uploadButtonColumn, styles.sideButtonPadding)}
>
<TextareaButton
icon={slashCommandState.hasSlots ? SlashCommandIcon : PlusCircleIcon}
label={slashCommandState.hasSlots ? i18n._(CLEAR_COMMAND_DESCRIPTOR) : i18n._(OPEN_MENU_DESCRIPTOR)}
disabled={textareaInputDisabled}
aria-hidden={textareaInputDisabled ? true : undefined}
onMouseDown={slashCommandState.hasSlots ? undefined : handlePlusMenuMouseDown}
onClick={slashCommandState.hasSlots ? handleClearSlashCommand : handlePlusMenuClick}
forceHover={!slashCommandState.hasSlots && plusContextMenuOpen}
className={plusContextMenuOpen ? styles.plusButtonAboveBackdrop : undefined}
ref={plusButtonRef}
/>
</flx-channel-textarea-upload-column>
<flx-channel-textarea-content
ref={contentAreaRef}
className={flxElementClassName(styles.contentAreaDense)}
>
<flx-channel-textarea-composer className={flxElementClassName(lexicalStyles.composerHost)}>
<LexicalComposerInput
placeholder={placeholderText}
disabled={textareaInputDisabled}
handleRef={handleRef}
initialValue={initialDraftRef.current.display}
initialSegments={initialDraftRef.current.segments}
slotResolvers={slotResolvers}
emojiShortcodeResolver={composerEmojiResolver}
channelId={channel.id}
guildId={channel.guildId}
submitOnEnter={!mobileLayout.enabled}
className={lexicalStyles.composerEditable}
autocompleteOptions={autocompleteOptions}
autocompleteType={autocompleteType}
autocompleteQuery={autocompleteQuery}
autocompleteEnabled={!textareaInputDisabled}
slotMenuActive={isSlotMenu}
autocompleteReferenceElement={containerRef.current}
autocompleteListboxId={autocompleteListId}
onAutocompleteSelect={handleSelect}
onChange={handleEditorChange}
onCursorMove={onCursorMove}
onEnter={handleSubmit}
onArrowUp={handleArrowUpEmpty}
onKeyDown={handleEditorKeyDown}
onFocus={() => {
setIsFocused(true);
setIsInputAreaFocused(true);
ChannelSearch.setInputFocused(channel.id, false);
}}
onBlur={() => {
setIsFocused(false);
setIsInputAreaFocused(false);
}}
onSlashCommandStateChange={setSlashCommandState}
/>
</flx-channel-textarea-composer>
</flx-channel-textarea-content>
<TextareaButtons
disabled={textareaInputDisabled}
showAllButtons={showAllButtons}
showGifButton={showGifButton}
showMemesButton={showMemesButton}
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
showVoiceMessageButton={false}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
isSlowmodeActive={isSubmissionBlockedBySlowmode}
isOverLimit={isOverCharacterLimit}
hasContent={hasMessageContent}
hasAttachments={uploadAttachments.length > 0}
expressionPickerTriggerRef={expressionPickerTriggerRef}
invisibleExpressionPickerTriggerRef={invisibleExpressionPickerTriggerRef}
onExpressionPickerToggle={handleExpressionPickerTabToggle}
onSubmit={handleSubmit}
channelId={channel.id}
</flx-channel-textarea-status-rail-left>
{isSlowmodeIndicatorVisible && (
<flx-channel-textarea-slowmode-slot className={flxElementClassName(wrapperStyles.statusSlowmodeSlot)}>
<SlowmodeIndicator
slowmodeRemaining={slowmodeRemaining}
slowmodeDuration={channel.rateLimitPerUser * 1000}
isImmune={isSlowmodeImmune}
/>
</flx-channel-textarea-box>,
styles.inputSection,
</flx-channel-textarea-slowmode-slot>
)}
<MessageCharacterCounter
currentLength={trimmedMessageContent.length}
maxLength={maxMessageLength}
canUpgrade={maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
/>
</flx-channel-textarea>
</FocusRing>
</flx-channel-textarea-status-rail>
{hasLeadingStatusContent && (
<div className={wrapperStyles.composerActionStack} data-flx="channel.textarea.composer-action-stack">
{topBarContent !== null && (
<div className={wrapperStyles.composerActionRow} data-flx="channel.textarea.composer-action-row">
{topBarContent}
</div>
)}
{slashCommandState.activeSlot !== null && (
<div className={wrapperStyles.composerActionRow} data-flx="channel.textarea.slash-command-action-row">
<SlashCommandParamBar activeSlot={slashCommandState.activeSlot} onClear={handleClearSlashCommand} />
</div>
)}
</div>
)}
{showAttachments &&
renderSection(<ChannelAttachmentArea channelId={channel.id} />, styles.collapsibleSection)}
{showStickers &&
renderSection(
<ChannelStickersArea channelId={channel.id} hasAttachments={hasAttachments} />,
styles.collapsibleSection,
)}
{renderSection(
<flx-channel-textarea-box
className={flxElementClassName(styles.mainWrapperDense, textareaInputDisabled && wrapperStyles.disabled)}
>
<flx-channel-textarea-upload-column
className={flxElementClassName(styles.uploadButtonColumn, styles.sideButtonPadding)}
>
<TextareaButton
iconProps={PLUS_ICON_PROPS}
icon={slashCommandState.hasSlots ? SlashCommandIcon : PlusIcon}
label={slashCommandState.hasSlots ? i18n._(CLEAR_COMMAND_DESCRIPTOR) : i18n._(OPEN_MENU_DESCRIPTOR)}
disabled={textareaInputDisabled}
aria-hidden={textareaInputDisabled ? true : undefined}
onMouseDown={slashCommandState.hasSlots ? undefined : handlePlusMenuMouseDown}
onClick={slashCommandState.hasSlots ? handleClearSlashCommand : handlePlusMenuClick}
forceHover={!slashCommandState.hasSlots && plusContextMenuOpen}
className={plusContextMenuOpen ? styles.plusButtonAboveBackdrop : undefined}
ref={plusButtonRef}
/>
</flx-channel-textarea-upload-column>
<flx-channel-textarea-content
ref={contentAreaRef}
className={flxElementClassName(styles.contentAreaDense)}
>
<flx-channel-textarea-composer className={flxElementClassName(lexicalStyles.composerHost)}>
<LexicalComposerInput
placeholder={placeholderText}
disabled={textareaInputDisabled}
handleRef={handleRef}
initialValue={initialDraftRef.current.display}
initialSegments={initialDraftRef.current.segments}
slotResolvers={slotResolvers}
emojiShortcodeResolver={composerEmojiResolver}
channelId={channel.id}
guildId={channel.guildId}
submitOnEnter={!mobileLayout.enabled}
focusRingTarget={containerRef}
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
className={lexicalStyles.composerEditable}
autocompleteOptions={autocompleteOptions}
autocompleteType={autocompleteType}
autocompleteQuery={autocompleteQuery}
autocompleteEnabled={!textareaInputDisabled}
slotMenuActive={isSlotMenu}
autocompleteReferenceElement={containerRef.current}
autocompleteListboxId={autocompleteListId}
onAutocompleteSelect={handleSelect}
onChange={handleEditorChange}
onCursorMove={onCursorMove}
onEnter={handleSubmit}
onArrowUp={handleArrowUpEmpty}
onKeyDown={handleEditorKeyDown}
onFocus={() => {
setIsFocused(true);
setIsInputAreaFocused(true);
ChannelSearch.setInputFocused(channel.id, false);
}}
onBlur={() => {
setIsFocused(false);
setIsInputAreaFocused(false);
}}
onSlashCommandStateChange={setSlashCommandState}
/>
</flx-channel-textarea-composer>
</flx-channel-textarea-content>
<TextareaButtons
disabled={textareaInputDisabled}
showAllButtons={showAllButtons}
showGifButton={showGifButton}
showMemesButton={showMemesButton}
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
showVoiceMessageButton={false}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
isSlowmodeActive={isSubmissionBlockedBySlowmode}
isOverLimit={isOverCharacterLimit}
hasContent={hasMessageContent}
hasAttachments={uploadAttachments.length > 0}
expressionPickerTriggerRef={expressionPickerTriggerRef}
invisibleExpressionPickerTriggerRef={invisibleExpressionPickerTriggerRef}
onExpressionPickerToggle={handleExpressionPickerTabToggle}
onSubmit={handleSubmit}
channelId={channel.id}
/>
</flx-channel-textarea-box>,
styles.inputSection,
)}
<MessageCharacterCounter
currentLength={trimmedMessageContent.length}
maxLength={maxMessageLength}
canUpgrade={maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
/>
</flx-channel-textarea>
{mobileLayout.enabled && (
<>
<ExpressionPickerSheet
@@ -2,6 +2,7 @@
.container {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
right: var(--composer-box-padding-inline, 0px);
bottom: calc(100% + var(--composer-status-safe-gap, 0.5rem));
z-index: 4;
}
@@ -4,9 +4,11 @@
display: flex;
align-items: center;
gap: 0.5rem;
min-height: var(--composer-status-pill-height, 1.5rem);
min-height: 2.25rem;
min-width: 0;
padding: 0 0.25rem 0 0.625rem;
width: 100%;
padding: 0 0.375rem 0 0.75rem;
box-sizing: border-box;
}
.text {
@@ -15,7 +17,7 @@
gap: 0.4375rem;
overflow: hidden;
min-width: 0;
flex: 0 1 auto;
flex: 1 1 auto;
}
.name {
@@ -46,6 +48,7 @@
display: flex;
align-items: center;
flex: 0 0 auto;
margin-left: auto;
}
.button {
@@ -56,17 +59,15 @@
flex-shrink: 0;
border: none;
background-color: transparent;
width: var(--composer-status-pill-height, 1.5rem);
height: var(--composer-status-pill-height, 1.5rem);
width: var(--composer-action-button-size, 2rem);
height: var(--composer-action-button-size, 2rem);
padding: 0;
border-radius: var(--radius-full);
color: var(--text-primary-muted);
line-height: 0;
}
.button:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.icon {
@@ -4,34 +4,17 @@
display: flex;
align-items: center;
gap: 0.1875rem;
height: var(--composer-status-pill-height, var(--slowmode-indicator-height));
height: var(--slowmode-indicator-height, 1.125rem);
max-width: 100%;
min-width: 0;
padding: 0 0.5rem;
border-radius: var(--radius-full);
background-color: var(--background-tertiary);
border: 0.0625rem solid color-mix(in srgb, var(--background-modifier-accent) 80%, transparent);
color: var(--text-primary-muted);
font-size: 0.75rem;
line-height: 1;
transition:
color 150ms ease,
background-color 150ms ease,
border-color 150ms ease;
transition: color 150ms ease;
}
.cooldown {
color: var(--status-danger);
background-color: color-mix(in srgb, var(--status-danger) 14%, transparent);
border-color: color-mix(in srgb, var(--status-danger) 45%, transparent);
}
.time {
flex: 0 0 auto;
font-weight: 500;
font-family: monospace;
font-size: 0.75rem;
line-height: 1;
}
.label {
@@ -42,4 +25,9 @@
font-weight: 600;
font-size: 0.75rem;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.icon {
flex: 0 0 auto;
}
@@ -16,22 +16,29 @@ import {ClockIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
const SLOWMODE_IS_ENABLED_BUT_YOU_ARE_IMMUNE_DESCRIPTOR = msg({
message: 'Slowmode is enabled, but you are immune.',
comment: 'Description text in the channel and chat slowmode indicator.',
});
const YOU_ARE_IN_SLOWMODE_PLEASE_WAIT_BEFORE_SENDING_DESCRIPTOR = msg({
message: "You're in slowmode. Wait before sending another message.",
comment: 'Description text in the channel and chat slowmode indicator.',
});
const SLOWMODE_IS_ENABLED_FOR_THIS_CHANNEL_DESCRIPTOR = msg({
message: 'Slowmode is enabled for this channel.',
comment: 'Description text in the channel and chat slowmode indicator.',
});
const SLOWMODE_DESCRIPTOR = msg({
message: '{durationLabel} slowmode',
const SLOWMODE_IS_SET_BUT_YOU_ARE_IMMUNE_DESCRIPTOR = msg({
message: 'Slowmode is set to {durationLabel}, but you are immune.',
comment:
'Short label in the channel and chat slowmode indicator. Keep it concise. Preserve {durationLabel}; it is inserted by code.',
'Tooltip on the composer slowmode indicator when the reader can bypass slowmode. Preserve {durationLabel}; it is inserted by code.',
});
const SLOWMODE_IS_SET_WAIT_BEFORE_SENDING_DESCRIPTOR = msg({
message: 'Slowmode is set to {durationLabel}. Wait before sending another message.',
comment:
'Tooltip on the composer slowmode indicator while the reader is counting down. Preserve {durationLabel}; it is inserted by code.',
});
const SLOWMODE_IS_SET_FOR_THIS_CHANNEL_DESCRIPTOR = msg({
message: 'Slowmode is set to {durationLabel} for this channel.',
comment:
'Tooltip on the composer slowmode indicator when the reader is free to send. Preserve {durationLabel}; it is inserted by code.',
});
const SLOWMODE_IS_ENABLED_DESCRIPTOR = msg({
message: 'Slowmode is enabled',
comment: 'Short label in the composer slowmode indicator when the reader is free to send. Keep it concise.',
});
const SLOWMODE_IS_ACTIVE_DESCRIPTOR = msg({
message: 'Slowmode is active ({remaining})',
comment:
'Short label in the composer slowmode indicator while the countdown runs. Keep it concise. Preserve {remaining}; it is an mm:ss or hh:mm:ss timer inserted by code.',
});
interface SlowmodeIndicatorProps {
@@ -83,28 +90,36 @@ export const SlowmodeIndicator = observer(({slowmodeRemaining, slowmodeDuration,
const {i18n} = useLingui();
const locale = i18n.locale;
const onCooldown = !isImmune && slowmodeRemaining > 0;
const tooltipText = isImmune
? i18n._(SLOWMODE_IS_ENABLED_BUT_YOU_ARE_IMMUNE_DESCRIPTOR)
: onCooldown
? i18n._(YOU_ARE_IN_SLOWMODE_PLEASE_WAIT_BEFORE_SENDING_DESCRIPTOR)
: i18n._(SLOWMODE_IS_ENABLED_FOR_THIS_CHANNEL_DESCRIPTOR);
const durationLabel = formatSlowmodeDuration(slowmodeDuration, locale);
let tooltipText: string;
if (isImmune) {
tooltipText = i18n._(SLOWMODE_IS_SET_BUT_YOU_ARE_IMMUNE_DESCRIPTOR, {durationLabel});
} else if (onCooldown) {
tooltipText = i18n._(SLOWMODE_IS_SET_WAIT_BEFORE_SENDING_DESCRIPTOR, {durationLabel});
} else {
tooltipText = i18n._(SLOWMODE_IS_SET_FOR_THIS_CHANNEL_DESCRIPTOR, {durationLabel});
}
let statusLabel: string;
if (onCooldown) {
statusLabel = i18n._(SLOWMODE_IS_ACTIVE_DESCRIPTOR, {remaining: formatSlowmodeTime(slowmodeRemaining, locale)});
} else {
statusLabel = i18n._(SLOWMODE_IS_ENABLED_DESCRIPTOR);
}
return (
<Tooltip text={tooltipText} data-flx="channel.slowmode-indicator.tooltip">
<div
className={clsx(styles.container, onCooldown && styles.cooldown)}
data-flx="channel.slowmode-indicator.container"
>
<ClockIcon size={remFromPx(12)} weight="fill" data-flx="channel.slowmode-indicator.clock-icon" />
{onCooldown ? (
<span className={styles.time} data-flx="channel.slowmode-indicator.time">
{formatSlowmodeTime(slowmodeRemaining, locale)}
</span>
) : (
<span className={styles.label} data-flx="channel.slowmode-indicator.label">
{i18n._(SLOWMODE_DESCRIPTOR, {durationLabel})}
</span>
)}
<span className={styles.label} data-flx="channel.slowmode-indicator.label">
{statusLabel}
</span>
<ClockIcon
size={remFromPx(12)}
weight="fill"
className={styles.icon}
data-flx="channel.slowmode-indicator.clock-icon"
/>
</div>
</Tooltip>
);
@@ -5,9 +5,19 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
color: var(--text-primary-muted);
}
.username {
font-weight: 600;
}
.composerStatus {
display: inline-flex;
align-items: center;
height: var(--typing-pill-height, 1.125rem);
max-width: 100%;
min-width: 0;
color: var(--text-primary-muted);
pointer-events: auto;
}
@@ -118,10 +118,7 @@ export const TypingUsers = observer(
className={`${messageStyles.typingContainer} ${messageStyles.typingCluster} ${messageStyles.typingClusterComposerStatus}`}
data-flx="channel.typing-users.div"
>
<div
className={`${messageStyles.typingPill} ${messageStyles.typingPillComposerStatus}`}
data-flx="channel.typing-users.div--2"
>
<div className={styles.composerStatus} data-flx="channel.typing-users.div--2">
<div className={messageStyles.typingIndicator} data-flx="channel.typing-users.div--3">
<Typing
className={styles.typing}
@@ -3,11 +3,11 @@
.barrierLayout {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(0, auto);
align-items: flex-start;
align-items: start;
position: relative;
min-height: var(--textarea-min-height);
box-sizing: border-box;
padding: var(--user-area-padding-y) 0;
padding: var(--textarea-padding-y) 0;
column-gap: var(--textarea-upload-gap);
min-width: 0;
width: 100%;
@@ -51,12 +51,18 @@
.actionArea > button {
margin: 0;
height: var(--textarea-button-height);
min-height: var(--textarea-button-height);
}
.timer {
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: var(--textarea-button-height);
border-radius: 0.375rem;
background-color: var(--background-secondary-alt);
padding: 0.375rem 0.75rem;
padding: 0 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.25rem;
@@ -55,9 +55,8 @@ const BarrierBase = observer(({message, action, icon}: BarrierBaseProps) => {
wrapperStyles.box,
wrapperStyles.wrapperSides,
textareaStyles.textareaOuter,
textareaStyles.textareaOuterMinHeight,
textareaStyles.textareaOuterRow,
wrapperStyles.roundedAll,
wrapperStyles.bottomSpacing,
)}
data-flx="channel.barriers.barrier-components.barrier-base.div"
>
@@ -1,19 +1,12 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.wrapperSides {
margin-left: 0;
margin-right: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.box {
position: relative;
background-color: var(--background-secondary-lighter);
border: none;
transition: border-color 0.2s ease;
margin-bottom: 0;
max-width: 100%;
box-sizing: border-box;
}
@@ -28,15 +21,11 @@
}
.stackSection + .stackSection {
border-top: 0.0625rem solid var(--user-area-divider-color);
}
.box:focus-within {
border-top: none;
border-top: 0.0625rem solid var(--composer-divider-color, var(--user-area-divider-color));
}
.roundedAll {
border-radius: 0;
border-radius: var(--composer-box-radius, 0px);
}
.roundedTop {
@@ -44,32 +33,42 @@
border-top-right-radius: 0;
}
.roundedBottom {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.noBottomBorder {
border-bottom-width: 0;
}
.bottomSpacing {
margin-bottom: 0;
}
.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.statusRail {
--composer-status-pill-height: 1.5rem;
--typing-pill-height: var(--composer-status-pill-height);
--slowmode-indicator-height: var(--composer-status-pill-height);
.composerRoot {
--composer-action-button-size: 2rem;
--composer-divider-color: var(--floating-surface-ring-color-strong);
}
.composerRoot::before {
content: '';
position: absolute;
inset-inline: var(--composer-status-rail-inset, 0);
top: 0;
transform: translateY(-50%);
bottom: calc(100% + var(--composer-box-inset, 0px));
inset-inline: calc(-1 * var(--composer-box-inset-inline, 0px));
height: max(0px, calc(var(--composer-status-safe-area) - var(--composer-box-inset, 0px)));
background: linear-gradient(
to bottom,
transparent 0%,
color-mix(in srgb, var(--composer-surface-color) 42%, transparent) 34%,
color-mix(in srgb, var(--composer-surface-color) 82%, transparent) 66%,
var(--composer-surface-color) 100%
);
pointer-events: none;
}
.statusRail {
--typing-pill-height: var(--composer-status-line-height, 1.125rem);
--slowmode-indicator-height: var(--composer-status-line-height, 1.125rem);
position: absolute;
inset-inline: var(--composer-box-padding-inline, 0px);
bottom: calc(100% + var(--composer-status-safe-gap, 0.5rem));
z-index: 3;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
@@ -77,24 +76,17 @@
gap: 0.5rem;
min-width: 0;
max-width: 100%;
min-height: calc(var(--composer-status-pill-height) + 0.5rem);
padding: 0.25rem 0;
height: var(--composer-status-line-height, 1.125rem);
box-sizing: border-box;
text-shadow:
0 0 0.1875rem var(--composer-surface-color),
0 0 0.5rem var(--composer-surface-color);
pointer-events: none;
}
.statusRailInFlow {
position: static;
inset: auto;
transform: none;
z-index: auto;
padding-top: 0.5rem;
}
.statusRailLeft {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
max-width: 100%;
overflow: hidden;
@@ -109,11 +101,6 @@
pointer-events: auto;
}
.statusRailLeftWithLeading .statusTypingSlot {
margin-left: auto;
max-width: min(22rem, 45%);
}
.statusTypingSlot > * {
min-width: 0;
max-width: 100%;
@@ -126,17 +113,36 @@
pointer-events: auto;
}
.topBarContainer {
position: relative;
z-index: 1;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
.composerActionStack {
display: flex;
justify-content: flex-start;
pointer-events: auto;
width: fit-content;
flex: 0 1 auto;
flex: 0 0 auto;
flex-direction: column;
margin-inline: calc(-1 * var(--composer-box-padding-inline, 0px));
min-width: 0;
width: auto;
overflow: hidden;
box-sizing: border-box;
border-bottom: 0.0625rem solid var(--composer-divider-color, var(--background-modifier-accent));
border-radius: var(--composer-box-radius, 0px) var(--composer-box-radius, 0px) 0 0;
background-color: color-mix(in srgb, var(--background-textarea) 84%, var(--background-tertiary) 16%);
}
.composerActionRow {
display: flex;
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.composerActionRow + .composerActionRow {
border-top: 0.0625rem solid color-mix(in srgb, var(--background-modifier-accent) 72%, transparent);
}
.composerActionRow > * {
width: 100%;
max-width: 100%;
background-color: transparent;
}
.barInner {
@@ -150,39 +156,16 @@
box-sizing: border-box;
}
.topBarContainer > * {
pointer-events: auto;
}
.topBarContainer > .box {
width: fit-content;
max-width: min(30rem, 100%);
border-radius: var(--radius-full);
background-color: color-mix(in srgb, var(--background-textarea) 84%, var(--background-tertiary) 16%);
border: 0.0625rem solid color-mix(in srgb, var(--background-modifier-accent) 82%, transparent);
overflow: hidden;
}
.topBarContainer .barInner {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: var(--composer-status-pill-height);
padding: 0 0.25rem 0 0.625rem;
width: 100%;
min-width: 0;
}
.topBarContainer .barInner > :first-child {
min-width: 0;
flex: 1 1 auto;
}
.separator {
height: 0.0625rem;
background-color: transparent;
}
.topBarContainer .separator {
.composerActionRow .barInner {
min-height: 2.25rem;
padding-inline: 0.75rem 0.375rem;
}
.composerActionRow .separator {
display: none;
}
@@ -9,7 +9,10 @@
height: var(--textarea-button-height);
padding: 0;
color: var(--text-primary-muted);
transition: color var(--transition-normal);
border-radius: var(--radius-md);
transition:
color var(--transition-normal),
background-color var(--transition-normal);
cursor: pointer;
background: transparent;
border: none;
@@ -20,6 +23,7 @@
.button:hover:not(:disabled),
.button.contextMenuHover:not(:disabled) {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.button:disabled {
@@ -2,19 +2,17 @@
:root {
--textarea-font-size: var(--font-size, 1rem);
--textarea-button-height: var(--user-area-content-height);
--textarea-button-icon-size: 1.625rem;
--textarea-button-height: 2rem;
--textarea-button-icon-size: 1.375rem;
--textarea-button-padding-x: 0px;
--textarea-button-min-width: auto;
--textarea-button-compact-height: var(--user-area-content-height);
--textarea-button-compact-icon-size: 1.375rem;
--textarea-button-compact-height: 2rem;
--textarea-button-compact-icon-size: 1.25rem;
--textarea-container-padding-y: 0px;
--textarea-container-padding-x: 0px;
--textarea-min-height: var(--input-container-min-height);
--textarea-horizontal-padding: var(--chat-horizontal-padding, var(--spacing-4));
--textarea-content-offset: calc((var(--user-area-content-height) - var(--textarea-line-height)) / 2);
--textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2));
--textarea-upload-gap: var(--message-gutter, 1rem);
--textarea-side-button-padding: max(
0px,
@@ -92,7 +90,7 @@
}
.textarea::placeholder {
color: var(--text-primary-muted);
color: var(--text-tertiary-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -168,7 +166,7 @@
width: 0.0625rem;
height: 1.5rem;
background-color: var(--background-modifier-hover);
margin: 0 0.25rem;
margin-inline: var(--composer-action-gap);
align-self: center;
}
@@ -176,17 +174,12 @@
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
padding-left: var(--textarea-horizontal-padding);
padding-right: var(--textarea-horizontal-padding);
padding-inline: var(--composer-box-padding-inline);
box-sizing: border-box;
box-shadow: inset 0 0.0625rem 0 var(--user-area-divider-color);
width: 100%;
max-width: 100%;
min-width: 0;
contain: inline-size;
overflow: visible;
--composer-status-rail-inset: var(--textarea-horizontal-padding);
}
.collapsibleSection {
@@ -201,7 +194,12 @@
.textareaOuterMobile {
padding: 0;
--composer-status-rail-inset: 0px;
min-height: 0;
--textarea-min-height: var(--composer-mobile-box-height);
--textarea-padding-y: var(--composer-mobile-padding-y);
--composer-box-inset: 0px;
--composer-box-inset-inline: 0px;
--composer-box-padding-inline: 0px;
}
.textareaMobile {
@@ -211,21 +209,17 @@
.mainWrapperDense {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(0, auto);
align-items: flex-start;
align-items: start;
position: relative;
min-height: var(--textarea-min-height);
box-sizing: border-box;
padding: var(--user-area-padding-y) 0;
padding: var(--textarea-padding-y) 0;
column-gap: var(--textarea-upload-gap);
min-width: 0;
width: 100%;
max-width: 100%;
}
.textareaFocusRing {
border-radius: var(--radius-xl);
}
.mainWrapperEditing {
display: flex;
align-items: flex-start;
@@ -234,7 +228,7 @@
background-color: var(--background-textarea);
border: 0.0625rem solid var(--background-modifier-accent);
border-radius: var(--radius-md);
transition: colors;
transition: border-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
--scrollbar-track-bg: var(--background-textarea);
}
@@ -253,7 +247,7 @@
display: flex;
align-items: center;
justify-content: center;
min-height: var(--user-area-content-height);
min-height: var(--textarea-button-height);
min-width: 0;
flex-shrink: 1;
}
@@ -271,7 +265,7 @@
grid-column: 2;
display: flex;
flex-direction: column;
min-height: var(--user-area-content-height);
min-height: var(--textarea-button-height);
min-width: 0;
padding-top: var(--textarea-content-offset);
}
@@ -300,9 +294,9 @@
.buttonContainerDense {
grid-column: 3;
display: flex;
align-items: flex-start;
gap: 0.625rem;
min-height: var(--user-area-content-height);
align-items: center;
gap: var(--composer-action-gap);
min-height: var(--textarea-button-height);
min-width: 0;
flex-shrink: 1;
}
@@ -371,6 +365,26 @@
min-width: 0;
}
.textareaOuterMinHeight {
min-height: var(--input-container-min-height);
.textareaOuterRow {
min-height: var(--textarea-min-height);
margin-block: var(--composer-box-inset);
margin-inline: var(--composer-box-inset-inline);
background-color: var(--background-textarea);
--composer-box-radius: var(--footer-box-radius);
--composer-box-ring-color: var(--floating-surface-ring-color);
--scrollbar-track-bg: var(--background-textarea);
}
.textareaOuterRow::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0.0625rem var(--composer-box-ring-color);
transition: box-shadow 0.15s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.textareaOuterRow:focus-within {
--composer-box-ring-color: var(--floating-surface-ring-color-strong);
}
@@ -1,12 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {requestChannelComposerAffordanceDismissal} from '@app/features/channel/components/ChannelComposerDismissal';
import type {Channel} from '@app/features/channel/models/Channel';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import type {MessageReplyState} from '@app/features/messaging/state/MessageReply';
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager';
import {isTextInputKeyEvent} from '@app/features/platform/utils/IsTextInputKeyEvent';
@@ -23,15 +20,6 @@ interface UseChannelComposerGlobalShortcutsParams {
textareaInputDisabled: boolean;
isFocused: boolean;
handleArrowUpEmpty: () => void;
editingMessage: Message | null | undefined;
replyingMessage: MessageReplyState | null;
mobileLayout: ComposerMobileLayout;
setValue: React.Dispatch<React.SetStateAction<string>>;
clearSegments: () => void;
}
interface ComposerMobileLayout {
enabled: boolean;
}
export function useChannelComposerGlobalShortcuts({
@@ -41,11 +29,6 @@ export function useChannelComposerGlobalShortcuts({
textareaInputDisabled,
isFocused,
handleArrowUpEmpty,
editingMessage,
replyingMessage,
mobileLayout,
setValue,
clearSegments,
}: UseChannelComposerGlobalShortcutsParams): void {
useEffect(() => {
if (textareaInputDisabled) {
@@ -108,31 +91,19 @@ export function useChannelComposerGlobalShortcuts({
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (event.shiftKey) return;
ComponentDispatch.dispatch('ESCAPE_PRESSED', {channelId: channel.id});
const isEditingInline = MessageEdit.getEditingMessageId(channel.id) != null;
if (isEditingInline) {
if (event.defaultPrevented || event.isComposing) return;
if (event.key !== 'Escape' || event.shiftKey) return;
if (requestChannelComposerAffordanceDismissal(channel.id)) {
event.preventDefault();
event.stopPropagation();
MessageCommands.stopEdit(channel.id);
return;
}
if (editingMessage && mobileLayout.enabled) {
event.preventDefault();
MessageCommands.stopEditMobile(channel.id);
setValue('');
clearSegments();
} else if (replyingMessage) {
event.preventDefault();
MessageCommands.stopReply(channel.id);
} else {
event.preventDefault();
}
ComponentDispatch.dispatch('ESCAPE_PRESSED', {channelId: channel.id});
event.preventDefault();
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [channel.id, editingMessage, replyingMessage, mobileLayout.enabled, textareaInputDisabled, clearSegments]);
}, [channel.id, textareaInputDisabled]);
}