Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6cd66e0
fix: tab indexes and reply to failed messages
Aerilym Feb 23, 2026
790928d
fix: add main screen focus state
Aerilym Feb 24, 2026
0483e3b
feat: add keyboard navigation to file attachments
Aerilym Feb 24, 2026
60f9501
fix: emoji reaction list rendering and emoji reaction bar focus
Aerilym Feb 24, 2026
8a87b9c
chore: made all of the message types selectable
Bilb Feb 20, 2026
41c168c
Merge remote-tracking branch 'upstream/dev' into delete-msg-options-u…
Bilb Feb 24, 2026
a813a6e
fix: emoji panel focus trap
Aerilym Feb 26, 2026
b110f97
chore: remove event focusin log
Aerilym Feb 26, 2026
41aa2c6
fix: only show reply in message info when available
Aerilym Feb 26, 2026
68e9739
chore: refactor message architecture as all are selectable
Bilb Feb 26, 2026
de81b32
chore: move message interaction logic to outer message container
Aerilym Feb 27, 2026
780661e
chore: merge delete-msg-options-updated into fix/tab_indexes
Aerilym Feb 27, 2026
a4eb7fd
fix: move popovers to interactable component
Aerilym Feb 27, 2026
1729fb0
fix: split message component into interactable parent
Aerilym Feb 27, 2026
d02b0d7
fix: track message deleted state as int instead of bool
Bilb Feb 27, 2026
b410b42
fix: standardise message deletion logic
Bilb Mar 2, 2026
1463f68
feat: move interactables to message list and flatten messages
Aerilym Mar 3, 2026
9fa5724
fix: multi select mode event propagation and message list padding
Aerilym Mar 3, 2026
3dca2c6
fix: message info view padding
Aerilym Mar 3, 2026
bee80e0
fix: conversation header button spacing
Aerilym Mar 3, 2026
25a92a0
chore: unify message padding for typing indicator
Aerilym Mar 3, 2026
3cd2183
Merge pull request #1872 from session-foundation/chore/flatten_messag…
Bilb Mar 3, 2026
83e8f15
fix: clean up mark as deleted logic & quote
Bilb Mar 3, 2026
fe8d0b0
Merge remote-tracking branch 'upstream/delete-msg-options-updated' in…
Bilb Mar 3, 2026
9b94899
fix: useMessageIsDeleted is not returning a bool anymore
Bilb Mar 3, 2026
ca67f03
Merge remote-tracking branch 'upstream/dev' into delete-msg-options-u…
Bilb Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ts/components/NoticeBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const StyledNoticeBanner = styled(Flex)<{ isClickable: boolean }>`
}
`;

const StyledText = styled.span`
const StyledBannerText = styled.span`
margin-right: var(--margins-sm);
`;

Expand Down Expand Up @@ -48,7 +48,7 @@ export const NoticeBanner = (props: NoticeBannerProps) => {
onBannerClick();
}}
>
<StyledText>{text}</StyledText>
<StyledBannerText>{text}</StyledBannerText>
{unicode ? (
<SessionLucideIconButton unicode={unicode} iconColor="inherit" iconSize="small" />
) : null}
Expand Down
1 change: 1 addition & 0 deletions ts/components/OutgoingLightBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const OutgoingLightBox = (props: NonNullable<OutgoingLightBoxOptions>) =>

return (
<SessionFocusTrap
focusTrapId="OutgoingLightBox"
initialFocus={() => ref.current}
allowOutsideClick={true}
returnFocusOnDeactivate={false}
Expand Down
106 changes: 87 additions & 19 deletions ts/components/SessionFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,102 @@
import { FocusTrap } from 'focus-trap-react';
import { ReactNode } from 'react';
import { FocusTrap, type FocusTrapProps } from 'focus-trap-react';
import { type ReactNode, useEffect, useState } from 'react';
import type { CSSProperties } from 'styled-components';
import { windowErrorFilters } from '../util/logger/renderer_process_logging';
import { getFeatureFlagMemo } from '../state/ducks/types/releasedFeaturesReduxTypes';

const focusTrapErrorSource = 'focus-trap';

type SessionFocusTrapProps = FocusTrapProps['focusTrapOptions'] & {
/** id used for debugging */
focusTrapId: string;
children: ReactNode;
active?: boolean;
containerDivStyle?: CSSProperties;
/** Suppress errors thrown from inside the focus trap, preventing logging or global error emission */
suppressErrors?: boolean;
/** Allows the focus trap to exist without detectable tabbable elements. This is required if the children
* are within a Shadow DOM. Internally sets suppressErrors to true. */
allowNoTabbableNodes?: boolean;
};

/**
* Focus trap which activates on mount.
*/
export function SessionFocusTrap({
focusTrapId,
children,
active = true,
allowOutsideClick = true,
returnFocusOnDeactivate,
initialFocus,
containerDivStyle,
}: {
children: ReactNode;
allowOutsideClick?: boolean;
returnFocusOnDeactivate?: boolean;
initialFocus: () => HTMLElement | null;
containerDivStyle?: CSSProperties;
}) {
suppressErrors,
allowNoTabbableNodes,
onActivate,
onPostActivate,
onDeactivate,
onPostDeactivate,
...rest
}: SessionFocusTrapProps) {
const debugFocusTrap = getFeatureFlagMemo('debugFocusTrap');
const defaultTabIndex = allowNoTabbableNodes ? 0 : -1;
const _suppressErrors = suppressErrors || allowNoTabbableNodes;
/**
* NOTE: the tab index tricks the focus trap into thinking it has
* tabbable children by setting a tab index on the empty div child. When
* the trap activates it will see the div in the tab list and render without
* error, then remove that div from the tab index list. Then when the trap
* deactivates the state is reset.
*/
const [tabIndex, setTabIndex] = useState<0 | 1 | -1>(defaultTabIndex);

const _onActivate = () => {
if (debugFocusTrap) {
window.log.debug(`[SessionFocusTrap] onActivate - ${focusTrapId}`);
}
onActivate?.();
};

const _onPostActivate = () => {
if (allowNoTabbableNodes) {
setTabIndex(-1);
}
onPostActivate?.();
};

const _onDeactivate = () => {
if (debugFocusTrap) {
window.log.debug(`[SessionFocusTrap] onDeactivate - ${focusTrapId}`);
}
if (allowNoTabbableNodes) {
setTabIndex(defaultTabIndex);
}
onDeactivate?.();
};

useEffect(() => {
if (!active || !_suppressErrors) {
return;
}
windowErrorFilters.add(focusTrapErrorSource);
// eslint-disable-next-line consistent-return -- This return is the destructor
return () => {
windowErrorFilters.remove(focusTrapErrorSource);
};
}, [_suppressErrors, active]);

return (
<FocusTrap
active={true}
active={active}
focusTrapOptions={{
initialFocus,
...rest,
allowOutsideClick,
returnFocusOnDeactivate,
onActivate: _onActivate,
onPostActivate: _onPostActivate,
onDeactivate: _onDeactivate,
onPostDeactivate,
}}
>
{/* Note: not too sure why, but without this div, the focus trap doesn't work */}
<div style={containerDivStyle}>{children}</div>
{/* Note: without this div, the focus trap doesn't work */}
<div style={containerDivStyle}>
{allowNoTabbableNodes ? <div tabIndex={tabIndex} /> : null}
{children}
</div>
</FocusTrap>
);
}
6 changes: 5 additions & 1 deletion ts/components/SessionSearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ export const SessionSearchInput = ({ searchType }: { searchType: SearchType }) =
iconColor="var(--text-secondary-color)"
iconSize={iconSize}
unicode={LUCIDE_ICONS_UNICODE.X}
tabIndex={0}
// NOTE: we don't want this clear button in the tab index list
// as the Escape key already does the clear action for keyboard
// users and we want the next tab after the search input to
// be the first search result
tabIndex={-1}
onClick={() => {
setCurrentSearchTerm('');
dispatch(searchActions.clearSearch());
Expand Down
3 changes: 2 additions & 1 deletion ts/components/SessionToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Slide, ToastContainer, ToastContainerProps } from 'react-toastify';
import styled from 'styled-components';
import { isTestIntegration } from '../shared/env_vars';

// NOTE: https://styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity
const StyledToastContainer = styled(ToastContainer)`
Expand Down Expand Up @@ -40,7 +41,7 @@ export const SessionToastContainer = () => {
return (
<WrappedToastContainer
position="bottom-right"
autoClose={5000}
autoClose={isTestIntegration() ? 1000 : 3000}
hideProgressBar={true}
newestOnTop={true}
closeOnClick={true}
Expand Down
17 changes: 14 additions & 3 deletions ts/components/SessionTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useRef,
useState,
useEffect,
type Dispatch,
} from 'react';
import styled, { type CSSProperties } from 'styled-components';
import useDebounce from 'react-use/lib/useDebounce';
Expand Down Expand Up @@ -47,6 +48,11 @@ export type PopoverTriggerPosition = {
offsetX?: number;
};

export type WithPopoverPosition = { triggerPosition: PopoverTriggerPosition | null };
export type WithSetPopoverPosition = {
setTriggerPosition: Dispatch<PopoverTriggerPosition | null>;
};

export const defaultTriggerPos: PopoverTriggerPosition = { x: 0, y: 0, width: 0, height: 0 };

export function getTriggerPositionFromBoundingClientRect(rect: DOMRect): PopoverTriggerPosition {
Expand All @@ -67,15 +73,20 @@ export const getTriggerPositionFromId = (id: string): PopoverTriggerPosition =>
};

// Returns null if the ref is null
export const useTriggerPosition = (
ref: RefObject<HTMLElement | null>
): PopoverTriggerPosition | null => {
export const getTriggerPosition = (ref: RefObject<HTMLElement | null>) => {
if (!ref.current) {
return null;
}
return getTriggerPositionFromBoundingClientRect(ref.current.getBoundingClientRect());
};

// Returns null if the ref is null
export const useTriggerPosition = (
ref: RefObject<HTMLElement | null>
): PopoverTriggerPosition | null => {
return getTriggerPosition(ref);
};

export const SessionTooltip = ({
children,
content,
Expand Down
6 changes: 5 additions & 1 deletion ts/components/SessionWrapperModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,11 @@ export const SessionWrapperModal = (props: SessionWrapperModalType & { onClose?:
props.headerChildren && moveHeaderIntoScrollableBody ? props.headerChildren : null;

return (
<SessionFocusTrap allowOutsideClick={allowOutsideClick} initialFocus={() => modalRef.current}>
<SessionFocusTrap
focusTrapId="SessionWrapperModal"
allowOutsideClick={allowOutsideClick}
initialFocus={() => modalRef.current}
>
<IsModalScrolledContext.Provider value={scrolled}>
<ModalHasActionButtonContext.Provider value={!!buttonChildren}>
<OnModalCloseContext.Provider value={onClose ?? null}>
Expand Down
2 changes: 2 additions & 0 deletions ts/components/basic/SessionRadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type SessionRadioItems = Array<{
label: string;
inputDataTestId: SessionDataTestId;
labelDataTestId: SessionDataTestId;
disabled?: boolean;
}>;

interface Props {
Expand Down Expand Up @@ -50,6 +51,7 @@ export const SessionRadioGroup = (props: Props) => {
label={item.label}
active={itemIsActive}
value={item.value}
disabled={item.disabled}
inputDataTestId={item.inputDataTestId}
labelDataTestId={item.labelDataTestId}
inputName={group}
Expand Down
2 changes: 0 additions & 2 deletions ts/components/calling/IncomingCallDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export const IncomingCallDialog = () => {
};
}, [incomingCallFromPubkey]);

// #region input handlers
const handleAcceptIncomingCall = async () => {
if (incomingCallFromPubkey) {
await CallManager.USER_acceptIncomingCallRequest(incomingCallFromPubkey);
Expand All @@ -77,7 +76,6 @@ export const IncomingCallDialog = () => {
if (!hasIncomingCall || !incomingCallFromPubkey) {
return null;
}
// #endregion

if (hasIncomingCall) {
return (
Expand Down
4 changes: 2 additions & 2 deletions ts/components/conversation/SessionConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,10 @@ export class SessionConversation extends Component<Props, State> {
if (msg.body.replace(/\s/g, '').includes(recoveryPhrase.replace(/\s/g, ''))) {
window.inboxStore?.dispatch(
updateConfirmModal({
title: tr('warning'),
title: { token: 'warning' },
i18nMessage: { token: 'recoveryPasswordWarningSendDescription' },
okTheme: SessionButtonColor.Danger,
okText: tr('send'),
okText: { token: 'send' },
onClickOk: () => {
void sendAndScroll();
},
Expand Down
69 changes: 33 additions & 36 deletions ts/components/conversation/SessionEmojiPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import Picker from '@emoji-mart/react';
import { forwardRef } from 'react';
import { type RefObject } from 'react';
import styled from 'styled-components';
import clsx from 'clsx';

import { usePrimaryColor } from '../../state/selectors/primaryColor';
import { useIsDarkTheme, useTheme } from '../../state/theme/selectors/theme';
import { COLORS, THEMES, ThemeStateType, type ColorsType } from '../../themes/constants/colors';
import { FixedBaseEmoji } from '../../types/Reaction';
import { i18nEmojiData } from '../../util/emoji';
import { hexColorToRGB } from '../../util/hexColorToRGB';
import { SessionFocusTrap } from '../SessionFocusTrap';

export const StyledEmojiPanel = styled.div<{
$isModal: boolean;
Expand All @@ -19,20 +19,11 @@ export const StyledEmojiPanel = styled.div<{
}>`
${props => (!props.$isModal ? 'padding: var(--margins-lg);' : '')}
z-index: 5;
opacity: 0;
visibility: hidden;
// this disables the slide-in animation when showing the emoji picker from a right click on a message
/* transition: var(--default-duration); */

button:focus {
outline: none;
}

&.show {
opacity: 1;
visibility: visible;
}

em-emoji-picker {
${props => props.$panelBackgroundRGB && `background-color: rgb(${props.$panelBackgroundRGB})`};
border: var(--default-borders);
Expand Down Expand Up @@ -75,21 +66,18 @@ export const StyledEmojiPanel = styled.div<{
`;

type Props = {
ref?: RefObject<HTMLDivElement | null>;
onEmojiClicked: (emoji: FixedBaseEmoji) => void;
show: boolean;
isModal?: boolean;
onClose?: () => void;
show: boolean;
};

const pickerProps = {
title: '',
showPreview: true,
autoFocus: true,
skinTonePosition: 'preview',
export const SessionEmojiPanel = (props: Props) => {
return props.show ? <EmojiPanel {...props} /> : null;
};

export const SessionEmojiPanel = forwardRef<HTMLDivElement, Props>((props: Props, ref) => {
const { onEmojiClicked, show, isModal = false, onClose } = props;
const EmojiPanel = ({ ref, onEmojiClicked, isModal = false, onClose }: Props) => {
const _primaryColor = usePrimaryColor();
const theme = useTheme();
const isDarkTheme = useIsDarkTheme();
Expand Down Expand Up @@ -125,22 +113,31 @@ export const SessionEmojiPanel = forwardRef<HTMLDivElement, Props>((props: Props
);

return (
<StyledEmojiPanel
$isModal={isModal}
$primaryColor={primaryColor}
$theme={theme}
$panelBackgroundRGB={panelBackgroundRGB}
$panelTextRGB={panelTextRGB}
className={clsx(show && 'show')}
ref={ref}
<SessionFocusTrap
focusTrapId="SessionEmojiPanel"
clickOutsideDeactivates={true}
allowNoTabbableNodes={true}
onDeactivate={onClose}
>
<Picker
theme={isDarkTheme ? 'dark' : 'light'}
i18n={i18nEmojiData}
onEmojiSelect={onEmojiClicked}
onClose={onClose}
{...pickerProps}
/>
</StyledEmojiPanel>
<StyledEmojiPanel
$isModal={isModal}
$primaryColor={primaryColor}
$theme={theme}
$panelBackgroundRGB={panelBackgroundRGB}
$panelTextRGB={panelTextRGB}
ref={ref}
>
<Picker
theme={isDarkTheme ? 'dark' : 'light'}
i18n={i18nEmojiData}
onEmojiSelect={onEmojiClicked}
onClose={onClose}
title=""
showPreview={true}
skinTonePosition={'preview'}
autoFocus={true}
/>
</StyledEmojiPanel>
</SessionFocusTrap>
);
});
};
Loading