Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@
3402AAB1271D9E180084CBAE /* OWSNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95544271B510400B05242 /* OWSNavigationBar.swift */; };
3402AAB2271D9E180084CBAE /* ContactTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95536271B510400B05242 /* ContactTableViewCell.swift */; };
3402AAB3271D9E180084CBAE /* CVTextLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95522271B510400B05242 /* CVTextLabel.swift */; };
5ECA11E62D1A000000000001 /* CVTextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ECA11E52D1A000000000001 /* CVTextTest.swift */; };
3402AAB4271D9E180084CBAE /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95519271B510400B05242 /* VideoPlayerView.swift */; };
3402AAB6271D9E180084CBAE /* ImageEditorCropViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95556271B510400B05242 /* ImageEditorCropViewController.swift */; };
3405C06E25435048008B24EF /* ConversationViewController+Banners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3405C06D25435048008B24EF /* ConversationViewController+Banners.swift */; };
Expand Down Expand Up @@ -4838,6 +4839,7 @@
34A95523271B510400B05242 /* CVCellMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVCellMeasurement.swift; sourceTree = "<group>"; };
34A95524271B510400B05242 /* CVUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVUtils.swift; sourceTree = "<group>"; };
34A95525271B510400B05242 /* CVText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVText.swift; sourceTree = "<group>"; };
5ECA11E52D1A000000000001 /* CVTextTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVTextTest.swift; sourceTree = "<group>"; };
34A95526271B510400B05242 /* DirectionalPanGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionalPanGestureRecognizer.swift; sourceTree = "<group>"; };
34A95529271B510400B05242 /* LinkingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkingTextView.swift; sourceTree = "<group>"; };
34A9552C271B510400B05242 /* MentionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionPicker.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -9665,6 +9667,7 @@
764981442A23194F00EC1287 /* CVItemViewModel.swift */,
34A95525271B510400B05242 /* CVText.swift */,
34A95522271B510400B05242 /* CVTextLabel.swift */,
5ECA11E52D1A000000000001 /* CVTextTest.swift */,
34A95524271B510400B05242 /* CVUtils.swift */,
);
path = ConversationView;
Expand Down Expand Up @@ -18780,6 +18783,7 @@
63C24D53306C50FD00D4C167 /* ContactShareAvatarFieldTests.swift in Sources */,
A20EC336FE7D969EBAA7B99E /* ContactSharingPickerViewModelTests.swift in Sources */,
848961B553640CEDA15C38D9 /* ContactSharingSystemContactWrapperTests.swift in Sources */,
5ECA11E62D1A000000000001 /* CVTextTest.swift in Sources */,
50BF51082BB2030C00C2C309 /* FormattedNumberFieldTest.swift in Sources */,
509085BA2C498C4400409B85 /* HTMLMetadataTests.swift in Sources */,
5073EACB2C4F3A16001FBB3E /* LinkPreviewFetcherTest.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,34 @@ class NotificationSettingsSoundViewController: OWSTableViewController2 {
soundName = sound.displayName
}

let contextMenuActionProvider: UIContextMenuActionProvider?
if case .custom = sound {
contextMenuActionProvider = { [weak self] _ in
guard let self else { return nil }
return UIMenu(children: [
UIAction(
title: OWSLocalizedString(
"NOTIFICATIONS_DELETE_CUSTOM_SOUND_ACTION",
comment: "Context menu action that deletes a custom notification sound.",
),
image: UIImage(systemName: "trash"),
attributes: .destructive,
) { _ in
self.didTapDeleteCustomSound(sound)
},
])
}
} else {
contextMenuActionProvider = nil
}

section.add(.init(
text: soundName,
actionBlock: { [weak self] in
self?.soundWasSelected(sound)
},
accessoryType: sound == notificationSound ? .checkmark : .none,
contextMenuActionProvider: contextMenuActionProvider,
))
}

Expand Down Expand Up @@ -151,6 +173,40 @@ class NotificationSettingsSoundViewController: OWSTableViewController2 {
})
}

private func didTapDeleteCustomSound(_ sound: Sound) {
OWSActionSheets.showConfirmationAlert(
title: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"NOTIFICATIONS_DELETE_CUSTOM_SOUND_CONFIRMATION_FORMAT",
comment: "Title of the confirmation alert shown before deleting a custom notification sound. Embeds the {{sound name}}.",
),
sound.displayName,
),
proceedTitle: OWSLocalizedString(
"NOTIFICATIONS_DELETE_CUSTOM_SOUND_ACTION",
comment: "Context menu action that deletes a custom notification sound.",
),
proceedStyle: .destructive,
proceedAction: { [weak self] _ in
self?.deleteCustomSound(sound)
},
fromViewController: self,
)
}

private func deleteCustomSound(_ sound: Sound) {
player?.stop()
player = nil

Sounds.deleteCustomSound(sound)

if notificationSound == sound {
notificationSound = NotificationPreferencesManager.Defaults.globalNotificationSound
}
updateTableContents()
updateNavigation()
}

private func didTapDone() {
if let thread {
Sounds.setNotificationSound(notificationSound, forThread: thread)
Expand Down
6 changes: 6 additions & 0 deletions Signal/translations/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -6520,6 +6520,12 @@
/* Label for settings UI that allows user to add a new notification sound. */
"NOTIFICATIONS_SECTION_SOUNDS_ADD_CUSTOM_SOUND" = "Add custom sound…";

/* Context menu action that deletes a custom notification sound. */
"NOTIFICATIONS_DELETE_CUSTOM_SOUND_ACTION" = "Delete Sound";

/* Title of the confirmation alert shown before deleting a custom notification sound. Embeds the {{sound name}}. */
"NOTIFICATIONS_DELETE_CUSTOM_SOUND_CONFIRMATION_FORMAT" = "Delete %@?";

/* No comment provided by engineer. */
"NOTIFICATIONS_SENDER_AND_MESSAGE" = "Name, Content, and Actions";

Expand Down
30 changes: 30 additions & 0 deletions SignalServiceKit/Util/Sounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,36 @@ public class Sounds {
keyValueStore.removeAll(transaction: tx)
}

/// Deletes the given custom sound, removing its file and clearing any
/// references to it in the per-thread and global notification sound
/// settings, so that they fall back to the default sound.
@discardableResult
public static func deleteCustomSound(_ sound: Sound) -> Bool {
guard case .custom(let customSound) = sound else {
owsFailDebug("Invalid sound.")
return false
}

SSKEnvironment.shared.databaseStorageRef.write { tx in
keyValueStore.removeValues(
forKeys: keyValueStore.allKeys(transaction: tx).filter {
keyValueStore.getUInt64($0, transaction: tx) == customSound.id
},
transaction: tx,
)

let notificationPreferencesManager = DependenciesBridge.shared.notificationPreferencesManager
if notificationPreferencesManager.globalNotificationSound(tx: tx).id == customSound.id {
notificationPreferencesManager.setGlobalNotificationSound(
NotificationPreferencesManager.Defaults.globalNotificationSound,
tx: tx,
)
}
}

return deleteCustomSound(customSound)
}

// MARK: - Util

private static func deleteCustomSound(_ sound: CustomSound) -> Bool {
Expand Down
55 changes: 55 additions & 0 deletions SignalServiceKit/tests/Util/SoundsTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Foundation
import Testing
import XCTest
@testable import SignalServiceKit

struct SoundsTest {
Expand All @@ -16,3 +17,57 @@ struct SoundsTest {
#expect(CustomSound.idFromFilename(testCase.filename) == testCase.id)
}
}

final class DeleteCustomSoundTest: SSKBaseTest {

private func writeTestSoundFile(_ filename: String) throws -> URL {
let soundUrl = URL(fileURLWithPath: Sounds.soundsDirectory, isDirectory: true)
.appendingPathComponent(filename)
try Data([0x00]).write(to: soundUrl)
addTeardownBlock {
try? FileManager.default.removeItem(at: soundUrl)
}
return soundUrl
}

func testDeleteCustomSound() throws {
let filename = "TestDeleteCustomSound.aifc"
let soundUrl = try writeTestSoundFile(filename)

let sound = Sound.custom(CustomSound(filename: filename))
XCTAssertTrue(Sounds.allNotificationSounds.contains(sound))

Sounds.deleteCustomSound(sound)

XCTAssertFalse(FileManager.default.fileExists(atPath: soundUrl.path))
XCTAssertFalse(Sounds.allNotificationSounds.contains(sound))
}

func testDeleteCustomSoundClearsReferences() throws {
let filename = "TestDeleteReferencedSound.aifc"
_ = try writeTestSoundFile(filename)

let sound = Sound.custom(CustomSound(filename: filename))
let notificationPreferencesManager = DependenciesBridge.shared.notificationPreferencesManager

let originalSound = SSKEnvironment.shared.databaseStorageRef.read { tx in
notificationPreferencesManager.globalNotificationSound(tx: tx)
}
addTeardownBlock {
SSKEnvironment.shared.databaseStorageRef.write { tx in
notificationPreferencesManager.setGlobalNotificationSound(originalSound, tx: tx)
}
}

write { tx in
notificationPreferencesManager.setGlobalNotificationSound(sound, tx: tx)
}

Sounds.deleteCustomSound(sound)

let globalSound = SSKEnvironment.shared.databaseStorageRef.read { tx in
notificationPreferencesManager.globalNotificationSound(tx: tx)
}
XCTAssertEqual(globalSound, NotificationPreferencesManager.Defaults.globalNotificationSound)
}
}
16 changes: 12 additions & 4 deletions SignalUI/ConversationView/CVText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,7 @@ public class CVText {
return result.ceil
}

#if TESTABLE_BUILD
public static func measureLabelUsingView(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize {
private static func measureLabelUsingView(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize {
guard !config.text.isEmpty else {
return .zero
}
Expand All @@ -337,7 +336,6 @@ public class CVText {
size.width = min(size.width, maxWidth)
return size
}
#endif

static func measureLabelUsingLayoutManager(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize {
guard !config.text.isEmpty else {
Expand All @@ -347,7 +345,17 @@ public class CVText {
textContainer.maximumNumberOfLines = config.numberOfLines
textContainer.lineBreakMode = config.lineBreakMode
textContainer.lineFragmentPadding = 0
return textContainer.size(for: config.text, font: config.font)
let result = textContainer.size(for: config.text, font: config.font)

// On some iOS versions, `usedRect(for:)` can fail to wrap long text
// and return a width larger than the container's, which would let the
// content stretch beyond its maximum width. Fall back to measuring
// with a UILabel, which wraps correctly.
guard result.width <= maxWidth else {
owsAssertDebug(false, "Layout manager measurement exceeded maxWidth.")
return measureLabelUsingView(config: config, maxWidth: maxWidth)
}
return result
}

// MARK: - CVTextLabel
Expand Down
31 changes: 31 additions & 0 deletions SignalUI/ConversationView/CVTextTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import XCTest
@testable import SignalUI

final class CVTextTest: XCTestCase {

/// Long text (e.g. some translations of the poll subtitle) must wrap to
/// fit `maxWidth` instead of being measured at its unwrapped width, which
/// would let message bubbles stretch beyond their maximum width.
func testMeasureLabelWrapsLongText() {
let config = CVLabelConfig.unstyledText(
"Umfrage · Wähle eine oder mehrere Optionen aus",
font: .dynamicTypeFootnote,
textColor: .label,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
)
let maxWidth: CGFloat = 200

let size = CVText.measureLabel(config: config, maxWidth: maxWidth)

XCTAssertLessThanOrEqual(size.width, maxWidth)
// The string is much wider than maxWidth on a single line, so it must
// have wrapped onto multiple lines.
XCTAssertGreaterThan(size.height, config.font.lineHeight)
}
}
2 changes: 2 additions & 0 deletions SignalUI/ViewControllers/OWSTableItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public class OWSTableItem {
accessoryText: String? = nil,
accessoryType: UITableViewCell.AccessoryType,
accessibilityIdentifier: String? = nil,
contextMenuActionProvider: UIContextMenuActionProvider? = nil,
) {
self.init(
customCellBlock: {
Expand All @@ -76,6 +77,7 @@ public class OWSTableItem {
)
},
actionBlock: actionBlock,
contextMenuActionProvider: contextMenuActionProvider,
)
}

Expand Down