Added toast and info message deletion, fixed layout issues & unit tests

Added a toast when hitting the emoji reacts rate limit
Added the ability to delete info messages
Fixed some odd layout behaviours with the VisibleMessageCell
Fixed some layout issues with reactions
Removed some unneeded custom code
This commit is contained in:
Morgan Pretty 2022-10-05 18:44:25 +11:00
parent db54bf657e
commit 27e0981913
60 changed files with 1280 additions and 1262 deletions

View File

@ -573,6 +573,13 @@
FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; };
FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; };
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; };
FD23EA5C28ED00F80058676E /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; };
FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; };
FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; };
FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; };
FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; };
FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; };
FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; };
FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; };
FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; };
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; };
@ -599,6 +606,11 @@
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; };
FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; };
FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; };
FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; };
FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; };
@ -1678,8 +1690,10 @@
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = "<group>"; };
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = "<group>"; };
FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = "<group>"; };
FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = "<group>"; };
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = "<group>"; };
FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = "<group>"; };
@ -3374,7 +3388,7 @@
C3C2A5A0255385C100C340D1 /* SessionSnodeKit */,
C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */,
C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */,
FD83B9BC27CF2215005E1583 /* SharedTest */,
FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */,
FD71160A28D00BAE00B47552 /* SessionTests */,
FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */,
FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */,
@ -3897,15 +3911,18 @@
path = General;
sourceTree = "<group>";
};
FD83B9BC27CF2215005E1583 /* SharedTest */ = {
FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */ = {
isa = PBXGroup;
children = (
FDC290A527D860CE005DAE71 /* Mock.swift */,
FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */,
FD83B9BD27CF2243005E1583 /* TestConstants.swift */,
FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */,
FD078E4727E02561000769AF /* CommonMockedExtensions.swift */,
FD23EA6028ED0B260058676E /* CombineExtensions.swift */,
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */,
);
path = SharedTest;
path = _SharedTestUtilities;
sourceTree = "<group>";
};
FD83B9C127CF33EE005E1583 /* Models */ = {
@ -4052,7 +4069,6 @@
isa = PBXGroup;
children = (
FDC438BC27BB2AB400C60D73 /* Mockable.swift */,
FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */,
FD859EF327C2F49200510D0C /* MockSodium.swift */,
FD3C906E27E43E8700CD579F /* MockBox.swift */,
FD859EF927C2F5C500510D0C /* MockGenericHash.swift */,
@ -5752,8 +5768,15 @@
buildActionMask = 2147483647;
files = (
FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */,
FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */,
FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */,
FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */,
FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */,
FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */,
FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */,
FD23EA5C28ED00F80058676E /* Mock.swift in Sources */,
FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */,
FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -5761,10 +5784,13 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */,
FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */,
FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */,
FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */,
FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,
FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */,
FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */,
FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */,
FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */,
);
@ -5789,6 +5815,7 @@
FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */,
FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */,
FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */,
FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */,
FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */,
FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */,
FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */,
@ -5800,6 +5827,7 @@
FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */,
FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */,
FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */,
FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */,
FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */,
FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */,
FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,

View File

@ -118,6 +118,18 @@
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FD71160828D00BAE00B47552"
BuildableName = "SessionTests.xctest"
BlueprintName = "SessionTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES"
@ -142,18 +154,6 @@
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FD71160828D00BAE00B47552"
BuildableName = "SessionTests.xctest"
BlueprintName = "SessionTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction

View File

@ -45,27 +45,18 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
return result
}()
private lazy var fadeView: UIView = {
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top).map { $0 + Values.veryLargeSpacing } ?? 64)
let result = UIView()
var frame = UIScreen.main.bounds
frame.size.height = height
let layer = CAGradientLayer()
layer.frame = frame
result.layer.insertSublayer(layer, at: 0)
private lazy var fadeView: GradientView = {
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top)
.map { $0 + Values.veryLargeSpacing })
.defaulting(to: 64)
let result: GradientView = GradientView()
result.themeBackgroundGradient = [
.value(.backgroundPrimary, alpha: 0.4),
.value(.backgroundPrimary, alpha: 0)
]
result.set(.height, to: height)
ThemeManager.onThemeChange(observer: result) { [weak layer] theme, _ in
guard let backgroundPrimary: UIColor = theme.color(for: .backgroundPrimary) else { return }
layer?.colors = [
backgroundPrimary.withAlphaComponent(0.4).cgColor,
backgroundPrimary.withAlphaComponent(0).cgColor
]
}
return result
}()

View File

@ -26,27 +26,18 @@ class VideoPreviewVC: UIViewController, CameraManagerDelegate {
return result
}()
private lazy var fadeView: UIView = {
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top).map { $0 + Values.veryLargeSpacing } ?? 64)
let result = UIView()
var frame = UIScreen.main.bounds
frame.size.height = height
let layer = CAGradientLayer()
layer.frame = frame
result.layer.insertSublayer(layer, at: 0)
private lazy var fadeView: GradientView = {
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top)
.map { $0 + Values.veryLargeSpacing })
.defaulting(to: 64)
let result: GradientView = GradientView()
result.themeBackgroundGradient = [
.value(.backgroundPrimary, alpha: 0.4),
.value(.backgroundPrimary, alpha: 0)
]
result.set(.height, to: height)
ThemeManager.onThemeChange(observer: result) { [weak layer] theme, _ in
guard let backgroundPrimary: UIColor = theme.color(for: .backgroundPrimary) else { return }
layer?.colors = [
backgroundPrimary.withAlphaComponent(0.4).cgColor,
backgroundPrimary.withAlphaComponent(0).cgColor
]
}
return result
}()

View File

@ -108,12 +108,15 @@ extension ContextMenuVC {
currentThreadIsMessageRequest: Bool,
delegate: ContextMenuActionDelegate?
) -> [Action]? {
// No context items for info messages
guard cellViewModel.variant != .standardIncomingDeleted else {
return [ Action.delete(cellViewModel, delegate) ]
}
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
return nil
switch cellViewModel.variant {
case .standardIncomingDeleted, .infoCall,
.infoScreenshotNotification, .infoMediaSavedNotification,
.infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
// Let the user delete info messages and unsent messages
return [ Action.delete(cellViewModel, delegate) ]
case .standardOutgoing, .standardIncoming: break
}
let canReply: Bool = (

View File

@ -225,7 +225,8 @@ final class ContextMenuVC: UIViewController {
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
default: break // Should never occur
default: // Should generally only be the 'delete' action
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
}
// Tap gesture

View File

@ -766,8 +766,9 @@ extension ConversationVC:
let index = self.viewModel.interactionData[sectionIndex]
.elements
.firstIndex(of: cellViewModel),
let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? VisibleMessageCell,
let snapshot = cell.snContentView.snapshotView(afterScreenUpdates: false),
let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell,
let contextSnapshotView: UIView = cell.contextSnapshotView,
let snapshot = contextSnapshotView.snapshotView(afterScreenUpdates: false),
contextMenuWindow == nil,
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
for: cellViewModel,
@ -789,7 +790,7 @@ extension ConversationVC:
self.contextMenuWindow = ContextMenuWindow()
self.contextMenuVC = ContextMenuVC(
snapshot: snapshot,
frame: cell.convert(cell.snContentView.frame, to: keyWindow),
frame: contextSnapshotView.convert(contextSnapshotView.bounds, to: keyWindow),
cellViewModel: cellViewModel,
actions: actions
) { [weak self] in
@ -1218,7 +1219,18 @@ extension ConversationVC:
guard
recentReactionTimestamps.count < 20 ||
(sentTimestamp - (recentReactionTimestamps.first ?? sentTimestamp)) > (60 * 1000)
else { return }
else {
let toastController: ToastController = ToastController(
text: "EMOJI_REACTS_RATE_LIMIT_TOAST".localized(),
background: .backgroundSecondary
)
toastController.presentToastView(
fromBottomOfView: self.view,
inset: (snInputView.bounds.height + Values.largeSpacing),
duration: .milliseconds(2500)
)
return
}
General.cache.mutate {
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
@ -1593,17 +1605,22 @@ extension ConversationVC:
}
func delete(_ cellViewModel: MessageViewModel) {
// Only allow deletion on incoming and outgoing messages
guard cellViewModel.variant != .standardIncomingDeleted else {
Storage.shared.writeAsync { db in
_ = try Interaction
.filter(id: cellViewModel.id)
.deleteAll(db)
}
return
}
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
return
switch cellViewModel.variant {
case .standardIncomingDeleted, .infoCall,
.infoScreenshotNotification, .infoMediaSavedNotification,
.infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
// Info messages and unsent messages should just trigger a local
// deletion (they are created as side effects so we wouldn't be
// able to delete them for all participants anyway)
Storage.shared.writeAsync { db in
_ = try Interaction
.filter(id: cellViewModel.id)
.deleteAll(db)
}
return
case .standardOutgoing, .standardIncoming: break
}
let threadId: String = self.viewModel.threadData.threadId

View File

@ -705,10 +705,23 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }),
let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
.firstIndex(where: { item -> Bool in
item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id
// Since the first item is probably a `DateHeaderCell` (which would likely
// be removed when inserting items above it) we check if the id matches
// either the first or second item
let messages: [MessageViewModel] = self.viewModel
.interactionData[oldSectionIndex]
.elements
return (
item.id == messages[safe: 0]?.id ||
item.id == messages[safe: 1]?.id
)
}),
let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
.filter({ $0.section == oldSectionIndex })
.filter({
$0.section == oldSectionIndex &&
self.viewModel.interactionData[$0.section].elements[$0.row].cellType != .dateHeader
})
.sorted()
.first,
let newVisibleIndex: Int = updatedData[newSectionIndex].elements
@ -722,7 +735,9 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
return ItemChangeInfo(
isInsertAtTop: (
newSectionIndex > oldSectionIndex ||
newFirstItemIndex > 0
// Note: Using `1` here instead of `0` as the first item will generally
// be a `DateHeaderCell` instead of a message
newFirstItemIndex > 1
),
firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex),

View File

@ -9,14 +9,18 @@ final class CallMessageCell: MessageCell {
private static let inset = Values.mediumSpacing
private static let margin = UIScreen.main.bounds.width * 0.1
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
private var isHandlingLongPress: Bool = false
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
override var contextSnapshotView: UIView? { return container }
// MARK: - UI
private lazy var topConstraint: NSLayoutConstraint = container.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
private lazy var iconImageViewWidthConstraint: NSLayoutConstraint = iconImageView.set(.width, to: 0)
private lazy var iconImageViewHeightConstraint: NSLayoutConstraint = iconImageView.set(.height, to: 0)
private lazy var infoImageViewWidthConstraint: NSLayoutConstraint = infoImageView.set(.width, to: 0)
private lazy var infoImageViewHeightConstraint: NSLayoutConstraint = infoImageView.set(.height, to: 0)
private lazy var iconImageView: UIImageView = UIImageView()
private lazy var infoImageView: UIImageView = {
let result: UIImageView = UIImageView(
@ -28,15 +32,6 @@ final class CallMessageCell: MessageCell {
return result
}()
private lazy var timestampLabel: UILabel = {
let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
result.themeTextColor = .textPrimary
result.textAlignment = .center
return result
}()
private lazy var label: UILabel = {
let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
@ -80,15 +75,6 @@ final class CallMessageCell: MessageCell {
return result
}()
private lazy var stackView: UIStackView = {
let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ])
result.axis = .vertical
result.alignment = .center
result.spacing = Values.smallSpacing
return result
}()
// MARK: - Lifecycle
override func setUpViewHierarchy() {
@ -96,16 +82,18 @@ final class CallMessageCell: MessageCell {
iconImageViewWidthConstraint.isActive = true
iconImageViewHeightConstraint.isActive = true
addSubview(stackView)
addSubview(container)
container.autoPinWidthToSuperview()
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
topConstraint.isActive = true
container.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
container.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
container.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
}
override func setUpGestureRecognizers() {
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
addGestureRecognizer(longPressRecognizer)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGestureRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(tapGestureRecognizer)
@ -130,6 +118,7 @@ final class CallMessageCell: MessageCell {
else { return }
self.viewModel = cellViewModel
self.topConstraint.constant = (cellViewModel.shouldShowDateHeader ? 0 : CallMessageCell.inset)
iconImageView.image = {
switch messageInfo.state {
@ -157,12 +146,24 @@ final class CallMessageCell: MessageCell {
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
label.text = cellViewModel.body
timestampLabel.text = cellViewModel.dateForUI.formattedForDisplay
}
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
}
// MARK: - Interaction
@objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) {
isHandlingLongPress = false
return
}
guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return }
delegate?.handleItemLongPressed(cellViewModel)
isHandlingLongPress = true
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard
let cellViewModel: MessageViewModel = self.viewModel,

View File

@ -76,15 +76,11 @@ final class DocumentView: UIView {
stackView.axis = .horizontal
stackView.spacing = Values.mediumSpacing
stackView.alignment = .center
stackView.layoutMargins = UIEdgeInsets(
top: Values.smallSpacing,
leading: Values.mediumSpacing,
bottom: Values.smallSpacing,
trailing: Values.mediumSpacing
)
stackView.isLayoutMarginsRelativeArrangement = true
addSubview(stackView)
stackView.pin(to: self)
stackView.pin(.top, to: .top, of: self, withInset: Values.smallSpacing)
stackView.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing)
stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing)
stackView.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
imageBackgroundView.pin(.top, to: .top, of: self)
imageBackgroundView.pin(.leading, to: .leading, of: self)

View File

@ -2,11 +2,17 @@
import UIKit
import SessionUIKit
import SessionUtilitiesKit
import SignalUtilitiesKit
final class ReactionContainerView: UIView {
var showingAllReactions = false
private var showNumbers = true
private static let arrowSize: CGSize = CGSize(width: 15, height: 13)
private static let arrowSpacing: CGFloat = Values.verySmallSpacing
private var maxWidth: CGFloat = 0
private var collapsedCount: Int = 0
private var showingAllReactions: Bool = false
private var showNumbers: Bool = true
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
private var oldSize: CGSize = .zero
@ -15,6 +21,16 @@ final class ReactionContainerView: UIView {
// MARK: - UI
private var collapseTextLabelRightConstraint: NSLayoutConstraint?
private let dummyReactionButton: ReactionButton = ReactionButton(
viewModel: ReactionViewModel(
emoji: EmojiWithSkinTones(baseEmoji: .a, skinTones: nil),
number: 0,
showBorder: false
)
)
private lazy var mainStackView: UIStackView = {
let result: UIStackView = UIStackView(arrangedSubviews: [ reactionContainerView, collapseButton ])
result.axis = .vertical
@ -24,6 +40,8 @@ final class ReactionContainerView: UIView {
return result
}()
var expandButton: ExpandingReactionButton?
private lazy var reactionContainerView: UIStackView = {
let result: UIStackView = UIStackView()
result.axis = .vertical
@ -33,34 +51,36 @@ final class ReactionContainerView: UIView {
return result
}()
var expandButton: ExpandingReactionButton?
var collapseButton: UIStackView = {
let arrow = UIImageView(
lazy var collapseButton: UIView = {
let arrow: UIImageView = UIImageView(
image: UIImage(named: "ic_chevron_up")?
.resizedImage(to: CGSize(width: 15, height: 13))?
.resizedImage(to: ReactionContainerView.arrowSize)?
.withRenderingMode(.alwaysTemplate)
)
arrow.themeTintColor = .textPrimary
let textLabel: UILabel = UILabel()
textLabel.setContentHuggingPriority(.required, for: .vertical)
textLabel.setContentHuggingPriority(.required, for: .horizontal)
textLabel.setContentCompressionResistancePriority(.required, for: .vertical)
textLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
textLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
textLabel.text = "Show less"
textLabel.themeTextColor = .textPrimary
let leftSpacer: UIView = UIView.hStretchingSpacer()
let rightSpacer: UIView = UIView.hStretchingSpacer()
let result: UIStackView = UIStackView(arrangedSubviews: [
leftSpacer,
arrow,
textLabel,
rightSpacer
])
result.isLayoutMarginsRelativeArrangement = true
result.spacing = Values.verySmallSpacing
result.alignment = .center
let result: UIView = UIView()
result.isHidden = true
rightSpacer.set(.width, to: .width, of: leftSpacer)
result.addSubview(arrow)
result.addSubview(textLabel)
arrow.pin(.top, to: .top, of: result)
arrow.pin(.leading, to: .leading, of: result)
arrow.pin(.bottom, to: .bottom, of: result)
textLabel.pin(.top, to: .top, of: result)
textLabel.pin(.leading, to: .trailing, of: arrow, withInset: ReactionContainerView.arrowSpacing)
collapseTextLabelRightConstraint = textLabel.pin(.trailing, to: .trailing, of: result)
textLabel.pin(.bottom, to: .bottom, of: result)
return result
}()
@ -84,123 +104,181 @@ final class ReactionContainerView: UIView {
addSubview(mainStackView)
mainStackView.pin(to: self)
collapseButton.set(.width, to: .width, of: mainStackView)
reactionContainerView.set(.width, to: .width, of: mainStackView)
}
override func layoutSubviews() {
super.layoutSubviews()
// Note: We update the 'collapseButton.layoutMargins' to try to make the "show less"
// Note: We update the 'collapseTextLabelRightConstraint' to try to make the "show less"
// button appear horizontally centered (if we don't do this it gets offset to one side)
guard frame != CGRect.zero, frame.size != oldSize else { return }
collapseButton.layoutMargins = UIEdgeInsets(
top: 0,
leading: -frame.minX,
bottom: 0,
trailing: -((superview?.frame.width ?? 0) - frame.maxX)
)
var targetSuperview: UIView? = {
var result: UIView? = self.superview
while result != nil, result?.isKind(of: UITableViewCell.self) != true {
result = result?.superview
}
return result
}()
if let targetSuperview: UIView = targetSuperview {
let parentWidth: CGFloat = targetSuperview.bounds.width
let frameInParent: CGRect = targetSuperview.convert(self.bounds, from: self)
let centeredWidth: CGFloat = (parentWidth - (frameInParent.minX * 2))
let diff: CGFloat = (frameInParent.width - centeredWidth)
collapseTextLabelRightConstraint?.constant = -(
diff +
((ReactionContainerView.arrowSize.width + ReactionContainerView.arrowSpacing) / 2)
)
}
oldSize = frame.size
}
public func update(_ reactions: [ReactionViewModel], showNumbers: Bool) {
public func update(
_ reactions: [ReactionViewModel],
maxWidth: CGFloat,
showingAllReactions: Bool,
showNumbers: Bool
) {
self.reactions = reactions
self.maxWidth = maxWidth
self.collapsedCount = {
var numReactions: Int = 0
var runningWidth: CGFloat = 0
let estimatedExpandingButtonWidth: CGFloat = 52
let itemSpacing: CGFloat = self.reactionContainerView.spacing
for reaction in reactions {
let reactionViewWidth: CGFloat = dummyReactionButton
.updating(with: reaction, showNumber: showNumbers)
.systemLayoutSizeFitting(CGSize(width: maxWidth, height: 9999))
.width
let estimatedFullWidth: CGFloat = (
runningWidth +
(reactionViewWidth + itemSpacing) +
estimatedExpandingButtonWidth
)
if estimatedFullWidth >= maxWidth {
break
}
runningWidth += (reactionViewWidth + itemSpacing)
numReactions += 1
}
return numReactions
}()
self.showNumbers = showNumbers
self.reactionViews = []
self.reactionContainerView.arrangedSubviews.forEach { $0.removeFromSuperview() }
prepareForUpdate()
if showingAllReactions {
updateAllReactions()
// Generate the lines of reactions (if the 'collapsedCount' matches the total number of
// reactions then just show them app)
if showingAllReactions || self.collapsedCount >= reactions.count {
self.updateAllReactions(reactions, maxWidth: maxWidth, showNumbers: showNumbers)
}
else {
updateCollapsedReactions(reactions)
self.updateCollapsedReactions(reactions, maxWidth: maxWidth, showNumbers: showNumbers)
}
// Just in case we couldn't show everything for some reason update this based on the
// internal logic
self.collapseButton.isHidden = (self.reactionContainerView.arrangedSubviews.count <= 1)
self.showingAllReactions = !self.collapseButton.isHidden
self.layoutIfNeeded()
}
private func updateCollapsedReactions(_ reactions: [ReactionViewModel]) {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = Values.smallSpacing
stackView.alignment = .center
private func createLineStackView() -> UIStackView {
let result: UIStackView = UIStackView()
result.axis = .horizontal
result.spacing = Values.smallSpacing
result.alignment = .center
result.set(.height, to: ReactionButton.height)
var displayedReactions: [ReactionViewModel]
var expandButtonReactions: [EmojiWithSkinTones]
return result
}
private func updateCollapsedReactions(
_ reactions: [ReactionViewModel],
maxWidth: CGFloat,
showNumbers: Bool
) {
guard !reactions.isEmpty else { return }
if reactions.count > maxEmojisPerLine {
displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)])
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine])
.map { $0.emoji }
}
else {
displayedReactions = reactions
expandButtonReactions = []
}
let maxSize: CGSize = CGSize(width: maxWidth, height: 9999)
let stackView: UIStackView = createLineStackView()
let displayedReactions: [ReactionViewModel] = Array(reactions.prefix(upTo: self.collapsedCount))
let expandButtonReactions: [EmojiWithSkinTones] = reactions
.suffix(from: self.collapsedCount)
.prefix(3)
.map { $0.emoji }
for reaction in displayedReactions {
let reactionView = ReactionButton(viewModel: reaction, showNumber: showNumbers)
let reactionViewWidth: CGFloat = reactionView.systemLayoutSizeFitting(maxSize).width
stackView.addArrangedSubview(reactionView)
reactionViews.append(reactionView)
reactionView.set(.width, to: reactionViewWidth)
}
if expandButtonReactions.count > 0 {
let expandButton: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
stackView.addArrangedSubview(expandButton)
self.expandButton = {
guard !expandButtonReactions.isEmpty else { return nil }
let result: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
stackView.addArrangedSubview(result)
self.expandButton = expandButton
}
else {
expandButton = nil
}
return result
}()
reactionContainerView.addArrangedSubview(stackView)
}
private func updateAllReactions() {
var reactions = self.reactions
var numberOfLines = 0
private func updateAllReactions(
_ reactions: [ReactionViewModel],
maxWidth: CGFloat,
showNumbers: Bool
) {
guard !reactions.isEmpty else { return }
while reactions.count > 0 {
var line: [ReactionViewModel] = []
let maxSize: CGSize = CGSize(width: maxWidth, height: 9999)
var lineStackView: UIStackView = createLineStackView()
reactionContainerView.addArrangedSubview(lineStackView)
for reaction in self.reactions {
let reactionView: ReactionButton = ReactionButton(viewModel: reaction, showNumber: showNumbers)
let reactionViewWidth: CGFloat = reactionView.systemLayoutSizeFitting(maxSize).width
reactionViews.append(reactionView)
while reactions.count > 0 && line.count < maxEmojisPerLine {
line.append(reactions.removeFirst())
// Check if we need to create a new line
let stackViewWidth: CGFloat = (lineStackView.arrangedSubviews.isEmpty ?
0 :
lineStackView.systemLayoutSizeFitting(maxSize).width
)
if stackViewWidth + reactionViewWidth > maxWidth {
lineStackView = createLineStackView()
reactionContainerView.addArrangedSubview(lineStackView)
}
updateCollapsedReactions(line)
numberOfLines += 1
lineStackView.addArrangedSubview(reactionView)
reactionView.set(.width, to: reactionViewWidth)
}
if numberOfLines > 1 {
collapseButton.isHidden = false
}
else {
showingAllReactions = false
}
}
private func prepareForUpdate() {
for subview in reactionContainerView.arrangedSubviews {
reactionContainerView.removeArrangedSubview(subview)
subview.removeFromSuperview()
}
collapseButton.isHidden = true
reactionViews = []
}
public func showAllEmojis() {
guard !showingAllReactions else { return }
showingAllReactions = true
update(reactions, showNumbers: showNumbers)
update(reactions, maxWidth: maxWidth, showingAllReactions: true, showNumbers: showNumbers)
}
public func showLessEmojis() {
guard showingAllReactions else { return }
showingAllReactions = false
update(reactions, showNumbers: showNumbers)
update(reactions, maxWidth: maxWidth, showingAllReactions: false, showNumbers: showNumbers)
}
}

View File

@ -15,10 +15,29 @@ final class ReactionButton: UIView {
// MARK: - Settings
private var height: CGFloat = 22
public static var height: CGFloat = 22
private var fontSize: CGFloat = Values.verySmallFontSize
private var spacing: CGFloat = Values.verySmallSpacing
// MARK: - UI
private lazy var emojiLabel: UILabel = {
let result: UILabel = UILabel()
result.setContentHuggingPriority(.required, for: .horizontal)
result.setContentCompressionResistancePriority(.required, for: .horizontal)
result.font = .systemFont(ofSize: fontSize)
return result
}()
private lazy var numberLabel: UILabel = {
let result: UILabel = UILabel()
result.font = .systemFont(ofSize: fontSize)
result.themeTextColor = .textPrimary
return result
}()
// MARK: - Lifecycle
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
@ -28,6 +47,7 @@ final class ReactionButton: UIView {
super.init(frame: CGRect.zero)
setUpViewHierarchy()
update(with: viewModel, showNumber: showNumber)
}
override init(frame: CGRect) {
@ -39,35 +59,45 @@ final class ReactionButton: UIView {
}
private func setUpViewHierarchy() {
let emojiLabel: UILabel = UILabel()
emojiLabel.font = .systemFont(ofSize: fontSize)
emojiLabel.text = viewModel.emoji.rawValue
let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel ])
let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
stackView.axis = .horizontal
stackView.spacing = spacing
stackView.alignment = .center
stackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
stackView.isLayoutMarginsRelativeArrangement = true
addSubview(stackView)
stackView.pin(to: self)
stackView.pin(.top, to: .top, of: self)
stackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing)
stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing)
stackView.pin(.bottom, to: .bottom, of: self)
themeBorderColor = (viewModel.showBorder ? .primary : .clear)
themeBackgroundColor = .messageBubble_incomingBackground
layer.cornerRadius = (self.height / 2)
layer.cornerRadius = (ReactionButton.height / 2)
layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness')
set(.height, to: self.height)
set(.height, to: ReactionButton.height)
if showNumber || viewModel.number > 1 {
let numberLabel = UILabel()
numberLabel.font = .systemFont(ofSize: fontSize)
numberLabel.text = (viewModel.number < 1000 ?
"\(viewModel.number)" :
String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
)
numberLabel.themeTextColor = .textPrimary
stackView.addArrangedSubview(numberLabel)
numberLabel.isHidden = (!showNumber && viewModel.number <= 1)
}
func update(with viewModel: ReactionViewModel, showNumber: Bool) {
_ = updating(with: viewModel, showNumber: showNumber)
}
func updating(with viewModel: ReactionViewModel, showNumber: Bool) -> ReactionButton {
emojiLabel.text = viewModel.emoji.rawValue
numberLabel.text = (viewModel.number < 1000 ?
"\(viewModel.number)" :
String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
)
numberLabel.isHidden = (!showNumber && viewModel.number <= 1)
UIView.performWithoutAnimation {
self.setNeedsLayout()
self.layoutIfNeeded()
}
return self
}
}

View File

@ -8,6 +8,10 @@ final class InfoMessageCell: MessageCell {
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
private var isHandlingLongPress: Bool = false
override var contextSnapshotView: UIView? { return label }
// MARK: - UI
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
@ -49,6 +53,11 @@ final class InfoMessageCell: MessageCell {
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
}
override func setUpGestureRecognizers() {
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
addGestureRecognizer(longPressRecognizer)
}
// MARK: - Updating
@ -90,4 +99,17 @@ final class InfoMessageCell: MessageCell {
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
}
// MARK: - Interaction
@objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) {
isHandlingLongPress = false
return
}
guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return }
delegate?.handleItemLongPressed(cellViewModel)
isHandlingLongPress = true
}
}

View File

@ -10,8 +10,9 @@ public enum SwipeState {
}
public class MessageCell: UITableViewCell {
weak var delegate: MessageCellDelegate?
var viewModel: MessageViewModel?
weak var delegate: MessageCellDelegate?
open var contextSnapshotView: UIView? { return nil }
// MARK: - Lifecycle

View File

@ -15,6 +15,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
var voiceMessageView: VoiceMessageView?
var audioStateChanged: ((TimeInterval, Bool) -> ())?
override var contextSnapshotView: UIView? { return snContentView }
// Constraints
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
@ -25,13 +27,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
private lazy var contentViewRightConstraint1 = snContentView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
private lazy var contentViewRightConstraint2 = snContentView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize)
private lazy var contentBottomConstraint = snContentView.bottomAnchor
.constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -1)
private lazy var reactionContainerViewLeftConstraint = reactionContainerView.pin(.left, to: .left, of: snContentView)
private lazy var reactionContainerViewRightConstraint = reactionContainerView.pin(.right, to: .right, of: snContentView)
private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: reactionContainerView, withInset: 0)
private lazy var messageStatusImageViewWidthConstraint = messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize)
private lazy var messageStatusImageViewHeightConstraint = messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize)
private lazy var underBubbleStackViewIncomingLeadingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.leading, to: .leading, of: snContentView)
private lazy var underBubbleStackViewIncomingTrailingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
private lazy var underBubbleStackViewOutgoingLeadingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
private lazy var underBubbleStackViewOutgoingTrailingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.trailing, to: .trailing, of: snContentView)
private lazy var underBubbleStackViewNoHeightConstraint: NSLayoutConstraint = underBubbleStackView.set(.height, to: 0)
private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
@ -92,16 +95,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
return result
}()
private lazy var reactionContainerView = ReactionContainerView()
internal lazy var messageStatusImageView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFit
result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2
result.layer.masksToBounds = true
return result
}()
private lazy var replyButton: UIView = {
let result = UIView()
let size = VisibleMessageCell.replyButtonSize + 8
@ -128,6 +121,27 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}()
private lazy var timerView: OWSMessageTimerView = OWSMessageTimerView()
lazy var underBubbleStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [])
result.setContentHuggingPriority(.required, for: .vertical)
result.setContentCompressionResistancePriority(.required, for: .vertical)
result.axis = .vertical
result.spacing = Values.verySmallSpacing
result.alignment = .trailing
return result
}()
private lazy var reactionContainerView = ReactionContainerView()
internal lazy var messageStatusImageView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFit
result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2
result.layer.masksToBounds = true
return result
}()
// MARK: - Settings
@ -197,19 +211,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
timerView.center(.vertical, in: snContentView)
timerViewOutgoingMessageConstraint.isActive = true
// Reaction view
addSubview(reactionContainerView)
reactionContainerView.pin(.top, to: .bottom, of: snContentView, withInset: Values.verySmallSpacing)
reactionContainerViewLeftConstraint.isActive = true
// Message status image view
addSubview(messageStatusImageView)
messageStatusImageViewTopConstraint.isActive = true
messageStatusImageView.pin(.right, to: .right, of: snContentView, withInset: -1)
messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1)
messageStatusImageViewWidthConstraint.isActive = true
messageStatusImageViewHeightConstraint.isActive = true
// Reply button
addSubview(replyButton)
replyButton.addSubview(replyIconImageView)
@ -219,6 +220,20 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
// Remaining constraints
authorLabel.pin(.left, to: .left, of: snContentView, withInset: VisibleMessageCell.authorLabelInset)
// Under bubble content
addSubview(underBubbleStackView)
underBubbleStackView.pin(.top, to: .bottom, of: snContentView, withInset: 5)
underBubbleStackView.pin(.bottom, to: .bottom, of: self)
underBubbleStackView.addArrangedSubview(reactionContainerView)
underBubbleStackView.addArrangedSubview(messageStatusImageView)
reactionContainerView.widthAnchor
.constraint(lessThanOrEqualTo: underBubbleStackView.widthAnchor)
.isActive = true
messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize)
messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize)
}
override func setUpGestureRecognizers() {
@ -298,12 +313,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
lastSearchText: lastSearchText
)
// Reaction view
reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty == true)
reactionContainerViewLeftConstraint.isActive = (cellViewModel.variant == .standardIncoming)
reactionContainerViewRightConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
populateReaction(for: cellViewModel, showExpandedReactions: showExpandedReactions)
// Author label
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
authorLabel.isHidden = (cellViewModel.senderName == nil)
@ -315,27 +324,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace)
authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0)
// Message status image view
let (image, tintColor) = cellViewModel.state.statusIconInfo(
variant: cellViewModel.variant,
hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt
)
messageStatusImageView.image = image
messageStatusImageView.themeTintColor = tintColor
messageStatusImageView.isHidden = (
cellViewModel.variant != .standardOutgoing ||
cellViewModel.variant == .infoCall ||
(
cellViewModel.state == .sent &&
!cellViewModel.isLast
)
)
messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5)
[ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ]
.forEach {
$0.constant = (messageStatusImageView.isHidden ? 0 : VisibleMessageCell.messageStatusImageViewSize)
}
// Timer
if
let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs,
@ -367,6 +355,43 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
else {
addGestureRecognizer(panGestureRecognizer)
}
// Under bubble content
underBubbleStackView.alignment = (cellViewModel.variant == .standardOutgoing ?
.trailing :
.leading
)
underBubbleStackViewIncomingLeadingConstraint.isActive = (cellViewModel.variant != .standardOutgoing)
underBubbleStackViewIncomingTrailingConstraint.isActive = (cellViewModel.variant != .standardOutgoing)
underBubbleStackViewOutgoingLeadingConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
underBubbleStackViewOutgoingTrailingConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
// Reaction view
reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty != false)
populateReaction(
for: cellViewModel,
maxWidth: VisibleMessageCell.getMaxWidth(
for: cellViewModel,
includingOppositeGutter: false
),
showExpandedReactions: showExpandedReactions
)
// Message status image view
let (image, tintColor) = cellViewModel.state.statusIconInfo(
variant: cellViewModel.variant,
hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt
)
messageStatusImageView.image = image
messageStatusImageView.themeTintColor = tintColor
messageStatusImageView.isHidden = (
cellViewModel.variant != .standardOutgoing ||
cellViewModel.variant == .infoCall ||
(
cellViewModel.state == .sent &&
!cellViewModel.isLast
)
)
}
private func populateContentView(
@ -595,7 +620,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}
}
private func populateReaction(for cellViewModel: MessageViewModel, showExpandedReactions: Bool) {
private func populateReaction(
for cellViewModel: MessageViewModel,
maxWidth: CGFloat,
showExpandedReactions: Bool
) {
let reactions: OrderedDictionary<EmojiWithSkinTones, ReactionViewModel> = (cellViewModel.reactionInfo ?? [])
.reduce(into: OrderedDictionary()) { result, reactionInfo in
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
@ -626,9 +655,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}
}
reactionContainerView.showingAllReactions = showExpandedReactions
reactionContainerView.update(
reactions.orderedValues,
maxWidth: maxWidth,
showingAllReactions: showExpandedReactions,
showNumbers: (
cellViewModel.threadVariant == .closedGroup ||
cellViewModel.threadVariant == .openGroup
@ -752,7 +782,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
let location = gestureRecognizer.location(in: self)
if reactionContainerView.frame.contains(location) {
if reactionContainerView.bounds.contains(reactionContainerView.convert(location, from: self)) {
let convertedLocation = reactionContainerView.convert(location, from: self)
for reactionView in reactionContainerView.reactionViews {
@ -774,7 +804,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
let location = gestureRecognizer.location(in: self)
if profilePictureView.frame.contains(location), cellViewModel.shouldShowProfile {
if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile {
// For open groups only attempt to start a conversation if the author has a blinded id
guard cellViewModel.threadVariant != .openGroup else {
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
@ -793,11 +823,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
openGroupPublicKey: nil
)
}
else if replyButton.alpha > 0 && replyButton.frame.contains(location) {
else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
reply()
}
else if reactionContainerView.frame.contains(location) {
else if reactionContainerView.bounds.contains(reactionContainerView.convert(location, from: self)) {
let convertedLocation = reactionContainerView.convert(location, from: self)
for reactionView in reactionContainerView.reactionViews {
@ -813,7 +843,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}
}
if let expandButton = reactionContainerView.expandButton, expandButton.frame.contains(convertedLocation) {
if let expandButton = reactionContainerView.expandButton, expandButton.bounds.contains(expandButton.convert(location, from: self)) {
reactionContainerView.showAllEmojis()
delegate?.needsLayout(for: cellViewModel, expandingReactions: true)
}
@ -823,7 +853,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
delegate?.needsLayout(for: cellViewModel, expandingReactions: false)
}
}
else if snContentView.frame.contains(location) {
else if snContentView.bounds.contains(snContentView.convert(location, from: self)) {
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
}
}
@ -966,11 +996,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
return CGSize(width: width, height: height)
}
static func getMaxWidth(for cellViewModel: MessageViewModel) -> CGFloat {
static func getMaxWidth(for cellViewModel: MessageViewModel, includingOppositeGutter: Bool = true) -> CGFloat {
let screen: CGRect = UIScreen.main.bounds
let oppositeEdgePadding: CGFloat = (includingOppositeGutter ? gutterSize : contactThreadHSpacing)
switch cellViewModel.variant {
case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize)
case .standardOutgoing:
return (screen.width - contactThreadHSpacing - oppositeEdgePadding)
case .standardIncoming, .standardIncomingDeleted:
let isGroupThread = (
cellViewModel.threadVariant == .openGroup ||
@ -978,7 +1011,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
)
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
return (screen.width - leftGutterSize - gutterSize)
return (screen.width - leftGutterSize - oppositeEdgePadding)
default: preconditionFailure()
}

View File

@ -28,8 +28,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
// MARK: - Variables
private let storage: Storage
private let scheduler: ValueObservationScheduler
private let dependencies: Dependencies
private let threadId: String
private let config: DisappearingMessagesConfiguration
private var storedSelection: TimeInterval
@ -38,13 +37,11 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
// MARK: - Initialization
init(
storage: Storage = Storage.shared,
scheduling scheduler: ValueObservationScheduler = Storage.defaultPublisherScheduler,
dependencies: Dependencies = Dependencies(),
threadId: String,
config: DisappearingMessagesConfiguration
) {
self.storage = storage
self.scheduler = scheduler
self.dependencies = dependencies
self.threadId = threadId
self.config = config
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
@ -133,7 +130,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
]
}
.removeDuplicates()
.publisher(in: storage, scheduling: scheduler)
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
// MARK: - Functions
@ -152,7 +149,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
guard self.config != updatedConfig else { return }
storage.writeAsync { db in
dependencies.storage.writeAsync { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
return
}

View File

@ -46,6 +46,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
// MARK: - Variables
private let dependencies: Dependencies
private let threadId: String
private let threadVariant: SessionThread.Variant
private let didTriggerSearch: () -> ()
@ -54,13 +55,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
// MARK: - Initialization
init(threadId: String, threadVariant: SessionThread.Variant, didTriggerSearch: @escaping () -> ()) {
init(
dependencies: Dependencies = Dependencies(),
threadId: String,
threadVariant: SessionThread.Variant,
didTriggerSearch: @escaping () -> ()
) {
self.dependencies = dependencies
self.threadId = threadId
self.threadVariant = threadVariant
self.didTriggerSearch = didTriggerSearch
self.oldDisplayName = (threadVariant != .contact ?
nil :
Storage.shared.read { db in
dependencies.storage.read { db in
try Profile
.filter(id: threadId)
.select(.nickname)
@ -73,55 +80,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
// MARK: - Navigation
lazy var navState: AnyPublisher<NavState, Never> = {
Publishers
.MergeMany(
isEditing
.filter { $0 }
.map { _ in .editing }
.eraseToAnyPublisher(),
navItemTapped
.filter { $0 == .edit }
.map { _ in .editing }
.handleEvents(receiveOutput: { [weak self] _ in
self?.setIsEditing(true)
})
.eraseToAnyPublisher(),
navItemTapped
.filter { $0 == .cancel }
.map { _ in .standard }
.handleEvents(receiveOutput: { [weak self] _ in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
})
.eraseToAnyPublisher(),
navItemTapped
.filter { $0 == .done }
.filter { [weak self] _ in self?.threadVariant == .contact }
.handleEvents(receiveOutput: { [weak self] _ in
self?.setIsEditing(false)
guard
let threadId: String = self?.threadId,
let editedDisplayName: String = self?.editedDisplayName
else { return }
let updatedNickname: String = editedDisplayName
.trimmingCharacters(in: .whitespacesAndNewlines)
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
Storage.shared.writeAsync { db in
try Profile
.filter(id: threadId)
.updateAll(
db,
Profile.Columns.nickname
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
)
}
})
.map { _ in .standard }
.eraseToAnyPublisher()
)
isEditing
.map { isEditing in (isEditing ? .editing : .standard) }
.removeDuplicates()
.prepend(.standard) // Initial value
.eraseToAnyPublisher()
@ -139,7 +99,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
)
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
.eraseToAnyPublisher()
@ -147,7 +110,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
navState
.map { [weak self] navState -> [NavItem] in
.map { [weak self, dependencies] navState -> [NavItem] in
// Only show the 'Edit' button if it's a contact thread
guard self?.threadVariant == .contact else { return [] }
@ -158,7 +121,29 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done button"
)
) { [weak self] in
self?.setIsEditing(false)
guard
self?.threadVariant == .contact,
let threadId: String = self?.threadId,
let editedDisplayName: String = self?.editedDisplayName
else { return }
let updatedNickname: String = editedDisplayName
.trimmingCharacters(in: .whitespacesAndNewlines)
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
dependencies.storage.writeAsync { db in
try Profile
.filter(id: threadId)
.updateAll(
db,
Profile.Columns.nickname
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
)
}
}
]
case .standard:
@ -167,7 +152,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
)
) { [weak self] in self?.setIsEditing(true) }
]
}
}
@ -196,8 +181,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
@ -387,7 +372,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
cancelStyle: .alert_text
),
onTap: { [weak self] in
Storage.shared.writeAsync { db in
dependencies.storage.writeAsync { db in
try MessageSender.leave(db, groupPublicKey: threadId)
}
}
@ -435,7 +420,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
onTap: {
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
Storage.shared.writeAsync { db in
dependencies.storage.writeAsync { db in
try SessionThread
.filter(id: threadId)
.updateAll(
@ -465,15 +450,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
onTap: {
let newValue: Bool = !(threadViewModel.threadMutedUntilTimestamp != nil)
Storage.shared.writeAsync { db in
dependencies.storage.writeAsync { db in
let currentValue: TimeInterval? = try SessionThread
.filter(id: threadId)
.select(.mutedUntilTimestamp)
.asRequest(of: TimeInterval.self)
.fetchOne(db)
try SessionThread
.filter(id: threadId)
.updateAll(
db,
SessionThread.Columns.mutedUntilTimestamp.set(
to: (newValue ?
to: (currentValue == nil ?
Date.distantFuture.timeIntervalSince1970 :
nil
)
@ -538,7 +527,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
]
}
.removeDuplicates()
.publisher(in: Storage.shared)
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
// MARK: - Functions
@ -575,7 +564,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
private func addUsersToOpenGoup(selectedUsers: Set<String>) {
let threadId: String = self.threadId
Storage.shared.writeAsync { db in
dependencies.storage.writeAsync { db in
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return }
let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?public_key=\(openGroup.publicKey)"
@ -622,7 +611,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
) {
guard oldBlockedState != isBlocked else { return }
Storage.shared.writeAsync(
dependencies.storage.writeAsync(
updates: { db in
try Contact
.fetchOrCreate(db, id: threadId)

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -476,6 +476,7 @@
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
/* New conversation screen*/
"vc_new_conversation_title" = "New Conversation";
"CREATE_GROUP_BUTTON_TITLE" = "Create";

View File

@ -63,66 +63,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
// MARK: - Navigation
lazy var navState: AnyPublisher<NavState, Never> = {
Publishers
.MergeMany(
isEditing
.filter { $0 }
.map { _ in .editing }
.eraseToAnyPublisher(),
navItemTapped
.filter { $0 == .cancel }
.map { _ in .standard }
.handleEvents(receiveOutput: { [weak self] _ in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
})
.eraseToAnyPublisher(),
navItemTapped
.filter { $0 == .done }
.handleEvents(receiveOutput: { [weak self] _ in
let updatedNickname: String = (self?.editedDisplayName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !updatedNickname.isEmpty else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "vc_settings_display_name_missing_error".localized(),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
guard !ProfileManager.isToLong(profileName: updatedNickname) else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "vc_settings_display_name_too_long_error".localized(),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
self?.setIsEditing(false)
self?.oldDisplayName = updatedNickname
self?.updateProfile(
name: updatedNickname,
profilePicture: nil,
profilePictureFilePath: nil,
isUpdatingDisplayName: true,
isUpdatingProfilePicture: false
)
})
.map { _ in .standard }
.eraseToAnyPublisher()
)
isEditing
.map { isEditing in (isEditing ? .editing : .standard) }
.removeDuplicates()
.prepend(.standard) // Initial value
.eraseToAnyPublisher()
@ -149,7 +91,10 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
)
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
}
@ -180,7 +125,47 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done button"
)
) { [weak self] in
let updatedNickname: String = (self?.editedDisplayName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !updatedNickname.isEmpty else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "vc_settings_display_name_missing_error".localized(),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
guard !ProfileManager.isToLong(profileName: updatedNickname) else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "vc_settings_display_name_too_long_error".localized(),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
self?.setIsEditing(false)
self?.oldDisplayName = updatedNickname
self?.updateProfile(
name: updatedNickname,
profilePicture: nil,
profilePictureFilePath: nil,
isUpdatingDisplayName: true,
isUpdatingProfilePicture: false
)
}
]
}
}

View File

@ -1344,9 +1344,12 @@ public enum OpenGroupAPI {
let method: String = (request.httpMethod ?? "GET")
let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970))
let nonce: Data = Data(dependencies.nonceGenerator16.nonce())
let serverPublicKeyData: Data = Data(hex: serverPublicKey)
guard let serverPublicKeyData: Data = serverPublicKey.dataFromHex() else { return nil }
guard let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil }
guard
!serverPublicKeyData.isEmpty,
let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes
else { return nil }
/// Get a hash of any body content
let bodyHash: Bytes? = {

View File

@ -1129,6 +1129,7 @@ extension OpenGroupManager {
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
sodium: SodiumType? = nil,
box: BoxType? = nil,
genericHash: GenericHashType? = nil,
@ -1146,6 +1147,7 @@ extension OpenGroupManager {
onionApi: onionApi,
generalCache: generalCache,
storage: storage,
scheduler: scheduler,
sodium: sodium,
box: box,
genericHash: genericHash,

View File

@ -510,10 +510,12 @@ public extension MessageViewModel {
// Interaction Info
let targetId: Int64 = (isTypingIndicator == true ?
MessageViewModel.typingIndicatorId :
MessageViewModel.genericId
)
let targetId: Int64 = {
guard isTypingIndicator != true else { return MessageViewModel.typingIndicatorId }
guard cellType != .dateHeader else { return -timestampMs }
return MessageViewModel.genericId
}()
self.rowId = targetId
self.id = targetId
self.variant = variant

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Sodium
import SessionSnodeKit
import SessionUtilitiesKit
@ -66,6 +67,7 @@ public class SMKDependencies: Dependencies {
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
sodium: SodiumType? = nil,
box: BoxType? = nil,
genericHash: GenericHashType? = nil,
@ -90,6 +92,7 @@ public class SMKDependencies: Dependencies {
super.init(
generalCache: generalCache,
storage: storage,
scheduler: scheduler,
standardUserDefaults: standardUserDefaults,
date: date
)

View File

@ -25,8 +25,9 @@ extension Sodium {
/// 64-byte blake2b hash then reduce to get the blinding factor
public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? {
/// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil }
guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else {
let serverPubKeyData: Data = Data(hex: serverPublicKey)
guard !serverPubKeyData.isEmpty, let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else {
return nil
}

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
@ -11,6 +12,7 @@ extension SMKDependencies {
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
sodium: SodiumType? = nil,
box: BoxType? = nil,
genericHash: GenericHashType? = nil,
@ -26,6 +28,7 @@ extension SMKDependencies {
onionApi: (onionApi ?? self._onionApi.wrappedValue),
generalCache: (generalCache ?? self._generalCache.wrappedValue),
storage: (storage ?? self._storage.wrappedValue),
scheduler: (scheduler ?? self._scheduler.wrappedValue),
sodium: (sodium ?? self._sodium.wrappedValue),
box: (box ?? self._box.wrappedValue),
genericHash: (genericHash ?? self._genericHash.wrappedValue),

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
@ -12,6 +13,7 @@ extension OpenGroupManager.OGMDependencies {
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
sodium: SodiumType? = nil,
box: BoxType? = nil,
genericHash: GenericHashType? = nil,
@ -28,6 +30,7 @@ extension OpenGroupManager.OGMDependencies {
onionApi: (onionApi ?? self._onionApi.wrappedValue),
generalCache: (generalCache ?? self._generalCache.wrappedValue),
storage: (storage ?? self._storage.wrappedValue),
scheduler: (scheduler ?? self._scheduler.wrappedValue),
sodium: (sodium ?? self._sodium.wrappedValue),
box: (box ?? self._box.wrappedValue),
genericHash: (genericHash ?? self._genericHash.wrappedValue),

View File

@ -14,8 +14,8 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
override func spec() {
var mockStorage: Storage!
var dataChangeCancellable: AnyCancellable?
var otherCancellables: [AnyCancellable] = []
var cancellables: [AnyCancellable] = []
var dependencies: Dependencies!
var viewModel: ThreadDisappearingMessagesViewModel!
describe("a ThreadDisappearingMessagesViewModel") {
@ -31,6 +31,10 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
SNUIKit.migrations()
]
)
dependencies = Dependencies(
storage: mockStorage,
scheduler: .immediate
)
mockStorage.write { db in
try SessionThread(
id: "TestId",
@ -38,26 +42,26 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
).insert(db)
}
viewModel = ThreadDisappearingMessagesViewModel(
storage: mockStorage,
scheduling: .immediate,
dependencies: dependencies,
threadId: "TestId",
config: DisappearingMessagesConfiguration.defaultWith("TestId")
)
dataChangeCancellable = viewModel.observableSettingsData
.receiveOnMain(immediately: true)
.sink(
receiveCompletion: { _ in },
receiveValue: { viewModel.updateSettings($0) }
)
cancellables.append(
viewModel.observableSettingsData
.receiveOnMain(immediately: true)
.sink(
receiveCompletion: { _ in },
receiveValue: { viewModel.updateSettings($0) }
)
)
}
afterEach {
dataChangeCancellable?.cancel()
otherCancellables.forEach { $0.cancel() }
cancellables.forEach { $0.cancel() }
mockStorage = nil
dataChangeCancellable = nil
otherCancellables = []
cancellables = []
dependencies = nil
viewModel = nil
}
@ -118,17 +122,18 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
_ = try config.saved(db)
}
viewModel = ThreadDisappearingMessagesViewModel(
storage: mockStorage,
scheduling: .immediate,
dependencies: dependencies,
threadId: "TestId",
config: config
)
dataChangeCancellable = viewModel.observableSettingsData
.receiveOnMain(immediately: true)
.sink(
receiveCompletion: { _ in },
receiveValue: { viewModel.updateSettings($0) }
)
cancellables.append(
viewModel.observableSettingsData
.receiveOnMain(immediately: true)
.sink(
receiveCompletion: { _ in },
receiveValue: { viewModel.updateSettings($0) }
)
)
expect(viewModel.settingsData.first?.elements.first)
.to(
@ -165,7 +170,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
it("has no right bar button") {
var items: [ParentType.NavItem]?
otherCancellables.append(
cancellables.append(
viewModel.rightNavItems
.receiveOnMain(immediately: true)
.sink(
@ -181,7 +186,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
var items: [ParentType.NavItem]?
beforeEach {
otherCancellables.append(
cancellables.append(
viewModel.rightNavItems
.receiveOnMain(immediately: true)
.sink(
@ -208,7 +213,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
it("dismisses the screen") {
var didDismissScreen: Bool = false
otherCancellables.append(
cancellables.append(
viewModel.dismissScreen
.receiveOnMain(immediately: true)
.sink(

View File

@ -6,7 +6,7 @@ import GRDB
import PromiseKit
import SignalCoreKit
public final class Storage {
open class Storage {
private static let dbFileName: String = "Session.sqlite"
private static let keychainService: String = "TSKeyChainService"
private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec"
@ -305,17 +305,17 @@ public final class Storage {
// MARK: - Functions
@discardableResult public func write<T>(updates: (Database) throws -> T?) -> T? {
@discardableResult public final func write<T>(updates: (Database) throws -> T?) -> T? {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
return try? dbWriter.write(updates)
}
public func writeAsync<T>(updates: @escaping (Database) throws -> T) {
open func writeAsync<T>(updates: @escaping (Database) throws -> T) {
writeAsync(updates: updates, completion: { _, _ in })
}
public func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
open func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
dbWriter.asyncWrite(
@ -326,7 +326,7 @@ public final class Storage {
)
}
@discardableResult public func read<T>(_ value: (Database) throws -> T?) -> T? {
@discardableResult public final func read<T>(_ value: (Database) throws -> T?) -> T? {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
return try? dbWriter.read(value)
@ -401,6 +401,7 @@ public extension Storage {
}
}
// FIXME: Can't overrwrite this in `SynchronousStorage` since it's in an extension
@discardableResult func writeAsync<T>(updates: @escaping (Database) throws -> Promise<T>) -> Promise<T> {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
return Promise(error: StorageError.databaseInvalid)

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
open class Dependencies {
public var _generalCache: Atomic<Atomic<GeneralCacheType>?>
@ -15,6 +16,12 @@ open class Dependencies {
set { _storage.mutate { $0 = newValue } }
}
public var _scheduler: Atomic<ValueObservationScheduler?>
public var scheduler: ValueObservationScheduler {
get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } }
set { _scheduler.mutate { $0 = newValue } }
}
public var _standardUserDefaults: Atomic<UserDefaultsType?>
public var standardUserDefaults: UserDefaultsType {
get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } }
@ -32,11 +39,13 @@ open class Dependencies {
public init(
generalCache: Atomic<GeneralCacheType>? = nil,
storage: Storage? = nil,
scheduler: ValueObservationScheduler? = nil,
standardUserDefaults: UserDefaultsType? = nil,
date: Date? = nil
) {
_generalCache = Atomic(generalCache)
_storage = Atomic(storage)
_scheduler = Atomic(scheduler)
_standardUserDefaults = Atomic(standardUserDefaults)
_date = Atomic(date)
}
@ -52,12 +61,4 @@ open class Dependencies {
return value
}
// 0 libswiftCore.dylib 0x00000001999fd40c _swift_release_dealloc + 32 (HeapObject.cpp:703)
// 1 SessionMessagingKit 0x0000000106aa958c 0x106860000 + 2397580
// 2 libswiftCore.dylib 0x00000001999fd424 _swift_release_dealloc + 56 (HeapObject.cpp:703)
// 3 SessionUtilitiesKit 0x0000000106cbd980 static Dependencies.getValueSettingIfNull<A>(_:_:) + 264 (Dependencies.swift:49)
// 4 SessionMessagingKit 0x0000000106aa90f4 closure #1 in SMKDependencies.sign.getter + 112 (SMKDependencies.swift:17)
// 5 SessionUtilitiesKit 0x0000000106cbd974 static Dependencies.getValueSettingIfNull<A>(_:_:) + 252 (Dependencies.swift:48)
// 6 SessionMessagingKit 0x000000010697aef8 specialized static OpenGroupAPI.sign(_:messageBytes:for:fallbackSigningType:using:) + 1158904 (OpenGroupAPI.swift:1190)
}

View File

@ -44,20 +44,6 @@ public extension String {
return localizedString
}
func dataFromHex() -> Data? {
guard self.count > 0 && (self.count % 2) == 0 else { return nil }
let chars = self.map { $0 }
let bytes: [UInt8] = stride(from: 0, to: chars.count, by: 2)
.map { index -> String in String(chars[index]) + String(chars[index + 1]) }
.compactMap { (str: String) -> UInt8? in UInt8(str, radix: 16) }
guard bytes.count > 0 else { return nil }
guard (self.count / bytes.count) == 2 else { return nil }
return Data(bytes)
}
func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range<Index>] {
var ranges: [Range<Index>] = []

View File

@ -22,7 +22,11 @@ public class ToastController: ToastViewDelegate {
// MARK: Public
public func presentToastView(fromBottomOfView view: UIView, inset: CGFloat) {
public func presentToastView(
fromBottomOfView view: UIView,
inset: CGFloat,
duration: DispatchTimeInterval = .milliseconds(1500)
) {
Logger.debug("")
toastView.alpha = 0
view.addSubview(toastView)
@ -46,7 +50,7 @@ public class ToastController: ToastViewDelegate {
self.toastView.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.5) {
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
// intentional strong reference to self.
// As with an AlertController, the caller likely expects toast to
// be presented and dismissed without maintaining a strong reference to ToastController

View File

@ -0,0 +1,19 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
public extension AnyPublisher {
func firstValue() -> Output? {
var value: Output?
_ = self
.receiveOnMain(immediately: true)
.sink(
receiveCompletion: { _ in },
receiveValue: { result in value = result }
)
return value
}
}

View File

@ -6,16 +6,16 @@ import Curve25519Kit
extension Box.KeyPair: Mocked {
static var mockValue: Box.KeyPair = Box.KeyPair(
publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes,
secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes
publicKey: Data(hex: TestConstants.publicKey).bytes,
secretKey: Data(hex: TestConstants.edSecretKey).bytes
)
}
extension ECKeyPair: Mocked {
static var mockValue: Self {
try! Self.init(
publicKeyData: Data.data(fromHex: TestConstants.publicKey)!,
privateKeyData: Data.data(fromHex: TestConstants.privateKey)!
publicKeyData: Data(hex: TestConstants.publicKey),
privateKeyData: Data(hex: TestConstants.privateKey)
)
}
}

View File

@ -0,0 +1,28 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import PromiseKit
import SessionUtilitiesKit
class SynchronousStorage: Storage {
override func writeAsync<T>(updates: @escaping (Database) throws -> T) {
super.write(updates: updates)
}
override func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
super.write { db in
do {
var result: T?
try db.inTransaction {
result = try updates(db)
return .commit
}
try? completion(db, .success(result!))
}
catch {
try? completion(db, .failure(error))
}
}
}
}