diff --git a/stylesheets/components/ConversationDetailsHeader.scss b/stylesheets/components/ConversationDetailsHeader.scss index 8ce1786a8..4b90473d3 100644 --- a/stylesheets/components/ConversationDetailsHeader.scss +++ b/stylesheets/components/ConversationDetailsHeader.scss @@ -29,6 +29,8 @@ padding-bottom: 8px; padding-top: 12px; user-select: text; + display: flex; + align-items: center; } &__title-contact-icon { diff --git a/stylesheets/components/SignalConversationMuteToggle.scss b/stylesheets/components/SignalConversationMuteToggle.scss new file mode 100644 index 000000000..e3c11e518 --- /dev/null +++ b/stylesheets/components/SignalConversationMuteToggle.scss @@ -0,0 +1,30 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../mixins'; +@use '../variables'; + +.SignalConversationMuteToggle { + @include mixins.light-theme() { + background-color: variables.$color-white; + border-top: 0.5px solid variables.$color-black-alpha-16; + } + + @include mixins.dark-theme() { + background-color: variables.$color-gray-95; + border-top: 0.5px solid variables.$color-gray-65; + } + + height: variables.$header-height; + + display: flex; + justify-content: center; + align-items: center; + + font-size: 14px; + color: variables.$color-ultramarine-light; + + &__text { + @include mixins.button-reset; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 33da0945a..a1bc75b0d 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -159,6 +159,7 @@ @use 'components/SendStoryModal.scss'; @use 'components/ShortcutGuide.scss'; @use 'components/SignalConnectionsModal.scss'; +@use 'components/SignalConversationMuteToggle.scss'; @use 'components/Slider.scss'; @use 'components/SpinnerV2.scss'; @use 'components/StagedLinkPreview.scss'; diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index cdf5f25c8..756975898 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import { IMAGE_JPEG } from '../types/MIME'; @@ -125,6 +125,10 @@ export default { selectedMessageIds: undefined, toggleSelectMode: action('toggleSelectMode'), toggleForwardMessagesModal: action('toggleForwardMessagesModal'), + // Signal Conversation + isSignalConversation: false, + isMuted: false, + setMuteExpiration: action('setMuteExpiration'), }, } satisfies Meta; @@ -263,3 +267,21 @@ export function NoFormattingMenu(args: Props): JSX.Element { ); } + +export function SignalConversationMuteToggle(args: Props): JSX.Element { + const theme = useContext(StorybookThemeContext); + const [isMuted, setIsMuted] = useState(true); + + function setIsMutedByTime(_: string, muteExpiresAt: number) { + setIsMuted(muteExpiresAt > Date.now()); + } + return ( + + ); +} diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 443aedc6a..21ca8f392 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -76,6 +76,7 @@ import type { ShowToastAction } from '../state/ducks/toast'; import type { DraftEditMessageType } from '../model-types.d'; import type { ForwardMessagesPayload } from '../state/ducks/globalModals'; import { ForwardMessagesModalType } from './ForwardMessagesModal'; +import { SignalConversationMuteToggle } from './conversation/SignalConversationMuteToggle'; export type OwnProps = Readonly<{ acceptedMessageRequest: boolean | null; @@ -118,6 +119,7 @@ export type OwnProps = Readonly<{ recordingState: RecordingState; messageCompositionId: string; shouldHidePopovers: boolean | null; + isMuted: boolean; isSmsOnlyOrUnregistered: boolean | null; left: boolean | null; linkPreviewLoading: boolean; @@ -130,6 +132,7 @@ export type OwnProps = Readonly<{ conversationId: string; files: ReadonlyArray; }) => unknown; + setMuteExpiration(conversationId: string, muteExpiresAt: number): unknown; setMediaQualitySetting(conversationId: string, isHQ: boolean): unknown; sendStickerMessage( id: string, @@ -239,6 +242,7 @@ export const CompositionArea = memo(function CompositionArea({ imageToBlurHash, isDisabled, isSignalConversation, + isMuted, isActive, lastEditableMessageId, messageCompositionId, @@ -254,6 +258,7 @@ export const CompositionArea = memo(function CompositionArea({ shouldHidePopovers, showToast, theme, + setMuteExpiration, // AttachmentList draftAttachments, @@ -737,8 +742,14 @@ export const CompositionArea = memo(function CompositionArea({ useEscapeHandling(handleEscape); if (isSignalConversation) { - // TODO DESKTOP-4547 - return
; + return ( + + ); } if (selectedMessageIds != null) { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a34760550..7ab2cd0d7 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -47,6 +47,7 @@ function HeaderInfoTitle({ type, i18n, isMe, + isSignalConversation, headerRef, }: { name: string | null; @@ -54,8 +55,18 @@ function HeaderInfoTitle({ type: ConversationTypeType; i18n: LocalizerType; isMe: boolean; + isSignalConversation: boolean; headerRef: React.RefObject; }) { + if (isSignalConversation) { + return ( +
+ + +
+ ); + } + if (isMe) { return (
@@ -294,6 +305,7 @@ export const ConversationHeader = memo(function ConversationHeader({ theme={theme} onViewUserStories={onViewUserStories} onViewConversationDetails={onViewConversationDetails} + isSignalConversation={isSignalConversation ?? false} /> {!isSmsOnlyOrUnregistered && !isSignalConversation && ( ; theme: ThemeType; + isSignalConversation: boolean; onViewUserStories: () => void; onViewConversationDetails: () => void; }) { @@ -476,6 +490,7 @@ function HeaderContent({ type={conversation.type} i18n={i18n} isMe={conversation.isMe} + isSignalConversation={isSignalConversation} headerRef={headerRef} /> {(conversation.expireTimer != null || conversation.isVerified) && ( diff --git a/ts/components/conversation/SignalConversationMuteToggle.tsx b/ts/components/conversation/SignalConversationMuteToggle.tsx new file mode 100644 index 000000000..e8a447115 --- /dev/null +++ b/ts/components/conversation/SignalConversationMuteToggle.tsx @@ -0,0 +1,34 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { LocalizerType } from '../../types/I18N'; + +type Props = { + isMuted: boolean; + i18n: LocalizerType; + setMuteExpiration: (conversationId: string, muteExpiresAt: number) => unknown; + conversationId: string; +}; +export function SignalConversationMuteToggle({ + isMuted, + i18n, + setMuteExpiration, + conversationId, +}: Props): JSX.Element { + const onMuteToggleClicked = () => { + setMuteExpiration(conversationId, isMuted ? 0 : Number.MAX_SAFE_INTEGER); + }; + + return ( +
+ +
+ ); +} diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index b191ec3a9..2476abcf1 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -65,6 +65,7 @@ const createProps = ( i18n, isAdmin: false, isGroup: true, + isSignalConversation: false, leaveGroup: action('leaveGroup'), loadRecentMediaItems: action('loadRecentMediaItems'), memberships: times(32, i => ({ @@ -244,3 +245,11 @@ export function InAnotherCallIndividual(): JSX.Element { return ; } + +export function SignalConversation(): JSX.Element { + const props = createProps(); + + return ( + + ); +} diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index a45e63943..0f16aff9f 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -84,6 +84,7 @@ export type StateProps = { i18n: LocalizerType; isAdmin: boolean; isGroup: boolean; + isSignalConversation: boolean; groupsInCommon: ReadonlyArray; maxGroupSize: number; maxRecommendedGroupSize: number; @@ -181,6 +182,7 @@ export function ConversationDetails({ i18n, isAdmin, isGroup, + isSignalConversation, leaveGroup, loadRecentMediaItems, memberships, @@ -397,6 +399,7 @@ export function ConversationDetails({ i18n={i18n} isMe={conversation.isMe} isGroup={isGroup} + isSignalConversation={isSignalConversation} membersCount={conversation.membersCount ?? null} startEditing={(isGroupTitle: boolean) => { setModalState( @@ -424,7 +427,7 @@ export function ConversationDetails({ {i18n('icu:ConversationDetails__HeaderButton--Message')} )} - {!conversation.isMe && ( + {!conversation.isMe && !isSignalConversation && ( <> )} - - {!isGroup || canEditGroupInfo ? ( - - } - info={ - isGroup - ? i18n( - 'icu:ConversationDetails--disappearing-messages-info--group' - ) - : i18n( - 'icu:ConversationDetails--disappearing-messages-info--direct' - ) - } - label={i18n('icu:ConversationDetails--disappearing-messages-label')} - right={ - - setDisappearingMessages(conversation.id, value) - } - /> - } - /> - ) : null} - {canHaveNicknameAndNote(conversation) && ( - - } - label={i18n('icu:ConversationDetails--nickname-label')} - onClick={onOpenEditNicknameAndNoteModal} - actions={ - (conversation.nicknameGivenName || - conversation.nicknameFamilyName || - conversation.note) && ( - + {!isGroup || canEditGroupInfo ? ( + + } + info={ + isGroup + ? i18n( + 'icu:ConversationDetails--disappearing-messages-info--group' + ) + : i18n( + 'icu:ConversationDetails--disappearing-messages-info--direct' + ) + } + label={i18n( + 'icu:ConversationDetails--disappearing-messages-label' + )} + right={ + { - setModalState(ModalState.ConfirmDeleteNicknameAndNote); + value={conversation.expireTimer || DurationInSeconds.ZERO} + onChange={value => + setDisappearingMessages(conversation.id, value) + } + /> + } + /> + ) : null} + {canHaveNicknameAndNote(conversation) && ( + + } + label={i18n('icu:ConversationDetails--nickname-label')} + onClick={onOpenEditNicknameAndNoteModal} + actions={ + (conversation.nicknameGivenName || + conversation.nicknameFamilyName || + conversation.note) && ( + { + setModalState( + ModalState.ConfirmDeleteNicknameAndNote + ); + }, }, - }, - ]} - > - {({ onClick }) => { - return ( - - ); + ]} + > + {({ onClick }) => { + return ( + + ); + }} + + ) + } + /> + )} + {selectedNavTab === NavTab.Chats && ( + + } + label={i18n('icu:showChatColorEditor')} + onClick={() => { + pushPanelForConversation({ + type: PanelType.ChatColorEditor, + }); + }} + right={ +
- ) - } - /> - )} - {selectedNavTab === NavTab.Chats && ( - - } - label={i18n('icu:showChatColorEditor')} - onClick={() => { - pushPanelForConversation({ - type: PanelType.ChatColorEditor, - }); - }} - right={ -
- } - /> - )} - {isGroup && ( - - } - label={i18n('icu:ConversationDetails--notifications')} - onClick={() => - pushPanelForConversation({ - type: PanelType.NotificationSettings, - }) - } - right={ - conversation.muteExpiresAt - ? getMutedUntilText(conversation.muteExpiresAt, i18n) - : undefined - } - /> - )} - {!isGroup && !conversation.isMe && ( - toggleSafetyNumberModal(conversation.id)} - icon={ - - } - label={ -
- {i18n('icu:ConversationDetails__viewSafetyNumber')} -
- } - /> - )} - - + /> + } + /> + )} + {isGroup && ( + + } + label={i18n('icu:ConversationDetails--notifications')} + onClick={() => + pushPanelForConversation({ + type: PanelType.NotificationSettings, + }) + } + right={ + conversation.muteExpiresAt + ? getMutedUntilText(conversation.muteExpiresAt, i18n) + : undefined + } + /> + )} + {!isGroup && !conversation.isMe && ( + toggleSafetyNumberModal(conversation.id)} + icon={ + + } + label={ +
+ {i18n('icu:ConversationDetails__viewSafetyNumber')} +
+ } + /> + )} + + )} {isGroup && ( - {!isGroup && !conversation.isMe && ( + {!isGroup && !conversation.isMe && !isSignalConversation && ( ) { membersCount={0} isGroup isMe={false} + isSignalConversation={false} theme={theme} toggleAboutContactModal={action('toggleAboutContactModal')} {...overrideProps} diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx index 37e8d1a64..a187e42f2 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -25,6 +25,7 @@ export type Props = { i18n: LocalizerType; isGroup: boolean; isMe: boolean; + isSignalConversation: boolean; membersCount: number | null; startEditing: (isGroupTitle: boolean) => void; toggleAboutContactModal: (contactId: string) => void; @@ -44,6 +45,7 @@ export function ConversationDetailsHeader({ i18n, isGroup, isMe, + isSignalConversation, membersCount, startEditing, toggleAboutContactModal, @@ -194,6 +196,13 @@ export function ConversationDetailsHeader({
); + } else if (isSignalConversation) { + title = ( +
+ + +
+ ); } else if (isGroup) { title = (
diff --git a/ts/services/releaseNotesFetcher.ts b/ts/services/releaseNotesFetcher.ts index c233ef87c..a9a65512b 100644 --- a/ts/services/releaseNotesFetcher.ts +++ b/ts/services/releaseNotesFetcher.ts @@ -396,6 +396,7 @@ export class ReleaseNotesFetcher { await this.#scheduleForNextRun(); this.setTimeoutForNextRun(); + window.SignalCI?.handleEvent('release_notes_fetcher_complete', {}); } catch (error) { const errorString = error instanceof HTTPError diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index dae083509..9a7f32315 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -67,6 +67,7 @@ import { useToastActions } from '../ducks/toast'; import { isShowingAnyModal } from '../selectors/globalModals'; import { isConversationEverUnregistered } from '../../util/isConversationUnregistered'; import { isDirectConversation } from '../../util/whatTypeOfConversation'; +import { isConversationMuted } from '../../util/isConversationMuted'; function renderSmartCompositionRecording( recProps: SmartCompositionRecordingProps @@ -232,6 +233,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ toggleSelectMode, scrollToMessage, setMessageToEdit, + setMuteExpiration, showConversation, } = useConversationsActions(); const { cancelRecording, completeRecording, startRecording, errorRecording } = @@ -325,7 +327,6 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ (isConversationSMSOnly(conversation) || isConversationEverUnregistered(conversation)) } - isSignalConversation={isSignalConversation(conversation)} isFetchingUUID={conversation.isFetchingUUID ?? null} isMissingMandatoryProfileSharing={isMissingRequiredProfileSharing( conversation @@ -335,6 +336,10 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ blockConversation={blockConversation} reportSpam={reportSpam} deleteConversation={deleteConversation} + // Signal Conversation + isSignalConversation={isSignalConversation(conversation)} + isMuted={isConversationMuted(conversation)} + setMuteExpiration={setMuteExpiration} // Groups groupVersion={conversation.groupVersion ?? null} isGroupV1AndDisabled={conversation.isGroupV1AndDisabled ?? null} diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index edbca885e..a28e31e47 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -40,6 +40,7 @@ import { useCallingActions } from '../ducks/calling'; import { useSearchActions } from '../ducks/search'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useLightboxActions } from '../ducks/lightbox'; +import { isSignalConversation } from '../../util/isSignalConversation'; export type SmartConversationDetailsProps = { conversationId: string; @@ -193,6 +194,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ i18n={i18n} isAdmin={isAdmin} isGroup={isGroup} + isSignalConversation={isSignalConversation(conversation)} leaveGroup={leaveGroup} loadRecentMediaItems={loadRecentMediaItems} maxGroupSize={maxGroupSize} diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index 08b39a4ad..8374d3777 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -129,6 +129,10 @@ export class App extends EventEmitter { return this.#waitForEvent('receipts'); } + public async waitForReleaseNotesFetcher(): Promise { + return this.#waitForEvent('release_notes_fetcher_complete'); + } + public async waitForStorageService(): Promise { return this.#waitForEvent('storageServiceComplete'); } diff --git a/ts/test-mock/release-notes/release_notes_test.ts b/ts/test-mock/release-notes/release_notes_test.ts index eca28aaaf..e7fd0020e 100644 --- a/ts/test-mock/release-notes/release_notes_test.ts +++ b/ts/test-mock/release-notes/release_notes_test.ts @@ -45,6 +45,7 @@ describe('release notes', function (this: Mocha.Suite) { it('shows release notes with an image and body ranges', async () => { const firstWindow = await app.getWindow(); + await app.waitForReleaseNotesFetcher(); await firstWindow.evaluate('window.SignalCI.resetReleaseNotesFetcher()'); await app.close(); diff --git a/ts/util/isSignalConversation.ts b/ts/util/isSignalConversation.ts index bdbc65339..0fb0de35f 100644 --- a/ts/util/isSignalConversation.ts +++ b/ts/util/isSignalConversation.ts @@ -11,7 +11,7 @@ export function isSignalConversation(conversation: { const { id, serviceId } = conversation; if (serviceId) { - return serviceId === SIGNAL_ACI; + return isSignalServiceId(serviceId); } return window.ConversationController.isSignalConversationId(id);