Merge remote-tracking branch 'upstream/dev' into feature/updated-user-config-handling

# Conflicts:
#	Podfile.lock
#	Session.xcodeproj/project.pbxproj
#	Session/Closed Groups/EditClosedGroupVC.swift
#	Session/Conversations/Settings/ThreadSettingsViewModel.swift
#	Session/Home/HomeVC.swift
#	Session/Home/HomeViewModel.swift
#	Session/Meta/Translations/de.lproj/Localizable.strings
#	Session/Meta/Translations/en.lproj/Localizable.strings
#	Session/Meta/Translations/es.lproj/Localizable.strings
#	Session/Meta/Translations/fa.lproj/Localizable.strings
#	Session/Meta/Translations/fi.lproj/Localizable.strings
#	Session/Meta/Translations/fr.lproj/Localizable.strings
#	Session/Meta/Translations/hi.lproj/Localizable.strings
#	Session/Meta/Translations/hr.lproj/Localizable.strings
#	Session/Meta/Translations/id-ID.lproj/Localizable.strings
#	Session/Meta/Translations/it.lproj/Localizable.strings
#	Session/Meta/Translations/ja.lproj/Localizable.strings
#	Session/Meta/Translations/nl.lproj/Localizable.strings
#	Session/Meta/Translations/pl.lproj/Localizable.strings
#	Session/Meta/Translations/pt_BR.lproj/Localizable.strings
#	Session/Meta/Translations/ru.lproj/Localizable.strings
#	Session/Meta/Translations/si.lproj/Localizable.strings
#	Session/Meta/Translations/sk.lproj/Localizable.strings
#	Session/Meta/Translations/sv.lproj/Localizable.strings
#	Session/Meta/Translations/th.lproj/Localizable.strings
#	Session/Meta/Translations/vi-VN.lproj/Localizable.strings
#	Session/Meta/Translations/zh-Hant.lproj/Localizable.strings
#	Session/Meta/Translations/zh_CN.lproj/Localizable.strings
#	Session/Shared/FullConversationCell.swift
#	SessionMessagingKit/Configuration.swift
#	SessionMessagingKit/Database/Models/SessionThread.swift
#	SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift
#	SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
#	SessionUIKit/Utilities/UIContextualAction+Theming.swift
#	SessionUtilitiesKit/Database/Models/Job.swift
#	SessionUtilitiesKit/General/Dictionary+Utilities.swift
#	SessionUtilitiesKit/JobRunner/JobRunner.swift
This commit is contained in:
Morgan Pretty 2023-04-06 18:09:26 +10:00
commit f4d6babca2
67 changed files with 1606 additions and 940 deletions

14
Podfile
View File

@ -10,7 +10,7 @@ abstract_target 'GlobalDependencies' do
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
pod 'GRDB.swift/SQLCipher'
pod 'SQLCipher', '~> 4.5.0' # FIXME: Version 4.5.2 is crashing when access DB settings
pod 'SQLCipher', '~> 4.5.3'
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
@ -102,6 +102,9 @@ post_install do |installer|
enable_whole_module_optimization_for_crypto_swift(installer)
set_minimum_deployment_target(installer)
enable_fts5_support(installer)
#FIXME: Remove this workaround once an official fix is released (hopefully Cocoapods 1.12.1)
xcode_14_3_workaround(installer)
end
def enable_whole_module_optimization_for_crypto_swift(installer)
@ -132,3 +135,12 @@ def enable_fts5_support(installer)
end
end
end
# Workaround for Xcode 14.3:
# Sourced from https://github.com/flutter/flutter/issues/123852#issuecomment-1493232105
def xcode_14_3_workaround(installer)
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\'')
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks.sh\'')
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\'')
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\'')
end

View File

@ -1,7 +1,7 @@
PODS:
- CocoaLumberjack (3.7.4):
- CocoaLumberjack/Core (= 3.7.4)
- CocoaLumberjack/Core (3.7.4)
- CocoaLumberjack (3.8.0):
- CocoaLumberjack/Core (= 3.8.0)
- CocoaLumberjack/Core (3.8.0)
- CryptoSwift (1.4.2)
- Curve25519Kit (2.1.0):
- CocoaLumberjack
@ -12,7 +12,7 @@ PODS:
- DifferenceKit/Core (1.3.0)
- DifferenceKit/UIKitExtension (1.3.0):
- DifferenceKit/Core
- GRDB.swift/SQLCipher (6.1.0):
- GRDB.swift/SQLCipher (6.10.1):
- SQLCipher (>= 3.4.2)
- libwebp (1.2.1):
- libwebp/demux (= 1.2.1)
@ -37,10 +37,10 @@ PODS:
- OpenSSL-Universal
- SocketRocket (0.5.1)
- Sodium (0.9.1)
- SQLCipher (4.5.0):
- SQLCipher/standard (= 4.5.0)
- SQLCipher/common (4.5.0)
- SQLCipher/standard (4.5.0):
- SQLCipher (4.5.3):
- SQLCipher/standard (= 4.5.3)
- SQLCipher/common (4.5.3)
- SQLCipher/standard (4.5.3):
- SQLCipher/common
- SwiftProtobuf (1.5.0)
- WebRTC-lib (96.0.0)
@ -128,7 +128,7 @@ DEPENDENCIES:
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
- SocketRocket (~> 0.5.1)
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
- SQLCipher (~> 4.5.0)
- SQLCipher (~> 4.5.3)
- SwiftProtobuf (~> 1.5.0)
- WebRTC-lib
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
@ -173,7 +173,7 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS:
Curve25519Kit:
:commit: b79c2ace600bfd3784e9c33cf1f254b121312edc
:commit: ee1bc83e61d9d672105eed85a4b8fbaec3d376f5
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
SignalCoreKit:
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
@ -189,11 +189,11 @@ CHECKOUT OPTIONS:
:git: https://github.com/signalapp/YYImage
SPEC CHECKSUMS:
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca
GRDB.swift: 611778a5e113385373baeb3e2ce474887d1aadb7
GRDB.swift: 1cc67278f1a9878d6eb1b849485518112b79cab7
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
@ -205,13 +205,13 @@ SPEC CHECKSUMS:
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
SQLCipher: 57fa9f863fa4a3ed9dd3c90ace52315db8c0fdca
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
YapDatabase: b418a4baa6906e8028748938f9159807fd039af4
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: 6ee08fc446436a2534ec34c041c3b9bc39801870
PODFILE CHECKSUM: f461937f78a0482496fea6fc4b2bb5d1351fe044
COCOAPODS: 1.11.3

View File

@ -113,7 +113,7 @@
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; };
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; };
7B50D64D28AC7CF80086CCEC /* silence.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7B50D64C28AC7CF80086CCEC /* silence.aiff */; };
7B521E0829BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B521E0729BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift */; };
7B521E0A29BFF84400C3C36A /* GroupLeavingJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B521E0929BFF84400C3C36A /* GroupLeavingJob.swift */; };
7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037422834B81F000DCF35 /* ReactionContainerView.swift */; };
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037442834BCC0000DCF35 /* ReactionView.swift */; };
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */; };
@ -431,7 +431,6 @@
C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; };
C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; };
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */; };
C3BBE0B52554F0E10050F1E3 /* (null) in Sources */ = {isa = PBXBuildFile; };
C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */; };
C3C2A5A3255385C100C340D1 /* SessionSnodeKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */; settings = {ATTRIBUTES = (Public, ); }; };
C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -463,8 +462,6 @@
C3D9E38A256760390040E4F3 /* OWSFileSystem.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBAB255A581500E217F9 /* OWSFileSystem.h */; settings = {ATTRIBUTES = (Public, ); }; };
C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB85255A581100E217F9 /* AppContext.m */; };
C3D9E3A4256763DE0040E4F3 /* AppContext.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB8A255A581200E217F9 /* AppContext.h */; settings = {ATTRIBUTES = (Public, ); }; };
C3D9E3BE25676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; };
C3D9E3BF25676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; };
C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB6255A581600E217F9 /* DataSource.m */; };
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */; };
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB81255A581100E217F9 /* UIImage+OWS.m */; };
@ -582,7 +579,6 @@
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 */; };
FD26FA512919F9CE005801D8 /* (null) in Sources */ = {isa = PBXBuildFile; };
FD26FA5E291CAFF9005801D8 /* (null) in Sources */ = {isa = PBXBuildFile; };
FD26FA6D291DADAE005801D8 /* (null) in Sources */ = {isa = PBXBuildFile; };
FD26FA7B291DF8F3005801D8 /* (null) in Sources */ = {isa = PBXBuildFile; };
@ -595,6 +591,8 @@
FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */; };
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; };
FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */; };
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.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 */; };
@ -796,7 +794,6 @@
FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386827B4E6B700C60D73 /* String+Utlities.swift */; };
FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; };
FDC4387227B5BB3B00C60D73 /* FileUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */; };
FDC4387427B5BB9B00C60D73 /* (null) in Sources */ = {isa = PBXBuildFile; };
FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; };
FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; platformFilter = ios; };
FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */; };
@ -1228,11 +1225,12 @@
7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; };
7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = "<group>"; };
7B2DB2AD26F1B0FF0035B509 /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/Localizable.strings; sourceTree = "<group>"; };
7B2E985729AC227C001792D7 /* UIContextualAction+Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Theming.swift"; sourceTree = "<group>"; };
7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMediaViewController.swift; sourceTree = "<group>"; };
7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = "<group>"; };
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = "<group>"; };
7B50D64C28AC7CF80086CCEC /* silence.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = silence.aiff; sourceTree = "<group>"; };
7B521E0729BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
7B521E0929BFF84400C3C36A /* GroupLeavingJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLeavingJob.swift; sourceTree = "<group>"; };
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionContainerView.swift; sourceTree = "<group>"; };
7B7037442834BCC0000DCF35 /* ReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionView.swift; sourceTree = "<group>"; };
7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallBanner.swift; sourceTree = "<group>"; };
@ -1242,6 +1240,7 @@
7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; };
7B81682928B6F1420069F315 /* ReactionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionResponse.swift; sourceTree = "<group>"; };
7B81682B28B72F480069F315 /* PendingChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChange.swift; sourceTree = "<group>"; };
7B89FF4529C016E300C4C708 /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
7B8C44C428B49DDA00FBE25F /* NewConversationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationVC.swift; sourceTree = "<group>"; };
7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Reaction.swift"; sourceTree = "<group>"; };
7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = "<group>"; };
@ -1731,6 +1730,8 @@
FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Contacts.swift"; sourceTree = "<group>"; };
FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = "<group>"; };
FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.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>"; };
@ -2326,6 +2327,7 @@
FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */,
C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */,
7BAADFCD27B215FE007BCF92 /* UIView+Draggable.swift */,
FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */,
7BFD1A892745C4F000FB91B9 /* Permissions.swift */,
);
path = Utilities;
@ -3482,6 +3484,7 @@
D221A08C169C9E5E00537ABF /* Frameworks */,
D221A08A169C9E5E00537ABF /* Products */,
2BADBA206E0B8D297E313FBA /* Pods */,
FD368A6629DE86A9000DBF1E /* Recovered References */,
);
sourceTree = "<group>";
};
@ -3636,7 +3639,7 @@
7BAA7B6528D2DE4700AE1489 /* _009_OpenGroupPermission.swift */,
FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */,
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */,
7B521E0729BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift */,
FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */,
FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */,
FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */,
);
@ -3773,6 +3776,15 @@
path = Database;
sourceTree = "<group>";
};
FD368A6629DE86A9000DBF1E /* Recovered References */ = {
isa = PBXGroup;
children = (
7B2E985729AC227C001792D7 /* UIContextualAction+Theming.swift */,
7B89FF4529C016E300C4C708 /* _012_AddFTSIfNeeded.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
FD37E9C428A1C701003AE748 /* Themes */ = {
isa = PBXGroup;
children = (
@ -4289,6 +4301,7 @@
FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */,
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */,
C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */,
7B521E0929BFF84400C3C36A /* GroupLeavingJob.swift */,
FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */,
);
path = Types;
@ -5672,6 +5685,7 @@
C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */,
FD245C672850665E00B966DD /* AttachmentDownloadJob.swift in Sources */,
C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */,
7B521E0A29BFF84400C3C36A /* GroupLeavingJob.swift in Sources */,
FD09799927FFC1A300936362 /* Attachment.swift in Sources */,
FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */,
C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */,
@ -5703,19 +5717,15 @@
FD09797527FAB64300936362 /* ProfileManager.swift in Sources */,
FD245C57285065F100B966DD /* Poller.swift in Sources */,
FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */,
7B521E0829BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift in Sources */,
FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */,
FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */,
FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */,
FD26FA512919F9CE005801D8 /* (null) in Sources */,
FD8ECF9029381FC200C0D1BB /* SessionUtil+UserProfile.swift in Sources */,
FD09B7E5288670BB00ED0B66 /* _008_EmojiReacts.swift in Sources */,
FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */,
7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */,
C3D9E3BF25676AD70040E4F3 /* (null) in Sources */,
B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */,
7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */,
C3BBE0B52554F0E10050F1E3 /* (null) in Sources */,
FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */,
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */,
FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */,
@ -5737,7 +5747,6 @@
FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */,
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */,
FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */,
FDC4387427B5BB9B00C60D73 /* (null) in Sources */,
B8DE1FB426C22F2F0079C9CE /* WebRTCSession.swift in Sources */,
FDC6D6F32860607300B04575 /* Environment.swift in Sources */,
C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */,
@ -5785,7 +5794,6 @@
FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */,
7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */,
FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */,
C3D9E3BE25676AD70040E4F3 /* (null) in Sources */,
C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */,
B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */,
FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */,
@ -5815,6 +5823,7 @@
B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */,
C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */,
FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */,
FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */,
FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */,
FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */,
C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */,
@ -6034,6 +6043,7 @@
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */,
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */,
7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */,
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */,
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */,
FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */,
FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */,
@ -6331,7 +6341,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 399;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6355,7 +6365,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.2.10;
MTL_ENABLE_DEBUG_INFO = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -6403,7 +6413,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 399;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6432,7 +6442,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.2.10;
MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -6468,7 +6478,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 399;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6491,7 +6501,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.2.10;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -6542,7 +6552,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 399;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6570,7 +6580,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.2.10;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -7454,7 +7464,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 399;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7492,7 +7502,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.2.10;
OTHER_LDFLAGS = "$(inherited)";
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
@ -7525,7 +7535,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 399;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7563,7 +7573,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.2.10;
OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
PRODUCT_NAME = Session;

View File

@ -18,6 +18,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
private let threadId: String
private let threadVariant: SessionThread.Variant
private var originalName: String = ""
private var originalMembersAndZombieIds: Set<String> = []
private var name: String = ""
@ -82,8 +83,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
// MARK: - Lifecycle
init(threadId: String) {
init(threadId: String, threadVariant: SessionThread.Variant) {
self.threadId = threadId
self.threadVariant = threadVariant
super.init(nibName: nil, bundle: nil)
}
@ -438,6 +440,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
let threadId: String = self.threadId
let threadVariant: SessionThread.Variant = self.threadVariant
let updatedName: String = self.name
let userPublicKey: String = self.userPublicKey
let updatedMemberIds: Set<String> = self.membersAndZombies
@ -464,7 +467,15 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
Storage.shared
.writePublisherFlatMap(receiveOn: DispatchQueue.main) { db -> AnyPublisher<Void, Error> in
if !updatedMemberIds.contains(userPublicKey) {
return MessageSender.leave(db, groupPublicKey: threadId)
try MessageSender.leave(
db,
groupPublicKey: threadId,
deleteThread: true
)
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
return MessageSender.update(

View File

@ -136,7 +136,8 @@ extension ContextMenuVC {
switch cellViewModel.variant {
case .standardIncomingDeleted, .infoCall,
.infoScreenshotNotification, .infoMediaSavedNotification,
.infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
.infoClosedGroupCreated, .infoClosedGroupUpdated,
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
// Let the user delete info messages and unsent messages
return [ Action.delete(cellViewModel, delegate) ]

View File

@ -1742,7 +1742,8 @@ extension ConversationVC:
switch cellViewModel.variant {
case .standardIncomingDeleted, .infoCall,
.infoScreenshotNotification, .infoMediaSavedNotification,
.infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
.infoClosedGroupCreated, .infoClosedGroupUpdated,
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
// Info messages and unsent messages should just trigger a local
// deletion (they are created as side effects so we wouldn't be
@ -2052,7 +2053,8 @@ extension ConversationVC:
try MessageSender.send(
db,
message: DataExtractionNotification(
kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))
kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)),
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
),
interactionId: nil,
threadId: threadId,
@ -2320,7 +2322,8 @@ extension ConversationVC:
try MessageSender.send(
db,
message: DataExtractionNotification(
kind: .screenshot
kind: .screenshot,
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
),
interactionId: nil,
threadId: threadId,
@ -2435,31 +2438,49 @@ extension ConversationVC {
}
@objc func deleteMessageRequest() {
MessageRequestsViewModel.deleteMessageRequest(
threadId: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions(
[.delete],
for: .trailing,
indexPath: IndexPath(row: 0, section: 0),
tableView: self.tableView,
threadViewModel: self.viewModel.threadData,
viewController: self
) { [weak self] in
)
guard let action: UIContextualAction = actions?.first else { return }
action.handler(action, self.view, { [weak self] didConfirm in
guard didConfirm else { return }
self?.stopObservingChanges()
DispatchQueue.main.async {
self?.navigationController?.popViewController(animated: true)
}
}
})
}
@objc func blockMessageRequest() {
MessageRequestsViewModel.blockMessageRequest(
threadId: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions(
[.block],
for: .trailing,
indexPath: IndexPath(row: 0, section: 0),
tableView: self.tableView,
threadViewModel: self.viewModel.threadData,
viewController: self
) { [weak self] in
)
guard let action: UIContextualAction = actions?.first else { return }
action.handler(action, self.view, { [weak self] didConfirm in
guard didConfirm else { return }
self?.stopObservingChanges()
DispatchQueue.main.async {
self?.navigationController?.popViewController(animated: true)
}
}
})
}
}

View File

@ -168,6 +168,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKey(
db,
currentUserBlindedPublicKeyForThisThread: self?.threadData.currentUserBlindedPublicKey
)
}

View File

@ -97,6 +97,7 @@ final class InfoMessageCell: MessageCell {
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
self.label.text = cellViewModel.body
self.label.themeTextColor = (cellViewModel.variant == .infoClosedGroupCurrentUserErrorLeaving) ? .danger : .textPrimary
}
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {

View File

@ -70,7 +70,8 @@ public class MessageCell: UITableViewCell {
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
return VisibleMessageCell.self
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
case .infoClosedGroupCreated, .infoClosedGroupUpdated,
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
.infoMessageRequestAccepted:
return InfoMessageCell.self

View File

@ -482,7 +482,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
label: "Edit group"
),
onTap: { [weak self] in
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
self?.transitionToScreen(
EditClosedGroupVC(threadId: threadId, threadVariant: threadVariant)
)
}
)
),
@ -500,21 +502,39 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
label: "Leave group"
),
confirmationInfo: ConfirmationModal.Info(
title: "CONFIRM_LEAVE_GROUP_TITLE".localized(),
explanation: (currentUserIsClosedGroupAdmin ?
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone." :
"CONFIRM_LEAVE_GROUP_DESCRIPTION".localized()
),
title: "leave_group_confirmation_alert_title".localized(),
attributedExplanation: {
if currentUserIsClosedGroupAdmin {
return NSAttributedString(string: "admin_group_leave_warning".localized())
}
let mutableAttributedString = NSMutableAttributedString(
string: String(
format: "leave_community_confirmation_alert_message".localized(),
threadViewModel.displayName
)
)
mutableAttributedString.addAttribute(
.font,
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
)
return mutableAttributedString
}(),
confirmTitle: "LEAVE_BUTTON_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self] in
dependencies.storage
.writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in
MessageSender.leave(db, groupPublicKey: threadId)
}
.sinkUntilComplete()
dependencies.storage.write { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadId,
threadVariant: threadVariant,
groupLeaveType: .standard,
calledFromConfigHandling: false
)
}
}
)
),

View File

@ -217,7 +217,12 @@ class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, U
self?.termForCurrentSearchResultSet = searchText
self?.searchResultSet = [
(hasResults ? nil : [
ArraySection(model: .noResults, elements: [SessionThreadViewModel()])
ArraySection(
model: .noResults,
elements: [
SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId)
]
)
]),
(hasResults ? sections : nil)
]

View File

@ -636,6 +636,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
switch section.model {
case .threads:
@ -644,268 +645,86 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
return nil
}
return generateSwipeActions(
[.toggleReadStatus],
for: .leading,
indexPath: indexPath,
tableView: tableView
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.toggleReadStatus],
for: .leading,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
default: return nil
}
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
switch section.model {
case .messageRequests:
return generateSwipeActions([.hide], for: .trailing, indexPath: indexPath, tableView: tableView)
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let sessionIdPrefix: SessionId.Prefix? = SessionId(from: threadViewModel.threadId)?.prefix
// Cannot properly sync outgoing blinded message requests and can only block contact
// threads so only provide these options if valid
let shouldHaveBlockAction: Bool = (
threadViewModel.threadVariant == .contact &&
!threadViewModel.threadIsNoteToSelf &&
sessionIdPrefix != .blinded
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.hide],
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
return generateSwipeActions(
[
(sessionIdPrefix == .blinded ? nil : .pin),
(!shouldHaveBlockAction ? nil : .block),
.delete
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView
case .threads:
let sessionIdPrefix: SessionId.Prefix? = SessionId(from: threadViewModel.threadId)?.prefix
// Cannot properly sync outgoing blinded message requests so only provide valid options
let shouldHavePinAction: Bool = (
sessionIdPrefix != .blinded
)
let shouldHaveMuteAction: Bool = {
switch threadViewModel.threadVariant {
case .contact: return (
!threadViewModel.threadIsNoteToSelf &&
sessionIdPrefix != .blinded
)
case .legacyGroup, .group: return (
threadViewModel.currentUserIsClosedGroupMember == true
)
case .community: return true
}
}()
let destructiveAction: UIContextualAction.SwipeAction = {
switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember) {
case (.contact, true, _): return .hide
case (.legacyGroup, _, true), (.group, _, true), (.community, _, _): return .leave
default: return .delete
}
}()
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(!shouldHavePinAction ? nil : .pin),
(!shouldHaveMuteAction ? nil : .mute),
destructiveAction
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
default: return nil
}
}
// MARK: - Swipe action generation
private enum SwipeAction {
case toggleReadStatus
case hide
case pin
case block
case delete
}
private func generateSwipeActions(
_ actions: [SwipeAction],
for side: UIContextualAction.Side,
indexPath: IndexPath,
tableView: UITableView
) -> UISwipeActionsConfiguration? {
guard !actions.isEmpty else { return nil }
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
// Note: for some reason the `UISwipeActionsConfiguration` expects actions to be left-to-right
// for leading actions, but right-to-left for trailing actions...
let targetActions: [SwipeAction] = (side == .trailing ? actions.reversed() : actions)
return UISwipeActionsConfiguration(
actions: targetActions
.enumerated()
.map { index, action -> UIContextualAction in
// Even though we have to reverse the actions above, the indexes in the view hierarchy
// are in the expected order
let targetIndex: Int = (side == .trailing ? (targetActions.count - index) : index)
switch action {
// MARK: -- toggleReadStatus
case .toggleReadStatus:
let isUnread: Bool = (
threadViewModel.threadWasMarkedUnread == true ||
(threadViewModel.threadUnreadCount ?? 0) > 0
)
return UIContextualAction(
title: (isUnread ?
"MARK_AS_READ".localized() :
"MARK_AS_UNREAD".localized()
),
icon: (isUnread ?
UIImage(systemName: "envelope.open") :
UIImage(systemName: "envelope.badge")
),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeRead,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
switch isUnread {
case true:
self?.viewModel.markAsRead(
threadViewModel: threadViewModel,
target: .threadAndInteractions(
interactionsBeforeInclusive: threadViewModel.interactionId
)
)
case false:
self?.viewModel.markAsUnread(threadViewModel: threadViewModel)
}
}
completionHandler(true)
}
// MARK: -- hide
case .hide:
return UIContextualAction(
title: "TXT_HIDE_TITLE".localized(),
icon: UIImage(systemName: "eye.slash"),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
completionHandler(true)
}
// MARK: -- pin
case .pin:
return UIContextualAction(
title: (threadViewModel.threadPinnedPriority > 0 ?
"UNPIN_BUTTON_TEXT".localized() :
"PIN_BUTTON_TEXT".localized()
),
icon: (threadViewModel.threadPinnedPriority > 0 ?
UIImage(systemName: "pin.slash") :
UIImage(systemName: "pin")
),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeTertiary,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
isPinned: !(threadViewModel.threadPinnedPriority > 0)
)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared.writeAsync { db in
try SessionThread
.filter(id: threadViewModel.threadId)
.updateAllAndConfig(
db,
SessionThread.Columns.pinnedPriority
.set(to: (threadViewModel.threadPinnedPriority == 0 ? 1 : 0))
)
}
}
}
// MARK: -- block
case .block:
return UIContextualAction(
title: (threadViewModel.threadIsBlocked == true ?
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
"BLOCK_LIST_BLOCK_BUTTON".localized()
),
icon: UIImage(named: "table_ic_block"),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeSecondary,
side: .trailing,
actionIndex: 1,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
isBlocked: (threadViewModel.threadIsBlocked == false)
)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared
.writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in
try Contact
.filter(id: threadViewModel.threadId)
.updateAllAndConfig(
db,
Contact.Columns.isBlocked.set(
to: (threadViewModel.threadIsBlocked == false ?
true:
false
)
)
)
}
.sinkUntilComplete()
}
}
// MARK: -- delete
case .delete:
return UIContextualAction(
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin"),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE".localized(),
explanation: (threadViewModel.currentUserIsClosedGroupAdmin == true ?
"admin_group_leave_warning".localized() :
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized()
),
confirmTitle: "TXT_DELETE_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { [weak self] _ in
self?.viewModel.deleteOrLeave(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self?.dismiss(animated: true, completion: nil)
completionHandler(true)
},
afterClosed: { completionHandler(false) }
)
)
self?.present(confirmationModal, animated: true, completion: nil)
}
}
}
)
}
// MARK: - Interaction
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {

View File

@ -271,7 +271,11 @@ public class HomeViewModel {
PagedData.processAndTriggerUpdates(
updatedData: updatedThreadData,
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
currentDataRetriever: { [weak self] in
guard self?.hasProcessedInitialThreadData == true else { return nil }
return (self?.unobservedThreadDataChanges?.0 ?? self?.threadData)
},
onDataChange: onThreadChange,
onUnobservedDataChange: { [weak self] updatedData, changeset in
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
@ -284,12 +288,15 @@ public class HomeViewModel {
// MARK: - Thread Data
private var hasProcessedInitialThreadData: Bool = false
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet {
self.hasProcessedInitialThreadData = (onThreadChange != nil || hasProcessedInitialThreadData)
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
@ -317,7 +324,10 @@ public class HomeViewModel {
[SectionModel(
section: .messageRequests,
elements: [
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
SessionThreadViewModel(
threadId: SessionThreadViewModel.messageRequestsSectionId,
unreadCount: UInt(finalUnreadMessageRequestCount)
)
]
)]
),
@ -352,29 +362,4 @@ public class HomeViewModel {
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
// MARK: - Functions
public func markAsRead(
threadViewModel: SessionThreadViewModel,
target: SessionThreadViewModel.ReadTarget
) {
threadViewModel.markAsRead(target: target)
}
public func markAsUnread(threadViewModel: SessionThreadViewModel) {
threadViewModel.markAsUnread()
}
public func deleteOrLeave(threadId: String, threadVariant: SessionThread.Variant) {
Storage.shared.writeAsync { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadId,
threadVariant: threadVariant,
shouldSendLeaveMessageForGroups: true,
calledFromConfigHandling: false
)
}
}
}

View File

@ -404,54 +404,23 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
switch section.model {
case .threads:
let threadId: String = section.elements[indexPath.row].threadId
let threadVariant: SessionThread.Variant = section.elements[indexPath.row].threadVariant
let delete: UIContextualAction = UIContextualAction(
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin"),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: (threadVariant == .contact ? 1 : 0),
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
MessageRequestsViewModel.deleteMessageRequest(
threadId: threadId,
threadVariant: threadVariant,
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(threadViewModel.threadVariant != .contact ? nil : .block),
.delete
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
completionHandler(true)
}
switch threadVariant {
case .contact:
let block: UIContextualAction = UIContextualAction(
title: "BLOCK_LIST_BLOCK_BUTTON".localized(),
icon: UIImage(named: "table_ic_block"),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeSecondary,
side: .trailing,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
MessageRequestsViewModel.blockMessageRequest(
threadId: threadId,
threadVariant: threadVariant,
viewController: self
)
completionHandler(true)
}
return UISwipeActionsConfiguration(actions: [ delete, block ])
case .legacyGroup, .group, .community:
return UISwipeActionsConfiguration(actions: [ delete ])
}
)
default: return nil
}

View File

@ -171,86 +171,6 @@ public class MessageRequestsViewModel {
// MARK: - Functions
static func deleteMessageRequest(
threadId: String,
threadVariant: SessionThread.Variant,
viewController: UIViewController?,
completion: (() -> Void)? = nil
) {
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
confirmTitle: "TXT_DELETE_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
) { _ in
Storage.shared.write { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadId,
threadVariant: threadVariant,
shouldSendLeaveMessageForGroups: false,
calledFromConfigHandling: false
)
}
completion?()
}
)
viewController?.present(modal, animated: true, completion: nil)
}
static func blockMessageRequest(
threadId: String,
threadVariant: SessionThread.Variant,
viewController: UIViewController?,
completion: (() -> Void)? = nil
) {
guard threadVariant == .contact else { return }
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
confirmTitle: "BLOCK_LIST_BLOCK_BUTTON".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
) { _ in
Storage.shared.writeAsync(
updates: { db in
// Update the contact
try Contact
.fetchOrCreate(db, id: threadId)
.save(db)
try Contact
.filter(id: threadId)
.updateAllAndConfig(
db,
Contact.Columns.isApproved.set(to: false),
Contact.Columns.isBlocked.set(to: true),
// Note: We set this to true so the current user will be able to send a
// message to the person who originally sent them the message request in
// the future if they unblock them
Contact.Columns.didApproveMe.set(to: true)
)
try SessionThread.deleteOrLeave(
db,
threadId: threadId,
threadVariant: .contact,
shouldSendLeaveMessageForGroups: false,
calledFromConfigHandling: false
)
},
completion: { _, _ in completion?() }
)
}
)
viewController?.present(modal, animated: true, completion: nil)
}
static func clearAllRequests(
contactThreadIds: [String],
groupThreadIds: [String]
@ -262,7 +182,7 @@ public class MessageRequestsViewModel {
db,
threadIds: contactThreadIds,
threadVariant: .contact,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
@ -271,7 +191,7 @@ public class MessageRequestsViewModel {
db,
threadIds: groupThreadIds,
threadVariant: .group,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
}

View File

@ -539,7 +539,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
message: DataExtractionNotification(
kind: .mediaSaved(
timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs)
)
),
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
),
interactionId: nil, // Show no interaction for the current user
threadId: threadId,

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Fertig";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Auswählen";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Du wirst in dieser Gruppe keine Nachrichten mehr versenden oder empfangen können.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Wollen Sie die Gruppe wirklich verlassen?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Dies kann nicht rückgängig gemacht werden.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Unterhaltung löschen?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Done";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Select";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "You will no longer be able to send or receive messages in this group.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Do you really want to leave?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Hecho";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Seleccionar";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "No podrás enviar o recibir más mensajes en este grupo.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "¿De verdad quieres abandonar el grupo?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Este paso no se puede deshacer.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "¿Eliminar conversación?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "انجام شد";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "انتخاب";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "شما دیگر قادر به ارسال یا دریافت پیام از این گروه نخواهید بود";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "آیا واقعا قصد ترک کردن دارید؟";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "این نمیتواند انجام نشود.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "گفتگو حذف شود؟";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "درحال جستجو...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Valmis";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Valitse";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Et pysty enään lähettämään tai vastaanottamaan viestejä tässä ryhmässä.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Haluatko varmasti poistua ryhmästä?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tätä ei voida perua.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Poistetaanko keskustelu?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Terminé";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Sélectionner";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Vous ne pourrez plus recevoir ni envoyer de messages dans ce groupe.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Voulez-vous vraiment quitter ce groupe?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Cette action est irréversible.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Supprimer la conversation?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "पूरा हुआ";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "चुनें";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "अब आप इस समूह में संदेश भेजने या प्राप्त करने में सक्षम नहीं होंगे।";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "क्या आप वाकई छोड़ना चाहते हैं?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Gotovo";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Odaberi";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Više nećete moći slati niti primati poruke u ovoj grupi.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Da li zaista želite izaći?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Ovaj je postupak nepovratan.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Obriši razgovor?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Selesai";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Pilih";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Anda tidak dapat lagi mengirim atau menerima pesan dari grup ini.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Apakah Anda benar-benar ingin keluar?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tindakan ini tidak dapat dibatalkan.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Hapus Percakapan?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Fatto";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Seleziona";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Non sarei più in grado di inviare o ricevere messaggi in questo gruppo.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Vuoi davvero lasciare?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Non potrà essere annullato.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Elimina conversazione?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "完了";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "選択";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "このグループとの会話が出来なくなりますがよろしいでしょうか。";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "離脱してよろしいですか?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "消去すると元に戻せません";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "消去しますか?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Ok";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Selecteer";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Je kunt geen berichten meer versturen of ontvangen in deze groep.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Wilt u echt deze groep verlaten?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Dit kan niet ongedaan worden gemaakt.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Gesprek verwijderen?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Gotowe";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Zaznacz";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Nie będziesz już móc odbierać lub wysyłać wiadomości w tej grupie.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Czy na pewno chcesz wyjść?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tego nie można cofnąć.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Usunąć konwersację?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Pronto";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Selecionar";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Você não poderá mais enviar nem receber mensagens neste grupo.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Você tem certeza que deseja sair?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Isso não pode ser desfeito.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Excluir conversa?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Готово";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Выбор";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Вы больше не сможете отправлять и получать сообщения в этой группе.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Вы хотите покинуть группу?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Это не может быть отменено.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Удалить разговор?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Done";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Select";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "You will no longer be able to send or receive messages in this group.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Do you really want to leave?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "This cannot be undone.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Delete Conversation?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Hotovo";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Vybrať";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Už nebudete môcť posielať a prijímať správy v tejto skupine.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Ste si istý/á, že chcete odísť?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Táto akcia sa nedá vrátiť.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Zmazať konverzáciu?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Klart";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Välj";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Du kommer inte längre att kunna skicka eller ta emot meddelanden i denna grupp.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Vill du verkligen lämna?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Detta kan inte ångras.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Radera konversation?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "เสร็จ";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "เลือก";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "คุณจะไม่สามารถส่งและรับข้อความในกลุ่มนี้ได้อีกต่อไป";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "แน่ใจออกจากไหม";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "การกระทำนี้ไม่สามารถยกเลิกได้";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "ลบการสนทนาไหม";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "Xong";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Chọn";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "Bạn sẽ không thể gửi hoặc nhận tin nhắn trong nhóm này nữa.";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "Bạn thực sự muốn rời khỏi nhóm?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "Tác vụ này không thể hoàn tất.";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "Xóa cuộc hội thoại?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "完成";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "選擇";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "您已經無法再於此群組傳送或接收訊息。";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "確定要離開嗎?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "此操作無法復原。";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "刪除對話?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -60,14 +60,6 @@
"BUTTON_DONE" = "完成";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "选择";
/* Alert body */
"CONFIRM_LEAVE_GROUP_DESCRIPTION" = "您将无法在此群组中继续发送或接收消息。";
/* Alert title */
"CONFIRM_LEAVE_GROUP_TITLE" = "确定离开群聊?";
/* Message for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE" = "该操作无法撤销。";
/* Title for the 'conversation delete confirmation' alert. */
"CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE" = "删除会话?";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Searching...";
/* keyboard toolbar label when no messages match the search string */
@ -609,6 +601,22 @@
"context_menu_resync" = "Resync";
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"mute_button_text" = "Mute";
"unmute_button_text" = "Unmute";
"mark_read_button_text" = "Mark read";
"mark_unread_button_text" = "Mark unread";
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"group_you_leaving" = "Leaving...";
"group_leave_error" = "Failed to leave Group!";
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";

View File

@ -237,6 +237,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let userBlindedKey: String? = SessionThread.getUserHexEncodedBlindedKey(
db,
threadId: thread.id,
threadVariant: thread.variant
)

View File

@ -5,7 +5,7 @@ import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
public final class FullConversationCell: UITableViewCell {
public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticCell {
public static let unreadCountViewSize: CGFloat = 20
private static let statusIndicatorSize: CGFloat = 14
@ -394,7 +394,7 @@ public final class FullConversationCell: UITableViewCell {
}
else {
accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground
accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12
accentLineView.alpha = (unreadCount > 0 ? 1 : 0)
}
isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0)
@ -434,12 +434,34 @@ public final class FullConversationCell: UITableViewCell {
typingIndicatorView.stopAnimation()
ThemeManager.onThemeChange(observer: snippetLabel) { [weak self, weak snippetLabel] theme, _ in
guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
snippetLabel?.attributedText = self?.getSnippet(
cellViewModel: cellViewModel,
textColor: textColor
)
if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserLeaving {
guard let textColor: UIColor = theme.color(for: .textSecondary) else { return }
self?.displayNameLabel.themeTextColor = .textSecondary
snippetLabel?.attributedText = self?.getSnippet(
cellViewModel: cellViewModel,
textColor: textColor
)
} else if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserErrorLeaving {
guard let textColor: UIColor = theme.color(for: .danger) else { return }
self?.displayNameLabel.themeTextColor = .textPrimary
snippetLabel?.attributedText = self?.getSnippet(
cellViewModel: cellViewModel,
textColor: textColor
)
} else {
guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
self?.displayNameLabel.themeTextColor = .textPrimary
snippetLabel?.attributedText = self?.getSnippet(
cellViewModel: cellViewModel,
textColor: textColor
)
}
}
}
@ -455,10 +477,23 @@ public final class FullConversationCell: UITableViewCell {
)
}
// MARK: - SwipeActionOptimisticCell
public func optimisticUpdate(
isBlocked: Bool? = nil,
isPinned: Bool? = nil
isMuted: Bool?,
isBlocked: Bool?,
isPinned: Bool?,
hasUnread: Bool?
) {
// TODO: Decide on this
if let isMuted: Bool = isMuted {
if isMuted {
} else {
}
}
if let isBlocked: Bool = isBlocked {
if isBlocked {
accentLineView.themeBackgroundColor = .danger
@ -466,16 +501,25 @@ public final class FullConversationCell: UITableViewCell {
}
else {
accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground
accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ?
1 :
0.0001 // Setting the alpha to exactly 0 causes an issue on iOS 12
)
accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ? 1 : 0)
}
}
if let isPinned: Bool = isPinned {
isPinnedIcon.isHidden = !isPinned
}
if let hasUnread: Bool = hasUnread {
if hasUnread {
unreadCountView.isHidden = false
unreadCountLabel.text = "1"
unreadCountLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
accentLineView.alpha = 1
} else {
unreadCountView.isHidden = true
accentLineView.alpha = 0
}
}
}
// MARK: - Snippet generation
@ -514,7 +558,10 @@ public final class FullConversationCell: UITableViewCell {
))
}
if cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community {
if
(cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community) &&
(cellViewModel.interactionVariant?.isGroupControlMessage == false)
{
let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant)
result.append(NSAttributedString(
@ -523,17 +570,22 @@ public final class FullConversationCell: UITableViewCell {
))
}
let previewText: String = {
if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserErrorLeaving { return "group_leave_error".localized() }
return Interaction.previewText(
variant: (cellViewModel.interactionVariant ?? .standardIncoming),
body: cellViewModel.interactionBody,
threadContactDisplayName: cellViewModel.threadContactName(),
authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant),
attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo,
attachmentCount: cellViewModel.interactionAttachmentCount,
isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true)
)
}()
result.append(NSAttributedString(
string: MentionUtilities.highlightMentionsNoAttributes(
in: Interaction.previewText(
variant: (cellViewModel.interactionVariant ?? .standardIncoming),
body: cellViewModel.interactionBody,
threadContactDisplayName: cellViewModel.threadContactName(),
authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant),
attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo,
attachmentCount: cellViewModel.interactionAttachmentCount,
isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true)
),
in: previewText,
threadVariant: cellViewModel.threadVariant,
currentUserPublicKey: cellViewModel.currentUserPublicKey,
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey

View File

@ -0,0 +1,521 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionMessagingKit
import SessionUIKit
protocol SwipeActionOptimisticCell {
func optimisticUpdate(isMuted: Bool?, isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool?)
}
extension SwipeActionOptimisticCell {
public func optimisticUpdate(isMuted: Bool) {
optimisticUpdate(isMuted: isMuted, isBlocked: nil, isPinned: nil, hasUnread: nil)
}
public func optimisticUpdate(isBlocked: Bool) {
optimisticUpdate(isMuted: nil, isBlocked: isBlocked, isPinned: nil, hasUnread: nil)
}
public func optimisticUpdate(isPinned: Bool) {
optimisticUpdate(isMuted: nil, isBlocked: nil, isPinned: isPinned, hasUnread: nil)
}
public func optimisticUpdate(hasUnread: Bool) {
optimisticUpdate(isMuted: nil, isBlocked: nil, isPinned: nil, hasUnread: hasUnread)
}
}
public extension UIContextualAction {
enum SwipeAction {
case toggleReadStatus
case hide
case pin
case mute
case block
case leave
case delete
}
static func configuration(for actions: [UIContextualAction]?) -> UISwipeActionsConfiguration? {
return actions.map { UISwipeActionsConfiguration(actions: $0) }
}
static func generateSwipeActions(
_ actions: [SwipeAction],
for side: UIContextualAction.Side,
indexPath: IndexPath,
tableView: UITableView,
threadViewModel: SessionThreadViewModel,
viewController: UIViewController?
) -> [UIContextualAction]? {
guard !actions.isEmpty else { return nil }
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
// Note: for some reason the `UISwipeActionsConfiguration` expects actions to be left-to-right
// for leading actions, but right-to-left for trailing actions...
let targetActions: [SwipeAction] = (side == .trailing ? actions.reversed() : actions)
let actionBackgroundColor: [ThemeValue] = [
.conversationButton_swipeDestructive,
.conversationButton_swipeSecondary,
.conversationButton_swipeTertiary
]
return targetActions
.enumerated()
.map { index, action -> UIContextualAction in
// Even though we have to reverse the actions above, the indexes in the view hierarchy
// are in the expected order
let targetIndex: Int = (side == .trailing ? (targetActions.count - index) : index)
let themeBackgroundColor: ThemeValue = actionBackgroundColor[
index % actionBackgroundColor.count
]
switch action {
// MARK: -- toggleReadStatus
case .toggleReadStatus:
let isUnread: Bool = (
threadViewModel.threadWasMarkedUnread == true ||
(threadViewModel.threadUnreadCount ?? 0) > 0
)
return UIContextualAction(
title: (isUnread ?
"MARK_AS_READ".localized() :
"MARK_AS_UNREAD".localized()
),
icon: (isUnread ?
UIImage(systemName: "envelope.open") :
UIImage(systemName: "envelope.badge")
),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeRead, // Always Custom
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
switch isUnread {
case true: threadViewModel.markAsRead(
target: .threadAndInteractions(
interactionsBeforeInclusive: threadViewModel.interactionId
)
)
case false: threadViewModel.markAsUnread()
}
}
completionHandler(true)
}
// MARK: -- hide
case .hide:
return UIContextualAction(
title: "TXT_HIDE_TITLE".localized(),
icon: UIImage(systemName: "eye.slash"),
themeTintColor: .white,
themeBackgroundColor: themeBackgroundColor,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
switch threadViewModel.threadId {
case SessionThreadViewModel.messageRequestsSectionId:
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
completionHandler(true)
default:
let confirmationModalExplanation: NSAttributedString = {
let message = String(
format: "hide_note_to_self_confirmation_alert_message".localized(),
threadViewModel.displayName
)
return NSAttributedString(string: message)
.adding(
attributes: [
.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize)
],
range: (message as NSString).range(of: threadViewModel.displayName)
)
}()
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "hide_note_to_self_confirmation_alert_title".localized(),
attributedExplanation: confirmationModalExplanation,
confirmTitle: "TXT_HIDE_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { _ in
Storage.shared.writeAsync { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
groupLeaveType: .forced,
calledFromConfigHandling: false
)
}
viewController?.dismiss(animated: true, completion: nil)
completionHandler(true)
},
afterClosed: { completionHandler(false) }
)
)
viewController?.present(confirmationModal, animated: true, completion: nil)
}
}
// MARK: -- pin
case .pin:
return UIContextualAction(
title: (threadViewModel.threadPinnedPriority > 0 ?
"UNPIN_BUTTON_TEXT".localized() :
"PIN_BUTTON_TEXT".localized()
),
icon: (threadViewModel.threadPinnedPriority > 0 ?
UIImage(systemName: "pin.slash") :
UIImage(systemName: "pin")
),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeTertiary, // Always Tertiary
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)?
.optimisticUpdate(
isPinned: !(threadViewModel.threadPinnedPriority > 0)
)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared.writeAsync { db in
try SessionThread
.filter(id: threadViewModel.threadId)
.updateAllAndConfig(
db,
SessionThread.Columns.pinnedPriority
.set(to: (threadViewModel.threadPinnedPriority == 0 ? 1 : 0))
)
}
}
}
// MARK: -- mute
case .mute:
return UIContextualAction(
title: (threadViewModel.threadMutedUntilTimestamp == nil ?
"mute_button_text".localized() :
"unmute_button_text".localized()
),
icon: (threadViewModel.threadMutedUntilTimestamp == nil ?
UIImage(systemName: "speaker.slash") :
UIImage(systemName: "speaker")
),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: themeBackgroundColor,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)?
.optimisticUpdate(
isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil)
)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared.writeAsync { db in
let currentValue: TimeInterval? = try SessionThread
.filter(id: threadViewModel.threadId)
.select(.mutedUntilTimestamp)
.asRequest(of: TimeInterval.self)
.fetchOne(db)
try SessionThread
.filter(id: threadViewModel.threadId)
.updateAll(
db,
SessionThread.Columns.mutedUntilTimestamp.set(
to: (currentValue == nil ?
Date.distantFuture.timeIntervalSince1970 :
nil
)
)
)
}
}
}
// MARK: -- block
case .block:
return UIContextualAction(
title: (threadViewModel.threadIsBlocked == true ?
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
"BLOCK_LIST_BLOCK_BUTTON".localized()
),
icon: UIImage(named: "table_ic_block"),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: themeBackgroundColor,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { [weak viewController] _, _, completionHandler in
let threadIsBlocked: Bool = (threadViewModel.threadIsBlocked == true)
let threadIsMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true)
let contactChanges: [ConfigColumnAssignment] = [
Contact.Columns.isBlocked.set(to: !threadIsBlocked),
(!threadIsMessageRequest ? nil : Contact.Columns.isApproved.set(to: false)),
// Note: We set this to true so the current user will be able to send a
// message to the person who originally sent them the message request in
// the future if they unblock them
(!threadIsMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true))
].compactMap { $0 }
let performBlock: (UIViewController?) -> () = { viewController in
(tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)?
.optimisticUpdate(
isBlocked: !threadIsBlocked
)
viewController?.dismiss(animated: true, completion: nil)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared
.writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in
// Create the contact if it doesn't exist
try Contact
.fetchOrCreate(db, id: threadViewModel.threadId)
.save(db)
try Contact
.filter(id: threadViewModel.threadId)
.updateAllAndConfig(db, contactChanges)
// Blocked message requests should be deleted
if threadIsMessageRequest {
try SessionThread.deleteOrLeave(
db,
threadId: threadViewModel.threadId,
threadVariant: .contact,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
}
}
.sinkUntilComplete()
}
}
switch threadIsMessageRequest {
case false: performBlock(nil)
case true:
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
confirmTitle: "BLOCK_LIST_BLOCK_BUTTON".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { _ in
performBlock(viewController)
},
afterClosed: { completionHandler(false) }
)
)
viewController?.present(confirmationModal, animated: true, completion: nil)
}
}
// MARK: -- leave
case .leave:
return UIContextualAction(
title: "LEAVE_BUTTON_TITLE".localized(),
icon: UIImage(systemName: "rectangle.portrait.and.arrow.right"),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: themeBackgroundColor,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { [weak viewController] _, _, completionHandler in
let confirmationModalTitle: String = {
switch threadViewModel.threadVariant {
case .legacyGroup, .group:
return "leave_group_confirmation_alert_title".localized()
default: return "leave_community_confirmation_alert_title".localized()
}
}()
let confirmationModalExplanation: NSAttributedString = {
if threadViewModel.currentUserIsClosedGroupAdmin == true {
return NSAttributedString(string: "admin_group_leave_warning".localized())
}
let mutableAttributedString = NSMutableAttributedString(
string: String(
format: "leave_community_confirmation_alert_message".localized(),
threadViewModel.displayName
)
)
mutableAttributedString.addAttribute(
.font,
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
)
return mutableAttributedString
}()
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: confirmationModalTitle,
attributedExplanation: confirmationModalExplanation,
confirmTitle: "LEAVE_BUTTON_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { _ in
Storage.shared.writeAsync { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
groupLeaveType: .standard,
calledFromConfigHandling: false
)
}
viewController?.dismiss(animated: true, completion: nil)
completionHandler(true)
},
afterClosed: { completionHandler(false) }
)
)
viewController?.present(confirmationModal, animated: true, completion: nil)
}
// MARK: -- delete
case .delete:
return UIContextualAction(
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin"),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: themeBackgroundColor,
side: side,
actionIndex: targetIndex,
indexPath: indexPath,
tableView: tableView
) { [weak viewController] _, _, completionHandler in
let isMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true)
let confirmationModalTitle: String = {
switch (threadViewModel.threadVariant, isMessageRequest) {
case (_, true): return "TXT_DELETE_TITLE".localized()
case (.contact, _):
return "delete_conversation_confirmation_alert_title".localized()
case (.legacyGroup, _), (.group, _):
return "delete_group_confirmation_alert_title".localized()
case (.community, _): return "TXT_DELETE_TITLE".localized()
}
}()
let confirmationModalExplanation: NSAttributedString = {
guard !isMessageRequest else {
return NSAttributedString(
string: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized()
)
}
guard threadViewModel.currentUserIsClosedGroupAdmin == false else {
return NSAttributedString(
string: "admin_group_leave_warning".localized()
)
}
let message = String(
format: {
switch threadViewModel.threadVariant {
case .contact:
return
"delete_conversation_confirmation_alert_message".localized()
case .legacyGroup, .group:
return
"delete_group_confirmation_alert_message".localized()
case .community:
return "leave_community_confirmation_alert_message".localized()
}
}(),
threadViewModel.displayName
)
return NSAttributedString(string: message)
.adding(
attributes: [
.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize)
],
range: (message as NSString).range(of: threadViewModel.displayName)
)
}()
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: confirmationModalTitle,
attributedExplanation: confirmationModalExplanation,
confirmTitle: "TXT_DELETE_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { _ in
Storage.shared.writeAsync { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
groupLeaveType: (isMessageRequest ? .silent : .forced),
calledFromConfigHandling: false
)
}
viewController?.dismiss(animated: true, completion: nil)
completionHandler(true)
},
afterClosed: { completionHandler(false) }
)
)
viewController?.present(confirmationModal, animated: true, completion: nil)
}
}
}
}
}

View File

@ -54,6 +54,7 @@ public enum SNMessagingKit { // Just to make the external API nice
JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer)
JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts)
JobRunner.add(executor: AttachmentUploadJob.self, for: .attachmentUpload)
JobRunner.add(executor: GroupLeavingJob.self, for: .groupLeaving)
JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload)
JobRunner.add(executor: ConfigurationSyncJob.self, for: .configurationSync)
}

View File

@ -98,6 +98,12 @@ public extension ClosedGroup {
// MARK: - Convenience
public extension ClosedGroup {
enum LeaveType {
case standard
case silent
case forced
}
static func removeKeysAndUnsubscribe(
_ db: Database? = nil,
threadId: String,

View File

@ -163,7 +163,7 @@ internal extension ControlMessageProcessRecord {
.infoClosedGroupCreated:
return nil
case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft:
case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving:
self.variant = .closedGroupControlMessage
case .infoDisappearingMessagesUpdate:

View File

@ -73,6 +73,8 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
case infoClosedGroupCreated = 1000
case infoClosedGroupUpdated
case infoClosedGroupCurrentUserLeft
case infoClosedGroupCurrentUserErrorLeaving
case infoClosedGroupCurrentUserLeaving
case infoDisappearingMessagesUpdate = 2000
@ -91,7 +93,8 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
public var isInfoMessage: Bool {
switch self {
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
case .infoClosedGroupCreated, .infoClosedGroupUpdated,
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
.infoMessageRequestAccepted, .infoCall:
return true
@ -101,6 +104,25 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
}
}
public var isGroupControlMessage: Bool {
switch self {
case .infoClosedGroupCreated, .infoClosedGroupUpdated,
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving:
return true
default:
return false
}
}
public var isGroupLeavingStatus: Bool {
switch self {
case .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving:
return true
default:
return false
}
}
/// This flag controls whether the `wasRead` flag is automatically set to true based on the message variant (as a result it they will
/// or won't affect the unread count)
fileprivate var canBeUnread: Bool {
@ -110,7 +132,8 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
case .standardOutgoing, .standardIncomingDeleted: return false
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
case .infoClosedGroupCreated, .infoClosedGroupUpdated,
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
.infoMessageRequestAccepted:
return false
@ -898,6 +921,8 @@ public extension Interaction {
case .infoClosedGroupCreated: return "GROUP_CREATED".localized()
case .infoClosedGroupCurrentUserLeft: return "GROUP_YOU_LEFT".localized()
case .infoClosedGroupCurrentUserLeaving: return "group_you_leaving".localized()
case .infoClosedGroupCurrentUserErrorLeaving: return "group_unable_to_leave".localized()
case .infoClosedGroupUpdated: return (body ?? "GROUP_UPDATED".localized())
case .infoMessageRequestAccepted: return (body ?? "MESSAGE_REQUESTS_ACCEPTED".localized())

View File

@ -290,14 +290,14 @@ public extension SessionThread {
_ db: Database,
threadId: String,
threadVariant: Variant,
shouldSendLeaveMessageForGroups: Bool,
groupLeaveType: ClosedGroup.LeaveType,
calledFromConfigHandling: Bool
) throws {
try deleteOrLeave(
db,
threadIds: [threadId],
threadVariant: threadVariant,
shouldSendLeaveMessageForGroups: shouldSendLeaveMessageForGroups,
groupLeaveType: groupLeaveType,
calledFromConfigHandling: calledFromConfigHandling
)
}
@ -306,14 +306,14 @@ public extension SessionThread {
_ db: Database,
threadIds: [String],
threadVariant: Variant,
shouldSendLeaveMessageForGroups: Bool,
groupLeaveType: ClosedGroup.LeaveType,
calledFromConfigHandling: Bool
) throws {
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
let remainingThreadIds: [String] = threadIds.filter { $0 != currentUserPublicKey }
switch threadVariant {
case .contact:
switch (threadVariant, groupLeaveType) {
case (.contact, _):
// We need to custom handle the 'Note to Self' conversation (it should just be
// hidden rather than deleted
if threadIds.contains(currentUserPublicKey) {
@ -337,24 +337,29 @@ public extension SessionThread {
.hide(db, contactIds: threadIds)
}
case .legacyGroup, .group:
if shouldSendLeaveMessageForGroups {
threadIds.forEach { threadId in
MessageSender
.leave(db, groupPublicKey: threadId)
.sinkUntilComplete()
}
}
else {
try ClosedGroup.removeKeysAndUnsubscribe(
db,
threadIds: threadIds,
removeGroupData: true,
calledFromConfigHandling: calledFromConfigHandling
)
_ = try SessionThread
.filter(ids: remainingThreadIds)
.deleteAll(db)
case (.legacyGroup, .standard), (.group, .standard):
try threadIds.forEach { threadId in
try MessageSender
.leave(
db,
groupPublicKey: threadId,
deleteThread: true
)
}
case .community:
case (.legacyGroup, .silent), (.legacyGroup, .forced), (.group, .forced), (.group, .silent):
try ClosedGroup.removeKeysAndUnsubscribe(
db,
threadIds: threadIds,
removeGroupData: true,
calledFromConfigHandling: calledFromConfigHandling
)
case (.community, _):
threadIds.forEach { threadId in
OpenGroupManager.shared.delete(
db,
@ -363,10 +368,6 @@ public extension SessionThread {
)
}
}
_ = try SessionThread
.filter(ids: remainingThreadIds)
.deleteAll(db)
}
}
@ -412,7 +413,7 @@ public extension SessionThread {
///
/// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the
/// `SessionThread.contact` association or it won't work
static func isMessageRequest(userPublicKey: String, includeNonVisible: Bool = false) -> SQLSpecificExpressible {
static func isMessageRequest(userPublicKey: String, includeNonVisible: Bool = false) -> SQLExpression {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let shouldBeVisibleSQL: SQL = (includeNonVisible ?
@ -427,7 +428,7 @@ public extension SessionThread {
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND
IFNULL(\(contact[.isApproved]), false) = false
"""
)
).sqlExpression
}
func isNoteToSelf(_ db: Database? = nil) -> Bool {
@ -503,42 +504,46 @@ public extension SessionThread {
}
static func getUserHexEncodedBlindedKey(
_ db: Database? = nil,
threadId: String,
threadVariant: Variant
) -> String? {
guard threadVariant == .community else { return nil }
guard let db: Database = db else {
return Storage.shared.read { db in
getUserHexEncodedBlindedKey(db, threadId: threadId, threadVariant: threadVariant)
}
}
// Retrieve the relevant open group info
struct OpenGroupInfo: Decodable, FetchableRecord {
let publicKey: String
let server: String
}
guard
threadVariant == .community,
let blindingInfo: (edkeyPair: KeyPair?, publicKey: String?, capabilities: Set<Capability.Variant>) = Storage.shared.read({ db in
struct OpenGroupInfo: Decodable, FetchableRecord {
let publicKey: String?
let server: String?
}
let openGroupInfo: OpenGroupInfo? = try OpenGroup
.filter(id: threadId)
.select(.publicKey, .server)
.asRequest(of: OpenGroupInfo.self)
.fetchOne(db)
return (
Identity.fetchUserEd25519KeyPair(db),
openGroupInfo?.publicKey,
(try? Capability
.select(.variant)
.filter(Capability.Columns.openGroupServer == openGroupInfo?.server?.lowercased())
.asRequest(of: Capability.Variant.self)
.fetchSet(db))
.defaulting(to: [])
)
}),
let userEdKeyPair: KeyPair = blindingInfo.edkeyPair,
let publicKey: String = blindingInfo.publicKey,
blindingInfo.capabilities.isEmpty || blindingInfo.capabilities.contains(.blind)
let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db),
let openGroupInfo: OpenGroupInfo = try? OpenGroup
.filter(id: threadId)
.select(.publicKey, .server)
.asRequest(of: OpenGroupInfo.self)
.fetchOne(db)
else { return nil }
// Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities)
let capabilities: Set<Capability.Variant> = (try? Capability
.select(.variant)
.filter(Capability.Columns.openGroupServer == openGroupInfo.server.lowercased())
.asRequest(of: Capability.Variant.self)
.fetchSet(db))
.defaulting(to: [])
guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil }
let sodium: Sodium = Sodium()
let blindedKeyPair: KeyPair? = sodium.blindedKeyPair(
serverPublicKey: publicKey,
serverPublicKey: openGroupInfo.publicKey,
edKeyPair: userEdKeyPair,
genericHash: sodium.getGenericHash()
)

View File

@ -25,7 +25,7 @@ public enum AttachmentDownloadJob: JobExecutor {
let attachment: Attachment = Storage.shared
.read({ db in try Attachment.fetchOne(db, id: details.attachmentId) })
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}

View File

@ -31,7 +31,7 @@ public enum AttachmentUploadJob: JobExecutor {
return (attachment, try OpenGroup.fetchOne(db, id: threadId))
})
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}

View File

@ -0,0 +1,158 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import SignalCoreKit
import SessionUtilitiesKit
import SessionSnodeKit
public enum GroupLeavingJob: JobExecutor {
public static var maxFailureCount: Int = 0
public static var requiresThreadId: Bool = true
public static var requiresInteractionId: Bool = true
public static func run(
_ job: SessionUtilitiesKit.Job,
queue: DispatchQueue,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ())
{
guard
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData),
let threadId: String = job.threadId,
let interactionId: Int64 = job.interactionId
else {
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}
let destination: Message.Destination = .closedGroup(groupPublicKey: threadId)
Storage.shared
.writePublisher(receiveOn: queue) { db in
guard (try? SessionThread.exists(db, id: threadId)) == true else {
SNLog("Can't update nonexistent closed group.")
throw MessageSenderError.noThread
}
guard (try? ClosedGroup.exists(db, id: threadId)) == true else {
throw MessageSenderError.invalidClosedGroupUpdate
}
return try MessageSender.preparedSendData(
db,
message: ClosedGroupControlMessage(
kind: .memberLeft
),
to: destination,
namespace: destination.defaultNamespace,
interactionId: job.interactionId,
isSyncMessage: false
)
}
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.receive(on: queue)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .failure:
Storage.shared.writeAsync { db in
try Interaction
.filter(id: job.interactionId)
.updateAll(
db,
[
Interaction.Columns.variant
.set(to: Interaction.Variant.infoClosedGroupCurrentUserErrorLeaving),
Interaction.Columns.body.set(to: "group_unable_to_leave".localized())
]
)
}
success(job, false)
case .finished:
Storage.shared.writeAsync { db in
// Update the group (if the admin leaves the group is disbanded)
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
let wasAdminUser: Bool = GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.profileId == currentUserPublicKey)
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
.isNotEmpty(db)
if wasAdminUser {
try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.deleteAll(db)
}
else {
try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.profileId == currentUserPublicKey)
.deleteAll(db)
}
// Update the transaction
try Interaction
.filter(id: interactionId)
.updateAll(
db,
[
Interaction.Columns.variant
.set(to: Interaction.Variant.infoClosedGroupCurrentUserLeft),
Interaction.Columns.body.set(to: "GROUP_YOU_LEFT".localized())
]
)
// Clear out the group info as needed
try ClosedGroup.removeKeysAndUnsubscribe(
db,
threadId: threadId,
removeGroupData: details.deleteThread,
calledFromConfigHandling: false
)
}
success(job, false)
}
}
)
}
}
// MARK: - GroupLeavingJob.Details
extension GroupLeavingJob {
public struct Details: Codable {
private enum CodingKeys: String, CodingKey {
case deleteThread
}
public let deleteThread: Bool
// MARK: - Initialization
public init(deleteThread: Bool) {
self.deleteThread = deleteThread
}
// MARK: - Codable
public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
self = Details(
deleteThread: try container.decode(Bool.self, forKey: .deleteThread)
)
}
public func encode(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encode(deleteThread, forKey: .deleteThread)
}
}
}

View File

@ -21,7 +21,7 @@ public enum MessageReceiveJob: JobExecutor {
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}

View File

@ -23,7 +23,7 @@ public enum MessageSendJob: JobExecutor {
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}
@ -36,7 +36,7 @@ public enum MessageSendJob: JobExecutor {
let jobId: Int64 = job.id,
let interactionId: Int64 = job.interactionId
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}

View File

@ -21,7 +21,7 @@ public enum NotifyPushServerJob: JobExecutor {
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}

View File

@ -23,7 +23,7 @@ public enum SendReadReceiptsJob: JobExecutor {
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}

View File

@ -175,7 +175,7 @@ internal extension SessionUtil {
db,
threadId: contact.id,
threadVariant: .contact,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .forced,
calledFromConfigHandling: true
)
@ -239,7 +239,7 @@ internal extension SessionUtil {
db,
threadIds: contactIdsToRemove,
threadVariant: .contact,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .forced,
calledFromConfigHandling: true
)

View File

@ -176,7 +176,7 @@ internal extension SessionUtil {
db,
threadIds: Array(communityIdsToRemove),
threadVariant: .community,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .forced,
calledFromConfigHandling: true
)
}
@ -336,7 +336,7 @@ internal extension SessionUtil {
db,
threadIds: Array(legacyGroupIdsToRemove),
threadVariant: .legacyGroup,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .forced,
calledFromConfigHandling: true
)
}

View File

@ -108,7 +108,7 @@ internal extension SessionUtil {
db,
threadId: userPublicKey,
threadVariant: .contact,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .forced,
calledFromConfigHandling: true
)
}

View File

@ -27,8 +27,13 @@ public final class DataExtractionNotification: ControlMessage {
// MARK: - Initialization
public init(kind: Kind) {
super.init()
public init(
kind: Kind,
sentTimestamp: UInt64? = nil
) {
super.init(
sentTimestamp: sentTimestamp
)
self.kind = kind
}

View File

@ -370,6 +370,7 @@ public extension Message {
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let blindedUserPublicKey: String? = SessionThread
.getUserHexEncodedBlindedKey(
db,
threadId: openGroupId,
threadVariant: .community
)

View File

@ -97,7 +97,7 @@ extension MessageReceiver {
db,
threadId: blindedIdLookup.blindedId,
threadVariant: .contact,
shouldSendLeaveMessageForGroups: false,
groupLeaveType: .forced,
calledFromConfigHandling: false
)
}

View File

@ -572,110 +572,31 @@ extension MessageSender {
/// The returned promise is fulfilled when the `MEMBER_LEFT` message has been sent to the group.
public static func leave(
_ db: Database,
groupPublicKey: String
) -> AnyPublisher<Void, Error> {
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
SNLog("Can't leave nonexistent closed group.")
return Fail(error: MessageSenderError.noThread)
.eraseToAnyPublisher()
}
guard thread.closedGroup.isNotEmpty(db) else {
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
.eraseToAnyPublisher()
}
groupPublicKey: String,
deleteThread: Bool
) throws {
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let sendData: MessageSender.PreparedSendData
do {
// Notify the user
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupCurrentUserLeft,
body: ClosedGroupControlMessage.Kind
.memberLeft
.infoMessage(db, sender: userPublicKey),
timestampMs: SnodeAPI.currentOffsetTimestampMs()
).inserted(db)
guard let interactionId: Int64 = interaction.id else {
return Fail(error: StorageError.objectNotSaved)
.eraseToAnyPublisher()
}
// Send the update to the group
sendData = try MessageSender
.preparedSendData(
db,
message: ClosedGroupControlMessage(
kind: .memberLeft
),
to: try Message.Destination.from(db, threadId: groupPublicKey, threadVariant: .legacyGroup),
namespace: try Message.Destination
.from(db, threadId: groupPublicKey, threadVariant: .legacyGroup)
.defaultNamespace,
interactionId: interactionId
// Notify the user
let interaction: Interaction = try Interaction(
threadId: groupPublicKey,
authorId: userPublicKey,
variant: .infoClosedGroupCurrentUserLeaving,
body: "group_you_leaving".localized(),
timestampMs: SnodeAPI.currentOffsetTimestampMs()
).inserted(db)
JobRunner.upsert(
db,
job: Job(
variant: .groupLeaving,
threadId: groupPublicKey,
interactionId: interaction.id,
details: GroupLeavingJob.Details(
deleteThread: deleteThread
)
// Update the group (if the admin leaves the group is disbanded)
let wasAdminUser: Bool = GroupMember
.filter(GroupMember.Columns.groupId == thread.id)
.filter(GroupMember.Columns.profileId == userPublicKey)
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
.isNotEmpty(db)
if wasAdminUser {
try GroupMember
.filter(GroupMember.Columns.groupId == thread.id)
.deleteAll(db)
}
else {
try GroupMember
.filter(GroupMember.Columns.groupId == thread.id)
.filter(GroupMember.Columns.profileId == userPublicKey)
.deleteAll(db)
}
}
catch {
switch error {
// There are some cases where the keys for a ClosedGroup can be lost or become invalid, in
// those cases we don't want to prevent the user from being able to leave a group so catch
// them and just remove the group from the users devices
case MessageSenderError.noKeyPair, MessageSenderError.encryptionFailed:
try? ClosedGroup.removeKeysAndUnsubscribe(
db,
threadId: groupPublicKey,
removeGroupData: false,
calledFromConfigHandling: false
)
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
default: break
}
return Fail(error: error)
.eraseToAnyPublisher()
}
return MessageSender
.sendImmediate(preparedSendData: sendData)
.handleEvents(
receiveCompletion: { result in
switch result {
case .failure: break
case .finished:
try? ClosedGroup.removeKeysAndUnsubscribe(
threadId: groupPublicKey,
removeGroupData: false,
calledFromConfigHandling: false
)
}
}
)
.eraseToAnyPublisher()
)
}
public static func sendLatestEncryptionKeyPair(

View File

@ -103,8 +103,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public var canWrite: Bool {
switch threadVariant {
case .contact: return true
case .legacyGroup, .group: return currentUserIsClosedGroupMember == true
case .community: return openGroupPermissions?.contains(.write) ?? false
case .legacyGroup, .group: return (currentUserIsClosedGroupMember == true && interactionVariant?.isGroupLeavingStatus != true)
case .community: return (openGroupPermissions?.contains(.write) ?? false)
}
}
@ -333,10 +333,11 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public extension SessionThreadViewModel {
static let invalidId: String = "INVALID_THREAD_ID"
static let messageRequestsSectionId: String = "MESSAGE_REQUESTS_SECTION_INVALID_THREAD_ID"
// Note: This init method is only used system-created cells or empty states
init(
threadId: String? = nil,
threadId: String,
threadVariant: SessionThread.Variant? = nil,
threadIsNoteToSelf: Bool = false,
threadIsBlocked: Bool? = nil,
@ -346,7 +347,7 @@ public extension SessionThreadViewModel {
unreadCount: UInt = 0
) {
self.rowId = -1
self.threadId = (threadId ?? SessionThreadViewModel.invalidId)
self.threadId = threadId
self.threadVariant = (threadVariant ?? .contact)
self.threadCreationDateTimestamp = 0
self.threadMemberNames = nil
@ -464,6 +465,7 @@ public extension SessionThreadViewModel {
}
func populatingCurrentUserBlindedKey(
_ db: Database? = nil,
currentUserBlindedPublicKeyForThisThread: String? = nil
) -> SessionThreadViewModel {
return SessionThreadViewModel(
@ -516,6 +518,7 @@ public extension SessionThreadViewModel {
currentUserBlindedPublicKey: (
currentUserBlindedPublicKeyForThisThread ??
SessionThread.getUserHexEncodedBlindedKey(
db,
threadId: self.threadId,
threadVariant: self.threadVariant
)
@ -562,14 +565,17 @@ public extension SessionThreadViewModel {
let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name)
let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name)
let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name)
let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name)
let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name)
let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name)
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to
/// parse and might throw
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 13
let numColumnsBetweenProfilesAndAttachmentInfo: Int = 11 // The attachment info columns will be combined
let numColumnsBeforeProfiles: Int = 14
let numColumnsBetweenProfilesAndAttachmentInfo: Int = 12 // The attachment info columns will be combined
let request: SQLRequest<ViewModel> = """
SELECT
@ -583,6 +589,11 @@ public extension SessionThreadViewModel {
\(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey),
\(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey),
\(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey),
(
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND
IFNULL(\(contact[.isApproved]), false) = false
) AS \(ViewModel.threadIsMessageRequestKey),
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey),
\(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey),
@ -594,7 +605,8 @@ public extension SessionThreadViewModel {
\(ViewModel.closedGroupProfileBackKey).*,
\(ViewModel.closedGroupProfileBackFallbackKey).*,
\(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey),
(\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey),
(\(ViewModel.currentUserIsClosedGroupMemberKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey),
(\(ViewModel.currentUserIsClosedGroupAdminKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey),
\(openGroup[.name]) AS \(ViewModel.openGroupNameKey),
\(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey),
@ -669,10 +681,15 @@ public extension SessionThreadViewModel {
LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id])
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(GroupMember.self) ON (
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND
\(groupMember[.groupId]) = \(closedGroup[.threadId]) AND
\(SQL("\(groupMember[.profileId]) = \(userPublicKey)"))
LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupMemberKey) ON (
\(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberRoleColumnLiteral) != \(GroupMember.Role.zombie)")) AND
\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND
\(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)"))
)
LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupAdminKey) ON (
\(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) AND
\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND
\(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)"))
)
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON (

View File

@ -28,7 +28,7 @@ public extension UIContextualAction {
title: String? = nil,
icon: UIImage? = nil,
iconHeight: CGFloat = Values.mediumFontSize,
themeTintColor: ThemeValue = .textPrimary,
themeTintColor: ThemeValue = .white,
themeBackgroundColor: ThemeValue,
side: Side,
actionIndex: Int,
@ -66,7 +66,7 @@ public extension UIContextualAction {
let stackView: UIStackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 3
stackView.spacing = 4
if let icon: UIImage = icon {
let aspectRatio: CGFloat = (icon.size.width / icon.size.height)

View File

@ -3,7 +3,7 @@
import Foundation
import GRDB
public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "job" }
internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId])
public static let dependantJobDependency = hasMany(
@ -103,6 +103,10 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// This is a job that runs once whenever an attachment is downloaded to attempt to decode and properly
/// download the attachment
case attachmentDownload
/// This is a job that runs once whenever the user leaves a group to send a group leaving message, remove group
/// record and group member record
case groupLeaving
/// This is a job that runs once whenever the user config or a closed group config changes, it retrieves the
/// state of all config objects and syncs any that are flagged as needing to be synced

View File

@ -3,7 +3,7 @@
import Foundation
import GRDB
public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct JobDependencies: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "jobDependencies" }
internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id])
internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id])

View File

@ -45,6 +45,14 @@ public extension Array {
return updatedArray
}
func inserting(contentsOf other: [Element]?, at index: Int) -> [Element] {
guard let other: [Element] = other else { return self }
var updatedArray: [Element] = self
updatedArray.insert(contentsOf: other, at: 0)
return updatedArray
}
func grouped<Key: Hashable>(by keyForValue: (Element) throws -> Key) -> [Key: [Element]] {
return ((try? Dictionary(grouping: self, by: keyForValue)) ?? [:])
}

View File

@ -66,6 +66,7 @@ public final class JobRunner {
jobVariants.remove(.messageSend),
jobVariants.remove(.notifyPushServer),
jobVariants.remove(.sendReadReceipts),
jobVariants.remove(.groupLeaving),
jobVariants.remove(.configurationSync)
].compactMap { $0 }
)
@ -635,9 +636,13 @@ private final class JobQueue {
}
fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool {
guard let detailsData: Data = detailsData else { return false }
let pendingJobs: [Job] = queue.wrappedValue
return pendingJobs.contains { job in job.details == detailsData }
guard !pendingJobs.contains(where: { job in job.details == detailsData }) else { return true }
return detailsForCurrentlyRunningJobs.wrappedValue.values.contains(detailsData)
}
fileprivate func removePendingJob(_ jobId: Int64) {
@ -772,13 +777,15 @@ private final class JobQueue {
}
// Check if the next job has any dependencies
let dependencyInfo: (expectedCount: Int, jobs: [Job]) = Storage.shared.read { db in
let numExpectedDependencies: Int = try JobDependencies
let dependencyInfo: (expectedCount: Int, jobs: Set<Job>) = Storage.shared.read { db in
let expectedDependencies: Set<JobDependencies> = try JobDependencies
.filter(JobDependencies.Columns.jobId == nextJob.id)
.fetchCount(db)
let jobDependencies: [Job] = try nextJob.dependencies.fetchAll(db)
.fetchSet(db)
let jobDependencies: Set<Job> = try Job
.filter(ids: expectedDependencies.compactMap { $0.dependantId })
.fetchSet(db)
return (numExpectedDependencies, jobDependencies)
return (expectedDependencies.count, jobDependencies)
}
.defaulting(to: (0, []))
@ -790,39 +797,15 @@ private final class JobQueue {
guard dependencyInfo.jobs.isEmpty else {
SNLog("[JobRunner] \(queueContext) found job with \(dependencyInfo.jobs.count) dependencies, running those first")
let jobDependencyIds: [Int64] = dependencyInfo.jobs
.compactMap { $0.id }
let jobIdsNotInQueue: Set<Int64> = jobDependencyIds
.asSet()
.subtracting(queue.wrappedValue.compactMap { $0.id })
// If there are dependencies which aren't in the queue we should just append them
guard !jobIdsNotInQueue.isEmpty else {
queue.mutate { queue in
queue.append(
contentsOf: dependencyInfo.jobs
.filter { jobIdsNotInQueue.contains($0.id ?? -1) }
)
queue.append(nextJob)
}
handleJobDeferred(nextJob)
return
/// Remove all jobs this one is dependant on from the queue and re-insert them at the start of the queue
///
/// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies
/// are successfully completed
queue.mutate { queue in
queue = queue
.filter { !dependencyInfo.jobs.contains($0) }
.inserting(contentsOf: Array(dependencyInfo.jobs), at: 0)
}
// Otherwise re-add the current job after it's dependencies (if this isn't a concurrent
// queue - don't want to immediately try to start the job again only for it to end up back
// in here)
if executionType != .concurrent {
queue.mutate { queue in
guard let lastDependencyIndex: Int = queue.lastIndex(where: { jobDependencyIds.contains($0.id ?? -1) }) else {
queue.append(nextJob)
return
}
queue.insert(nextJob, at: lastDependencyIndex + 1)
}
}
handleJobDeferred(nextJob)
return
}
@ -940,6 +923,12 @@ private final class JobQueue {
/// This function is called when a job succeeds
private func handleJobSucceeded(_ job: Job, shouldStop: Bool) {
/// Retrieve the dependant jobs first (the `JobDependecies` table has cascading deletion when the original `Job` is
/// removed so we need to retrieve these records before that happens)
let dependantJobs: [Job] = Storage.shared
.read { db in try job.dependantJobs.fetchAll(db) }
.defaulting(to: [])
switch job.behaviour {
case .runOnce, .runOnceNextLaunch:
Storage.shared.write { db in
@ -1002,26 +991,17 @@ private final class JobQueue {
default: break
}
// For concurrent queues retrieve any 'dependant' jobs and re-add them here (if they have other
// dependencies they will be removed again when they try to execute)
if executionType == .concurrent {
let dependantJobs: [Job] = Storage.shared
.read { db in try job.dependantJobs.fetchAll(db) }
.defaulting(to: [])
let dependantJobIds: [Int64] = dependantJobs
.compactMap { $0.id }
let jobIdsNotInQueue: Set<Int64> = dependantJobIds
.asSet()
.subtracting(queue.wrappedValue.compactMap { $0.id })
// If there are dependant jobs which aren't in the queue we should just append them
if !jobIdsNotInQueue.isEmpty {
queue.mutate { queue in
queue.append(
contentsOf: dependantJobs
.filter { jobIdsNotInQueue.contains($0.id ?? -1) }
)
}
/// Now that the job has been completed we want to insert any jobs that were dependant on it to the start of the queue (the
/// most likely case is that we want an entire job chain to be completed at the same time rather than being blocked by other
/// unrelated jobs)
///
/// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be
/// removed from the queue, replaced by their dependencies
if !dependantJobs.isEmpty {
queue.mutate { queue in
queue = queue
.filter { !dependantJobs.contains($0) }
.inserting(contentsOf: dependantJobs, at: 0)
}
}
@ -1081,19 +1061,30 @@ private final class JobQueue {
let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job))
Storage.shared.write { db in
/// Remove any dependant jobs from the queue (shouldn't be in there but filter the queue just in case so we don't try
/// to run a deleted job or get stuck in a loop of trying to run dependencies indefinitely)
let dependantJobIds: [Int64] = try job.dependantJobs
.select(.id)
.asRequest(of: Int64.self)
.fetchAll(db)
if !dependantJobIds.isEmpty {
queue.mutate { queue in
queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) }
}
}
/// Delete/update the failed jobs and any dependencies
let updatedFailureCount: UInt = (job.failureCount + 1)
guard
!permanentFailure && (
maxFailureCount < 0 ||
job.failureCount + 1 < maxFailureCount
updatedFailureCount <= maxFailureCount
)
else {
SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")")
let dependantJobIds: [Int64] = try job.dependantJobs
.select(.id)
.asRequest(of: Int64.self)
.fetchAll(db)
// If the job permanently failed or we have performed all of our retry attempts
// then delete the job and all of it's dependant jobs (it'll probably never succeed)
_ = try job.dependantJobs
@ -1101,13 +1092,6 @@ private final class JobQueue {
_ = try job.delete(db)
// Remove the dependant jobs from the queue (so we don't try to run a deleted job)
if !dependantJobIds.isEmpty {
queue.mutate { queue in
queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) }
}
}
performCleanUp(for: job, result: .failed)
return
}
@ -1116,7 +1100,7 @@ private final class JobQueue {
_ = try job
.with(
failureCount: (job.failureCount + 1),
failureCount: updatedFailureCount,
nextRunTimestamp: nextRunTimestamp
)
.saved(db)
@ -1127,22 +1111,9 @@ private final class JobQueue {
try job.dependantJobs
.updateAll(
db,
Job.Columns.failureCount.set(to: (job.failureCount + 1)),
Job.Columns.failureCount.set(to: updatedFailureCount),
Job.Columns.nextRunTimestamp.set(to: (nextRunTimestamp + (1 / 1000)))
)
let dependantJobIds: [Int64] = try job.dependantJobs
.select(.id)
.asRequest(of: Int64.self)
.fetchAll(db)
// Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying
// to run dependecies indefinitely)
if !dependantJobIds.isEmpty {
queue.mutate { queue in
queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) }
}
}
}
performCleanUp(for: job, result: .failed)

View File

@ -46,8 +46,25 @@ public enum HTTP {
guard SecTrustSetAnchorCertificates(trust, certificates as CFArray) == errSecSuccess else {
return completionHandler(.cancelAuthenticationChallenge, nil)
}
// We want to make sure that the pinned certification was valid during it's validity
// period (which has now expired) so set the date to validate against to be within the
// valid period
let dateFormatter: DateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd/MM/yyyy HH:mm:ss"
if let validDate: Date = dateFormatter.date(from: "01/01/2022 12:00:00") {
if SecTrustSetVerifyDate(trust, validDate as CFDate) != errSecSuccess {
SNLog("Unable to set date for seed node certificate validation.")
}
}
else {
SNLog("Unable to set date for seed node certificate validation.")
}
// Check that the presented certificate is one of the seed node certificates
var result: SecTrustResultType = .invalid
guard SecTrustEvaluate(trust, &result) == errSecSuccess else {
return completionHandler(.cancelAuthenticationChallenge, nil)
}