From 65e0e102ab915c24fb7a835f42c5848dd6fea913 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 2 Apr 2026 09:41:03 +1100 Subject: [PATCH 1/3] fix: small ui fixes --- ts/components/buttons/MenuButton.tsx | 8 ++- .../message-content/MessageContent.tsx | 1 + .../leftpane/LeftPaneSectionHeader.tsx | 70 +++++++++++++++++-- ts/react.d.ts | 1 + 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/ts/components/buttons/MenuButton.tsx b/ts/components/buttons/MenuButton.tsx index 790838c176..d5616d7b8d 100644 --- a/ts/components/buttons/MenuButton.tsx +++ b/ts/components/buttons/MenuButton.tsx @@ -21,6 +21,7 @@ const StyledMenuButton = styled.button` width: 51px; height: 33px; cursor: pointer; + flex-shrink: 0; transition: var(--default-duration); @@ -54,11 +55,16 @@ export function useNewConversationCallback() { */ export const MenuButton = () => { const onClick = useNewConversationCallback(); + const leftOverlayMode = useLeftOverlayModeType(); + + if (leftOverlayMode) { + return null; + } return ( diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index 30af642265..c0cf116ba8 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -152,6 +152,7 @@ export const MessageContent = (props: Props) => { role="button" title={toolTipTitle} $msgDirection={direction} + data-testid="message-content-inner" > {hideAvatar ? null : ( diff --git a/ts/components/leftpane/LeftPaneSectionHeader.tsx b/ts/components/leftpane/LeftPaneSectionHeader.tsx index 32d37ace48..583fbe76c6 100644 --- a/ts/components/leftpane/LeftPaneSectionHeader.tsx +++ b/ts/components/leftpane/LeftPaneSectionHeader.tsx @@ -1,5 +1,6 @@ +import { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { getAppDispatch } from '../../state/dispatch'; import { LeftOverlayType, sectionActions } from '../../state/ducks/section'; import { useLeftOverlayModeType } from '../../state/selectors/section'; @@ -23,6 +24,7 @@ import { tr } from '../../localization/localeTools'; import { userSettingsModal } from '../../state/ducks/modalDialog'; import { SettingsKey } from '../../data/settings-key'; import { LeftPaneAnnouncements } from './LeftPaneAnnoucements'; +import { useAppIsFocused } from '../../hooks/useAppFocused'; const StyledLeftPaneSectionHeader = styled(Flex)` height: var(--main-view-header-height); @@ -30,8 +32,32 @@ const StyledLeftPaneSectionHeader = styled(Flex)` transition: var(--default-duration); `; -const SectionTitle = styled(H4)` +const SectionTitleWrapper = styled.div` flex-grow: 1; + overflow: hidden; + min-width: 0; + flex-shrink: 100; + margin-inline-end: var(--margins-sm); +`; + +const SectionTitle = styled(H4)<{ $overflowPx?: number; $appIsFocused: boolean }>` + white-space: nowrap; + ${({ $overflowPx, $appIsFocused }) => + $overflowPx && $appIsFocused + ? css` + animation: sectionTitleMarquee 6s ease-in-out infinite; + @keyframes sectionTitleMarquee { + 0%, + 20% { + transform: translateX(0); + } + 80%, + 100% { + transform: translateX(-${$overflowPx}px); + } + } + ` + : ''}; `; const StyledProgressBarContainer = styled.div` @@ -167,6 +193,8 @@ export const LeftPaneSectionHeader = () => { const noOverlayMode = !leftOverlayMode; const showRecoveryPhrasePrompt = useSelector(getShowRecoveryPhrasePrompt); + const isAppFocused = useAppIsFocused(); + const dispatch = getAppDispatch(); const goBack = () => { if (!leftOverlayMode) { @@ -193,6 +221,27 @@ export const LeftPaneSectionHeader = () => { const label = getLeftPaneHeaderLabel(leftOverlayMode); + const wrapperRef = useRef(null); + const [overflowPx, setOverflowPx] = useState(undefined); + + useEffect(() => { + const container = wrapperRef.current; + if (!container) { + return; + } + const check = () => { + const diff = container.scrollWidth - container.clientWidth; + setOverflowPx(diff > 0 ? diff : undefined); + }; + check(); + const ro = new ResizeObserver(check); + ro.observe(container); + // eslint-disable-next-line consistent-return + return () => { + ro.disconnect(); + }; + }, [label]); + return ( { unicode={LUCIDE_ICONS_UNICODE.CHEVRON_LEFT} onClick={goBack} dataTestId="back-button" - padding="var(--margins-sm)" + padding="var(--margins-xs)" /> ) : ( )} - - {label} - - {!leftOverlayMode && } + + + {label} + + + {noOverlayMode && showRecoveryPhrasePrompt ? : null} {noOverlayMode ? : null} diff --git a/ts/react.d.ts b/ts/react.d.ts index db457f43be..8e8df69650 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -318,6 +318,7 @@ declare module 'react' { // generic readably message (not control message) | 'message-content' | 'message-container' + | 'message-content-inner' // control message types | 'message-request-response-message' From 0038040d0478b04d390c1dfac13fecea0345826c Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 2 Apr 2026 11:11:49 +1100 Subject: [PATCH 2/3] fix: no separate on hover on actionsrow --- ts/components/buttons/MenuButton.tsx | 4 +- .../buttons/avatar/AvatarExitQrCodeButton.tsx | 12 ++-- ts/components/icon/Icons.tsx | 9 ++- .../overlay/choose-action/ActionRow.tsx | 58 ++++++++++++++----- .../choose-action/OverlayChooseAction.tsx | 10 +++- 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/ts/components/buttons/MenuButton.tsx b/ts/components/buttons/MenuButton.tsx index d5616d7b8d..fde758d0ac 100644 --- a/ts/components/buttons/MenuButton.tsx +++ b/ts/components/buttons/MenuButton.tsx @@ -49,13 +49,15 @@ export function useNewConversationCallback() { }; } +const useLocalLeftOverlayModeType = useLeftOverlayModeType; + /** * This is the Session Menu Button. i.e. the button on top of the conversation list to start a new conversation. * It has two state: selected or not and so we use an checkbox input to keep the state in sync. */ export const MenuButton = () => { const onClick = useNewConversationCallback(); - const leftOverlayMode = useLeftOverlayModeType(); + const leftOverlayMode = useLocalLeftOverlayModeType(); if (leftOverlayMode) { return null; diff --git a/ts/components/buttons/avatar/AvatarExitQrCodeButton.tsx b/ts/components/buttons/avatar/AvatarExitQrCodeButton.tsx index 967a745577..7d0a5082b7 100644 --- a/ts/components/buttons/avatar/AvatarExitQrCodeButton.tsx +++ b/ts/components/buttons/avatar/AvatarExitQrCodeButton.tsx @@ -1,12 +1,11 @@ import { focusVisibleBoxShadowOutsetStr } from '../../../styles/focusVisible'; -import { LUCIDE_ICONS_UNICODE } from '../../icon/lucide'; -import { SessionLucideIconButton } from '../../icon/SessionIconButton'; +import { SessionIconButton } from '../../icon/SessionIconButton'; export function AvatarExitQrCodeButton({ onExitQrCodeView }: { onExitQrCodeView: () => void }) { return ( - ); } diff --git a/ts/components/icon/Icons.tsx b/ts/components/icon/Icons.tsx index 0c4c619950..0227907689 100644 --- a/ts/components/icon/Icons.tsx +++ b/ts/components/icon/Icons.tsx @@ -35,7 +35,8 @@ export type SessionIconType = | 'timer50' // no lucide alternative yet | 'timer55' // no lucide alternative yet | 'timer60' // no lucide alternative yet - | 'timerFixed'; // no lucide alternative yet + | 'timerFixed' // no lucide alternative yet + | 'userRoundFilled'; // no lucide alternative export type SessionIconSize = 'small' | 'medium' | 'large' | 'huge' | 'huge2' | 'max'; @@ -221,4 +222,10 @@ export const icons: Record = { viewBox: '0 0 12 12', ratio: 1, }, + + userRoundFilled: { + path: 'M4.95 6.6a4.95 4.95 0 1 1 9.9 0 4.95 4.95 0 0 1-9.9 0M2.475 17.384a6.893 6.893 0 0 1 6.893-6.892h1.064a6.893 6.893 0 0 1 6.893 6.892.766.766 0 0 1-.766.766H3.241a.766.766 0 0 1-.766-.766', + viewBox: '0 0 20 20', + ratio: 1, + }, }; diff --git a/ts/components/leftpane/overlay/choose-action/ActionRow.tsx b/ts/components/leftpane/overlay/choose-action/ActionRow.tsx index 90fc804ceb..6ed23da4c5 100644 --- a/ts/components/leftpane/overlay/choose-action/ActionRow.tsx +++ b/ts/components/leftpane/overlay/choose-action/ActionRow.tsx @@ -4,6 +4,8 @@ import { Flex } from '../../../basic/Flex'; import { LucideIcon } from '../../../icon/LucideIcon'; import type { WithLucideUnicode } from '../../../icon/lucide'; +export const ActionRowIconWidth = 58; + export const StyledChooseActionTitle = styled.span` color: var(--text-primary-color); font-size: 18px; @@ -13,15 +15,8 @@ export const StyledChooseActionTitle = styled.span` `; const StyledIcon = styled.div` - width: 58px; -`; - -const StyledHR = styled.hr` - height: 0px; - width: 100%; - border: 0.5px solid var(--borders-color); - padding: 0; - margin: 0; + width: ${ActionRowIconWidth}px; + flex-shrink: 0; `; const StyledActionRow = styled.button` @@ -35,10 +30,35 @@ const StyledActionRow = styled.button` &:hover { background: var(--conversation-tab-background-hover-color); } +`; - &:focus-visible ${StyledHR} { - // hide the border so the focus-visible looks better - border-color: var(--transparent-color); +export const StyledActionRowHr = styled.div<{ $marginLeft?: number }>` + height: 0.5px; + width: 100%; + border-top: 0.5px solid var(--borders-color); + padding: 0; + margin: 0; + margin-left: ${({ $marginLeft }) => $marginLeft}px; + transition: opacity var(--default-duration); + pointer-events: none; + + // tsc is not happy when we merge those together, so we have to keep them separate... + // hide the separator when the previous row is hovered or focused + &:has(+ ${StyledActionRow}:hover) { + opacity: 0; + } + + &:has(+ ${StyledActionRow}:focus-visible) { + opacity: 0; + } + + // hide the separator when the next row is hovered and focused + ${StyledActionRow}:hover + & { + opacity: 0; + } + + ${StyledActionRow}:focus-visible + & { + opacity: 0; } `; @@ -46,9 +66,18 @@ export const StyledActionRowContainer = styled(Flex)` width: 100%; border-top: var(--default-borders); border-bottom: var(--default-borders); + transition: + border-top-color var(--default-duration), + border-bottom-color var(--default-duration); + + &:has(button:first-of-type:hover), + &:has(button:first-of-type:focus-visible) { + border-top-color: var(--transparent-color); + } - ${StyledActionRow}:last-child ${StyledHR} { - border-color: transparent; + &:has(button:last-of-type:hover), + &:has(button:last-of-type:focus-visible) { + border-bottom-color: var(--transparent-color); } `; @@ -75,7 +104,6 @@ export function ActionRow(props: ActionRowProps) { width={'100%'} > {title} - ); diff --git a/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx b/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx index f4177e15bd..9e169f77a9 100644 --- a/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx +++ b/ts/components/leftpane/overlay/choose-action/OverlayChooseAction.tsx @@ -4,7 +4,12 @@ import { isEmpty, isString } from 'lodash'; import { getAppDispatch } from '../../../../state/dispatch'; import { SpacerSM } from '../../../basic/Text'; import { StyledLeftPaneOverlay } from '../OverlayMessage'; -import { ActionRow, StyledActionRowContainer } from './ActionRow'; +import { + ActionRow, + ActionRowIconWidth, + StyledActionRowContainer, + StyledActionRowHr, +} from './ActionRow'; import { ContactsListWithBreaks } from './ContactsListWithBreaks'; import { sectionActions } from '../../../../state/ducks/section'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; @@ -88,6 +93,7 @@ export const OverlayChooseAction = () => { onClick={() => openNewMessage()} dataTestId="chooser-new-conversation-button" /> + { onClick={() => openCreateGroup()} dataTestId="chooser-new-group" /> + { onClick={() => openJoinCommunity()} dataTestId="chooser-new-community" /> + Date: Thu, 9 Apr 2026 09:22:14 +1000 Subject: [PATCH 3/3] chore: wip --- .../media-gallery/MediaGridItem.tsx | 67 +++++++++++++++++-- .../message-content/MessageContent.tsx | 3 + .../message/message-content/quote/Quote.tsx | 4 +- ts/components/lightbox/LightboxGallery.tsx | 4 +- .../DataExtractionNotificationMessage.ts | 5 +- ts/util/attachment/attachmentsUtil.ts | 2 + 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/ts/components/conversation/media-gallery/MediaGridItem.tsx b/ts/components/conversation/media-gallery/MediaGridItem.tsx index 36e714f9f3..d781a8d251 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.tsx @@ -13,6 +13,12 @@ import { LUCIDE_ICONS_UNICODE } from '../../icon/lucide'; import { createButtonOnKeyDownForClickEventHandler } from '../../../util/keyboardShortcuts'; import { getAppDispatch } from '../../../state/dispatch'; import { focusVisibleBoxShadowOutset } from '../../../styles/focusVisible'; +import { Menu, MenuItem } from '../../menu/items/MenuItem'; +import { SessionContextMenuContainer } from '../../SessionContextMenuContainer'; +import { tr } from '../../../localization'; +import { saveAttachmentToDisk } from '../../../util/attachment/attachmentsUtil'; +import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation'; +import { showContextMenu } from '../../../util/contextMenu'; type Props = { mediaItem: MediaItemType; @@ -40,8 +46,7 @@ const StyledMediaGridItemImageContainer = styled.div` position: relative; `; -const MediaGridItemContent = (props: Props) => { - const { mediaItem } = props; +const MediaGridItemContent = ({ mediaItem }: Pick) => { const { attachment, contentType } = mediaItem; const urlToDecrypt = mediaItem.thumbnailObjectUrl || ''; @@ -109,20 +114,68 @@ const MediaGridItemContent = (props: Props) => { return ; }; -export const MediaGridItem = (props: Props) => { +export const MediaGridItem = ({ mediaItem, mediaItems }: Props) => { const dispatch = getAppDispatch(); + const msgId = mediaItem.messageId; + const contextMenuId = `media-grid-item-menu-${msgId}`; + const onClick = () => { const lightBoxOptions: LightBoxOptions = { - media: props.mediaItems, - attachment: props.mediaItem.attachment, + media: mediaItems, + attachment: mediaItem.attachment, }; dispatch(updateLightBoxOptions(lightBoxOptions)); }; const onKeyDown = createButtonOnKeyDownForClickEventHandler(onClick); + return ( - - + { + showContextMenu({ event: e, id: contextMenuId }); + }} + > + + ); }; + +function GridItemContextMenu({ + contextMenuId, + mediaItem, +}: { + contextMenuId: string; + mediaItem: MediaItemType; +}) { + const selectedConversationKey = useSelectedConversationKey(); + function onSave() { + if (selectedConversationKey) { + void saveAttachmentToDisk({ + attachment: mediaItem.attachment, + index: mediaItem.index, + messageSender: mediaItem.messageSender, + messageTimestamp: mediaItem.messageTimestamp, + conversationId: selectedConversationKey, + }); + } + } + + return ( + + + + {tr('save')} + + + + ); +} diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index c0cf116ba8..6df9ea2bbe 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -172,6 +172,9 @@ export const MessageContent = (props: Props) => { flexDirection: 'column', gap: 'var(--margins-xs)', maxWidth: '100%', + // this is needed to make sure a message with a short body and attachments + // is not forced to take the attachments width + alignItems: direction === 'incoming' ? 'flex-start' : 'flex-end', }} > diff --git a/ts/components/conversation/message/message-content/quote/Quote.tsx b/ts/components/conversation/message/message-content/quote/Quote.tsx index ccd36baf1b..186f67abed 100644 --- a/ts/components/conversation/message/message-content/quote/Quote.tsx +++ b/ts/components/conversation/message/message-content/quote/Quote.tsx @@ -24,7 +24,9 @@ const StyledQuote = styled.div<{ display: flex; flex-direction: row; align-items: stretch; - margin: var(--margins-xs); + margin-block: var(--margins-xs); + // margin-inline-start is 0 so that a body & start of quote are aligned + margin-inline: 0 var(--margins-xs); ${props => !props.$hasAttachment && 'border-left: 4px solid;'} border-color: ${props => props.$isIncoming diff --git a/ts/components/lightbox/LightboxGallery.tsx b/ts/components/lightbox/LightboxGallery.tsx index 3b62b044a9..4569a1ca1b 100644 --- a/ts/components/lightbox/LightboxGallery.tsx +++ b/ts/components/lightbox/LightboxGallery.tsx @@ -12,7 +12,7 @@ import { AttachmentTypeWithPath } from '../../types/Attachment'; import { saveAttachmentToDisk } from '../../util/attachment/attachmentsUtil'; import { saveURLAsFile } from '../../util/saveURLAsFile'; -export interface MediaItemType { +export type MediaItemType = { objectURL?: string; thumbnailObjectUrl?: string; contentType: MIME.MIMEType; @@ -21,7 +21,7 @@ export interface MediaItemType { messageTimestamp: number; messageSender: string; messageId: string; -} +}; type Props = { media: Array; diff --git a/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts b/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts index 5d984fcdd0..7bcad41338 100644 --- a/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts @@ -8,6 +8,7 @@ import { ExpirableMessageNoProfile, ExpirableMessageParams } from '../ExpirableM import { NetworkTime } from '../../../../util/NetworkTime'; import { MessageQueue } from '../../../sending'; import { uuidV4 } from '../../../../util/uuid'; +import { ed25519Str } from '../../../utils/String'; type DataExtractionNotificationMessageParams = ExpirableMessageParams & { referencedAttachmentTimestamp: number; @@ -60,7 +61,9 @@ export const sendDataExtractionNotification = async ( UserUtils.isUsFromCache(attachmentSender) || convo.isBlocked() ) { - window.log.warn('Not sending saving attachment notification for', attachmentSender); + window.log.warn( + `Not sending saving attachment notification for ${ed25519Str(attachmentSender)}` + ); return; } const { expirationType, expireTimer } = diff --git a/ts/util/attachment/attachmentsUtil.ts b/ts/util/attachment/attachmentsUtil.ts index 8f43d8997d..ca98994b58 100644 --- a/ts/util/attachment/attachmentsUtil.ts +++ b/ts/util/attachment/attachmentsUtil.ts @@ -170,11 +170,13 @@ export const saveAttachmentToDisk = async ({ conversationId: string; index: number; }) => { + debugger; const decryptedUrl = await DecryptedAttachmentsManager.getDecryptedMediaUrl( attachment.url, attachment.contentType, false ); + await save({ attachment: { ...attachment, url: decryptedUrl }, getAbsolutePath: getAbsoluteAttachmentPath,