Compare commits

...

26 Commits

Author SHA1 Message Date
Morgan Pretty 6a9ffdd22b
Merge pull request #940 from oxen-io/dev
Release 2.4.4
5 months ago
Morgan Pretty f532496ee4 Increased build and version numbers 5 months ago
Morgan Pretty d8dc801e5f
Merge pull request #939 from RyanRory/turn-server-fix
TURN server fix
5 months ago
Ryan ZHAO 6d2e0b457e fix: wrong server removed 5 months ago
Ryan ZHAO e6cf75dd3f remove frigg&fenrir turn servers for now 5 months ago
Morgan Pretty 109a81f33f
Merge pull request #929 from mpretty-cyro/fix/media-interactions
Fixed a few bugs and crashes around media interactions
7 months ago
Morgan Pretty 05460ca2b3 Fixed a bug where the play button wouldn't appear when swiping attachments 7 months ago
Morgan Pretty de7d85f4cb Merge remote-tracking branch 'upstream/dev' into fix/media-interactions 7 months ago
Morgan Pretty 89b38dc2f5
Merge pull request #928 from mpretty-cyro/fix/blocked-contacts-crash
Fixed a crash on the blocked contacts screen and refactoring
7 months ago
Morgan Pretty 638685a8cc
Merge pull request #925 from mpretty-cyro/fix/blank-display-name-handling
Fixed a bug where profiles with blank name values wouldn't fallback correctly
7 months ago
Morgan Pretty e427e59544
Merge pull request #926 from mpretty-cyro/fix/rare-multi-threading-crash
Fixed a crash which could occur when scrolling conversation messages
7 months ago
Morgan Pretty aec2aed81f
Merge pull request #927 from mpretty-cyro/fix/snode-info-deduping
Fixed an issue where the messages might not get reprocessed when they should
7 months ago
Morgan Pretty 4b9e15e5c1
Merge pull request #924 from mpretty-cyro/fix/theme-nav-issue
Fixed an issue where theme changes stopped updating nav styling
7 months ago
Morgan Pretty bd98db2612 Fixed a few bugs and crashes around media interactions
Fixed a crash when trying to grant permission to access additional photos
Fixed a bug where audio files would incorrectly get recognised as voice messages
Replaced our custom video/audio players with the native ones (which have additional built-in controls)
Updated the errors from SSKKeychainStorage to include useful information
Updated layout for audio attachments
7 months ago
Morgan Pretty b3eb78aaee Fixed the broken tests 7 months ago
Morgan Pretty f97170fdcd Fixed a crash on the blocked contacts screen and refactoring
Refactored the SessionThreadViewModel to reduce boilerplate and clean up the interface a little
Refactored the MessageRequestsViewController to use the SessionTableViewController
Fixed a crash when returning from the background on the BlockedContactsViewModel
Fixed some minor lag on the NotificationSoundViewModel
Added an optional initial loading message to the SessionTableViewController
7 months ago
Morgan Pretty 085a1a59aa Fixed an issue where the messages might not get reprocessed when they should
Dropped the auto-incrementing id from the SnodeReceivedMessageInfo
Changed the 'key, hash' from a uniqueKey to a primaryKey to allow "upsert" behaviours to work
8 months ago
Morgan Pretty 658240e549 Fixed a crash which could occur when scrolling conversation messages 8 months ago
Morgan Pretty 819106b0f2 Fixed a bug where profiles with blank name values wouldn't fallback correctly 8 months ago
Morgan Pretty 3a9ada581d Fixed an issue where theme changes stopped updating nav styling 8 months ago
Morgan Pretty 6d57523ede
Merge pull request #923 from mpretty-cyro/fix/apple-required-copy-change
Change the 'Grant Camera Access' copy to 'Continue' at Apple's request & Updated translations
8 months ago
Morgan Pretty 06f12a58b0 Change the 'Grant Camera Access' copy to 'Continue' at Apple's request
Updated translations again
8 months ago
Morgan Pretty 187902e48a
Merge pull request #861 from KeeJef/master
Update Session iOS screenshot
8 months ago
Morgan Pretty c81616c145
Merge pull request #922 from mpretty-cyro/fix/string-linter-for-archives
Fixed an issue where string validation was failing on archive builds
8 months ago
Morgan Pretty 8346a2e610 Fixed an issue where string validation was failing on archive builds 8 months ago
Kee Jefferys 9f3d9cf7ab
Update Session iOS screenshot 12 months ago

@ -6,7 +6,7 @@
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
<img src="https://i.imgur.com/SocRFTh.jpg" width="320" />
<img src="https://i.imgur.com/Ioub5bx.png" width="320" />
## Want to contribute? Found a bug or have a feature request?

@ -104,8 +104,19 @@ enum ScriptAction: String {
guard
let builtProductsPath: String = ProcessInfo.processInfo.environment["BUILT_PRODUCTS_DIR"],
let productName: String = ProcessInfo.processInfo.environment["FULL_PRODUCT_NAME"],
let productPathInfo = try? URL(fileURLWithPath: "\(builtProductsPath)/\(productName)")
.resourceValues(forKeys: [.isSymbolicLinkKey, .isAliasFileKey]),
let finalProductUrl: URL = try? { () -> URL in
let possibleAliasUrl: URL = URL(fileURLWithPath: "\(builtProductsPath)/\(productName)")
guard productPathInfo.isSymbolicLink == true || productPathInfo.isAliasFile == true else {
return possibleAliasUrl
}
return try URL(resolvingAliasFileAt: possibleAliasUrl, options: URL.BookmarkResolutionOptions())
}(),
let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
at: URL(fileURLWithPath: "\(builtProductsPath)/\(productName)"),
at: finalProductUrl,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
),

@ -125,7 +125,6 @@
7B81682C28B72F480069F315 /* PendingChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682B28B72F480069F315 /* PendingChange.swift */; };
7B8C44C528B49DDA00FBE25F /* NewConversationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8C44C428B49DDA00FBE25F /* NewConversationVC.swift */; };
7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */; };
7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */; };
7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */; };
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; };
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */; };
@ -337,7 +336,6 @@
C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */; };
C38EF00C255B61CC007E1867 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; };
C38EF22B255B6D5D007E1867 /* ShareViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */; };
C38EF22C255B6D5D007E1867 /* OWSVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */; };
C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF240255B6D67007E1867 /* UIView+OWS.swift */; };
C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF241255B6D67007E1867 /* Collection+OWS.swift */; };
C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; };
@ -379,7 +377,6 @@
C38EF3FB255B6DF7007E1867 /* UIAlertController+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3DD255B6DF1007E1867 /* UIAlertController+OWS.swift */; };
C38EF3FF255B6DF7007E1867 /* TappableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E1255B6DF3007E1867 /* TappableView.swift */; };
C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E2255B6DF3007E1867 /* GalleryRailView.swift */; };
C38EF401255B6DF7007E1867 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */; };
C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */; };
C38EF405255B6DF7007E1867 /* OWSButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E7255B6DF5007E1867 /* OWSButton.swift */; };
C38EF407255B6DF7007E1867 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E9255B6DF6007E1867 /* Toast.swift */; };
@ -484,6 +481,14 @@
FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; };
FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77AF29B69A65009169BA /* TopBannerController.swift */; };
FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */; };
FD12A83D2AD63BCC00EEBA0D /* EditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A83C2AD63BCC00EEBA0D /* EditableState.swift */; };
FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */; };
FD12A8412AD63BEA00EEBA0D /* NavigatableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */; };
FD12A8432AD63BF600EEBA0D /* ObservableTableSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8422AD63BF600EEBA0D /* ObservableTableSource.swift */; };
FD12A8452AD63C2200EEBA0D /* TableDataState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8442AD63C2200EEBA0D /* TableDataState.swift */; };
FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8462AD63C3400EEBA0D /* PagedObservationSource.swift */; };
FD12A8492AD63C4700EEBA0D /* SessionNavItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A8482AD63C4700EEBA0D /* SessionNavItem.swift */; };
FD12A84B2AD6458800EEBA0D /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD12A84A2AD6458800EEBA0D /* DifferenceKit+Utilities.swift */; };
FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */; };
FD16AB5F2A1DD98F0083D849 /* ProfilePictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */; };
FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */; };
@ -648,6 +653,7 @@
FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */; };
FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; };
FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; };
FD6DF00B2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */; };
FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */; };
FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; };
FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; };
@ -681,7 +687,6 @@
FD71164828E2CE8700B47552 /* SessionCell+AccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */; };
FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164928E3EA5B00B47552 /* DismissType.swift */; };
FD71164E28E3F8CC00B47552 /* SessionCell+Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164D28E3F8CC00B47552 /* SessionCell+Info.swift */; };
FD71165028E3F9FA00B47552 /* SessionTableViewModel+NavItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71164F28E3F9FA00B47552 /* SessionTableViewModel+NavItem.swift */; };
FD71165228E410BE00B47552 /* SessionTableSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71165128E410BE00B47552 /* SessionTableSection.swift */; };
FD71165828E436E800B47552 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; };
FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090628B49738006098F6 /* ConfirmationModal.swift */; };
@ -719,7 +724,6 @@
FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.swift */; };
FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; };
FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */; };
FD87DD0028B820F200AF0F98 /* BlockedContactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */; };
FD87DD0428B8727D00AF0F98 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DD0328B8727D00AF0F98 /* Configuration.swift */; };
FD8ECF7B29340FFD00C0D1BB /* SessionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7A29340FFD00C0D1BB /* SessionUtil.swift */; };
FD8ECF7D2934293A00C0D1BB /* _013_SessionUtilChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */; };
@ -753,6 +757,7 @@
FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */; };
FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; };
FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */; };
FDB6A87C2AD75B7F002D4F96 /* PhotosUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */; };
FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; };
FDB7400D28EBEC240094D718 /* DateHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */; };
FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */; };
@ -1243,7 +1248,6 @@
7B81682B28B72F480069F315 /* PendingChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChange.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>"; };
7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; };
7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; };
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListSheet.swift; sourceTree = "<group>"; };
@ -1474,7 +1478,6 @@
C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoEnvelope+Conversion.swift"; sourceTree = "<group>"; };
C38EF224255B6D5D007E1867 /* SignalAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SignalAttachment.swift; path = "SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift"; sourceTree = SOURCE_ROOT; };
C38EF226255B6D5D007E1867 /* ShareViewDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewDelegate.swift; path = SignalUtilitiesKit/Utilities/ShareViewDelegate.swift; sourceTree = SOURCE_ROOT; };
C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSVideoPlayer.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift"; sourceTree = SOURCE_ROOT; };
C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIDevice+featureSupport.swift"; path = "SessionUtilitiesKit/General/UIDevice+featureSupport.swift"; sourceTree = SOURCE_ROOT; };
C38EF23D255B6D66007E1867 /* UIView+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "UIView+OWS.h"; path = "SessionUtilitiesKit/General/UIView+OWS.h"; sourceTree = SOURCE_ROOT; };
C38EF23E255B6D66007E1867 /* UIView+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "UIView+OWS.m"; path = "SessionUtilitiesKit/General/UIView+OWS.m"; sourceTree = SOURCE_ROOT; };
@ -1529,7 +1532,6 @@
C38EF3DD255B6DF1007E1867 /* UIAlertController+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIAlertController+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIAlertController+OWS.swift"; sourceTree = SOURCE_ROOT; };
C38EF3E1255B6DF3007E1867 /* TappableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TappableView.swift; path = "SignalUtilitiesKit/Shared Views/TappableView.swift"; sourceTree = SOURCE_ROOT; };
C38EF3E2255B6DF3007E1867 /* GalleryRailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GalleryRailView.swift; path = "SignalUtilitiesKit/Shared Views/GalleryRailView.swift"; sourceTree = SOURCE_ROOT; };
C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = VideoPlayerView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/VideoPlayerView.swift"; sourceTree = SOURCE_ROOT; };
C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CommonStrings.swift; path = SignalUtilitiesKit/Utilities/CommonStrings.swift; sourceTree = SOURCE_ROOT; };
C38EF3E7255B6DF5007E1867 /* OWSButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSButton.swift; path = "SignalUtilitiesKit/Shared Views/OWSButton.swift"; sourceTree = SOURCE_ROOT; };
C38EF3E9255B6DF6007E1867 /* Toast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Toast.swift; path = "SignalUtilitiesKit/Shared Views/Toast.swift"; sourceTree = SOURCE_ROOT; };
@ -1635,6 +1637,14 @@
FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = "<group>"; };
FD0B77AF29B69A65009169BA /* TopBannerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBannerController.swift; sourceTree = "<group>"; };
FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUtilitiesSpec.swift; sourceTree = "<group>"; };
FD12A83C2AD63BCC00EEBA0D /* EditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableState.swift; sourceTree = "<group>"; };
FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigatable.swift; sourceTree = "<group>"; };
FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatableState.swift; sourceTree = "<group>"; };
FD12A8422AD63BF600EEBA0D /* ObservableTableSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTableSource.swift; sourceTree = "<group>"; };
FD12A8442AD63C2200EEBA0D /* TableDataState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableDataState.swift; sourceTree = "<group>"; };
FD12A8462AD63C3400EEBA0D /* PagedObservationSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedObservationSource.swift; sourceTree = "<group>"; };
FD12A8482AD63C4700EEBA0D /* SessionNavItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNavItem.swift; sourceTree = "<group>"; };
FD12A84A2AD6458800EEBA0D /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = "<group>"; };
FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfilePictureView+Convenience.swift"; sourceTree = "<group>"; };
FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = "<group>"; };
FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
@ -1759,6 +1769,7 @@
FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJob.swift; sourceTree = "<group>"; };
FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = "<group>"; };
FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddSnodeReveivedMessageInfoPrimaryKey.swift; sourceTree = "<group>"; };
FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyUnsubscribeRequest.swift; sourceTree = "<group>"; };
FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = "<group>"; };
FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = "<group>"; };
@ -1789,7 +1800,6 @@
FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCell+AccessoryView.swift"; sourceTree = "<group>"; };
FD71164928E3EA5B00B47552 /* DismissType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissType.swift; sourceTree = "<group>"; };
FD71164D28E3F8CC00B47552 /* SessionCell+Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCell+Info.swift"; sourceTree = "<group>"; };
FD71164F28E3F9FA00B47552 /* SessionTableViewModel+NavItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionTableViewModel+NavItem.swift"; sourceTree = "<group>"; };
FD71165128E410BE00B47552 /* SessionTableSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTableSection.swift; sourceTree = "<group>"; };
FD71165A28E6DDBC00B47552 /* StyledNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyledNavigationController.swift; sourceTree = "<group>"; };
FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = "<group>"; };
@ -1826,7 +1836,6 @@
FD859EF127BF6BA200510D0C /* Data+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = "<group>"; };
FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSettingsViewModel.swift; sourceTree = "<group>"; };
FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = "<group>"; };
FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactCell.swift; sourceTree = "<group>"; };
FD87DD0328B8727D00AF0F98 /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
FD8ECF7A29340FFD00C0D1BB /* SessionUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionUtil.swift; sourceTree = "<group>"; };
FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _013_SessionUtilChanges.swift; sourceTree = "<group>"; };
@ -1897,6 +1906,7 @@
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = "<group>"; };
FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = "<group>"; };
FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManagerError.swift; sourceTree = "<group>"; };
FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PhotosUI.framework; path = System/Library/Frameworks/PhotosUI.framework; sourceTree = SDKROOT; };
FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Utilities.swift"; sourceTree = "<group>"; };
FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHeaderCell.swift; sourceTree = "<group>"; };
FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_AddJobPriority.swift; sourceTree = "<group>"; };
@ -2154,6 +2164,7 @@
files = (
B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */,
B8FF8DAF25C0D00F004D1F22 /* SessionUtilitiesKit.framework in Frameworks */,
FDB6A87C2AD75B7F002D4F96 /* PhotosUI.framework in Frameworks */,
C37F54DC255BB84A002AEA92 /* SessionSnodeKit.framework in Frameworks */,
C37F5414255BAFA7002AEA92 /* SignalUtilitiesKit.framework in Frameworks */,
455A16DD1F1FEA0000F86704 /* Metal.framework in Frameworks */,
@ -2397,7 +2408,6 @@
children = (
FD716E6F28505E5100C96BF4 /* Views */,
FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */,
7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */,
);
path = "Message Requests";
sourceTree = "<group>";
@ -3129,9 +3139,7 @@
C379DCEA2567334F0002D4EB /* Attachment Approval */,
C379DCE9256733390002D4EB /* Image Editing */,
C38EF358255B6DCC007E1867 /* MediaMessageView.swift */,
C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */,
C38EF3B5255B6DE6007E1867 /* OWSViewController+ImageEditor.swift */,
C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */,
);
path = "Media Viewing & Editing";
sourceTree = "<group>";
@ -3503,6 +3511,7 @@
D221A08C169C9E5E00537ABF /* Frameworks */ = {
isa = PBXGroup;
children = (
FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */,
3496955F21A2FC8100DCFE74 /* CloudKit.framework */,
455A16DB1F1FEA0000F86704 /* Metal.framework */,
455A16DC1F1FEA0000F86704 /* MetalKit.framework */,
@ -3577,6 +3586,7 @@
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */,
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */,
FD23CE1E2A65269C0000B97C /* Crypto.swift */,
FD12A84A2AD6458800EEBA0D /* DifferenceKit+Utilities.swift */,
FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */,
FD09796A27F6C67500936362 /* Failable.swift */,
FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */,
@ -3670,6 +3680,7 @@
FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */,
FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */,
FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */,
FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -3822,7 +3833,6 @@
FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */,
FD37E9DA28A244E9003AE748 /* ThemePreviewView.swift */,
FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */,
FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */,
FD39352B28F382920084DADA /* VersionFooterView.swift */,
);
path = Views;
@ -4003,12 +4013,18 @@
isa = PBXGroup;
children = (
FD71164928E3EA5B00B47552 /* DismissType.swift */,
FD12A83C2AD63BCC00EEBA0D /* EditableState.swift */,
FD12A83E2AD63BDF00EEBA0D /* Navigatable.swift */,
FD12A8402AD63BEA00EEBA0D /* NavigatableState.swift */,
FD12A8422AD63BF600EEBA0D /* ObservableTableSource.swift */,
FD12A8442AD63C2200EEBA0D /* TableDataState.swift */,
FD12A8462AD63C3400EEBA0D /* PagedObservationSource.swift */,
FD71163328E2C48400B47552 /* TransitionType.swift */,
FD71164D28E3F8CC00B47552 /* SessionCell+Info.swift */,
FD71164328E2CB8A00B47552 /* SessionCell+Accessory.swift */,
FDF848F429413EEC007DCAE5 /* SessionCell+Styling.swift */,
FD12A8482AD63C4700EEBA0D /* SessionNavItem.swift */,
FD71165128E410BE00B47552 /* SessionTableSection.swift */,
FD71164F28E3F9FA00B47552 /* SessionTableViewModel+NavItem.swift */,
);
path = Types;
sourceTree = "<group>";
@ -5665,7 +5681,6 @@
C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */,
C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */,
C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */,
C38EF22C255B6D5D007E1867 /* OWSVideoPlayer.swift in Sources */,
C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */,
C38EF407255B6DF7007E1867 /* Toast.swift in Sources */,
C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */,
@ -5690,7 +5705,6 @@
C38EF3BB255B6DE7007E1867 /* ImageEditorStrokeItem.swift in Sources */,
C38EF3C0255B6DE7007E1867 /* ImageEditorCropViewController.swift in Sources */,
FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */,
C38EF401255B6DF7007E1867 /* VideoPlayerView.swift in Sources */,
C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */,
C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */,
C38EF324255B6DBF007E1867 /* Bench.swift in Sources */,
@ -5765,6 +5779,7 @@
FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */,
FDF8489429405C1B007DCAE5 /* SnodeAPI.swift in Sources */,
FDF848C829405C5B007DCAE5 /* ONSResolveRequest.swift in Sources */,
FD6DF00B2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift in Sources */,
C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */,
FDF848C929405C5B007DCAE5 /* SnodeRequest.swift in Sources */,
FDF848CF29405C5B007DCAE5 /* SendMessageRequest.swift in Sources */,
@ -5822,6 +5837,7 @@
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
FD1936412ACA7BD8004BCF0F /* Result+Utilities.swift in Sources */,
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
FD12A84B2AD6458800EEBA0D /* DifferenceKit+Utilities.swift in Sources */,
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */,
FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */,
@ -6124,6 +6140,7 @@
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
7BFA8AE32831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift in Sources */,
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */,
FD12A8432AD63BF600EEBA0D /* ObservableTableSource.swift in Sources */,
FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */,
7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */,
B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */,
@ -6135,6 +6152,8 @@
B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */,
454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */,
451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */,
FD12A8492AD63C4700EEBA0D /* SessionNavItem.swift in Sources */,
FD12A83D2AD63BCC00EEBA0D /* EditableState.swift in Sources */,
34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */,
FD37EA0528AA00C1003AE748 /* NotificationSettingsViewModel.swift in Sources */,
C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */,
@ -6147,6 +6166,7 @@
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */,
FD71164E28E3F8CC00B47552 /* SessionCell+Info.swift in Sources */,
B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */,
FD12A8452AD63C2200EEBA0D /* TableDataState.swift in Sources */,
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */,
7B0EFDF62755CC5400FFAAE7 /* CallMissedTipsModal.swift in Sources */,
C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */,
@ -6222,7 +6242,6 @@
FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */,
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */,
4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */,
FD87DD0028B820F200AF0F98 /* BlockedContactCell.swift in Sources */,
C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */,
7BB92B3F28C825FD0082762F /* NewConversationViewModel.swift in Sources */,
4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */,
@ -6238,6 +6257,7 @@
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */,
FD12A8412AD63BEA00EEBA0D /* NavigatableState.swift in Sources */,
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */,
FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */,
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
@ -6273,14 +6293,15 @@
C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */,
FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */,
B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */,
FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */,
B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */,
7B0EFDF0275084AA00FFAAE7 /* CallMessageCell.swift in Sources */,
7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */,
C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */,
B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */,
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
FD37E9D128A1F2EB003AE748 /* ThemeSelectionView.swift in Sources */,
FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */,
FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */,
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */,
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */,
7B2561C22978B307005C086C /* MediaInfoVC+MediaInfoView.swift in Sources */,
@ -6303,7 +6324,6 @@
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */,
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
FD71165028E3F9FA00B47552 /* SessionTableViewModel+NavItem.swift in Sources */,
FD71163828E2C50700B47552 /* SessionTableViewModel.swift in Sources */,
FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */,
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
@ -6610,7 +6630,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 427;
CURRENT_PROJECT_VERSION = 428;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6634,7 +6654,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.4.3;
MARKETING_VERSION = 2.4.4;
MTL_ENABLE_DEBUG_INFO = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -6682,7 +6702,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 427;
CURRENT_PROJECT_VERSION = 428;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6711,7 +6731,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.4.3;
MARKETING_VERSION = 2.4.4;
MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -6747,7 +6767,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 427;
CURRENT_PROJECT_VERSION = 428;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6770,7 +6790,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.4.3;
MARKETING_VERSION = 2.4.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -6821,7 +6841,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 427;
CURRENT_PROJECT_VERSION = 428;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6849,7 +6869,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.4.3;
MARKETING_VERSION = 2.4.4;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -7781,7 +7801,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 427;
CURRENT_PROJECT_VERSION = 428;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7819,7 +7839,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 2.4.3;
MARKETING_VERSION = 2.4.4;
OTHER_LDFLAGS = "$(inherited)";
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
@ -7852,7 +7872,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 427;
CURRENT_PROJECT_VERSION = 428;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7890,7 +7910,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 2.4.3;
MARKETING_VERSION = 2.4.4;
OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
PRODUCT_NAME = Session;

@ -1,6 +1,8 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import AVKit
import AVFoundation
import Combine
import CoreServices
import Photos
@ -895,7 +897,7 @@ extension ConversationVC:
}
switch cellViewModel.cellType {
case .audio: viewModel.playOrPauseAudio(for: cellViewModel)
case .voiceMessage: viewModel.playOrPauseAudio(for: cellViewModel)
case .mediaMessage:
guard
@ -945,6 +947,18 @@ extension ConversationVC:
// Ignore invalid media
guard mediaView.attachment.isValid else { return }
guard albumView.numItems > 1 || !mediaView.attachment.isVideo else {
guard
let originalFilePath: String = mediaView.attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return SNLog("Missing video file") }
let viewController: AVPlayerViewController = AVPlayerViewController()
viewController.player = AVPlayer(url: URL(fileURLWithPath: originalFilePath))
self.navigationController?.present(viewController, animated: true)
return
}
let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController(
for: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
@ -975,6 +989,17 @@ extension ConversationVC:
}
}
case .audio:
guard
let attachment: Attachment = cellViewModel.attachments?.first,
let originalFilePath: String = attachment.originalFilePath
else { return }
// Use the native player to play audio files
let viewController: AVPlayerViewController = AVPlayerViewController()
viewController.player = AVPlayer(url: URL(fileURLWithPath: originalFilePath))
self.navigationController?.present(viewController, animated: true)
case .genericAttachment:
guard
let attachment: Attachment = cellViewModel.attachments?.first,
@ -1038,7 +1063,7 @@ extension ConversationVC:
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) {
switch cellViewModel.cellType {
// The user can double tap a voice message when it's playing to speed it up
case .audio: self.viewModel.speedUpAudio(for: cellViewModel)
case .voiceMessage: self.viewModel.speedUpAudio(for: cellViewModel)
default: break
}
}
@ -1777,7 +1802,7 @@ extension ConversationVC:
UIPasteboard.general.string = cellViewModel.body
case .audio, .genericAttachment, .mediaMessage:
case .audio, .voiceMessage, .genericAttachment, .mediaMessage:
guard
cellViewModel.attachments?.count == 1,
let attachment: Attachment = cellViewModel.attachments?.first,
@ -2444,11 +2469,14 @@ extension ConversationVC {
guard threadVariant == .contact else { return }
let updateNavigationBackStack: () -> Void = {
// Remove the 'MessageRequestsViewController' from the nav hierarchy if present
// Remove the 'SessionTableViewController<MessageRequestsViewModel>' from the nav hierarchy if present
DispatchQueue.main.async { [weak self] in
if
let viewControllers: [UIViewController] = self?.navigationController?.viewControllers,
let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }),
let messageRequestsIndex = viewControllers
.firstIndex(where: { viewCon -> Bool in
(viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self
}),
messageRequestsIndex > 0
{
var newViewControllers = viewControllers

@ -497,6 +497,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
startObservingChanges()
/// If the view is removed and readded to the view hierarchy then `viewWillDisappear` will be called but `viewDidDisappear`
/// **won't**, as a result `viewIsDisappearing` would never get set to `false` - do so here to handle this case
viewIsDisappearing = false
viewIsAppearing = true
}

@ -49,11 +49,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
private var markAsReadPublisher: AnyPublisher<Void, Never>?
public lazy var blockedBannerMessage: String = {
switch self.threadData.threadVariant {
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
switch threadData.threadVariant {
case .contact:
let name: String = Profile.displayName(
id: self.threadData.threadId,
threadVariant: self.threadData.threadVariant
id: threadData.threadId,
threadVariant: threadData.threadVariant
)
return "\(name) is blocked. Unblock them?"
@ -140,16 +142,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
self.threadData = SessionThreadViewModel(
threadId: threadId,
threadVariant: threadVariant,
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
threadIsBlocked: initialData?.threadIsBlocked,
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
openGroupPermissions: initialData?.openGroupPermissions
).populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
self._threadData = Atomic(
SessionThreadViewModel(
threadId: threadId,
threadVariant: threadVariant,
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
threadIsBlocked: initialData?.threadIsBlocked,
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
openGroupPermissions: initialData?.openGroupPermissions
).populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
)
)
self.pagedDataObserver = nil
@ -179,8 +183,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Thread Data
private var _threadData: Atomic<SessionThreadViewModel>
/// This value is the current state of the view
public private(set) var threadData: SessionThreadViewModel
public var threadData: SessionThreadViewModel { _threadData.wrappedValue }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -200,6 +206,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
let oldThreadData: SessionThreadViewModel? = self?._threadData.wrappedValue
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
@ -209,8 +216,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKeys(
db,
currentUserBlinded15PublicKeyForThisThread: self?.threadData.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: self?.threadData.currentUserBlinded25PublicKey
currentUserBlinded15PublicKeyForThisThread: oldThreadData?.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: oldThreadData?.currentUserBlinded25PublicKey
)
}
}
@ -219,7 +226,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
self.threadData = updatedData
self._threadData.mutate { $0 = updatedData }
}
// MARK: - Interaction Data
@ -393,6 +400,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
optimisticMessages: [MessageViewModel]?,
initialUnreadInteractionId: Int64?
) -> [SectionModel] {
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
let sortedData: [MessageViewModel] = data
.filter { $0.id != MessageViewModel.optimisticUpdateId } // Remove old optimistic updates
@ -498,6 +506,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
) -> OptimisticMessageData {
// Generate the optimistic data
let optimisticMessageId: UUID = UUID()
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
let interaction: Interaction = Interaction(
threadId: threadData.threadId,
@ -658,7 +667,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Mentions
public func mentions(for query: String = "") -> [MentionInfo] {
let threadData: SessionThreadViewModel = self.threadData
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
return Storage.shared
.read { db -> [MentionInfo] in
@ -733,15 +742,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
.handleEvents(
receiveOutput: { [weak self] target, timestampMs in
let threadData: SessionThreadViewModel? = self?._threadData.wrappedValue
switch target {
case .thread: self?.threadData.markAsRead(target: target)
case .thread: threadData?.markAsRead(target: target)
case .threadAndInteractions(let interactionId):
guard
timestampMs == nil ||
(self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) ||
(self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0)
else {
self?.threadData.markAsRead(target: .thread)
threadData?.markAsRead(target: .thread)
return
}
@ -751,8 +762,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self?.lastInteractionTimestampMsMarkedAsRead = timestampMs
}
self?.lastInteractionIdMarkedAsRead = (interactionId ?? self?.threadData.interactionId)
self?.threadData.markAsRead(target: target)
self?.lastInteractionIdMarkedAsRead = (interactionId ?? threadData?.interactionId)
threadData?.markAsRead(target: target)
}
}
)
@ -791,7 +802,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
public func trustContact() {
guard self.threadData.threadVariant == .contact else { return }
guard self._threadData.wrappedValue.threadVariant == .contact else { return }
let threadId: String = self.threadId
@ -822,7 +833,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
public func unblockContact() {
guard self.threadData.threadVariant == .contact else { return }
guard self._threadData.wrappedValue.threadVariant == .contact else { return }
let threadId: String = self.threadId
@ -1048,7 +1059,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let currentIndex: Int = messageSection.elements
.firstIndex(where: { $0.id == interactionId }),
currentIndex < (messageSection.elements.count - 1),
messageSection.elements[currentIndex + 1].cellType == .audio,
messageSection.elements[currentIndex + 1].cellType == .voiceMessage,
Storage.shared[.shouldAutoPlayConsecutiveAudioMessages] == true
else { return }

@ -33,20 +33,35 @@ final class DocumentView: UIView {
)
imageView.setContentCompressionResistancePriority(.required, for: .horizontal)
imageView.setContentHuggingPriority(.required, for: .horizontal)
imageView.contentMode = .scaleAspectFit
imageView.themeTintColor = textColor
imageView.set(.height, to: 22)
imageView.set(.width, to: 24)
imageView.set(.height, to: 32)
if attachment.isAudio {
let audioImageView = UIImageView(
image: UIImage(systemName: "music.note")?
.withRenderingMode(.alwaysTemplate)
)
audioImageView.contentMode = .scaleAspectFit
audioImageView.themeTintColor = textColor
imageView.addSubview(audioImageView)
audioImageView.center(.horizontal, in: imageView)
audioImageView.center(.vertical, in: imageView, withInset: 4)
audioImageView.set(.height, to: .height, of: imageView, multiplier: 0.32)
}
// Body label
let titleLabel = UILabel()
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
titleLabel.text = (attachment.sourceFilename ?? "File")
titleLabel.text = attachment.documentFileName
titleLabel.themeTextColor = textColor
titleLabel.lineBreakMode = .byTruncatingTail
// Size label
let sizeLabel = UILabel()
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
sizeLabel.text = Format.fileSize(attachment.byteCount)
sizeLabel.text = attachment.documentFileInfo
sizeLabel.themeTextColor = textColor
sizeLabel.lineBreakMode = .byTruncatingTail
@ -55,14 +70,19 @@ final class DocumentView: UIView {
labelStackView.axis = .vertical
// Download image view
let downloadImageView = UIImageView(
image: UIImage(systemName: "arrow.down")?
.withRenderingMode(.alwaysTemplate)
let rightImageView = UIImageView(
image: {
switch attachment.isAudio {
case true: return UIImage(systemName: "play.fill")
case false: return UIImage(systemName: "arrow.down")
}
}()?.withRenderingMode(.alwaysTemplate)
)
downloadImageView.setContentCompressionResistancePriority(.required, for: .horizontal)
downloadImageView.setContentHuggingPriority(.required, for: .horizontal)
downloadImageView.themeTintColor = textColor
downloadImageView.set(.height, to: 16)
rightImageView.setContentCompressionResistancePriority(.required, for: .horizontal)
rightImageView.setContentHuggingPriority(.required, for: .horizontal)
rightImageView.contentMode = .scaleAspectFit
rightImageView.themeTintColor = textColor
rightImageView.set(.height, to: 24)
// Stack view
let stackView = UIStackView(
@ -70,7 +90,7 @@ final class DocumentView: UIView {
imageView,
UIView.spacer(withWidth: 0),
labelStackView,
downloadImageView
rightImageView
]
)
stackView.axis = .horizontal

@ -9,6 +9,8 @@ public class MediaAlbumView: UIStackView {
private let items: [Attachment]
public let itemViews: [MediaView]
public var moreItemsView: MediaView?
public var numItems: Int { return items.count }
public var numVisibleItems: Int { return itemViews.count }
private static let kSpacingPts: CGFloat = 4
private static let kMaxItems = 3
@ -24,13 +26,22 @@ public class MediaAlbumView: UIStackView {
isOutgoing: Bool,
maxMessageWidth: CGFloat
) {
let itemsToDisplay: [Attachment] = MediaAlbumView.itemsToDisplay(forItems: items)
self.items = items
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
.map {
self.itemViews = itemsToDisplay.enumerated()
.map { index, attachment -> MediaView in
MediaView(
mediaCache: mediaCache,
attachment: $0,
attachment: attachment,
isOutgoing: isOutgoing,
shouldSupressControls: (
// If there are extra items that aren't displayed and this is the
// last one that will be displayed then suppress any custom controls
// otherwise the '+' icon will be obscured
itemsToDisplay.count != items.count &&
(index == (itemsToDisplay.count - 1))
),
cornerRadius: VisibleMessageCell.largeCornerRadius
)
}

@ -22,6 +22,7 @@ public class MediaView: UIView {
private let mediaCache: NSCache<NSString, AnyObject>?
public let attachment: Attachment
private let isOutgoing: Bool
private let shouldSupressControls: Bool
private var loadBlock: (() -> Void)?
private var unloadBlock: (() -> Void)?
@ -51,11 +52,13 @@ public class MediaView: UIView {
mediaCache: NSCache<NSString, AnyObject>? = nil,
attachment: Attachment,
isOutgoing: Bool,
shouldSupressControls: Bool,
cornerRadius: CGFloat
) {
self.mediaCache = mediaCache
self.attachment = attachment
self.isOutgoing = isOutgoing
self.shouldSupressControls = shouldSupressControls
super.init(frame: .zero)
@ -275,7 +278,29 @@ public class MediaView: UIView {
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
if !addUploadProgressIfNecessary(stillImageView) {
if !addUploadProgressIfNecessary(stillImageView) && !shouldSupressControls {
if let duration: TimeInterval = attachment.duration {
let fadeView: GradientView = GradientView()
fadeView.themeBackgroundGradient = [
.value(.black, alpha: 0),
.value(.black, alpha: 0.4)
]
stillImageView.addSubview(fadeView)
fadeView.set(.height, to: 40)
fadeView.pin(.leading, to: .leading, of: stillImageView)
fadeView.pin(.trailing, to: .trailing, of: stillImageView)
fadeView.pin(.bottom, to: .bottom, of: stillImageView)
let durationLabel: UILabel = UILabel()
durationLabel.font = .systemFont(ofSize: Values.smallFontSize)
durationLabel.text = Format.duration(duration)
durationLabel.themeTextColor = .white
stillImageView.addSubview(durationLabel)
durationLabel.pin(.trailing, to: .trailing, of: stillImageView, withInset: -Values.smallSpacing)
durationLabel.pin(.bottom, to: .bottom, of: stillImageView, withInset: -Values.smallSpacing)
}
// Add the play button above the duration label and fade
let videoPlayIcon = UIImage(named: "CirclePlay")
let videoPlayButton = UIImageView(image: videoPlayIcon)
videoPlayButton.set(.width, to: 72)

@ -611,7 +611,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
unloadContent = { albumView.unloadMedia() }
case .audio:
case .voiceMessage:
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
return
}
@ -630,7 +630,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
snContentView.addArrangedSubview(bubbleBackgroundView)
self.voiceMessageView = voiceMessageView
case .genericAttachment:
case .audio, .genericAttachment:
guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() }
let inset: CGFloat = 12
@ -741,7 +741,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}
switch cellViewModel.cellType {
case .audio:
case .voiceMessage:
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
return
}

@ -9,27 +9,14 @@ import SessionMessagingKit
import SessionUtilitiesKit
import SessionSnodeKit
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadDisappearingMessagesSettingsViewModel.NavButton, ThreadDisappearingMessagesSettingsViewModel.Section, ThreadDisappearingMessagesSettingsViewModel.Item> {
// MARK: - Config
enum NavButton: Equatable {
case cancel
case save
}
public enum Section: SessionTableSection {
case content
}
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = String
public struct Item: Equatable, Hashable, Differentiable {
let title: String
public var differenceIdentifier: String { title }
}
// MARK: - Variables
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let dependencies: Dependencies
private let threadId: String
private let threadVariant: SessionThread.Variant
private let config: DisappearingMessagesConfiguration
@ -52,65 +39,65 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
self.currentSelection = CurrentValueSubject(self.storedSelection)
}
// MARK: - Navigation
// MARK: - Config
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
Just([
NavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in self?.dismissScreen() }
]).eraseToAnyPublisher()
enum NavItem: Equatable {
case cancel
case save
}
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
currentSelection
.removeDuplicates()
.map { [weak self] currentSelection in (self?.storedSelection != currentSelection) }
.map { [weak self, dependencies] isChanged in
guard isChanged else { return [] }
return [
NavItem(
id: .save,
systemItem: .save,
accessibilityIdentifier: "Save button"
) {
self?.saveChanges(using: dependencies)
self?.dismissScreen()
}
]
}
.eraseToAnyPublisher()
public enum Section: SessionTableSection {
case content
}
// MARK: - Content
// MARK: - Navigation
override var title: String { "DISAPPEARING_MESSAGES".localized() }
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in self?.dismissScreen() }
]
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = currentSelection
.removeDuplicates()
.map { [weak self] currentSelection in (self?.storedSelection != currentSelection) }
.map { [weak self, dependencies] isChanged in
guard isChanged else { return [] }
return [
SessionNavItem(
id: .save,
systemItem: .save,
accessibilityIdentifier: "Save button"
) {
self?.saveChanges(using: dependencies)
self?.dismissScreen()
}
]
}
.eraseToAnyPublisher()
// MARK: - Content
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "DISAPPEARING_MESSAGES".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, config, dependencies, threadId = self.threadId] db -> [SectionModel] in
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [dependencies, threadId = self.threadId] db -> SessionThreadViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies)
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
return try SessionThreadViewModel
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
}
.map { [weak self, config, dependencies, threadId = self.threadId] maybeThreadViewModel -> [SectionModel] in
return [
SectionModel(
model: .content,
elements: [
SessionCell.Info(
id: Item(title: "DISAPPEARING_MESSAGES_OFF".localized()),
id: "DISAPPEARING_MESSAGES_OFF".localized(),
title: "DISAPPEARING_MESSAGES_OFF".localized(),
rightAccessory: .radio(
isSelected: { (self?.currentSelection.value == 0) }
@ -130,7 +117,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
let title: String = duration.formatted(format: .long)
return SessionCell.Info(
id: Item(title: title),
id: title,
title: title,
rightAccessory: .radio(
isSelected: { (self?.currentSelection.value == duration) }
@ -149,10 +136,6 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
)
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadDisappearingMessageSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)
// MARK: - Functions

@ -11,7 +11,43 @@ import SignalUtilitiesKit
import SessionUtilitiesKit
import SessionSnodeKit
class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.NavButton, ThreadSettingsViewModel.Section, ThreadSettingsViewModel.Setting> {
class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, EditableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let editableState: EditableState<TableItem> = EditableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let threadId: String
private let threadVariant: SessionThread.Variant
private let didTriggerSearch: () -> ()
private var oldDisplayName: String?
private var editedDisplayName: String?
// MARK: - Initialization
init(
threadId: String,
threadVariant: SessionThread.Variant,
didTriggerSearch: @escaping () -> (),
using dependencies: Dependencies = Dependencies()
) {
self.dependencies = dependencies
self.threadId = threadId
self.threadVariant = threadVariant
self.didTriggerSearch = didTriggerSearch
self.oldDisplayName = (threadVariant != .contact ?
nil :
dependencies.storage.read { db in
try Profile
.filter(id: threadId)
.select(.nickname)
.asRequest(of: String.self)
.fetchOne(db)
}
)
}
// MARK: - Config
enum NavState {
@ -19,7 +55,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
case editing
}
enum NavButton: Equatable {
enum NavItem: Equatable {
case edit
case cancel
case done
@ -30,7 +66,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
case content
}
public enum Setting: Differentiable {
public enum TableItem: Differentiable {
case avatar
case nickname
case sessionId
@ -49,39 +85,6 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
case blockUser
}
// MARK: - Variables
private let dependencies: Dependencies
private let threadId: String
private let threadVariant: SessionThread.Variant
private let didTriggerSearch: () -> ()
private var oldDisplayName: String?
private var editedDisplayName: String?
// MARK: - Initialization
init(
threadId: String,
threadVariant: SessionThread.Variant,
didTriggerSearch: @escaping () -> (),
using dependencies: Dependencies = Dependencies()
) {
self.dependencies = dependencies
self.threadId = threadId
self.threadVariant = threadVariant
self.didTriggerSearch = didTriggerSearch
self.oldDisplayName = (threadVariant != .contact ?
nil :
dependencies.storage.read { db in
try Profile
.filter(id: threadId)
.select(.nickname)
.asRequest(of: String.self)
.fetchOne(db)
}
)
}
// MARK: - Navigation
lazy var navState: AnyPublisher<NavState, Never> = {
@ -104,113 +107,97 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.eraseToAnyPublisher()
}()
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
navState
.map { [weak self] navState -> [NavItem] in
// Only show the 'Edit' button if it's a contact thread
guard self?.threadVariant == .contact else { return [] }
guard navState == .editing else { return [] }
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { [weak self] navState -> [SessionNavItem<NavItem>] in
// Only show the 'Edit' button if it's a contact thread
guard self?.threadVariant == .contact else { return [] }
guard navState == .editing else { return [] }
return [
NavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
.eraseToAnyPublisher()
}
return [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
.eraseToAnyPublisher()
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
navState
.map { [weak self, dependencies] navState -> [NavItem] in
// Only show the 'Edit' button if it's a contact thread
guard self?.threadVariant == .contact else { return [] }
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { [weak self, dependencies] navState -> [SessionNavItem<NavItem>] in
// Only show the 'Edit' button if it's a contact thread
guard self?.threadVariant == .contact else { return [] }
switch navState {
case .editing:
return [
NavItem(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
) { [weak self] in
self?.setIsEditing(false)
guard
self?.threadVariant == .contact,
let threadId: String = self?.threadId,
let editedDisplayName: String = self?.editedDisplayName
else { return }
let updatedNickname: String = editedDisplayName
.trimmingCharacters(in: .whitespacesAndNewlines)
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
switch navState {
case .editing:
return [
SessionNavItem(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
) { [weak self] in
self?.setIsEditing(false)
guard
self?.threadVariant == .contact,
let threadId: String = self?.threadId,
let editedDisplayName: String = self?.editedDisplayName
else { return }
let updatedNickname: String = editedDisplayName
.trimmingCharacters(in: .whitespacesAndNewlines)
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
dependencies.storage.writeAsync(using: dependencies) { db in
try Profile
.filter(id: threadId)
.updateAllAndConfig(
db,
Profile.Columns.nickname
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
)
}
}
]
dependencies.storage.writeAsync(using: dependencies) { db in
try Profile
.filter(id: threadId)
.updateAllAndConfig(
db,
Profile.Columns.nickname
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
)
}
}
]
case .standard:
return [
NavItem(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button",
accessibilityLabel: "Edit user nickname"
) { [weak self] in self?.setIsEditing(true) }
]
}
}
.eraseToAnyPublisher()
}
case .standard:
return [
SessionNavItem(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button",
accessibilityLabel: "Edit user nickname"
) { [weak self] in self?.setIsEditing(true) }
]
}
}
.eraseToAnyPublisher()
// MARK: - Content
private var originalState: SessionThreadViewModel?
override var title: String {
private struct State: Equatable {
let threadViewModel: SessionThreadViewModel?
let notificationSound: Preferences.Sound
let disappearingMessagesConfig: DisappearingMessagesConfiguration
}
var title: String {
switch threadVariant {
case .contact: return "vc_settings_title".localized()
case .legacyGroup, .group, .community: return "vc_group_settings_title".localized()
}
}
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in
let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies)
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
// If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted
// so dismiss the screen
guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else {
self?.dismissScreen(type: .popToRoot)
return []
}
// Additional Queries
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
.defaulting(to: Preferences.Sound.defaultNotificationSound)
let notificationSound: Preferences.Sound = try SessionThread
@ -222,23 +209,36 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
.fetchOne(db, id: threadId)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
return State(
threadViewModel: threadViewModel,
notificationSound: notificationSound,
disappearingMessagesConfig: disappearingMessagesConfig
)
}
.mapWithPrevious { [weak self, dependencies] previous, current -> [SectionModel] in
// If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted
// so dismiss the screen
guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else {
self?.dismissScreen(type: .popToRoot)
return []
}
let currentUserIsClosedGroupMember: Bool = (
(
threadVariant == .legacyGroup ||
threadVariant == .group
threadViewModel.threadVariant == .legacyGroup ||
threadViewModel.threadVariant == .group
) &&
threadViewModel.currentUserIsClosedGroupMember == true
)
let currentUserIsClosedGroupAdmin: Bool = (
(
threadVariant == .legacyGroup ||
threadVariant == .group
threadViewModel.threadVariant == .legacyGroup ||
threadViewModel.threadVariant == .group
) &&
threadViewModel.currentUserIsClosedGroupAdmin == true
)
let editIcon: UIImage? = UIImage(named: "icon_edit")
let originalState: SessionThreadViewModel = (self?.originalState ?? threadViewModel)
self?.originalState = threadViewModel
return [
SectionModel(
@ -249,7 +249,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
accessory: .profile(
id: threadViewModel.id,
size: .hero,
threadVariant: threadVariant,
threadVariant: threadViewModel.threadVariant,
customImageData: threadViewModel.openGroupProfilePictureData,
profile: threadViewModel.profile,
profileIcon: .none,
@ -266,7 +266,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
),
SessionCell.Info(
id: .nickname,
leftAccessory: (threadVariant != .contact ? nil :
leftAccessory: (threadViewModel.threadVariant != .contact ? nil :
.icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .fit,
@ -278,17 +278,17 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
font: .titleLarge,
alignment: .center,
editingPlaceholder: "CONTACT_NICKNAME_PLACEHOLDER".localized(),
interaction: (threadVariant == .contact ? .editable : .none)
interaction: (threadViewModel.threadVariant == .contact ? .editable : .none)
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
trailing: (threadVariant != .contact ?
trailing: (threadViewModel.threadVariant != .contact ?
nil :
-(((editIcon?.size.width ?? 0) + (Values.smallSpacing * 2)) / 2)
),
bottom: (threadVariant != .contact ?
bottom: (threadViewModel.threadVariant != .contact ?
nil :
Values.smallSpacing
),
@ -306,7 +306,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
),
(threadVariant != .contact ? nil :
(threadViewModel.threadVariant != .contact ? nil :
SessionCell.Info(
id: .sessionId,
subtitle: SessionCell.TextInfo(
@ -333,14 +333,14 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
SectionModel(
model: .content,
elements: [
(threadVariant == .legacyGroup || threadVariant == .group ? nil :
(threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil :
SessionCell.Info(
id: .copyThreadId,
leftAccessory: .icon(
UIImage(named: "ic_copy")?
.withRenderingMode(.alwaysTemplate)
),
title: (threadVariant == .community ?
title: (threadViewModel.threadVariant == .community ?
"COPY_GROUP_URL".localized() :
"vc_conversation_settings_copy_session_id_button_title".localized()
),
@ -349,9 +349,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
label: "Copy Session ID"
),
onTap: {
switch threadVariant {
switch threadViewModel.threadVariant {
case .contact, .legacyGroup, .group:
UIPasteboard.general.string = threadId
UIPasteboard.general.string = threadViewModel.threadId
case .community:
guard
@ -389,8 +389,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
onTap: { [weak self] in
self?.transitionToScreen(
MediaGalleryViewModel.createAllMediaViewController(
threadId: threadId,
threadVariant: threadVariant,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
focusedAttachmentId: nil
)
)
@ -413,7 +413,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
),
(threadVariant != .community ? nil :
(threadViewModel.threadVariant != .community ? nil :
SessionCell.Info(
id: .addToOpenGroup,
leftAccessory: .icon(
@ -440,12 +440,12 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
),
(threadVariant == .community || threadViewModel.threadIsBlocked == true ? nil :
(threadViewModel.threadVariant == .community || threadViewModel.threadIsBlocked == true ? nil :
SessionCell.Info(
id: .disappearingMessages,
leftAccessory: .icon(
UIImage(
named: (disappearingMessagesConfig.isEnabled ?
named: (current.disappearingMessagesConfig.isEnabled ?
"ic_timer" :
"ic_timer_disabled"
)
@ -455,10 +455,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
),
title: "DISAPPEARING_MESSAGES".localized(),
subtitle: (disappearingMessagesConfig.isEnabled ?
subtitle: (current.disappearingMessagesConfig.isEnabled ?
String(
format: "DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER".localized(),
arguments: [disappearingMessagesConfig.durationString]
arguments: [current.disappearingMessagesConfig.durationString]
) :
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
),
@ -470,9 +470,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
self?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: threadId,
threadVariant: threadVariant,
config: disappearingMessagesConfig
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
config: current.disappearingMessagesConfig
)
)
)
@ -494,7 +494,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
),
onTap: { [weak self] in
self?.transitionToScreen(
EditClosedGroupVC(threadId: threadId, threadVariant: threadVariant)
EditClosedGroupVC(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
)
}
)
@ -540,8 +543,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
dependencies.storage.write { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadId,
threadVariant: threadVariant,
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
groupLeaveType: .standard,
calledFromConfigHandling: false
)
@ -559,19 +562,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
),
title: "SETTINGS_ITEM_NOTIFICATION_SOUND".localized(),
rightAccessory: .dropDown(
.dynamicString { notificationSound.displayName }
.dynamicString { current.notificationSound.displayName }
),
onTap: { [weak self] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: NotificationSoundViewModel(threadId: threadId)
viewModel: NotificationSoundViewModel(threadId: threadViewModel.threadId)
)
)
}
)
),
(threadVariant == .contact ? nil :
(threadViewModel.threadVariant == .contact ? nil :
SessionCell.Info(
id: .notificationMentionsOnly,
leftAccessory: .icon(
@ -583,7 +586,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
rightAccessory: .toggle(
.boolValue(
threadViewModel.threadOnlyNotifyForMentions == true,
oldValue: (originalState.threadOnlyNotifyForMentions == true)
oldValue: ((previous?.threadViewModel ?? threadViewModel).threadOnlyNotifyForMentions == true)
)
),
isEnabled: (
@ -602,7 +605,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
dependencies.storage.writeAsync { db in
try SessionThread
.filter(id: threadId)
.filter(id: threadViewModel.threadId)
.updateAll(
db,
SessionThread.Columns.onlyNotifyForMentions
@ -624,7 +627,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
rightAccessory: .toggle(
.boolValue(
threadViewModel.threadMutedUntilTimestamp != nil,
oldValue: (originalState.threadMutedUntilTimestamp != nil)
oldValue: ((previous?.threadViewModel ?? threadViewModel).threadMutedUntilTimestamp != nil)
)
),
isEnabled: (
@ -641,13 +644,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
onTap: {
dependencies.storage.writeAsync { db in
let currentValue: TimeInterval? = try SessionThread
.filter(id: threadId)
.filter(id: threadViewModel.threadId)
.select(.mutedUntilTimestamp)
.asRequest(of: TimeInterval.self)
.fetchOne(db)
try SessionThread
.filter(id: threadId)
.filter(id: threadViewModel.threadId)
.updateAll(
db,
SessionThread.Columns.mutedUntilTimestamp.set(
@ -662,7 +665,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
),
(threadViewModel.threadIsNoteToSelf || threadVariant != .contact ? nil :
(threadViewModel.threadIsNoteToSelf || threadViewModel.threadVariant != .contact ? nil :
SessionCell.Info(
id: .blockUser,
leftAccessory: .icon(
@ -673,7 +676,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
rightAccessory: .toggle(
.boolValue(
threadViewModel.threadIsBlocked == true,
oldValue: (originalState.threadIsBlocked == true)
oldValue: ((previous?.threadViewModel ?? threadViewModel).threadIsBlocked == true)
)
),
accessibility: Accessibility(
@ -711,7 +714,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
self?.updateBlockedState(
from: isBlocked,
isBlocked: !isBlocked,
threadId: threadId,
threadId: threadViewModel.threadId,
displayName: threadViewModel.displayName
)
}
@ -721,10 +724,6 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)
// MARK: - Functions

@ -617,7 +617,9 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
switch section.model {
case .messageRequests:
let viewController: MessageRequestsViewController = MessageRequestsViewController()
let viewController: SessionTableViewController = SessionTableViewController(
viewModel: MessageRequestsViewModel()
)
self.navigationController?.pushViewController(viewController, animated: true)
case .threads:
@ -776,7 +778,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
let finalViewControllers: [UIViewController] = [
self,
(isMessageRequest ? MessageRequestsViewController() : nil),
(isMessageRequest ? SessionTableViewController(viewModel: MessageRequestsViewModel()) : nil),
ConversationVC(
threadId: threadId,
threadVariant: variant,

@ -1,486 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
import SessionUtilitiesKit
class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
private static let loadingHeaderHeight: CGFloat = 40
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
// MARK: - SessionUtilRespondingViewController
let isConversationList: Bool = true
// MARK: - Intialization
init() {
Storage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
private lazy var loadingConversationsLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "LOADING_CONVERSATIONS".localized()
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
return result
}()
private lazy var tableView: UITableView = {
let result: UITableView = UITableView()
result.translatesAutoresizingMaskIntoConstraints = false
result.separatorStyle = .none
result.themeBackgroundColor = .clear
result.showsVerticalScrollIndicator = false
result.contentInset = UIEdgeInsets(
top: 0,
left: 0,
bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
right: 0
)
result.register(view: FullConversationCell.self)
result.dataSource = self
result.delegate = self
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result
}()
private lazy var emptyStateLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "MESSAGE_REQUESTS_EMPTY_TEXT".localized()
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
result.isHidden = true
return result
}()
private lazy var fadeView: GradientView = {
let result: GradientView = GradientView()
result.themeBackgroundGradient = [
.value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
.backgroundPrimary,
.backgroundPrimary,
.backgroundPrimary,
.backgroundPrimary
]
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
return result
}()
private lazy var clearAllButton: SessionButton = {
let result: SessionButton = SessionButton(style: .destructive, size: .large)
result.translatesAutoresizingMaskIntoConstraints = false
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
result.accessibilityIdentifier = "Clear all"
return result
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: "MESSAGE_REQUESTS_TITLE".localized(),
hasCustomBackButton: false
)
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
// the dataSource has the correct data)
view.addSubview(loadingConversationsLabel)
view.addSubview(tableView)
view.addSubview(emptyStateLabel)
view.addSubview(fadeView)
view.addSubview(clearAllButton)
setupLayout()
// Notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidResignActive(_:)),
name: UIApplication.didEnterBackgroundNotification, object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startObservingChanges()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.viewHasAppeared = true
self.autoLoadNextPageIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
DispatchQueue.main.async { [weak self] in
self?.startObservingChanges(didReturnFromBackground: true)
}
}
@objc func applicationDidResignActive(_ notification: Notification) {
stopObservingChanges()
}
// MARK: - Layout
private func setupLayout() {
NSLayoutConstraint.activate([
loadingConversationsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.veryLargeSpacing),
loadingConversationsLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.massiveSpacing),
loadingConversationsLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.massiveSpacing),
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
clearAllButton.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -Values.smallSpacing
),
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
])
}
// MARK: - Updating
private func startObservingChanges(didReturnFromBackground: Bool = false) {
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
}
// Note: When returning from the background we could have received notifications but the
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date
if didReturnFromBackground {
self.viewModel.pagedDataObserver?.reload()
}
}
private func stopObservingChanges() {
self.viewModel.onThreadChange = nil
}
private func handleThreadUpdates(
_ updatedData: [MessageRequestsViewModel.SectionModel],
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
initialLoad: Bool = false
) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else {
UIView.performWithoutAnimation {
// Hide the 'loading conversations' label (now that we have received conversation data)
loadingConversationsLabel.isHidden = true
// Show the empty state if there is no data
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
emptyStateLabel.isHidden = !clearAllButton.isHidden
// Update the content
viewModel.updateThreadData(updatedData)
tableView.reloadData()
hasLoadedInitialThreadData = true
}
return
}
// Hide the 'loading conversations' label (now that we have received conversation data)
loadingConversationsLabel.isHidden = true
// Show the empty state if there is no data
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
emptyStateLabel.isHidden = !clearAllButton.isHidden
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
// Complete page loading
self?.isLoadingMore = false
self?.autoLoadNextPageIfNeeded()
}
// Reload the table content (animate changes after the first load)
tableView.reload(
using: changeset,
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .top,
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateThreadData(updatedData)
}
CATransaction.commit()
}
private func autoLoadNextPageIfNeeded() {
guard
self.hasLoadedInitialThreadData &&
!self.isAutoLoadingNextPage &&
!self.isLoadingMore
else { return }
self.isAutoLoadingNextPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
self?.isAutoLoadingNextPage = false
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
.enumerated()
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
.defaulting(to: [])
let shouldLoadMore: Bool = sections
.contains { section, headerRect in
section == .loadMore &&
headerRect != .zero &&
(self?.tableView.bounds.contains(headerRect) == true)
}
guard shouldLoadMore else { return }
self?.isLoadingMore = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
}
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.threadData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.accessibilityIdentifier = "Message request"
cell.isAccessibilityElement = true
cell.update(with: threadViewModel)
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.themeTintColor = .textPrimary
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
default: return nil
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
default: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
switch section.model {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
default: break
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let conversationVC: ConversationVC = ConversationVC(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self.navigationController?.pushViewController(conversationVC, animated: true)
default: break
}
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
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:
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(threadViewModel.threadVariant != .contact ? nil : .block),
.delete
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
default: return nil
}
}
// MARK: - Interaction
@objc private func clearAllTapped() {
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
return
}
let contactThreadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }?
.elements
.filter { $0.threadVariant == .contact }
.map { $0.threadId })
.defaulting(to: [])
let groupThreadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }?
.elements
.filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group }
.map { $0.threadId })
.defaulting(to: [])
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
style: .destructive
) { _ in
MessageRequestsViewModel.clearAllRequests(
contactThreadIds: contactThreadIds,
groupThreadIds: groupThreadIds
)
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
self.present(alertVC, animated: true, completion: nil)
}
}

@ -1,35 +1,37 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionUIKit
import SessionUtilitiesKit
import SignalUtilitiesKit
public class MessageRequestsViewModel {
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
// MARK: - Section
public enum Section: Differentiable {
case threads
case loadMore
}
class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource {
typealias TableItem = SessionThreadViewModel
typealias PagedTable = SessionThread
typealias PagedDataModel = SessionThreadViewModel
// MARK: - Variables
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
public let dependencies: Dependencies
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, SessionThreadViewModel> = ObservableTableSourceState()
public let navigatableState: NavigatableState = NavigatableState()
// MARK: - Initialization
init() {
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = getUserHexEncodedPublicKey()
let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies)
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,
@ -101,14 +103,8 @@ public class MessageRequestsViewModel {
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
PagedData.processAndTriggerUpdates(
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
currentDataRetriever: { self?.threadData },
onDataChange: self?.onThreadChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
currentDataRetriever: { self?.tableData },
valueSubject: self?.pendingTableDataSubject
)
}
)
@ -120,35 +116,35 @@ public class MessageRequestsViewModel {
}
}
// MARK: - Thread Data
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
// MARK: - Section
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet {
// 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 changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
switch Thread.isMainThread {
case true: performChange?(changes.0, changes.1)
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
}
self.unobservedThreadDataChanges = nil
public enum Section: SessionTableSection {
case threads
case loadMore
var style: SessionTableSectionStyle {
switch self {
case .threads: return .none
case .loadMore: return .loadMore
}
}
}
// MARK: - Content
public let title: String = "MESSAGE_REQUESTS_TITLE".localized()
public let initialLoadMessage: String? = "LOADING_CONVERSATIONS".localized()
public let emptyStateTextPublisher: AnyPublisher<String?, Never> = Just("MESSAGE_REQUESTS_EMPTY_TEXT".localized())
.eraseToAnyPublisher()
public let cellType: SessionTableViewCellType = .fullConversation
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
let groupedOldData: [String: [SessionCell.Info<SessionThreadViewModel>]] = (self.tableData
.first(where: { $0.model == .threads })?
.elements)
.defaulting(to: [])
.grouped(by: \.threadId)
.grouped(by: \.id.threadId)
return [
[
@ -156,14 +152,28 @@ public class MessageRequestsViewModel {
section: .threads,
elements: data
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlinded25PublicKey
.map { viewModel -> SessionCell.Info<SessionThreadViewModel> in
SessionCell.Info(
id: viewModel.populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.id
.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.id
.currentUserBlinded25PublicKey
),
accessibility: Accessibility(
identifier: "Message request"
),
onTap: { [weak self] in
let viewController: ConversationVC = ConversationVC(
threadId: viewModel.threadId,
threadVariant: viewModel.threadVariant
)
self?.transitionToScreen(viewController, transitionType: .push)
}
)
}
)
@ -175,35 +185,100 @@ public class MessageRequestsViewModel {
].flatMap { $0 }
}
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
lazy var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> = observableState
.pendingTableDataSubject
.map { [dependencies] (currentThreadData: [SectionModel], _: StagedChangeset<[SectionModel]>) in
let threadInfo: [(id: String, variant: SessionThread.Variant)] = (currentThreadData
.first(where: { $0.model == .threads })?
.elements
.map { ($0.id.id, $0.id.threadVariant) })
.defaulting(to: [])
return SessionButton.Info(
style: .destructive,
title: "MESSAGE_REQUESTS_CLEAR_ALL".localized(),
isEnabled: !threadInfo.isEmpty,
accessibility: Accessibility(
identifier: "Clear all"
),
onTap: { [weak self] in
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
accessibility: Accessibility(
identifier: "Clear all"
),
confirmTitle: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
confirmAccessibility: Accessibility(
identifier: "Clear"
),
confirmStyle: .danger,
cancelStyle: .alert_text,
onConfirm: { _ in
// Clear the requests
dependencies.storage.write { db in
// Remove the one-to-one requests
try SessionThread.deleteOrLeave(
db,
threadIds: threadInfo
.filter { _, variant in variant == .contact }
.map { id, _ in id },
threadVariant: .contact,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
// Remove the group requests
try SessionThread.deleteOrLeave(
db,
threadIds: threadInfo
.filter { _, variant in variant == .legacyGroup || variant == .group }
.map { id, _ in id },
threadVariant: .group,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
}
}
)
)
self?.transitionToScreen(modal, transitionType: .present)
}
)
}
.eraseToAnyPublisher()
// MARK: - Functions
static func clearAllRequests(
contactThreadIds: [String],
groupThreadIds: [String]
) {
// Clear the requests
Storage.shared.write { db in
// Remove the one-to-one requests
try SessionThread.deleteOrLeave(
db,
threadIds: contactThreadIds,
threadVariant: .contact,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
// Remove the group requests
try SessionThread.deleteOrLeave(
db,
threadIds: groupThreadIds,
threadVariant: .group,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
func canEditRow(at indexPath: IndexPath) -> Bool {
let section: SectionModel = tableData[indexPath.section]
return (section.model == .threads)
}
func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? {
let section: SectionModel = tableData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row].id
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(threadViewModel.threadVariant != .contact ? nil : .block),
.delete
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: viewController
)
)
default: return nil
}
}
}

@ -695,7 +695,7 @@ private final class ScanQRCodePlaceholderVC: UIViewController {
// Set up call to action button
let callToActionButton = UIButton()
callToActionButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
callToActionButton.setTitle("vc_scan_qr_code_grant_camera_access_button_title".localized(), for: UIControl.State.normal)
callToActionButton.setTitle("continue_2".localized(), for: .normal)
callToActionButton.setThemeTitleColor(.primary, for: .normal)
callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: UIControl.Event.touchUpInside)

@ -376,10 +376,17 @@ class DocumentCell: UITableViewCell {
// MARK: - UI
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
private let iconImageView: UIImageView = {
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "File").withRenderingMode(.alwaysTemplate))
let result: UIImageView = UIImageView(image: UIImage(systemName: "doc")?.withRenderingMode(.alwaysTemplate))
result.translatesAutoresizingMaskIntoConstraints = false
result.themeTintColor = .textPrimary
result.contentMode = .scaleAspectFit
return result
}()
private let audioImageView: UIImageView = {
let result = UIImageView(image: UIImage(systemName: "music.note")?.withRenderingMode(.alwaysTemplate))
result.translatesAutoresizingMaskIntoConstraints = false
result.themeTintColor = .textPrimary
result.contentMode = .scaleAspectFit
@ -439,6 +446,8 @@ class DocumentCell: UITableViewCell {
contentView.addSubview(titleLabel)
contentView.addSubview(timeLabel)
contentView.addSubview(detailLabel)
iconImageView.addSubview(audioImageView)
}
// MARK: - Layout
@ -458,6 +467,8 @@ class DocumentCell: UITableViewCell {
lessThanOrEqualTo: contentView.bottomAnchor,
constant: -(Values.verySmallSpacing + Values.verySmallSpacing)
),
iconImageView.widthAnchor.constraint(equalToConstant: 36),
iconImageView.heightAnchor.constraint(equalToConstant: 46),
titleLabel.topAnchor.constraint(
equalTo: contentView.topAnchor,
@ -485,6 +496,10 @@ class DocumentCell: UITableViewCell {
lessThanOrEqualTo: contentView.bottomAnchor,
constant: -(Values.verySmallSpacing + Values.smallSpacing)
),
audioImageView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor),
audioImageView.centerYAnchor.constraint(equalTo: iconImageView.centerYAnchor, constant: 7),
audioImageView.heightAnchor.constraint(equalTo: iconImageView.heightAnchor, multiplier: 0.32)
])
}
@ -504,11 +519,12 @@ class DocumentCell: UITableViewCell {
func update(with item: MediaGalleryViewModel.Item) {
let attachment = item.attachment
titleLabel.text = (attachment.sourceFilename ?? "File")
detailLabel.text = "\(Format.fileSize(attachment.byteCount)))"
titleLabel.text = attachment.documentFileName
detailLabel.text = attachment.documentFileInfo
timeLabel.text = Date(
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
).formattedForDisplay
audioImageView.isHidden = !attachment.isAudio
}
}

@ -3,6 +3,7 @@
import Foundation
import Combine
import Photos
import PhotosUI
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit

@ -1,6 +1,8 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import AVKit
import AVFoundation
import YYImage
import SessionUIKit
import SignalUtilitiesKit
@ -13,7 +15,7 @@ public enum MediaGalleryOption {
case showAllMediaButton
}
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate {
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate {
public let galleryItem: MediaGalleryViewModel.Item
public weak var delegate: MediaDetailViewControllerDelegate?
private var image: UIImage?
@ -37,9 +39,19 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
}()
public var mediaView: UIView = UIView()
private var playVideoButton: UIButton = UIButton()
private var videoProgressBar: PlayerProgressBar = PlayerProgressBar()
private var videoPlayer: OWSVideoPlayer?
private lazy var playVideoButton: UIButton = {
let result: UIButton = UIButton()
result.contentMode = .scaleAspectFill
result.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
result.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
result.alpha = 0
let playButtonSize: CGFloat = ScaleFromIPhone5(70)
result.set(.width, to: playButtonSize)
result.set(.height, to: playButtonSize)
return result
}()
// MARK: - Initialization
@ -86,10 +98,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
fatalError("init(coder:) has not been implemented")
}
deinit {
self.stopAnyVideo()
}
// MARK: - Lifecycle
override func viewDidLoad() {
@ -98,7 +106,10 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
self.view.themeBackgroundColor = .newConversation_background
self.view.addSubview(scrollView)
self.view.addSubview(playVideoButton)
scrollView.pin(to: self.view)
playVideoButton.center(in: self.view)
self.updateContents()
}
@ -112,12 +123,18 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if self.parent == nil || !(self.parent is MediaPageViewController) {
parentDidAppear()
}
}
public func parentDidAppear() {
if mediaView is YYAnimatedImageView {
// Add a slight delay before starting the gif animation to prevent it from looking
// buggy due to the custom transition
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
(self?.mediaView as? YYAnimatedImageView)?.startAnimating()
}
(mediaView as? YYAnimatedImageView)?.startAnimating()
}
if self.galleryItem.attachment.isVideo {
UIView.animate(withDuration: 0.2) { self.playVideoButton.alpha = 1 }
}
}
@ -128,6 +145,12 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
self.centerMediaViewConstraints()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
UIView.animate(withDuration: 0.15) { [weak playVideoButton] in playVideoButton?.alpha = 0 }
}
// MARK: - Functions
private func updateMinZoomScale() {
@ -174,8 +197,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
private func updateContents() {
self.mediaView.removeFromSuperview()
self.playVideoButton.removeFromSuperview()
self.videoProgressBar.removeFromSuperview()
self.scrollView.zoomScale = 1
if self.galleryItem.attachment.isAnimated {
@ -195,15 +216,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
self.mediaView = UIView()
self.mediaView.themeBackgroundColor = .newConversation_background
}
else if self.galleryItem.attachment.isVideo {
if self.galleryItem.attachment.isValid {
self.mediaView = self.buildVideoPlayerView()
}
else {
self.mediaView = UIView()
self.mediaView.themeBackgroundColor = .newConversation_background
}
}
else {
// Present the static image using standard UIImageView
self.mediaView = UIImageView(image: self.image)
@ -230,61 +242,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
// some performance cost.
self.mediaView.layer.minificationFilter = .trilinear
self.mediaView.layer.magnificationFilter = .trilinear
if self.galleryItem.attachment.isVideo {
self.videoProgressBar = PlayerProgressBar()
self.videoProgressBar.delegate = self
self.videoProgressBar.player = self.videoPlayer?.avPlayer
// We hide the progress bar until either:
// 1. Video completes playing
// 2. User taps the screen
self.videoProgressBar.isHidden = false
self.view.addSubview(self.videoProgressBar)
self.videoProgressBar.autoPinWidthToSuperview()
self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top)
self.videoProgressBar.autoSetDimension(.height, toSize: 44)
self.playVideoButton = UIButton()
self.playVideoButton.contentMode = .scaleAspectFill
self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
self.view.addSubview(self.playVideoButton)
self.playVideoButton.set(.width, to: 72)
self.playVideoButton.set(.height, to: 72)
self.playVideoButton.center(in: self.view)
}
}
private func buildVideoPlayerView() -> UIView {
guard
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else {
owsFailDebug("Missing video file")
return UIView()
}
self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath))
self.videoPlayer?.seek(to: .zero)
self.videoPlayer?.delegate = self
let imageSize: CGSize = (self.image?.size ?? .zero)
let playerView: VideoPlayerView = VideoPlayerView()
playerView.player = self.videoPlayer?.avPlayer
NSLayoutConstraint.autoSetPriority(.defaultLow) {
playerView.autoSetDimensions(to: imageSize)
}
return playerView
}
public func setShouldHideToolbars(_ shouldHideToolbars: Bool) {
self.videoProgressBar.isHidden = shouldHideToolbars
}
private func addGestureRecognizers(to view: UIView) {
@ -330,14 +287,10 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
self.scrollView.zoom(to: translatedRect, animated: true)
}
@objc public func didPressPlayBarButton() {
public func didPressPlayBarButton() {
self.playVideo()
}
@objc public func didPressPauseBarButton() {
self.pauseVideo()
}
// MARK: - UIScrollViewDelegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
@ -391,49 +344,17 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
// MARK: - Video Playback
@objc public func playVideo() {
self.playVideoButton.isHidden = true
self.videoPlayer?.play()
self.delegate?.mediaDetailViewController(self, isPlayingVideo: true)
}
private func pauseVideo() {
self.videoPlayer?.pause()
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
}
public func stopAnyVideo() {
guard self.galleryItem.attachment.isVideo else { return }
guard
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return SNLog("Missing video file") }
self.stopVideo()
}
private func stopVideo() {
self.videoPlayer?.stop()
self.playVideoButton.isHidden = false
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
}
// MARK: - OWSVideoPlayerDelegate
func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
self.stopVideo()
}
// MARK: - PlayerProgressBarDelegate
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) {
self.videoPlayer?.pause()
}
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) {
self.videoPlayer?.seek(to: time)
}
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) {
self.videoPlayer?.seek(to: time)
if shouldResumePlayback {
self.videoPlayer?.play()
let videoUrl: URL = URL(fileURLWithPath: originalFilePath)
let player: AVPlayer = AVPlayer(url: videoUrl)
let viewController: AVPlayerViewController = AVPlayerViewController()
viewController.player = player
self.present(viewController, animated: true) { [weak player] in
player?.play()
}
}
}
@ -441,6 +362,5 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
// MARK: - MediaDetailViewControllerDelegate
protocol MediaDetailViewControllerDelegate: AnyObject {
func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool)
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
}

@ -18,6 +18,7 @@ extension MediaInfoVC {
let result: MediaView = MediaView.init(
attachment: attachment,
isOutgoing: isOutgoing,
shouldSupressControls: false,
cornerRadius: 0
)

@ -50,8 +50,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
updateTitle(item: item)
updateCaption(item: item)
setViewControllers([galleryPage], direction: direction, animated: isAnimated)
updateFooterBarButtonItems(isPlayingVideo: false)
setViewControllers([galleryPage], direction: direction, animated: isAnimated) { [weak galleryPage] _ in
galleryPage?.parentDidAppear() // Trigger any custom appearance animations
}
updateFooterBarButtonItems()
updateMediaRail(item: item)
}
@ -204,7 +206,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
updateTitle(item: currentItem)
updateCaption(item: currentItem)
updateMediaRail(item: currentItem)
updateFooterBarButtonItems(isPlayingVideo: false)
updateFooterBarButtonItems()
// Gestures
@ -237,6 +239,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
hasAppeared = true
becomeFirstResponder()
children.forEach { child in
switch child {
case let detailViewController as MediaDetailViewController:
detailViewController.parentDidAppear()
default: break
}
}
}
public override func viewWillDisappear(_ animated: Bool) {
@ -291,7 +302,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
// MARK: View Helpers
public func willBePresentedAgain() {
updateFooterBarButtonItems(isPlayingVideo: false)
updateFooterBarButtonItems()
}
public func wasPresented() {
@ -309,7 +320,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false)
UIView.animate(withDuration: 0.1) {
self.currentViewController.setShouldHideToolbars(self.shouldHideToolbars)
self.bottomContainer.isHidden = self.shouldHideToolbars
}
}
@ -354,24 +364,12 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
return videoPlayBarButton
}()
lazy var videoPauseBarButton: UIBarButtonItem = {
let videoPauseBarButton = UIBarButtonItem(
barButtonSystemItem: .pause,
target: self,
action: #selector(didPressPauseBarButton)
)
videoPauseBarButton.themeTintColor = .textPrimary
return videoPauseBarButton
}()
private func updateFooterBarButtonItems(isPlayingVideo: Bool) {
private func updateFooterBarButtonItems() {
self.footerBar.setItems(
[
shareBarButton,
buildFlexibleSpace(),
(self.currentItem.isVideo && isPlayingVideo ? self.videoPauseBarButton : nil),
(self.currentItem.isVideo && !isPlayingVideo ? self.videoPlayBarButton : nil),
(self.currentItem.isVideo ? self.videoPlayBarButton : nil),
(self.currentItem.isVideo ? buildFlexibleSpace() : nil),
deleteBarButton
].compactMap { $0 },
@ -465,8 +463,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
// MARK: - Actions
@objc public func didPressAllMediaButton(sender: Any) {
currentViewController.stopAnyVideo()
// If the screen wasn't presented or it was presented from a location which isn't the
// MediaTileViewController then just pop/dismiss the screen
let parentNavController: UINavigationController? = {
@ -622,15 +618,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
currentViewController.didPressPlayBarButton()
}
@objc public func didPressPauseBarButton() {
guard let currentViewController = self.viewControllers?.first as? MediaDetailViewController else {
SNLog("currentViewController was unexpectedly nil")
return
}
currentViewController.didPressPauseBarButton()
}
// MARK: UIPageViewControllerDelegate
var pendingViewController: MediaDetailViewController?
@ -650,9 +637,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
} else {
self.captionContainerView.pendingText = nil
}
// Ensure upcoming page respects current toolbar status
pendingViewController.setShouldHideToolbars(self.shouldHideToolbars)
}
}
@ -676,11 +660,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
captionContainerView.completePagerTransition()
}
currentViewController.parentDidAppear() // Trigger any custom appearance animations
updateTitle(item: currentItem)
updateMediaRail(item: currentItem)
previousPage.zoomOut(animated: false)
previousPage.stopAnyVideo()
updateFooterBarButtonItems(isPlayingVideo: false)
updateFooterBarButtonItems()
} else {
captionContainerView.pendingText = nil
}
@ -801,7 +785,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
// Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way.
// currentVC
currentViewController.zoomOut(animated: true)
currentViewController.stopAnyVideo()
self.navigationController?.view.isUserInteractionEnabled = false
self.navigationController?.dismiss(animated: true, completion: { [weak self] in
@ -823,16 +806,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
self.shouldHideToolbars = !self.shouldHideToolbars
}
public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) {
guard mediaDetailViewController == currentViewController else {
Logger.verbose("ignoring stale delegate.")
return
}
self.shouldHideToolbars = isPlayingVideo
self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo)
}
// MARK: - Dynamic Header
private lazy var dateFormatter: DateFormatter = {

@ -8,73 +8,75 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollectionPickerViewModel.Section, PhotoCollectionPickerViewModel.Item> {
// MARK: - Config
class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSource {
typealias TableItem = String
public enum Section: SessionTableSection {
case content
}
public let dependencies: Dependencies
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
public struct Item: Equatable, Hashable, Differentiable {
let id: String
}
private let library: PhotoLibrary
private let onCollectionSelected: (PhotoCollection) -> Void
private var photoCollections: CurrentValueSubject<[PhotoCollection], Error>
// MARK: - Initialization
init(library: PhotoLibrary, onCollectionSelected: @escaping (PhotoCollection) -> Void) {
init(
library: PhotoLibrary,
onCollectionSelected: @escaping (PhotoCollection) -> Void,
using dependencies: Dependencies = Dependencies()
) {
self.dependencies = dependencies
self.library = library
self.onCollectionSelected = onCollectionSelected
self.photoCollections = CurrentValueSubject(library.allPhotoCollections())
}
// MARK: - Config
public enum Section: SessionTableSection {
case content
}
// MARK: - Content
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
override var observableTableData: ObservableData { _observableTableData }
let title: String = "NOTIFICATIONS_STYLE_SOUND_TITLE".localized()
private lazy var _observableTableData: ObservableData = {
self.photoCollections
.map { collections in
[
SectionModel(
model: .content,
elements: collections.map { collection in
let contents: PhotoCollectionContents = collection.contents()
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
thumbnailSize: CGSize(
width: IconSize.extraLarge.size,
height: IconSize.extraLarge.size
)
lazy var observation: TargetObservation = ObservationBuilder
.subject(photoCollections)
.map { collections -> [SectionModel] in
[
SectionModel(
model: .content,
elements: collections.map { collection in
let contents: PhotoCollectionContents = collection.contents()
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
thumbnailSize: CGSize(
width: IconSize.extraLarge.size,
height: IconSize.extraLarge.size
)
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
return SessionCell.Info(
id: Item(id: collection.id),
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
// be able to load the thumbnail
lastAssetItem?.asyncThumbnail { [weak imageView] image in
imageView?.image = image
}
},
title: collection.localizedTitle(),
subtitle: "\(contents.assetCount)",
onTap: { [weak self] in
self?.onCollectionSelected(collection)
)
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
return SessionCell.Info(
id: collection.id,
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
// be able to load the thumbnail
lastAssetItem?.asyncThumbnail { [weak imageView] image in
imageView?.image = image
}
)
}
)
]
}
.removeDuplicates()
.eraseToAnyPublisher()
.mapToSessionTableViewData(for: self)
}()
},
title: collection.localizedTitle(),
subtitle: "\(contents.assetCount)",
onTap: { [weak self] in
self?.onCollectionSelected(collection)
}
)
}
)
]
}
// MARK: PhotoLibraryDelegate

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "امسح رمز الاستجابة السريع";
"vc_enter_public_key_explanation" = "ابدأ محادثة جديدة بإدخال معرف جلسة شخص ما أو مشاركة معرفك معهم.";
"vc_scan_qr_code_camera_access_explanation" = "تطبيق\"الجلسة\"يحتاج الوصول إلى الكاميرا لمسح رموز الاستجابة السريعة";
"vc_scan_qr_code_grant_camera_access_button_title" = "منح صلاحية الكاميرا";
"vc_create_closed_group_title" = "انشئ مجموعة";
"vc_create_closed_group_text_field_hint" = "أدخل إسم المجموعة";
"vc_create_closed_group_empty_state_message" = "ليست لديك أية جهات اتصال حتى الآن";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Načíst QR kód";
"vc_enter_public_key_explanation" = "Začněte novou konverzaci zadáním Session ID jiného uživatele a nebo sdílejte své Session ID s ostatními.";
"vc_scan_qr_code_camera_access_explanation" = "Session potřebuje ke skenování kódů QR přístup k fotoaparátu";
"vc_scan_qr_code_grant_camera_access_button_title" = "Povolit přístup k fotoaparátu";
"vc_create_closed_group_title" = "Vytvořit skupinu";
"vc_create_closed_group_text_field_hint" = "Zadejte název skupiny";
"vc_create_closed_group_empty_state_message" = "Zatím nemáte žádné kontakty";
@ -584,233 +583,233 @@
"context_menu_info" = "Info";
/* An error that is displayed when the application fails for create it's initial connection to the database */
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_STARTUP_FAILED" = "Při otevření databáze došlo k chybě\n\nProtokoly aplikací můžete exportovat, abyste je mohli sdílet při řešení problémů, nebo můžete zařízení obnovit.\n\nVarování: Obnovení zařízení povede ke ztrátě všech dat starších než dva týdny";
/* A warning displayed to the user when the application takes too long to launch */
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
"APP_STARTUP_TIMEOUT" = "Spouštění aplikace trvá dlouho\n\nMůžete pokračovat v čekání na spuštění aplikace, exportovat protokoly aplikace a sdílet je pro řešení problémů nebo můžete zkusit aplikaci otevřít znovu";
/* The title of a button on a modal shown when the application fails to start, pressing the button closes the application */
"APP_STARTUP_EXIT" = "Exit";
"APP_STARTUP_EXIT" = "Ukončit";
/* An error which occurs if the user tries to restore the database after an initial failure and it fails to restore */
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
"DATABASE_RESTORE_FAILED" = "Při otevírání obnovené databáze došlo k chybě\n\nProtokoly aplikace můžete exportovat do sdílené složky za účelem řešení problémů, ale abyste mohli relaci Session používat i nadále, bude možná nutné ji znovu nainstalovat";
/* Text displayed in place of a quoted message when the original message is not on the device */
"QUOTED_MESSAGE_NOT_FOUND" = "Original message not found.";
"QUOTED_MESSAGE_NOT_FOUND" = "Původní zpráva nebyla nalezena.";
/* EMOJI_REACTS_SHOW_LESS */
"EMOJI_REACTS_SHOW_LESS" = "Show less";
"EMOJI_REACTS_SHOW_LESS" = "Zobrazit méně";
/* PRIVACY_SECTION_MESSAGE_REQUESTS */
"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests";
"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Žádosti o zprávy";
/* PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE */
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests";
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Požadavky na zprávy komunity";
/* PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION */
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations.";
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Povolit požadavky na zprávy z konverzací komunity.";
/* Information displayed above the input when sending a message to a new user for the first time explaining limitations around the types of messages which can be sent before being approved */
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "Budete moci posílat hlasové zprávy a přílohy, jakmile příjemce schválí tuto žádost o zprávu";
/* State of a message while it's still in the process of being sent */
"MESSAGE_DELIVERY_STATUS_SENDING" = "Sending";
"MESSAGE_DELIVERY_STATUS_SENDING" = "Odesílání";
/* State of a message once it has been sent */
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_SENT" = "Odesláno";
/* State of a message after the recipient has read the message */
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_READ" = "Přečteno";
/* State of a message if it failed to be sent */
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Odeslání se nezdařilo";
/* Title of the message information screen describing the date/time a message was sent */
"MESSAGE_INFO_SENT" = "Sent";
"MESSAGE_INFO_SENT" = "Odesláno";
/* Title of the message information screen describing the date/time a message was received on a specific device */
"MESSAGE_INFO_RECEIVED" = "Received";
"MESSAGE_INFO_RECEIVED" = "Přijato";
/* Title of the message information screen describing the sender of the message */
"MESSAGE_INFO_FROM" = "From";
"MESSAGE_INFO_FROM" = "Od";
/* Title of the message information screen describing the identifier of the attachment */
"ATTACHMENT_INFO_FILE_ID" = "File ID";
"ATTACHMENT_INFO_FILE_ID" = "ID souboru";
/* Title of the message information screen describing the file type of the attachment */
"ATTACHMENT_INFO_FILE_TYPE" = "File Type";
"ATTACHMENT_INFO_FILE_TYPE" = "Typ souboru";
/* Title of the message information screen describing the size of the attachment */
"ATTACHMENT_INFO_FILE_SIZE" = "File Size";
"ATTACHMENT_INFO_FILE_SIZE" = "Velikost souboru";
/* Title on the message information screen describing the resolution of a media attachment */
"ATTACHMENT_INFO_RESOLUTION" = "Resolution";
"ATTACHMENT_INFO_RESOLUTION" = "Rozlišení";
/* Title on the message information screen describing the duration of a media attachment */
"ATTACHMENT_INFO_DURATION" = "Duration";
"ATTACHMENT_INFO_DURATION" = "Doba trvání";
/* State of a message after it failed to sync to the current users other devices */
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Synchronizace se nezdařila";
/* State of a message while it's in the process of being synced to the users other devices */
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Synchronizace";
/* Title of the modal that appears after a user taps on the state of a message which failed to send */
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Odeslání zprávy se nezdařilo";
/* Title of the modal that appears after a user taps on the state of a message which failed to sync to the users other devices */
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Nepodařilo se synchronizovat zprávu s ostatními zařízeními";
/* Action for the modal shown when asking the user whether they want to delete from all of their devices */
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"delete_message_for_me_and_my_devices" = "Smazat ze všech mých zařízení";
/* Action in the long-press menu to trigger a message to be sent again after it has failed */
"context_menu_resend" = "Resend";
"context_menu_resend" = "Odeslat znovu";
/* Action in the long-press menu to trigger a message to be synced again after it has failed */
"context_menu_resync" = "Resync";
"context_menu_resync" = "Znovu synchronizovat";
/* Title of a modal show the first time a user tries to search for GIFs */
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_TITLE" = "Hledat GIFy?";
/* Message of a modal show the first time a user tries to search for GIFs */
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"GIPHY_PERMISSION_MESSAGE" = "Session se připojí k Giphy pro poskytnutí výsledků vyhledávání. Při odesílání GIFů nebudete mít úplnou ochranu metadat.";
/* Action in the long-press menu to view more information about a specific message */
"message_info_title" = "Message Info";
"message_info_title" = "Informace o zprávě";
/* Action to mute a conversation in the swipe menu */
"mute_button_text" = "Mute";
"mute_button_text" = "Ztlumit";
/* Action in the swipe menu to unmute a conversation */
"unmute_button_text" = "Unmute";
"unmute_button_text" = "Zrušit ztlumení";
/* Action in the swipe menu to mark a conversation as read */
"MARK_AS_READ" = "Mark read";
"MARK_AS_READ" = "Označit jako přečtené";
/* Action in the swipe menu to mark a conversation as unread */
"MARK_AS_UNREAD" = "Mark unread";
"MARK_AS_UNREAD" = "Označit jako nepřečtené";
/* Title of the confirmation modal show when attempting to leave a group conversation */
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_group_confirmation_alert_title" = "Opustit skupinu";
/* Title of the confirmation modal show when attempting to leave a community conversation */
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_title" = "Opustit komunitu";
/* Message in the confirmation modal when leaving a community conversation */
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"leave_community_confirmation_alert_message" = "Opravdu chcete opustit %@?";
/* Conversation subtitle while the user in the process of leaving */
"group_you_leaving" = "Leaving...";
"group_you_leaving" = "Opouštění...";
/* Conversation subtitle if the user in the failed to leave */
"group_leave_error" = "Failed to leave Group!";
"group_leave_error" = "Nepodařilo se opustit skupinu!";
/* Message within a conversation indicating the device was unable to leave a group conversation */
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"group_unable_to_leave" = "Nelze opustit skupinu, zkuste to prosím znovu";
/* Title in the confirmation modal to delete a group */
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_title" = "Smazat skupinu";
/* Message in the confirmation modal to delete a group */
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_group_confirmation_alert_message" = "Opravdu chcete smazat %@?";
/* Title in the confirmation modal when the user tries to delete a one-to-one conversation */
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_title" = "Smazat konverzaci";
/* Message in the confirmation modal when the user tries to delete a one-to-one conversation */
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"delete_conversation_confirmation_alert_message" = "Opravdu chcete smazat konverzaci s %@?";
/* Title in the confirmation modal when the user tries to hide the 'Note to Self' conversation */
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_title" = "Skrýt Poznámku pro mně";
/* Message in the confirmation modal when the user tries to hide the 'Note to Self' conversation */
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"hide_note_to_self_confirmation_alert_message" = "Opravdu chcete skrýt %@?";
/* Title in the modal for updating the users profile display picture */
"update_profile_modal_title" = "Set Display Picture";
"update_profile_modal_title" = "Nastavit zobrazovaný obrázek";
/* Save action in the modal for updating the users profile display picture */
"update_profile_modal_save" = "Save";
"update_profile_modal_save" = "Uložit";
/* Remove action in the modal for updating the users profile display picture */
"update_profile_modal_remove" = "Remove";
"update_profile_modal_remove" = "Odstranit";
/* Title for the error when failing to remove the users profile display picture */
"update_profile_modal_remove_error_title" = "Unable to remove avatar image";
"update_profile_modal_remove_error_title" = "Nelze odstranit obrázek avataru";
/* Title for the error when the user selects a profile display picture that is too large */
"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded";
"update_profile_modal_max_size_error_title" = "Překročena maximální velikost souboru";
/* Message for the error when the user selects a profile display picture that is too large */
"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again";
"update_profile_modal_max_size_error_message" = "Vyberte prosím menší fotografii a zkuste to znovu";
/* Title for the error when the user fails to update their profile display picture */
"update_profile_modal_error_title" = "Couldn't Update Profile";
"update_profile_modal_error_title" = "Nepodařilo se aktualizovat profil";
/* Message for the error when the user fails to update their profile display picture */
"update_profile_modal_error_message" = "Please check your internet connection and try again";
"update_profile_modal_error_message" = "Zkontrolujte prosím připojení k internetu a zkuste to znovu";
/* Placeholder when entering a nickname for a contact */
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"CONTACT_NICKNAME_PLACEHOLDER" = "Zadejte jméno";
/* The separator within a conversation indicating that following messages are unread */
"UNREAD_MESSAGES" = "Unread Messages";
"UNREAD_MESSAGES" = "Nepřečtené zprávy";
/* Empty state for a conversation */
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE" = "Nemáte žádné zprávy od %@. Pošlete zprávu pro zahájení konverzace!";
/* Empty state for a read-only conversation */
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "V %@ nejsou žádné zprávy.";
/* Empty state for the 'Note to Self' conversation */
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "V %@ nemáte žádné zprávy.";
/* Message to indicate a user has Community Message Requests disabled */
"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message.";
"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ má vypnuté žádosti o chat pocházející z komunit. Odeslání zprávy tedy není možné.";
/* Warning to indicate one of the users devices is running an old version of Session */
"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated.";
"USER_CONFIG_OUTDATED_WARNING" = "Některá z vašich zařízení používají zastaralé verze. Synchronizace může být nespolehlivá, dokud nebudou aktualizovány.";
/* Ann error displayed if the device is unable to retrieve the users recovery password */
"LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue.";
"LOAD_RECOVERY_PASSWORD_ERROR" = "Došlo k chybě při pokusu o načtení hesla pro obnovení.\n\nPro vyřešení problému prosím exportujte své logy a soubor nahrajte pomocí Session Help Desku.";
/* An error displayed when trying to send a message if the device is unable to save it to the database */
"FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages.";
"FAILED_TO_STORE_OUTGOING_MESSAGE" = "Došlo k chybě při ukládání odchozí zprávy pro odesílání, možná budete muset restartovat aplikaci, než budete moci odesílat zprávy.";
/* An error indicating that the device was unable to access the database for some reason */
"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again.";
"database_inaccessible_error" = "Při otevírání databáze se vyskytl problém. Prosím, restartujte aplikaci a zkuste to znovu.";
/* A message indicating how the disappearing messages setting applies in a one-to-one conversation */
"DISAPPERING_MESSAGES_SUBTITLE_CONTACTS" = "This setting applies to everyone in this conversation.";
"DISAPPERING_MESSAGES_SUBTITLE_CONTACTS" = "Toto nastavení se týká všech účastníků této konverzace.";
/* A message indicating how the disappearing messages setting applies in a group conversation */
"DISAPPERING_MESSAGES_SUBTITLE_GROUPS" = "Messages disappear after they have been sent.";
"DISAPPERING_MESSAGES_SUBTITLE_GROUPS" = "Zprávy zmizí po odeslání.";
/* A record that appears within the message history to indicate that the current user turned on disappearing messages */
"YOU_DISAPPEARING_MESSAGES_INFO_ENABLE" = "You have set messages to disappear %@ after they have been %@";
"YOU_DISAPPEARING_MESSAGES_INFO_ENABLE" = "Nastavili jste mizení zpráv %@ po odeslání %@";
/* A record that appears within the message history to indicate that the current user update the disappearing messages setting */
"YOU_DISAPPEARING_MESSAGES_INFO_UPDATE" = "You have changed messages to disappear %@ after they have been %@";
"YOU_DISAPPEARING_MESSAGES_INFO_UPDATE" = "Nastavili jste mizení zpráv %@ po jejich %@";
/* A record that appears within the message history to indicate that the current user has disabled disappearing messages */
"YOU_DISAPPEARING_MESSAGES_INFO_DISABLE" = "You have turned off disappearing messages";
"YOU_DISAPPEARING_MESSAGES_INFO_DISABLE" = "Zakázali jste mizející zprávy";
/* The title for the legacy type of disappearing messages on the disappearing messages configuration screen */
"DISAPPEARING_MESSAGES_TYPE_LEGACY_TITLE" = "Legacy";
"DISAPPEARING_MESSAGES_TYPE_LEGACY_TITLE" = "Zastaralé";
/* The description for the legacy type of disappearing messages on the disappearing messages configuration screen */
"DISAPPEARING_MESSAGES_TYPE_LEGACY_DESCRIPTION" = "Original version of disappearing messages.";
"DISAPPEARING_MESSAGES_TYPE_LEGACY_DESCRIPTION" = "Původní verze mizejících zpráv.";
/* A warning shown at the top of a conversation to indicate a participant is using an old version of Session which may not support the updated disappearing messages functionality */
"DISAPPEARING_MESSAGES_OUTDATED_CLIENT_BANNER" = "%@ is using an outdated client. Disappearing messages may not work as expected.";
"DISAPPEARING_MESSAGES_OUTDATED_CLIENT_BANNER" = "%@ používá zastaralého klienta. Mizející zprávy nemusí fungovat podle očekávání.";
/* An error which can occur when a user tries to update from a version that Session no longer supports updating from */
"DATABASE_UNSUPPORTED_MIGRATION" = "You are trying to updated from a version which no longer supports upgrading\n\nIn order to continue to use session you need to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_UNSUPPORTED_MIGRATION" = "Pokoušíte se aktualizovat z verze, která již nepodporuje aktualizaci\n\nChcete-li nadále používat Session, je potřeba obnovit zařízení\n\nVarování: Obnovení zařízení bude mít za následek ztrátu jakýchkoli dat starších než dva týdny";
/* DISAPPEARING_MESSAGE_STATE_READ
The point that a message will disappear in a disappearing message update message for disappear after read */
"DISAPPEARING_MESSAGE_STATE_READ" = "read";
"DISAPPEARING_MESSAGE_STATE_READ" = "přečteno";
/* The point that a message will disappear in a disappearing message update message for disappear after send */
"DISAPPEARING_MESSAGE_STATE_SENT" = "sent";
"DISAPPEARING_MESSAGE_STATE_SENT" = "odesláno";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Kode";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session behøver kameraadgang for at scanne QR Koder";
"vc_scan_qr_code_grant_camera_access_button_title" = "Tillad Adgang Til Kamera";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Indtast et gruppenavn";
"vc_create_closed_group_empty_state_message" = "Du har endnu ingen kontakter";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "QR-Code scannen";
"vc_enter_public_key_explanation" = "Beginne eine neue Unterhaltung, indem du die Session-ID von jemandem eingibst oder deine Session-ID mit ihm teilt.";
"vc_scan_qr_code_camera_access_explanation" = "Session benötigt Kamerazugriff, um die QR-Codes scannen zu können.";
"vc_scan_qr_code_grant_camera_access_button_title" = "Kamerazugriff gewähren";
"vc_create_closed_group_title" = "Gruppe erstellen";
"vc_create_closed_group_text_field_hint" = "Gib einen Gruppennamen ein";
"vc_create_closed_group_empty_state_message" = "Du hast noch keine Kontakte";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Σάρωση Κωδικού QR";
"vc_enter_public_key_explanation" = "Ξεκινήστε μια νέα συνομιλία εισάγοντας το Session ID κάποιου ή μοιραστείτε το δικό σας Session ID μαζί τους.";
"vc_scan_qr_code_camera_access_explanation" = "Το Session χρειάζεται πρόσβαση στην κάμερα για σάρωση κωδικών QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Παραχωρήστε Πρόσβαση στην Κάμερα";
"vc_create_closed_group_title" = "Δημιουργία Ομάδας";
"vc_create_closed_group_text_field_hint" = "Εισαγάγετε ένα όνομα ομάδας";
"vc_create_closed_group_empty_state_message" = "Δεν έχετε επαφές ακόμη";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skani QR-Kodon";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session bezonas fotilan aliron por skani QR-kodojn";
"vc_scan_qr_code_grant_camera_access_button_title" = "Permesi Fotilan Aliron";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Entajpu grupnomon";
"vc_create_closed_group_empty_state_message" = "Vi ankoraŭ ne havas kontaktojn";

@ -181,7 +181,7 @@
/* Notification action button title */
"PUSH_MANAGER_REPLY" = "Responder";
/* Description of how and why Session iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'. */
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Autenticar para abrir Session.";
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Identifícate para acceder a Session.";
/* Title for alert indicating that screen lock could not be unlocked. */
"SCREEN_LOCK_UNLOCK_FAILED" = "Fallo al identificarse";
/* alert title when user attempts to leave the send media flow when they have an in-progress album */
@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Escanear código QR";
"vc_enter_public_key_explanation" = "Empieza una nueva conversa poniendo el ID de sesión de alguien o comparte tu ID de sesión con ellos.";
"vc_scan_qr_code_camera_access_explanation" = "Session necesita acceso a la cámara para escanear códigos QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Permitir acceso a cámara";
"vc_create_closed_group_title" = "Crear un grupo";
"vc_create_closed_group_text_field_hint" = "Ingresa un nombre de grupo";
"vc_create_closed_group_empty_state_message" = "Aún no tienes contactos";
@ -552,102 +551,102 @@
"DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER" = "Desaparecer después de %@";
"COPY_GROUP_URL" = "Copiar URL del grupo";
"NEW_CONVERSATION_CONTACTS_SECTION_TITLE" = "Contactos";
"GROUP_ERROR_NO_MEMBER_SELECTION" = "Please pick at least 1 group member";
"GROUP_CREATION_PLEASE_WAIT" = "Please wait while the group is created...";
"GROUP_CREATION_ERROR_TITLE" = "Couldn't Create Group";
"GROUP_CREATION_ERROR_MESSAGE" = "Please check your internet connection and try again.";
"GROUP_UPDATE_ERROR_TITLE" = "Couldn't Update Group";
"GROUP_UPDATE_ERROR_MESSAGE" = "Can't leave while adding or removing other members.";
"GROUP_ACTION_REMOVE" = "Remove";
"GROUP_TITLE_MEMBERS" = "Members";
"GROUP_TITLE_FALLBACK" = "Group";
"DM_ERROR_DIRECT_BLINDED_ID" = "You can only send messages to Blinded IDs from within a Community";
"DM_ERROR_INVALID" = "Please check the Session ID or ONS name and try again";
"COMMUNITY_ERROR_INVALID_URL" = "Please check the URL you entered and try again.";
"COMMUNITY_ERROR_GENERIC" = "Couldn't Join";
"DISAPPERING_MESSAGES_TITLE" = "Disappearing Messages";
"DISAPPERING_MESSAGES_TYPE_TITLE" = "Delete Type";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_TITLE" = "Disappear After Read";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_DESCRIPTION" = "Messages delete after they have been read.";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_TITLE" = "Disappear After Send";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_DESCRIPTION" = "Messages delete after they have been sent.";
"DISAPPERING_MESSAGES_TIMER_TITLE" = "Timer";
"DISAPPERING_MESSAGES_SAVE_TITLE" = "Set";
"DISAPPERING_MESSAGES_GROUP_WARNING" = "This setting applies to everyone in this conversation.";
"DISAPPERING_MESSAGES_GROUP_WARNING_ADMIN_ONLY" = "This setting applies to everyone in this conversation.\nOnly group admins can change this setting.";
"DISAPPERING_MESSAGES_SUMMARY" = "Disappear After %@ - %@";
"DISAPPERING_MESSAGES_INFO_ENABLE" = "%@ has set messages to disappear %@ after they have been %@";
"DISAPPERING_MESSAGES_INFO_UPDATE" = "%@ has changed messages to disappear %@ after they have been %@";
"DISAPPERING_MESSAGES_INFO_DISABLE" = "%@ has turned off disappearing messages";
"GROUP_ERROR_NO_MEMBER_SELECTION" = "Elige al menos 1 miembro del grupo";
"GROUP_CREATION_PLEASE_WAIT" = "El grupo se está creando...";
"GROUP_CREATION_ERROR_TITLE" = "No se pudo crear el grupo";
"GROUP_CREATION_ERROR_MESSAGE" = "Por favor, compruebe su conexión a internet e inténtelo de nuevo.";
"GROUP_UPDATE_ERROR_TITLE" = "No se pudo actualizar el grupo";
"GROUP_UPDATE_ERROR_MESSAGE" = "No se puede salir mientras se agregan o eliminan miembros.";
"GROUP_ACTION_REMOVE" = "Eliminar";
"GROUP_TITLE_MEMBERS" = "Miembros";
"GROUP_TITLE_FALLBACK" = "Grupo";
"DM_ERROR_DIRECT_BLINDED_ID" = "Solo puede enviar mensajes a ID encubiertos desde dentro de una comunidad";
"DM_ERROR_INVALID" = "Por favor, compruebe el ID de Session o el nombre ONS e inténtelo de nuevo";
"COMMUNITY_ERROR_INVALID_URL" = "Por favor, compruebe el URL que ha introducido y vuelve a intentarlo.";
"COMMUNITY_ERROR_GENERIC" = "Error al unirse";
"DISAPPERING_MESSAGES_TITLE" = "Desaparición de mensajes";
"DISAPPERING_MESSAGES_TYPE_TITLE" = "Tipo de borrado";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_TITLE" = "Desaparece tras leerlo";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_DESCRIPTION" = "Los mensajes se borran después de haber sido leídos.";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_TITLE" = "Desaparece tras enviarlo";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_DESCRIPTION" = "Los mensajes se borran después de haber sido enviados.";
"DISAPPERING_MESSAGES_TIMER_TITLE" = "Cronómetro";
"DISAPPERING_MESSAGES_SAVE_TITLE" = "Definir";
"DISAPPERING_MESSAGES_GROUP_WARNING" = "Esta opción se aplica a todos los usuarios en esta conversación.";
"DISAPPERING_MESSAGES_GROUP_WARNING_ADMIN_ONLY" = "Esta opción se aplica a todos los usuarios en esta conversación.\nSolo los administradores del grupo la pueden cambiar.";
"DISAPPERING_MESSAGES_SUMMARY" = "Desaparece tras %@ - %@";
"DISAPPERING_MESSAGES_INFO_ENABLE" = "%@ ha establecido que los mensajes desaparezcan %@ tras haber sido %@";
"DISAPPERING_MESSAGES_INFO_UPDATE" = "%@ ha cambiado que los mensajes desaparezcan %@ tras haber sido %@";
"DISAPPERING_MESSAGES_INFO_DISABLE" = "%@ ha desactivado la desaparición de mensajes";
/* context_menu_info */
"context_menu_info" = "Info";
"context_menu_info" = "Información";
/* An error that is displayed when the application fails for create it's initial connection to the database */
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_STARTUP_FAILED" = "Se ha producido un error al abrir la base de datos\n\nPuedes exportar tus registros de aplicación para compartirlos y solucionar los problemas, o restaurar el dispositivo\n\nAdvertencia: Restaurar su dispositivo causará una pérdida de todos los datos que sean más antiguos a dos semanas";
/* A warning displayed to the user when the application takes too long to launch */
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
"APP_STARTUP_TIMEOUT" = "La aplicación está tardando mucho tiempo para iniciar\n\nPuedes continuar esperando a que la aplicación se inicie, exportar los registros de tu aplicación y compartirlos para solucionar los problemas o puedes intentar volver a abrir la aplicación";
/* The title of a button on a modal shown when the application fails to start, pressing the button closes the application */
"APP_STARTUP_EXIT" = "Exit";
"APP_STARTUP_EXIT" = "Salir";
/* An error which occurs if the user tries to restore the database after an initial failure and it fails to restore */
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
"DATABASE_RESTORE_FAILED" = "Se ha producido un error al abrir la base de datos restaurada\n\nPuede exportar los registros de su aplicación para compartir y solucionar problemas, pero para continuar usando Session puede que necesite reinstalarlo";
/* Text displayed in place of a quoted message when the original message is not on the device */
"QUOTED_MESSAGE_NOT_FOUND" = "Original message not found.";
"QUOTED_MESSAGE_NOT_FOUND" = "No se encontró el mensaje original.";
/* EMOJI_REACTS_SHOW_LESS */
"EMOJI_REACTS_SHOW_LESS" = "Show less";
"EMOJI_REACTS_SHOW_LESS" = "Ver menos";
/* PRIVACY_SECTION_MESSAGE_REQUESTS */
"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests";
"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Solicitudes de mensajes";
/* PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE */
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests";
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Solicitudes de mensaje de la comunidad";
/* PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION */
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations.";
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Permitir peticiones de mensajes de conversaciones comunitarias.";
/* Information displayed above the input when sending a message to a new user for the first time explaining limitations around the types of messages which can be sent before being approved */
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "Podrás enviar mensajes de voz y archivos adjuntos cuando el destinatario haya aprobado esta solicitud de mensaje";
/* State of a message while it's still in the process of being sent */
"MESSAGE_DELIVERY_STATUS_SENDING" = "Sending";
"MESSAGE_DELIVERY_STATUS_SENDING" = "Enviando";
/* State of a message once it has been sent */
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_SENT" = "Enviado";
/* State of a message after the recipient has read the message */
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_READ" = "Leído";
/* State of a message if it failed to be sent */
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Fallo al enviar";
/* Title of the message information screen describing the date/time a message was sent */
"MESSAGE_INFO_SENT" = "Sent";
"MESSAGE_INFO_SENT" = "Enviado";
/* Title of the message information screen describing the date/time a message was received on a specific device */
"MESSAGE_INFO_RECEIVED" = "Received";
"MESSAGE_INFO_RECEIVED" = "Recibido";
/* Title of the message information screen describing the sender of the message */
"MESSAGE_INFO_FROM" = "From";
"MESSAGE_INFO_FROM" = "De";
/* Title of the message information screen describing the identifier of the attachment */
"ATTACHMENT_INFO_FILE_ID" = "File ID";
"ATTACHMENT_INFO_FILE_ID" = "ID de archivo";
/* Title of the message information screen describing the file type of the attachment */
"ATTACHMENT_INFO_FILE_TYPE" = "File Type";
"ATTACHMENT_INFO_FILE_TYPE" = "Tipo de archivo";
/* Title of the message information screen describing the size of the attachment */
"ATTACHMENT_INFO_FILE_SIZE" = "File Size";
"ATTACHMENT_INFO_FILE_SIZE" = "Tamaño del archivo";
/* Title on the message information screen describing the resolution of a media attachment */
"ATTACHMENT_INFO_RESOLUTION" = "Resolution";
"ATTACHMENT_INFO_RESOLUTION" = "Resolución";
/* Title on the message information screen describing the duration of a media attachment */
"ATTACHMENT_INFO_DURATION" = "Duration";
"ATTACHMENT_INFO_DURATION" = "Duración";
/* State of a message after it failed to sync to the current users other devices */
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
@ -800,17 +799,17 @@
"DISAPPEARING_MESSAGES_TYPE_LEGACY_TITLE" = "Legacy";
/* The description for the legacy type of disappearing messages on the disappearing messages configuration screen */
"DISAPPEARING_MESSAGES_TYPE_LEGACY_DESCRIPTION" = "Original version of disappearing messages.";
"DISAPPEARING_MESSAGES_TYPE_LEGACY_DESCRIPTION" = "Versión original de los mensajes desaparecidos.";
/* A warning shown at the top of a conversation to indicate a participant is using an old version of Session which may not support the updated disappearing messages functionality */
"DISAPPEARING_MESSAGES_OUTDATED_CLIENT_BANNER" = "%@ is using an outdated client. Disappearing messages may not work as expected.";
"DISAPPEARING_MESSAGES_OUTDATED_CLIENT_BANNER" = "%@ está usando un cliente desactualizado. Los mensajes que desaparecen pueden no funcionar correctamente.";
/* An error which can occur when a user tries to update from a version that Session no longer supports updating from */
"DATABASE_UNSUPPORTED_MIGRATION" = "You are trying to updated from a version which no longer supports upgrading\n\nIn order to continue to use session you need to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_UNSUPPORTED_MIGRATION" = "Estás intentando actualizar desde una versión que ya no soporta actualizar\n\nPara continuar usando Session, necesitas restaurar tu dispositivo\n\nAdvertencia: Restaurar tu dispositivo causará la pérdida de cualquier información anterior a dos semanas";
/* DISAPPEARING_MESSAGE_STATE_READ
The point that a message will disappear in a disappearing message update message for disappear after read */
"DISAPPEARING_MESSAGE_STATE_READ" = "read";
"DISAPPEARING_MESSAGE_STATE_READ" = "leído";
/* The point that a message will disappear in a disappearing message update message for disappear after send */
"DISAPPEARING_MESSAGE_STATE_SENT" = "sent";
"DISAPPEARING_MESSAGE_STATE_SENT" = "enviado";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "اسکن کد QR";
"vc_enter_public_key_explanation" = "یک مکالمه جدید را با وارد کردن شناسه Session شخصی شروع کنید یا شناسه جلسه خود را با آنها به اشتراک بگذارید.";
"vc_scan_qr_code_camera_access_explanation" = "برای اسکن کدهای Session ،QR نیاز به دسترسی به دوربین دارد";
"vc_scan_qr_code_grant_camera_access_button_title" = "اعطای دسترسی دوربین";
"vc_create_closed_group_title" = "ایجاد گروه";
"vc_create_closed_group_text_field_hint" = "وارد کردن یک نام گروه";
"vc_create_closed_group_empty_state_message" = "هنوز هیچ مخاطبی ندارید";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Lue QR-koodi";
"vc_enter_public_key_explanation" = "Aloita uusi keskustelu syöttämällä käyttäjän Session ID tai jaa oma ID:si heille.";
"vc_scan_qr_code_camera_access_explanation" = "Session tarvitsee QR-koodien lukua varten kameran käyttöoikeuden";
"vc_scan_qr_code_grant_camera_access_button_title" = "Myönnä kameran käyttöoikeus";
"vc_create_closed_group_title" = "Luo ryhmä";
"vc_create_closed_group_text_field_hint" = "Anna ryhmälle nimi";
"vc_create_closed_group_empty_state_message" = "Sinulla ei ole yhteystietoja";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scanner un QR Code";
"vc_enter_public_key_explanation" = "Commencez une nouvelle conversation en entrant l'ID Session de quelqu'un ou en lui partageant votre ID Session.";
"vc_scan_qr_code_camera_access_explanation" = "Session a besoin d'accéder à l'appareil photo pour scanner les QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Autoriser l'accès";
"vc_create_closed_group_title" = "Créer un groupe";
"vc_create_closed_group_text_field_hint" = "Saisissez un nom de groupe";
"vc_create_closed_group_empty_state_message" = "Vous n'avez pas encore de contacts";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skeniraj QR kôd";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session treba pristup kameri za skeniranje QR kôdova";
"vc_scan_qr_code_grant_camera_access_button_title" = "Odobri pristup kameri";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Unesite naziv grupe";
"vc_create_closed_group_empty_state_message" = "Još nemate kontakata";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "QR kód beolvasása";
"vc_enter_public_key_explanation" = "Beszélgetés kezdeményezéséhez addj meg egy Session ID-t vagy oszd meg a tiédet másokkal.";
"vc_scan_qr_code_camera_access_explanation" = "Kamera hozzáférésre van szükség a QR kód beolvasásához";
"vc_scan_qr_code_grant_camera_access_button_title" = "Adjon hozzáférést a kamerához";
"vc_create_closed_group_title" = "Csoport létrehozása";
"vc_create_closed_group_text_field_hint" = "Adja meg a csoport nevét";
"vc_create_closed_group_empty_state_message" = "Még nincsenek névjegyei";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Pindai kode QR";
"vc_enter_public_key_explanation" = "Mulai percakapan baru dengan memasukkan ID Session seseorang atau bagikan ID Session Anda dengan mereka.";
"vc_scan_qr_code_camera_access_explanation" = "Session membutuhkan akses kamera untuk memindai kode QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Berikan akses kamera";
"vc_create_closed_group_title" = "Buat Grup";
"vc_create_closed_group_text_field_hint" = "Masukkan nama grup";
"vc_create_closed_group_empty_state_message" = "anda belum memiliki kontak";

@ -3,11 +3,11 @@
/* Title for 'caption' mode of the attachment approval view. */
"ATTACHMENT_APPROVAL_CAPTION_TITLE" = "Didascalia";
/* Format string for file extension label in call interstitial view */
"ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT" = "Formato file: %@";
"ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT" = "Tipo file: %@";
/* Format string for file size label in call interstitial view. Embeds: {{file size as 'N mb' or 'N kb'}}. */
"ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT" = "Dimensione: %@";
/* One-line label indicating the user can add no more text to the media message field. */
"ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED" = "Limite del messaggio raggiunto";
"ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED" = "Limite caratteri raggiunto";
/* Label for 'send' button in the 'attachment approval' dialog. */
"ATTACHMENT_APPROVAL_SEND_BUTTON" = "Invia";
/* Generic filename for an attachment with no known name */
@ -27,17 +27,17 @@
/* Attachment error message for attachments whose data exceed file size limits */
"ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE" = "Allegato troppo pesante.";
/* Attachment error message for attachments with invalid data */
"ATTACHMENT_ERROR_INVALID_DATA" = "Contenuto dell'allegato non valido.";
"ATTACHMENT_ERROR_INVALID_DATA" = "L'allegato contiene contenuti non validi.";
/* Attachment error message for attachments with an invalid file format */
"ATTACHMENT_ERROR_INVALID_FILE_FORMAT" = "Il formato file dell'allegato non è valido.";
"ATTACHMENT_ERROR_INVALID_FILE_FORMAT" = "Il formato dell'allegato non è valido.";
/* Attachment error message for attachments without any data */
"ATTACHMENT_ERROR_MISSING_DATA" = "L'allegato è vuoto.";
/* Alert title when picking a document fails for an unknown reason */
"ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE" = "Selezione del documento fallito.";
"ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE" = "Selezione del documento non riuscita.";
/* Alert body when picking a document fails because user picked a directory/bundle */
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY" = "Crea un archivio compresso di questo file o cartella e quindi prova a inviarlo.";
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY" = "Crea un archivio compresso di questo file o cartella e prova a inviare quello.";
/* Alert title when picking a document fails because user picked a directory/bundle */
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE" = "Documento non supportato";
"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE" = "File non supportato";
/* Short text label for a voice message attachment, used for thread preview and on the lock screen */
"ATTACHMENT_TYPE_VOICE_MESSAGE" = "Messaggio vocale";
/* Button label for the 'block' button */
@ -61,7 +61,7 @@
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Seleziona";
/* keyboard toolbar label when starting to search with no current results */
"CONVERSATION_SEARCH_SEARCHING" = "Ricerca in corso...";
"CONVERSATION_SEARCH_SEARCHING" = "Cerco...";
/* keyboard toolbar label when no messages match the search string */
"CONVERSATION_SEARCH_NO_RESULTS" = "Nessun risultato";
/* keyboard toolbar label when exactly 1 message matches the search string */
@ -71,33 +71,33 @@
/* table cell label in conversation settings */
"CONVERSATION_SETTINGS_BLOCK_THIS_USER" = "Blocca questo utente ";
/* label for 'mute thread' cell in conversation settings */
"CONVERSATION_SETTINGS_MUTE_LABEL" = "Silenzioso";
"CONVERSATION_SETTINGS_MUTE_LABEL" = "Silenzia";
/* Table cell label in conversation settings which returns the user to the conversation with 'search mode' activated */
"CONVERSATION_SETTINGS_SEARCH" = "Cerca nella conversazione";
/* Title for the 'crop/scale image' dialog. */
"CROP_SCALE_IMAGE_VIEW_TITLE" = "Sposta e ridimensiona";
/* Subtitle shown while the app is updating its database. */
"DATABASE_VIEW_OVERLAY_SUBTITLE" = "P richiedere alcuni minuti.";
"DATABASE_VIEW_OVERLAY_SUBTITLE" = "Potrebbe richiedere alcuni minuti.";
/* Title shown while the app is updating its database. */
"DATABASE_VIEW_OVERLAY_TITLE" = "Ottimizzazione del database";
"DATABASE_VIEW_OVERLAY_TITLE" = "Ottimizzazione database";
/* The present; the current time. */
"DATE_NOW" = "Ora";
/* table cell label in conversation settings */
"DISAPPEARING_MESSAGES" = "Messaggi a scomparsa";
"DISAPPEARING_MESSAGES" = "Messaggi effimeri";
/* table cell label in conversation settings */
"EDIT_GROUP_ACTION" = "Modifica gruppo";
/* Label indicating media gallery is empty */
"GALLERY_TILES_EMPTY_GALLERY" = "Non hai alcun contenuto multimediale in questa conversazione.";
"GALLERY_TILES_EMPTY_GALLERY" = "Non hai media in questa conversazione.";
/* Label indicating loading is in progress */
"GALLERY_TILES_LOADING_MORE_RECENT_LABEL" = "Caricamento media più recenti...";
"GALLERY_TILES_LOADING_MORE_RECENT_LABEL" = "Carico media più recenti...";
/* Label indicating loading is in progress */
"GALLERY_TILES_LOADING_OLDER_LABEL" = "Caricamento media più vecchi...";
"GALLERY_TILES_LOADING_OLDER_LABEL" = "Carico media meno recenti…";
/* Error displayed when there is a failure fetching a GIF from the remote service. */
"GIF_PICKER_ERROR_FETCH_FAILURE" = "Errore nel recuperare la GIF richiesta. Si prega di verificare la connessione.";
"GIF_PICKER_ERROR_FETCH_FAILURE" = "Errore nel recupero della GIF richiesta. Verifica che la connessione sia attiva.";
/* Generic error displayed when picking a GIF */
"GIF_PICKER_ERROR_GENERIC" = "Si è verificato un errore sconosciuto.";
/* Shown when selected GIF couldn't be fetched */
"GIF_PICKER_FAILURE_ALERT_TITLE" = "Impossibile recuperare la GIF selezionata";
"GIF_PICKER_FAILURE_ALERT_TITLE" = "Impossibile selezionare GIF";
/* Alert message shown when user tries to search for GIFs without entering any search terms. */
"GIF_PICKER_VIEW_MISSING_QUERY" = "Inserisci la tua ricerca.";
/* Indicates that an error occurred while searching. */
@ -107,31 +107,31 @@
/* No comment provided by engineer. */
"GROUP_CREATED" = "Gruppo creato.";
/* No comment provided by engineer. */
"GROUP_MEMBER_JOINED" = "%@ è entrato nel gruppo. ";
"GROUP_MEMBER_JOINED" = "%@ fa ora parte del gruppo. ";
/* No comment provided by engineer. */
"GROUP_MEMBER_LEFT" = "%@ ha lasciato il gruppo. ";
"GROUP_MEMBER_LEFT" = "%@ ha abbandonato il gruppo. ";
/* No comment provided by engineer. */
"GROUP_MEMBER_REMOVED" = "%@ è stato rimosso dal gruppo. ";
/* No comment provided by engineer. */
"GROUP_MEMBERS_REMOVED" = "%@ sono stati rimossi dal gruppo. ";
/* No comment provided by engineer. */
"GROUP_TITLE_CHANGED" = "Il nuovo titolo è '%@'";
"GROUP_TITLE_CHANGED" = "Il titolo ora è '%@'. ";
/* No comment provided by engineer. */
"GROUP_UPDATED" = "Gruppo aggiornato.";
/* No comment provided by engineer. */
"GROUP_YOU_LEFT" = "Hai lasciato il gruppo.";
"GROUP_YOU_LEFT" = "Hai abbandonato il gruppo.";
/* No comment provided by engineer. */
"YOU_WERE_REMOVED" = " Sei stato rimosso da questo gruppo. ";
"YOU_WERE_REMOVED" = " Sei stato rimosso dal gruppo. ";
/* Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared. */
"IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT" = "Non puoi condividere più di %@ elementi.";
/* alert title */
"IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS" = "Selezione allegato fallita";
"IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS" = "Selezione allegato non riuscita.";
/* Message for the alert indicating that an audio file is invalid. */
"INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE" = "Audio file non valido.";
"INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE" = "File audio non valido.";
/* Confirmation button within contextual alert */
"LEAVE_BUTTON_TITLE" = "Abbandona";
/* table cell label in conversation settings */
"LEAVE_GROUP_ACTION" = "Abbandona il gruppo";
"LEAVE_GROUP_ACTION" = "Abbandona gruppo";
/* nav bar button item */
"MEDIA_DETAIL_VIEW_ALL_MEDIA_BUTTON" = "Tutti i media";
/* Confirmation button text to delete selected media from the gallery, embeds {{number of messages}} */
@ -147,7 +147,7 @@
/* Section header in media gallery collection view */
"MEDIA_GALLERY_THIS_MONTH_HEADER" = "Questo mese";
/* status message for failed messages */
"MESSAGE_STATUS_FAILED" = "Invio fallito.";
"MESSAGE_STATUS_FAILED" = "Invio non riuscito.";
/* status message for read messages */
"MESSAGE_STATUS_READ" = "Letto";
/* message status while message is sending. */
@ -155,19 +155,19 @@
/* status message for sent messages */
"MESSAGE_STATUS_SENT" = "Inviato";
/* status message while attachment is uploading */
"MESSAGE_STATUS_UPLOADING" = "Aggiornamento...";
"MESSAGE_STATUS_UPLOADING" = "Carico…";
/* notification title. Embeds {{author name}} and {{group name}} */
"NEW_GROUP_MESSAGE_NOTIFICATION_TITLE" = "%@ su %@";
/* Label for 1:1 conversation with yourself. */
"NOTE_TO_SELF" = "Note personali";
/* Lock screen notification text presented after user powers on their device without unlocking. Embeds {{device model}} (either 'iPad' or 'iPhone') */
"NOTIFICATION_BODY_PHONE_LOCKED_FORMAT" = "Puoi aver ricevuto messaggi mentre il tuo %@ si riavviava.";
"NOTIFICATION_BODY_PHONE_LOCKED_FORMAT" = "Potresti aver ricevuto messaggi mentre il tuo %@ si riavviava.";
/* No comment provided by engineer. */
"BUTTON_OK" = "OK,";
"BUTTON_OK" = "OK";
/* Info Message when {{other user}} disables or doesn't support disappearing messages */
"OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION" = "%@ ha disabilitato la scomparsa dei messaggi.";
"OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION" = "%@ ha disabilitato i messaggi effimeri.";
/* Info Message when {{other user}} updates message expiration to {{time amount}}, see the *_TIME_AMOUNT strings for context. */
"OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION" = "%@ ha impostato il tempo di scomparsa dei messaggi a %@.";
"OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION" = "%@ ha impostato il timer dei messaggi effimeri su %@.";
/* alert title, generic error preventing user from capturing a photo */
"PHOTO_CAPTURE_GENERIC_ERROR" = "Impossibile scattare foto.";
/* alert title */
@ -181,11 +181,11 @@
/* Notification action button title */
"PUSH_MANAGER_REPLY" = "Rispondi";
/* Description of how and why Session iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'. */
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Autenticarsi per aprire Session.";
"SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK" = "Identificati per aprire Session.";
/* Title for alert indicating that screen lock could not be unlocked. */
"SCREEN_LOCK_UNLOCK_FAILED" = "Autenticazione fallita";
/* alert title when user attempts to leave the send media flow when they have an in-progress album */
"SEND_MEDIA_ABANDON_TITLE" = "Scartare il media?";
"SEND_MEDIA_ABANDON_TITLE" = "Scartare media?";
/* alert action, confirming the user wants to exit the media flow and abandon any photos they've taken */
"SEND_MEDIA_CONFIRM_ABANDON_ALBUM" = "Scarta media";
/* Format string for the default 'Note' sound. Embeds the system {{sound name}}. */
@ -221,7 +221,7 @@
/* {{number of weeks}}, embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 weeks}}'. See other *_TIME_AMOUNT strings */
"TIME_AMOUNT_WEEKS" = "%@ settimane";
/* Label text below navbar button, embeds {{number of weeks}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5w' not '5 w'. See other *_TIME_AMOUNT strings */
"TIME_AMOUNT_WEEKS_SHORT_FORMAT" = "%@w";
"TIME_AMOUNT_WEEKS_SHORT_FORMAT" = "%@sett";
/* Label for the cancel button in an alert or action sheet. */
"TXT_CANCEL_TITLE" = "Annulla";
/* No comment provided by engineer. */
@ -233,52 +233,52 @@
/* Title for the alert indicating the 'voice message' needs to be held to be held down to record. */
"VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE" = "Messaggio vocale";
/* Info Message when you disable disappearing messages */
"YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION" = "Hai disabilitato la scomparsa dei messaggi.";
"YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION" = "Hai disabilitato i messaggi effimeri.";
/* Info message embedding a {{time amount}}, see the *_TIME_AMOUNT strings for context. */
"YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION" = "Hai impostato la scomparsa dei messaggi a %@.";
"YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION" = "Hai impostato il timer dei messaggi effimeri su %@.";
// MARK: - Session
"continue_2" = "Continua";
"copy" = "Copia";
"invalid_url" = "URL non valido";
"next" = "Successivo";
"next" = "Avanti";
"share" = "Condividi";
"invalid_session_id" = "Session ID non valido";
"cancel" = "Annulla";
"your_session_id" = "Il tuo Session ID";
"vc_landing_title_2" = "La tua Sessione inizia qui...";
"vc_landing_title_2" = "La tua sessione inizia qui...";
"vc_landing_register_button_title" = "Crea Session ID";
"vc_landing_restore_button_title" = "Continua la Sessione";
"vc_landing_link_button_title" = "Collegamento a un account esistente";
"view_fake_chat_bubble_1" = "Che cos'è una Sessione?";
"view_fake_chat_bubble_2" = "È un'app di messaggistica decentralizzato e crittografato";
"view_fake_chat_bubble_3" = "Quindi non raccoglie informazioni personali o metadati di conversazione? Come funziona?";
"view_fake_chat_bubble_4" = "Utilizza una combinazione di routing anonimo avanzato e tecnologie di crittografia end-to-end.";
"view_fake_chat_bubble_5" = "Gli amici non lasciano i suoi amici di utilizzare messaggistica compromessa. Prego.";
"vc_landing_restore_button_title" = "Continua la tua sessione";
"vc_landing_link_button_title" = "Collega un dispositivo";
"view_fake_chat_bubble_1" = "Che cos'è Session?";
"view_fake_chat_bubble_2" = "È un'app di messaggistica decentralizzata e cifrata";
"view_fake_chat_bubble_3" = "Quindi non raccoglie le mie informazioni personali o i metadati delle mie conversazioni? Come funziona?";
"view_fake_chat_bubble_4" = "Utilizzando una combinazione di instradamento anonimo avanzato e tecnologie di cifratura end-to-end.";
"view_fake_chat_bubble_5" = "Gli amici veri non lasciano che i propri amici utilizzino app di messaggistica compromesse. Non c'è di che.";
"vc_register_title" = "Ecco il tuo Session ID";
"vc_register_explanation" = "Il Session ID è l'indirizzo univoco che le persone possono utilizzare per contattarti su una Sessione. Senza alcuna connessione con la tua vera identità, il Session ID è totalmente anonimo e privato fin dal incezione.";
"vc_register_explanation" = "Il tuo Session ID è l'indirizzo univoco che le persone possono utilizzare per contattarti su Session. Senza alcun legame con la tua vera identità, il Session ID è progettato per essere totalmente anonimo e privato.";
"vc_restore_title" = "Ripristina il tuo account";
"vc_restore_explanation" = "Inserisci la frase di recupero che ti è stata data quando ti sei registrato per ripristinare il tuo account.";
"vc_restore_seed_text_field_hint" = "Inserisci la frase di recupero";
"vc_link_device_title" = "Collega dispositivo";
"vc_restore_seed_text_field_hint" = "Inserisci la tua frase di recupero";
"vc_link_device_title" = "Collega un dispositivo";
"vc_link_device_scan_qr_code_tab_title" = "Scansiona il codice QR";
"vc_display_name_title_2" = "Scegli il nome da visualizzare";
"vc_display_name_explanation" = "Questo sarà il tuo nome quando usi una Sessione. Può essere il tuo vero nome, un soprannome o qualsiasi altra cosa.";
"vc_display_name_text_field_hint" = "Inserisci il nome da visualizzare";
"vc_display_name_display_name_missing_error" = "Scegli il nome da visualizzare";
"vc_display_name_display_name_too_long_error" = "Scegli un nome più breve";
"vc_display_name_title_2" = "Scegli il tuo nome";
"vc_display_name_explanation" = "Questo sarà il tuo nome quando usi Session. Può essere il tuo vero nome, uno pseudonimo o quello che ti pare.";
"vc_display_name_text_field_hint" = "Inserisci un nome";
"vc_display_name_display_name_missing_error" = "Scegli un nome";
"vc_display_name_display_name_too_long_error" = "Inserisci un nome più breve";
"vc_pn_mode_recommended_option_tag" = "Consigliato";
"vc_pn_mode_no_option_picked_modal_title" = "Scegli un'opzione";
"vc_home_empty_state_message" = "Non hai ancora nessun contatto";
"vc_home_empty_state_button_title" = "Inizia una sessione";
"vc_seed_title" = "Frase di recupero";
"vc_seed_title_2" = "La frase di recupero";
"vc_seed_explanation" = "La frase di recupero è la chiave principale per il Session ID: puoi usarla per ripristinare il Session ID se perdi l'accesso al dispositivo. Conserva la frase di recupero in un luogo sicuro e non rivelarla a nessuno.";
"vc_seed_title" = "La tua frase di recupero";
"vc_seed_title_2" = "Ecco la tua frase di recupero";
"vc_seed_explanation" = "La tua frase di recupero è la chiave principale per il tuo Session ID: puoi usarla per ripristinare il Session ID se perdi l'accesso al tuo dispositivo. Conserva la frase di recupero in un luogo sicuro e non condividerla con nessuno.";
"vc_seed_reveal_button_title" = "Tieni premuto per rivelare";
"view_seed_reminder_subtitle_1" = "Proteggi il tuo account salvando la frase di recupero";
"view_seed_reminder_subtitle_2" = "Tocca e tieni premute le parole redatte per rivelare la frase di recupero, salva in modo sicuro per proteggere il tuo Session ID.";
"view_seed_reminder_subtitle_3" = "Assicurati di salvare la frase di recupero in un luogo sicuro";
"view_seed_reminder_subtitle_2" = "Tocca e tieni premute le parole redatte per rivelare la tua frase di recupero, poi salvala in un posto sicuro per proteggere il tuo Session ID.";
"view_seed_reminder_subtitle_3" = "Assicurati di salvare la tua frase di recupero in un luogo sicuro";
"vc_path_title" = "Percorso";
"vc_path_explanation" = "Session nasconde il tuo IP facendo rimbalzare i messaggi attraverso diversi nodi di servizio nella sua rete decentralizzata. Questi sono i paesi in cui la connessione viene rimbalzata attualmente:";
"vc_path_explanation" = "Session nasconde il tuo IP facendo rimbalzare i messaggi attraverso diversi nodi di servizio nella sua rete decentralizzata. Questi sono i paesi attraverso cui la tua connessione sta passando al momento:";
"vc_path_device_row_title" = "Tu";
"vc_path_guard_node_row_title" = "Nodo di entrata";
"vc_path_service_node_row_title" = "Nodo di servizio";
@ -287,22 +287,21 @@
"vc_create_private_chat_title" = "Nuovo messaggio";
"vc_create_private_chat_enter_session_id_tab_title" = "Inserisci il Session ID";
"vc_create_private_chat_scan_qr_code_tab_title" = "Scansiona il codice QR";
"vc_enter_public_key_explanation" = "Inizia una nuova conversazione inserendo il Session ID di qualcuno o condividendogli il tuo Session ID.";
"vc_scan_qr_code_camera_access_explanation" = "La Sessione richiede l'accesso alla fotocamera per scansionare i codici QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Concedi l'accesso alla fotocamera";
"vc_enter_public_key_explanation" = "Inizia una nuova conversazione inserendo il Session ID di qualcuno o condividendo con loro il tuo Session ID.";
"vc_scan_qr_code_camera_access_explanation" = "La Sessione ha bisogno dell'accesso alla fotocamera per scansionare i codici QR";
"vc_create_closed_group_title" = "Crea gruppo";
"vc_create_closed_group_text_field_hint" = "Inserisci un nome per il gruppo";
"vc_create_closed_group_empty_state_message" = "Non hai ancora nessun contatto";
"vc_create_closed_group_group_name_missing_error" = "Inserisci un nome per il gruppo";
"vc_create_closed_group_group_name_too_long_error" = "Inserisci un nome gruppo più breve";
"vc_create_closed_group_too_many_group_members_error" = "Un gruppo chiuso non può avere più di 100 membri";
"vc_join_public_chat_title" = "Entra nella community";
"vc_join_public_chat_enter_group_url_tab_title" = "URL della Community";
"vc_join_public_chat_title" = "Unisciti alla comunità";
"vc_join_public_chat_enter_group_url_tab_title" = "URL comunità";
"vc_join_public_chat_scan_qr_code_tab_title" = "Scansiona il codice QR";
"vc_enter_chat_url_text_field_hint" = "Inserisci l'URL della Community";
"vc_enter_chat_url_text_field_hint" = "Inserisci l'URL della comunità";
"vc_settings_title" = "Impostazioni";
"vc_group_settings_title" = "Impostazioni del gruppo";
"vc_settings_display_name_missing_error" = "Scegli il nome da visualizzare";
"vc_group_settings_title" = "Impostazioni gruppo";
"vc_settings_display_name_missing_error" = "Scegli un nome visualizzare";
"vc_settings_display_name_too_long_error" = "Scegli un nome più breve";
"vc_settings_privacy_button_title" = "Privacy";
"vc_settings_notifications_button_title" = "Notifiche";
@ -311,14 +310,14 @@
"vc_qr_code_title" = "Codice QR";
"vc_qr_code_view_my_qr_code_tab_title" = "Visualizza il mio codice QR";
"vc_qr_code_view_scan_qr_code_tab_title" = "Scansiona il codice QR";
"vc_qr_code_view_scan_qr_code_explanation" = "Scansiona il codice QR di un utente per iniziare una conversazione con questa persona";
"vc_view_my_qr_code_explanation" = "Questo è il tuo codice QR. Altri utenti possono scansionarlo per iniziare una sessione con te.";
"vc_qr_code_view_scan_qr_code_explanation" = "Scansiona il codice QR di un altro utente per avviare una conversazione";
"vc_view_my_qr_code_explanation" = "Questo è il tuo codice QR. Gli altri utenti possono scansionarlo per iniziare una sessione con te.";
// MARK: - Not Yet Translated
"fast_mode_explanation" = "Riceverai notifiche di nuovi messaggi in modo affidabile e immediato utilizzando i server di notifica di Apple.";
"fast_mode" = "Modalità Veloce";
"fast_mode" = "Modalità veloce";
"slow_mode_explanation" = "Session controllerà di tanto in tanto la presenza di nuovi messaggi in background.";
"slow_mode" = "Modalità Lenta";
"vc_pn_mode_title" = "Notifiche Messaggi";
"slow_mode" = "Modalità lenta";
"vc_pn_mode_title" = "Notifiche messaggi";
"vc_link_device_recovery_phrase_tab_title" = "Frase di recupero";
"vc_link_device_scan_qr_code_explanation" = "Vai su Impostazioni → Frase di recupero sul tuo altro dispositivo per mostrare il tuo codice QR.";
"vc_enter_recovery_phrase_title" = "Frase di recupero";
@ -330,51 +329,51 @@
"copied" = "Copiato";
"vc_conversation_settings_copy_session_id_button_title" = "Copia Session ID";
"vc_conversation_input_prompt" = "Messaggio";
"vc_conversation_voice_message_cancel_message" = "Scorri per cancellare";
"vc_conversation_voice_message_cancel_message" = "Scorri per annullare";
"modal_download_attachment_title" = "Ti fidi di %@?";
"modal_download_attachment_explanation" = "Sei sicuro di voler scaricare i media inviati da %@?";
"modal_download_attachment_explanation" = "Vuoi scaricare i media inviati da %@?";
"modal_download_button_title" = "Scarica";
"modal_open_url_title" = "Aprire URL?";
"modal_open_url_explanation" = "Sei sicuro di voler aprire %@?";
"modal_open_url_explanation" = "Vuoi aprire %@?";
"modal_open_url_button_title" = "Apri";
"modal_copy_url_button_title" = "Copia link";
"modal_blocked_title" = "Sbloccare %@?";
"modal_blocked_explanation" = "Sei sicuro di voler sbloccare %@?";
"modal_blocked_explanation" = "Vuoi sbloccare %@?";
"modal_blocked_button_title" = "Sblocca";
"modal_link_previews_title" = "Abilitare Anteprima Link?";
"modal_link_previews_explanation" = "Abilitando le anteprime dei collegamenti Session mostrerà le anteprime degli URL che invii e ricevi. Questo può essere utile, ma Session dovrà contattare i siti web collegati per generare le anteprime. Puoi sempre disabilitare le anteprime dei link nelle impostazioni di Session.";
"modal_link_previews_title" = "Abilitare anteprime dei link?";
"modal_link_previews_explanation" = "Abilitare le anteprime dei link farà mostrare delle anteprime per gli URL che invii e ricevi. Questo può essere utile, ma Session dovrà contattare i siti web collegati per generare le anteprime. Puoi sempre disabilitare le anteprime dei link nelle impostazioni di Session.";
"modal_link_previews_button_title" = "Abilita";
"vc_share_title" = "Condividi con Session";
"vc_share_loading_message" = "Preparazione allegati...";
"vc_share_loading_message" = "Preparo allegati...";
"vc_share_sending_message" = "Invio...";
"vc_share_link_previews_unsecure" = "Anteprima non caricata: il link non è sicuro";
"vc_share_link_previews_error" = "Impossibile caricare l'anteprima";
"vc_share_link_previews_disabled_title" = "Anteprima dei link disabilitata";
"vc_share_link_previews_disabled_explanation" = "Abilitando le anteprime dei collegamenti verranno mostrate le anteprime degli URL che condividi. Questo può essere utile, ma Session dovrà contattare i siti web collegati per generare le anteprime.\n\nPuoi sempre abilitare le anteprime dei link nelle impostazioni di Session.";
"vc_share_link_previews_disabled_title" = "Anteprime dei link disabilitate";
"vc_share_link_previews_disabled_explanation" = "Abilitare le anteprime dei link farà mostrare delle anteprime per gli URL che invii e ricevi. Questo può essere utile, ma Session dovrà contattare i siti web collegati per generare le anteprime.\n\nPuoi abilitare le anteprime dei link nelle impostazioni di Session.";
"view_open_group_invitation_description" = "Apri invito di gruppo";
"vc_conversation_settings_invite_button_title" = "Aggiungi membri";
"modal_send_seed_title" = "Attenzione";
"modal_send_seed_explanation" = "Questa è la tua frase di recupero. Qualora dovessi inviarla a qualcuno questi avranno accesso totale al tuo account.";
"modal_send_seed_explanation" = "Questa è la tua frase di recupero. Se la invii a qualcuno, quella persona avrà accesso totale al tuo account.";
"modal_send_seed_send_button_title" = "Invia";
"vc_conversation_settings_notify_for_mentions_only_title" = "Notifica solo per le menzioni";
"vc_conversation_settings_notify_for_mentions_only_explanation" = "Se abilitato, verrai avvisato solo per i messaggi che ti menzionano.";
"view_conversation_title_notify_for_mentions_only" = "Invio notifiche solo per le menzioni";
"vc_conversation_settings_notify_for_mentions_only_explanation" = "Se abilitato, verrai notificato solo per i messaggi che ti menzionano.";
"view_conversation_title_notify_for_mentions_only" = "Notificando solo per le menzioni";
"message_deleted" = "Questo messaggio è stato eliminato";
"delete_message_for_me" = "Elimina solo per me";
"delete_message_for_everyone" = "Elimina per tutti";
"delete_message_for_me_and_recipient" = "Elimina per me e %@";
"context_menu_reply" = "Rispondi";
"context_menu_save" = "Salva";
"context_menu_ban_user" = "Banna utente";
"context_menu_ban_and_delete_all" = "Banna ed elimina tutto";
"context_menu_ban_user" = "Bandisci utente";
"context_menu_ban_and_delete_all" = "Bandisci ed elimina tutto";
"context_menu_ban_user_error_alert_message" = "Impossibile bandire l'utente";
"accessibility_expanding_attachments_button" = "Aggiungi allegati";
"accessibility_gif_button" = "Gif";
"accessibility_document_button" = "Documento";
"accessibility_library_button" = "Galleria foto";
"accessibility_library_button" = "Libreria foto";
"accessibility_camera_button" = "Fotocamera";
"accessibility_main_button_collapse" = "Comprimi opzioni allegato";
"invalid_recovery_phrase" = "Frase Di Recupero non valida";
"invalid_recovery_phrase" = "Frase di recupero non valida";
"DISMISS_BUTTON_TEXT" = "Chiudi";
/* Button text which opens the settings app */
"OPEN_SETTINGS_BUTTON" = "Impostazioni";
@ -382,37 +381,37 @@
"call_incoming" = "%@ ti ha chiamato";
"call_missed" = "Chiamata persa da %@";
"APN_Message" = "Hai ricevuto un nuovo messaggio";
"APN_Collapsed_Messages" = "Hai ricevuto %@ nuovi messaggi.";
"APN_Collapsed_Messages" = "Hai %@ nuovi messaggi.";
"PIN_BUTTON_TEXT" = "Fissa";
"UNPIN_BUTTON_TEXT" = "Non fissare in alto";
"UNPIN_BUTTON_TEXT" = "Togli";
"modal_call_missed_tips_title" = "Chiamata persa";
"modal_call_missed_tips_explanation" = "Chiamata persa da '%@' perché era necessario abilitare l'autorizzazione 'Voce e video chiamate' nelle Impostazioni Privacy.";
"modal_call_missed_tips_explanation" = "Chiamata persa da '%@' perché era necessario abilitare l'autorizzazione \"Chiamate vocali e video\" nelle impostazioni di privacy.";
"media_saved" = "Media salvato da %@.";
"screenshot_taken" = "%@ ha acquisito uno screenshot.";
"SEARCH_SECTION_CONTACTS" = "Contatti e Gruppi";
"screenshot_taken" = "%@ ha acquisito una schermata.";
"SEARCH_SECTION_CONTACTS" = "Contatti e gruppi";
"SEARCH_SECTION_MESSAGES" = "Messaggi";
"MESSAGE_REQUESTS_TITLE" = "Richieste Di Messaggio";
"MESSAGE_REQUESTS_EMPTY_TEXT" = "Nessuna richiesta aperta";
"MESSAGE_REQUESTS_TITLE" = "Richieste di messaggi";
"MESSAGE_REQUESTS_EMPTY_TEXT" = "Nessuna richiesta in sospeso";
"MESSAGE_REQUESTS_CLEAR_ALL" = "Cancella tutto";
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Eliminare veramente tutte le richieste di messaggio?";
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Cancellare tutte le richieste di messaggi?";
"MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Cancella";
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Sei sicuro di voler eliminare questa richiesta di messaggio?";
"MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON" = "Sei sicuro di voler bloccare questo contatto?";
"MESSAGE_REQUESTS_INFO" = "Mandando un messaggio a questo utente automaticamente accetti la richiesta di messaggi e rivelerai il tuo Session ID.";
"MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Vuoi eliminare questa richiesta?";
"MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON" = "Vuoi bloccare questo contatto?";
"MESSAGE_REQUESTS_INFO" = "Se mandi un messaggio a questo utente, accetterai automaticamente la sua richiesta e rivelerai il tuo Session ID.";
"MESSAGE_REQUESTS_ACCEPTED" = "La tua richiesta di messaggio è stata accettata.";
"MESSAGE_REQUESTS_NOTIFICATION" = "Hai una nuova richiesta di messaggio";
"TXT_HIDE_TITLE" = "Nascondi";
"TXT_DELETE_ACCEPT" = "Accetta";
"TXT_BLOCK_USER_TITLE" = "Blocca utente";
"ALERT_ERROR_TITLE" = "Errore";
"modal_call_permission_request_title" = "Permessi Di Chiamata Richiesti";
"modal_call_permission_request_explanation" = "È possibile abilitare l'autorizzazione 'Voce e video chiamate' nelle Impostazioni Privacy.";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, si è verificato un errore";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Si prega di riprovare più tardi";
"LOADING_CONVERSATIONS" = "Caricamento conversazioni...";
"DATABASE_MIGRATION_FAILED" = "Si è verificato un errore durante l'ottimizzazione del database\n\nÈ possibile esportare i log dell'applicazione per poterli condividere per la risoluzione dei problemi o è possibile ripristinare il dispositivo\n\nAttenzione: Il ripristino del dispositivo causerà la perdita di tutti i dati più vecchi di due settimane";
"modal_call_permission_request_title" = "Permessi di chiamata necessari";
"modal_call_permission_request_explanation" = "Puoi concedere l'autorizzazione alle \"Chiamate vocali e video\" nelle impostazioni di privacy.";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Ops, si è verificato un errore";
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Riprova più tardi";
"LOADING_CONVERSATIONS" = "Carico conversazioni...";
"DATABASE_MIGRATION_FAILED" = "Si è verificato un errore durante l'ottimizzazione del database\n\nPuoi esportare il resoconto per aiutare a risolvere l'errore o puoi ripristinare il tuo dispositivo\n\nAttenzione: ripristinare il tuo dispositivo comporterà la perdita di tutti i dati più vecchi di due settimane";
"RECOVERY_PHASE_ERROR_GENERIC" = "Qualcosa è andato storto. Controlla la tua frase di recupero e riprova.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Sembra che tu non abbia inserito abbastanza parole. Per favore controlla la tua frase di recupero e riprova.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Sembra che tu non abbia inserito abbastanza parole. Controlla la tua frase di recupero e riprova.";
"RECOVERY_PHASE_ERROR_LAST_WORD" = "Sembra che manchi l'ultima parola della tua frase di recupero. Controlla cosa hai inserito e riprova.";
"RECOVERY_PHASE_ERROR_INVALID_WORD" = "Sembra ci sia una parola non valida nella tua frase di recupero. Controlla cosa hai inserito e riprova.";
"RECOVERY_PHASE_ERROR_FAILED" = "Non è stato possibile verificare la tua frase di recupero. Controlla cosa hai inserito e riprova.";
@ -421,28 +420,28 @@
/* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Autenticazione fallita.";
/* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Troppi tentativi di autenticazione falliti. Si prega di riprovare più tardi.";
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT" = "Troppi tentativi di autenticazione falliti. Riprova più tardi.";
/* Indicates that Touch ID/Face ID/Phone Passcode are not available on this device. */
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Devi abilitare il codice nelle impostazioni iOS per poter usare il blocco schermo.";
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE" = "Devi abilitare il codice nelle impostazioni di iOS per poter usare il blocco schermo.";
/* Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device. */
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Devi abilitare il codice nelle impostazioni iOS per poter usare il blocco schermo.";
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED" = "Devi abilitare il codice nelle impostazioni di iOS per poter usare il blocco schermo.";
/* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Devi abilitare il codice nelle impostazioni iOS per poter usare il blocco schermo.";
"SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Devi abilitare il codice nelle impostazioni di iOS per poter usare il blocco schermo.";
/* Label for the button to send a message */
"SEND_BUTTON_TITLE" = "Invia";
/* Generic text for button that retries whatever the last action was. */
"RETRY_BUTTON_TEXT" = "Riprova";
/* notification action */
"SHOW_THREAD_BUTTON_TITLE" = "Mostra Chat";
"SHOW_THREAD_BUTTON_TITLE" = "Mostra chat";
/* notification body */
"SEND_FAILED_NOTIFICATION_BODY" = "Invio del messaggio fallito.";
"INVALID_SESSION_ID_MESSAGE" = "Per favore controlla il Session ID e riprova.";
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Per favore controlla la frase di recupero e riprova.";
"SEND_FAILED_NOTIFICATION_BODY" = "Invio del tuo messaggio non riuscito.";
"INVALID_SESSION_ID_MESSAGE" = "Controlla il Session ID e riprova.";
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Controlla la frase di recupero e riprova.";
"MEDIA_TAB_TITLE" = "Media";
"DOCUMENT_TAB_TITLE" = "Documenti";
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "Non hai alcun documento in questa conversazione.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Caricamento del documento più recente…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Caricamento del documento più vecchio…";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Carico il documento più recente…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Carico il documento meno recente…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Attività";
/* The name for the emoji category 'Animals & Nature' */
@ -454,17 +453,17 @@
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Oggetti";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Usati di Recente";
"EMOJI_CATEGORY_RECENTS_NAME" = "Usate di recente";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Sorrisi & Persone";
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Faccine e persone";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Simboli";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Viaggi & Luoghi";
"EMOJI_CATEGORY_TRAVEL_NAME" = "Viaggi e luoghi";
"EMOJI_REACTS_NOTIFICATION" = "%@ ha reagito al messaggio con %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "E 1 altro ha reagito %@ a questo messaggio.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "E 1 altro ha reagito con %@ a questo messaggio.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "E %@ altri hanno reagito con %@ a questo messaggio.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Rallenta! Hai inviato troppe reazioni emoji. Riprova più tardi.";
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Rallenta! Hai inviato troppe reazioni. Riprova fra poco.";
/* New conversation screen*/
"vc_new_conversation_title" = "Nuova conversazione";
"CREATE_GROUP_BUTTON_TITLE" = "Crea";
@ -482,37 +481,37 @@
"PRIVACY_SECTION_LINK_PREVIEWS" = "Anteprime dei link";
"PRIVACY_LINK_PREVIEWS_TITLE" = "Invia le anteprime dei link";
"PRIVACY_LINK_PREVIEWS_DESCRIPTION" = "Genera le anteprime dei link per gli URL supportati.";
"PRIVACY_SECTION_CALLS" = "Chiamate (Beta)";
"PRIVACY_SECTION_CALLS" = "Chiamate (beta)";
"PRIVACY_CALLS_TITLE" = "Chiamate vocali e video";
"PRIVACY_CALLS_DESCRIPTION" = "Abilita chiamate vocali e video da e verso altri utenti.";
"PRIVACY_CALLS_WARNING_TITLE" = "Chiamate vocali e video (Beta)";
"PRIVACY_CALLS_WARNING_DESCRIPTION" = "Il tuo indirizzo IP è visibile al tuo partner di chiamata e a un server della Oxen Foundation durante l'utilizzo delle chiamate beta. Sei sicuro di voler abilitare le chiamate vocali e video?";
"PRIVACY_CALLS_DESCRIPTION" = "Abilita chiamate vocali e video da e verso gli altri utenti.";
"PRIVACY_CALLS_WARNING_TITLE" = "Chiamate vocali e video (beta)";
"PRIVACY_CALLS_WARNING_DESCRIPTION" = "Il tuo indirizzo IP è visibile al tuo interlocutore e a un server della Oxen Foundation nel corso delle chiamate beta. Vuoi abilitare le chiamate vocali e video?";
"NOTIFICATIONS_TITLE" = "Notifiche";
"NOTIFICATIONS_SECTION_STRATEGY" = "Strategia di notifica";
"NOTIFICATIONS_STRATEGY_FAST_MODE_TITLE" = "Usa la Modalità veloce";
"NOTIFICATIONS_STRATEGY_FAST_MODE_TITLE" = "Usa la modalità veloce";
"NOTIFICATIONS_STRATEGY_FAST_MODE_DESCRIPTION" = "Riceverai le notifiche dei nuovi messaggi in modo affidabile e immediato utilizzando i server di notifica di Apple.";
"NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION" = "Vai alle impostazioni di notifica del dispositivo";
"NOTIFICATIONS_SECTION_STYLE" = "Stile delle notifiche";
"NOTIFICATIONS_SECTION_STYLE" = "Stile notifiche";
"NOTIFICATIONS_STYLE_SOUND_TITLE" = "Suono";
"NOTIFICATIONS_STYLE_SOUND_WHEN_OPEN_TITLE" = "Suono quando l'app è aperta";
"NOTIFICATIONS_STYLE_CONTENT_TITLE" = "Contenuto della notifica";
"NOTIFICATIONS_STYLE_CONTENT_TITLE" = "Contenuto notifiche";
"NOTIFICATIONS_STYLE_CONTENT_DESCRIPTION" = "Le informazioni mostrate nelle notifiche.";
"NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_AND_CONTENT" = "Nome e contenuto";
"NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_ONLY" = "Solo il nome";
"NOTIFICATIONS_STYLE_CONTENT_OPTION_NO_NAME_OR_CONTENT" = "Nessun nome o contenuto";
"CONVERSATION_SETTINGS_TITLE" = "Conversazioni";
"CONVERSATION_SETTINGS_SECTION_MESSAGE_TRIMMING" = "Cancellazione dei messaggi";
"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_TITLE" = "Cancellazione dei gruppi";
"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_DESCRIPTION" = "Elimina i messaggi più vecchi di 6 mesi nei gruppi che hanno più di 2.000 messaggi.";
"CONVERSATION_SETTINGS_SECTION_MESSAGE_TRIMMING" = "Sfoltitura dei messaggi";
"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_TITLE" = "Sfoltisci comunità";
"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_DESCRIPTION" = "Elimina i messaggi più vecchi di 6 mesi nelle comunità che hanno più di 2.000 messaggi.";
"CONVERSATION_SETTINGS_SECTION_AUDIO_MESSAGES" = "Messaggi vocali";
"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_TITLE" = "Riproduzione automatica dei messaggi vocali";
"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_DESCRIPTION" = "Riproduzione automatica di messaggi vocali consecutivi.";
"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_TITLE" = "Riproduci automaticamente i messaggi vocali";
"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_DESCRIPTION" = "Riproduci automaticamente i messaggi vocali consecutivi.";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE" = "Contatti bloccati";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE" = "Non hai contatti bloccati.";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK" = "Sblocca";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE" = "Sei sicuro di voler sbloccare %@?";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE" = "Vuoi sbloccare %@?";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK" = "questo contatto";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1" = "Sei sicuro di voler sbloccare %@";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1" = "Vuoi sbloccare %@";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE" = "e %@?";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3" = "e %d altri?";
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON" = "Sblocca";
@ -520,297 +519,297 @@
"APPEARANCE_THEMES_TITLE" = "Temi";
"APPEARANCE_PRIMARY_COLOR_TITLE" = "Colore primario";
"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_QUOTE" = "Come stai?";
"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_MESSAGE" = "Sto bene, grazie, tu?";
"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_MESSAGE" = "Sto bene, grazie. Tu?";
"APPEARANCE_PRIMARY_COLOR_PREVIEW_OUT_MESSAGE" = "Sto benissimo, grazie.";
"APPEARANCE_NIGHT_MODE_TITLE" = "Modalità notturna automatica";
"APPEARANCE_NIGHT_MODE_TITLE" = "Modalità notte automatica";
"APPEARANCE_NIGHT_MODE_TOGGLE" = "Utilizza le impostazioni di sistema";
"HELP_TITLE" = "Aiuto";
"HELP_REPORT_BUG_TITLE" = "Segnala un bug";
"HELP_REPORT_BUG_DESCRIPTION" = "Esporta i tuoi log, poi carica il file attraverso l'Help Desk di Session.";
"HELP_REPORT_BUG_ACTION_TITLE" = "Esporta i log";
"HELP_REPORT_BUG_TITLE" = "Segnala un errore";
"HELP_REPORT_BUG_DESCRIPTION" = "Esporta i tuoi resoconti, poi carica il file attraverso il Supporto di Session.";
"HELP_REPORT_BUG_ACTION_TITLE" = "Esporta i resoconti";
"HELP_TRANSLATE_TITLE" = "Traduci Session";
"HELP_FEEDBACK_TITLE" = "Ci piacerebbe avere un tuo feedback";
"HELP_FAQ_TITLE" = "FAQ";
"HELP_FEEDBACK_TITLE" = "Dicci la tua opinione";
"HELP_FAQ_TITLE" = "Domande frequenti";
"HELP_SUPPORT_TITLE" = "Assistenza";
"modal_clear_all_data_title" = "Elimina tutti i dati";
"modal_clear_all_data_explanation" = "Questo eliminerà definitivamente i tuoi messaggi e contatti. Vorresti cancellare solo questo dispositivo o eliminare anche i tuoi dati dalla rete?";
"modal_clear_all_data_explanation_2" = "Sei sicuro di voler eliminare i tuoi dati dalla rete? Se continui, non potrai più recuperare i tuoi messaggi o i contatti.";
"modal_clear_all_data_device_only_button_title" = "Cancella solo il dispositivo";
"modal_clear_all_data_entire_account_button_title" = "Cancella dispositivo e rete";
"dialog_clear_all_data_deletion_failed_1" = "Dati non eliminati da 1 nodi di servizio. ID Nodo di servizio: %@.";
"dialog_clear_all_data_deletion_failed_2" = "Dati non eliminati da %@ nodi di servizio. ID Nodo di servizio: %@.";
"dialog_clear_all_data_deletion_failed_1" = "Dati non eliminati da 1 nodo di servizio. ID nodo di servizio: %@.";
"dialog_clear_all_data_deletion_failed_2" = "Dati non eliminati da %@ nodi di servizio. ID nodi di servizio: %@.";
"modal_clear_all_data_confirm" = "Cancella";
"modal_seed_title" = "Frase di recupero";
"modal_seed_title" = "La tua frase di recupero";
"modal_seed_explanation" = "Puoi usare la tua frase di recupero per recuperare il tuo account o collegare un dispositivo.";
"modal_permission_explanation" = "Session ha bisogno dell'accesso a %@ per continuare. Puoi consentire l'accesso nelle impostazioni iOS.";
"modal_permission_explanation" = "Session ha bisogno dell'accesso a %@ per continuare. Puoi consentire l'accesso nelle impostazioni di iOS.";
"modal_permission_settings_title" = "Impostazioni";
"modal_permission_camera" = "fotocamera";
"modal_permission_microphone" = "microfono";
"modal_permission_library" = "libreria";
"DISAPPEARING_MESSAGES_OFF" = "Off";
"DISAPPEARING_MESSAGES_SUBTITLE_OFF" = "Off";
"DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER" = "Scompaiono dopo: %@";
"modal_permission_camera" = "Fotocamera";
"modal_permission_microphone" = "Microfono";
"modal_permission_library" = "Libreria";
"DISAPPEARING_MESSAGES_OFF" = "No";
"DISAPPEARING_MESSAGES_SUBTITLE_OFF" = "No";
"DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER" = "Timer: %@";
"COPY_GROUP_URL" = "Copia l'URL del gruppo";
"NEW_CONVERSATION_CONTACTS_SECTION_TITLE" = "Contatti";
"GROUP_ERROR_NO_MEMBER_SELECTION" = "Per favore scegli almeno 1 membro del gruppo";
"GROUP_CREATION_PLEASE_WAIT" = "Attendere mentre il gruppo viene creato...";
"GROUP_CREATION_ERROR_TITLE" = "Impossibile creare gruppo";
"GROUP_CREATION_ERROR_MESSAGE" = "Per favore verifica la connessione a Internet e riprova.";
"GROUP_ERROR_NO_MEMBER_SELECTION" = "Scegli almeno 1 membro del gruppo";
"GROUP_CREATION_PLEASE_WAIT" = "Attendi mentre il gruppo viene creato...";
"GROUP_CREATION_ERROR_TITLE" = "Impossibile creare il gruppo";
"GROUP_CREATION_ERROR_MESSAGE" = "Verifica la tua connessione di rete e riprova.";
"GROUP_UPDATE_ERROR_TITLE" = "Impossibile aggiornare il gruppo";
"GROUP_UPDATE_ERROR_MESSAGE" = "Non puoi abbandonare durante l'aggiunta o la rimozione di altri membri.";
"GROUP_ACTION_REMOVE" = "Rimuovi";
"GROUP_TITLE_MEMBERS" = "Membri";
"GROUP_TITLE_FALLBACK" = "Gruppo";
"DM_ERROR_DIRECT_BLINDED_ID" = "È possibile inviare messaggi a ID Blinded solo da una Community";
"DM_ERROR_INVALID" = "Per favore controlla l'ID Session o il nome ONS e riprova";
"COMMUNITY_ERROR_INVALID_URL" = "Per favore controlla l'URL inserito e riprova.";
"DM_ERROR_DIRECT_BLINDED_ID" = "Puoi inviare messaggi a ID nascosti solo da dentro a una comunità";
"DM_ERROR_INVALID" = "Controlla il Session ID o il nome ONS e riprova";
"COMMUNITY_ERROR_INVALID_URL" = "Controlla l'URL inserito e riprova.";
"COMMUNITY_ERROR_GENERIC" = "Impossibile unirsi";
"DISAPPERING_MESSAGES_TITLE" = "Messaggi a scomparsa";
"DISAPPERING_MESSAGES_TYPE_TITLE" = "Tipologia di eliminazione";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_TITLE" = "Scompaiono dopo la lettura";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_DESCRIPTION" = "I messaggi si cancellano dopo che sono stati letti.";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_TITLE" = "Scompaiono dopo l'invio";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_DESCRIPTION" = "I messaggi si cancellano dopo che sono stati inviati.";
"DISAPPERING_MESSAGES_TITLE" = "Messaggi effimeri";
"DISAPPERING_MESSAGES_TYPE_TITLE" = "Tipo di eliminazione";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_TITLE" = "Scomparsa dopo lettura";
"DISAPPERING_MESSAGES_TYPE_AFTER_READ_DESCRIPTION" = "I messaggi vengono eliminati dopo che sono stati letti.";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_TITLE" = "Scomparsa dopo invio";
"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_DESCRIPTION" = "I messaggi vengono eliminati dopo che sono stati inviati.";
"DISAPPERING_MESSAGES_TIMER_TITLE" = "Timer";
"DISAPPERING_MESSAGES_SAVE_TITLE" = "Imposta";
"DISAPPERING_MESSAGES_GROUP_WARNING" = "Questa impostazione si applica a tutti i partecipanti di questa conversazione.";
"DISAPPERING_MESSAGES_GROUP_WARNING_ADMIN_ONLY" = "Questa impostazione si applica a tutti i partecipanti di questa conversazione. Solo gli amministratori di gruppo possono modificare questa impostazione.";
"DISAPPERING_MESSAGES_GROUP_WARNING_ADMIN_ONLY" = "Questa impostazione si applica a tutti i partecipanti di questa conversazione.\nSolo gli amministratori possono modificarla.";
"DISAPPERING_MESSAGES_SUMMARY" = "Scompaiono dopo %@ - %@";
"DISAPPERING_MESSAGES_INFO_ENABLE" = "%@ ha impostato che i messaggi scompaiano dopo %@ che sono stati %@";
"DISAPPERING_MESSAGES_INFO_UPDATE" = "%@ ha cambiato che i messaggi scompaiano dopo %@ che sono stati %@";
"DISAPPERING_MESSAGES_INFO_DISABLE" = "%@ ha disattivato che i messaggi scompaiano";
"DISAPPERING_MESSAGES_INFO_ENABLE" = "%@ ha impostato il timer dei messaggi effimeri a %@ che sono stati %@";
"DISAPPERING_MESSAGES_INFO_UPDATE" = "%@ ha modificato il timer dei messaggi effimeri a %@ che sono stati %@";
"DISAPPERING_MESSAGES_INFO_DISABLE" = "%@ ha disattivato i messaggi effimeri";
/* context_menu_info */
"context_menu_info" = "Info";
/* An error that is displayed when the application fails for create it's initial connection to the database */
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_STARTUP_FAILED" = "Si è verificato un errore durante l'apertura del database\n\nPuoi esportare il resoconto per aiutare a risolvere l'errore o puoi provare a ripristinare il tuo dispositivo\n\nAttenzione: ripristinare il tuo dispositivo comporterà la perdita di tutti i dati più vecchi di due settimane";
/* A warning displayed to the user when the application takes too long to launch */
"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again";
"APP_STARTUP_TIMEOUT" = "L'app ci sta mettendo molto ad aprirsi\n\nPuoi continuare ad aspettare che si apra, esportare il resoconto per aiutare a risolvere il problema o provare a chiudere e riaprire l'app.";
/* The title of a button on a modal shown when the application fails to start, pressing the button closes the application */
"APP_STARTUP_EXIT" = "Exit";
"APP_STARTUP_EXIT" = "Esci";
/* An error which occurs if the user tries to restore the database after an initial failure and it fails to restore */
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall";
"DATABASE_RESTORE_FAILED" = "Si è verificato un errore durante l'apertura del database\n\nPuoi esportare e condividere il resoconto per aiutare a risolvere il problema, ma per continuare a usare Session potresti aver bisogno di reinstallare l'app.";
/* Text displayed in place of a quoted message when the original message is not on the device */
"QUOTED_MESSAGE_NOT_FOUND" = "Original message not found.";
"QUOTED_MESSAGE_NOT_FOUND" = "Messaggio originale non trovato.";
/* EMOJI_REACTS_SHOW_LESS */
"EMOJI_REACTS_SHOW_LESS" = "Show less";
"EMOJI_REACTS_SHOW_LESS" = "Mostra meno";
/* PRIVACY_SECTION_MESSAGE_REQUESTS */
"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Message Requests";
"PRIVACY_SECTION_MESSAGE_REQUESTS" = "Richieste di messaggi";
/* PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE */
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Community Message Requests";
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_TITLE" = "Richieste di messaggi di comunità";
/* PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION */
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Allow message requests from Community conversations.";
"PRIVACY_SCREEN_MESSAGE_REQUESTS_COMMUNITY_DESCRIPTION" = "Consenti richieste di messaggi da conversazioni di comunità.";
/* Information displayed above the input when sending a message to a new user for the first time explaining limitations around the types of messages which can be sent before being approved */
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request";
"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "Potrai inviare messaggi vocali e allegati una volta che il destinatario avrà approvato questa richiesta.";
/* State of a message while it's still in the process of being sent */
"MESSAGE_DELIVERY_STATUS_SENDING" = "Sending";
"MESSAGE_DELIVERY_STATUS_SENDING" = "Invio";
/* State of a message once it has been sent */
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
"MESSAGE_DELIVERY_STATUS_SENT" = "Inviato";
/* State of a message after the recipient has read the message */
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
"MESSAGE_DELIVERY_STATUS_READ" = "Letto";
/* State of a message if it failed to be sent */
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"MESSAGE_DELIVERY_STATUS_FAILED" = "Invio non riuscito";
/* Title of the message information screen describing the date/time a message was sent */
"MESSAGE_INFO_SENT" = "Sent";
"MESSAGE_INFO_SENT" = "Inviato";
/* Title of the message information screen describing the date/time a message was received on a specific device */
"MESSAGE_INFO_RECEIVED" = "Received";
"MESSAGE_INFO_RECEIVED" = "Ricevuto";
/* Title of the message information screen describing the sender of the message */
"MESSAGE_INFO_FROM" = "From";
"MESSAGE_INFO_FROM" = "Da";
/* Title of the message information screen describing the identifier of the attachment */
"ATTACHMENT_INFO_FILE_ID" = "File ID";
"ATTACHMENT_INFO_FILE_ID" = "ID file";
/* Title of the message information screen describing the file type of the attachment */
"ATTACHMENT_INFO_FILE_TYPE" = "File Type";
"ATTACHMENT_INFO_FILE_TYPE" = "Tipo file";
/* Title of the message information screen describing the size of the attachment */
"ATTACHMENT_INFO_FILE_SIZE" = "File Size";
"ATTACHMENT_INFO_FILE_SIZE" = "Dimensione file";
/* Title on the message information screen describing the resolution of a media attachment */
"ATTACHMENT_INFO_RESOLUTION" = "Resolution";
"ATTACHMENT_INFO_RESOLUTION" = "Risoluzione";
/* Title on the message information screen describing the duration of a media attachment */
"ATTACHMENT_INFO_DURATION" = "Duration";
"ATTACHMENT_INFO_DURATION" = "Durata";
/* State of a message after it failed to sync to the current users other devices */
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Sincronizzazione fallita";
/* State of a message while it's in the process of being synced to the users other devices */
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Sincronizzazione";
/* Title of the modal that appears after a user taps on the state of a message which failed to send */
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
"MESSAGE_DELIVERY_FAILED_TITLE" = "Invio del messaggio non riuscito";
/* Title of the modal that appears after a user taps on the state of a message which failed to sync to the users other devices */
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Sincronizzazione del messaggio suoi tuoi altri dispositivi non riuscita";
/* Action for the modal shown when asking the user whether they want to delete from all of their devices */
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
"delete_message_for_me_and_my_devices" = "Elimina da tutti i miei dispositivi";
/* Action in the long-press menu to trigger a message to be sent again after it has failed */
"context_menu_resend" = "Resend";
"context_menu_resend" = "Invia di nuovo";
/* Action in the long-press menu to trigger a message to be synced again after it has failed */
"context_menu_resync" = "Resync";
"context_menu_resync" = "Sincronizza di nuovo";
/* Title of a modal show the first time a user tries to search for GIFs */
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
"GIPHY_PERMISSION_TITLE" = "Cerca GIF?";
/* Message of a modal show the first time a user tries to search for GIFs */
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
"GIPHY_PERMISSION_MESSAGE" = "Session si connetterà a Giphy per fornire i risultati di ricerca. Non avrai la totale protezione dei metadati quando invierai GIF.";
/* Action in the long-press menu to view more information about a specific message */
"message_info_title" = "Message Info";
"message_info_title" = "Info messaggio";
/* Action to mute a conversation in the swipe menu */
"mute_button_text" = "Mute";
"mute_button_text" = "Silenzia";
/* Action in the swipe menu to unmute a conversation */
"unmute_button_text" = "Unmute";
"unmute_button_text" = "Riattiva";
/* Action in the swipe menu to mark a conversation as read */
"MARK_AS_READ" = "Mark read";
"MARK_AS_READ" = "Segna come letta";
/* Action in the swipe menu to mark a conversation as unread */
"MARK_AS_UNREAD" = "Mark unread";
"MARK_AS_UNREAD" = "Segna come non letta";
/* Title of the confirmation modal show when attempting to leave a group conversation */
"leave_group_confirmation_alert_title" = "Leave Group";
"leave_group_confirmation_alert_title" = "Abbandona gruppo";
/* Title of the confirmation modal show when attempting to leave a community conversation */
"leave_community_confirmation_alert_title" = "Leave Community";
"leave_community_confirmation_alert_title" = "Abbandona comunità";
/* Message in the confirmation modal when leaving a community conversation */
"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?";
"leave_community_confirmation_alert_message" = "Vuoi abbandonare %@?";
/* Conversation subtitle while the user in the process of leaving */
"group_you_leaving" = "Leaving...";
"group_you_leaving" = "Abbandono...";
/* Conversation subtitle if the user in the failed to leave */
"group_leave_error" = "Failed to leave Group!";
"group_leave_error" = "Abbandono del gruppo non riuscito!";
/* Message within a conversation indicating the device was unable to leave a group conversation */
"group_unable_to_leave" = "Unable to leave the Group, please try again";
"group_unable_to_leave" = "Impossibile abbandonare il gruppo, riprova";
/* Title in the confirmation modal to delete a group */
"delete_group_confirmation_alert_title" = "Delete Group";
"delete_group_confirmation_alert_title" = "Elimina gruppo";
/* Message in the confirmation modal to delete a group */
"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?";
"delete_group_confirmation_alert_message" = "Vuoi eliminare %@?";
/* Title in the confirmation modal when the user tries to delete a one-to-one conversation */
"delete_conversation_confirmation_alert_title" = "Delete Conversation";
"delete_conversation_confirmation_alert_title" = "Elimina conversazione";
/* Message in the confirmation modal when the user tries to delete a one-to-one conversation */
"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?";
"delete_conversation_confirmation_alert_message" = "Vuoi eliminare la tua conversazione con %@?";
/* Title in the confirmation modal when the user tries to hide the 'Note to Self' conversation */
"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self";
"hide_note_to_self_confirmation_alert_title" = "Nascondi Note personali";
/* Message in the confirmation modal when the user tries to hide the 'Note to Self' conversation */
"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?";
"hide_note_to_self_confirmation_alert_message" = "Vuoi nascondere %@?";
/* Title in the modal for updating the users profile display picture */
"update_profile_modal_title" = "Set Display Picture";
"update_profile_modal_title" = "Imposta foto profilo";
/* Save action in the modal for updating the users profile display picture */
"update_profile_modal_save" = "Save";
"update_profile_modal_save" = "Salva";
/* Remove action in the modal for updating the users profile display picture */
"update_profile_modal_remove" = "Remove";
"update_profile_modal_remove" = "Rimuovi";
/* Title for the error when failing to remove the users profile display picture */
"update_profile_modal_remove_error_title" = "Unable to remove avatar image";
"update_profile_modal_remove_error_title" = "Impossibile rimuovere la foto profilo";
/* Title for the error when the user selects a profile display picture that is too large */
"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded";
"update_profile_modal_max_size_error_title" = "Dimensione massima del file superata";
/* Message for the error when the user selects a profile display picture that is too large */
"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again";
"update_profile_modal_max_size_error_message" = "Seleziona una foto meno pesante e riprova";
/* Title for the error when the user fails to update their profile display picture */
"update_profile_modal_error_title" = "Couldn't Update Profile";
"update_profile_modal_error_title" = "Impossibile aggiornare il profilo";
/* Message for the error when the user fails to update their profile display picture */
"update_profile_modal_error_message" = "Please check your internet connection and try again";
"update_profile_modal_error_message" = "Controlla la tua connessione e riprova";
/* Placeholder when entering a nickname for a contact */
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"CONTACT_NICKNAME_PLACEHOLDER" = "Inserisci un nome";
/* The separator within a conversation indicating that following messages are unread */
"UNREAD_MESSAGES" = "Unread Messages";
"UNREAD_MESSAGES" = "Messaggi non letti";
/* Empty state for a conversation */
"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!";
"CONVERSATION_EMPTY_STATE" = "Non hai messaggi da %@. Invia un messaggio per iniziare una conversazione!";
/* Empty state for a read-only conversation */
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@.";
"CONVERSATION_EMPTY_STATE_READ_ONLY" = "Non ci sono messaggi in %@.";
/* Empty state for the 'Note to Self' conversation */
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@.";
"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "Non hai messaggi in %@.";
/* Message to indicate a user has Community Message Requests disabled */
"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ has message requests from Community conversations turned off, so you cannot send them a message.";
"COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE" = "%@ ha disattivato le richieste di messaggi dalle comunità, quindi non puoi inviare un messaggio.";
/* Warning to indicate one of the users devices is running an old version of Session */
"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated.";
"USER_CONFIG_OUTDATED_WARNING" = "Alcuni dei tuoi dispositivi stanno utilizzando una versione obsoleta. La sincronizzazione potrebbe essere inaffidabile finché non vengono aggiornati.";
/* Ann error displayed if the device is unable to retrieve the users recovery password */
"LOAD_RECOVERY_PASSWORD_ERROR" = "An error occurred when trying to load your recovery password.\n\nPlease export your logs, then upload the file though Session's Help Desk to help resolve this issue.";
"LOAD_RECOVERY_PASSWORD_ERROR" = "Si è verificato un errore durante il tentativo di caricare la tua password di recupero.\n\nEsporta il resoconto, poi carica il file attraverso il Supporto di Session per aiutare a risolvere il problema.";
/* An error displayed when trying to send a message if the device is unable to save it to the database */
"FAILED_TO_STORE_OUTGOING_MESSAGE" = "An error occurred when trying to store the outgoing message for sending, you may need to restart the app before you can send messages.";
"FAILED_TO_STORE_OUTGOING_MESSAGE" = "Si è verificato un errore durante l'archiviazione del messaggio in uscita per l'invio, potresti dover riavviare l'app prima di poter inviare i messaggi.";
/* An error indicating that the device was unable to access the database for some reason */
"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again.";
"database_inaccessible_error" = "C'è un problema nell'apertura del database. Riavvia l'app e riprova.";
/* A message indicating how the disappearing messages setting applies in a one-to-one conversation */
"DISAPPERING_MESSAGES_SUBTITLE_CONTACTS" = "This setting applies to everyone in this conversation.";
"DISAPPERING_MESSAGES_SUBTITLE_CONTACTS" = "Questa impostazione si applica a tutti i partecipanti di questa conversazione.";
/* A message indicating how the disappearing messages setting applies in a group conversation */
"DISAPPERING_MESSAGES_SUBTITLE_GROUPS" = "Messages disappear after they have been sent.";
"DISAPPERING_MESSAGES_SUBTITLE_GROUPS" = "I messaggi spariscono dopo essere stati inviati.";
/* A record that appears within the message history to indicate that the current user turned on disappearing messages */
"YOU_DISAPPEARING_MESSAGES_INFO_ENABLE" = "You have set messages to disappear %@ after they have been %@";
"YOU_DISAPPEARING_MESSAGES_INFO_ENABLE" = "Hai impostato il timer dei messaggi per farli scomparire %@ dopo che sono stati %@";
/* A record that appears within the message history to indicate that the current user update the disappearing messages setting */
"YOU_DISAPPEARING_MESSAGES_INFO_UPDATE" = "You have changed messages to disappear %@ after they have been %@";
"YOU_DISAPPEARING_MESSAGES_INFO_UPDATE" = "Hai modificato il timer dei messaggi per farli scomparire %@ dopo che sono stati %@";
/* A record that appears within the message history to indicate that the current user has disabled disappearing messages */
"YOU_DISAPPEARING_MESSAGES_INFO_DISABLE" = "You have turned off disappearing messages";
"YOU_DISAPPEARING_MESSAGES_INFO_DISABLE" = "Hai disattivato i messaggi effimeri";
/* The title for the legacy type of disappearing messages on the disappearing messages configuration screen */
"DISAPPEARING_MESSAGES_TYPE_LEGACY_TITLE" = "Legacy";
"DISAPPEARING_MESSAGES_TYPE_LEGACY_TITLE" = "Originale";
/* The description for the legacy type of disappearing messages on the disappearing messages configuration screen */
"DISAPPEARING_MESSAGES_TYPE_LEGACY_DESCRIPTION" = "Original version of disappearing messages.";
"DISAPPEARING_MESSAGES_TYPE_LEGACY_DESCRIPTION" = "La versione originale dei messaggi effimeri.";
/* A warning shown at the top of a conversation to indicate a participant is using an old version of Session which may not support the updated disappearing messages functionality */
"DISAPPEARING_MESSAGES_OUTDATED_CLIENT_BANNER" = "%@ is using an outdated client. Disappearing messages may not work as expected.";
"DISAPPEARING_MESSAGES_OUTDATED_CLIENT_BANNER" = "%@ sta usando un client obsoleto. I messaggi effimeri potrebbero non funzionare come previsto.";
/* An error which can occur when a user tries to update from a version that Session no longer supports updating from */
"DATABASE_UNSUPPORTED_MIGRATION" = "You are trying to updated from a version which no longer supports upgrading\n\nIn order to continue to use session you need to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_UNSUPPORTED_MIGRATION" = "Stai provando ad aggiornare da una versione che non supporta più aggiornamenti\n\nPer continuare a usare Session devi ripristinare il tuo dispositivo\n\nAttenzione: ripristinare il tuo dispositivo comporterà la perdita di tutti i dati più vecchi di due settimane";
/* DISAPPEARING_MESSAGE_STATE_READ
The point that a message will disappear in a disappearing message update message for disappear after read */
"DISAPPEARING_MESSAGE_STATE_READ" = "read";
"DISAPPEARING_MESSAGE_STATE_READ" = "letti";
/* The point that a message will disappear in a disappearing message update message for disappear after send */
"DISAPPEARING_MESSAGE_STATE_SENT" = "sent";
"DISAPPEARING_MESSAGE_STATE_SENT" = "inviati";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "QR コードをスキャンする";
"vc_enter_public_key_explanation" = "新しい会話を始めるには、セッションIDを入力するか、セッションIDを共有します。";
"vc_scan_qr_code_camera_access_explanation" = "Session で QR コードをスキャンするにはカメラへのアクセスが必要です";
"vc_scan_qr_code_grant_camera_access_button_title" = "カメラへのアクセスを許可する";
"vc_create_closed_group_title" = "グループを作成";
"vc_create_closed_group_text_field_hint" = "グループ名を入力してください";
"vc_create_closed_group_empty_state_message" = "まだ連絡先がありません";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "QR 코드 스캔";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skenuoti QR kodą";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Įveskite grupės pavadinimą";
"vc_create_closed_group_empty_state_message" = "Kol kas neturite jokių adresatų";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skenēt QR kodu";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Lai skenētu QR kodus, Session ir nepieciešama piekļuve kamerai";
"vc_scan_qr_code_grant_camera_access_button_title" = "Piešķirt piekļuvi kamerai";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Ievadīt grupas nosaukumu";
"vc_create_closed_group_empty_state_message" = "Patreiz Tev nav neviena kontakta";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR Code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scan QR-code";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session heeft toegang nodig tot de camera om QR codes te scannen";
"vc_scan_qr_code_grant_camera_access_button_title" = "Toegang tot camera verlenen";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Vul een groepsnaam in";
"vc_create_closed_group_empty_state_message" = "U heeft nog geen contactpersonen";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skann QR-kode";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session trenger kameratilgang for å skanne QR-koder";
"vc_scan_qr_code_grant_camera_access_button_title" = "Gi kameratilgang";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Skriv inn et gruppenavn";
"vc_create_closed_group_empty_state_message" = "Du har ingen kontakter ennå";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skanowania QR code";
"vc_enter_public_key_explanation" = "Rozpocznij nową rozmowę, wpisując czyjeś ID sesji lub udostępnij im swój identyfikator sesji.";
"vc_scan_qr_code_camera_access_explanation" = "Session wymaga dostępu do kamery aby skanować kody QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Udziel dostępu do kamery";
"vc_create_closed_group_title" = "Utwórz Grupę";
"vc_create_closed_group_text_field_hint" = "Wpisz nazwę grupy";
"vc_create_closed_group_empty_state_message" = "Nie masz jeszcze żadnych kontaktów";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Escanear código QR";
"vc_enter_public_key_explanation" = "Inicie uma nova conversa inserindo o ID da sessão de alguém ou compartilhe o ID da sessão com alguém.";
"vc_scan_qr_code_camera_access_explanation" = "O Session precisa de acesso à câmera para escanear códigos QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Conceder acesso à câmera";
"vc_create_closed_group_title" = "Criar Grupo";
"vc_create_closed_group_text_field_hint" = "Digite o nome do grupo";
"vc_create_closed_group_empty_state_message" = "Você ainda não possui contatos";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Ler código QR";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Conceder acesso à câmera";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Introduzir o nome do grupo";
"vc_create_closed_group_empty_state_message" = "Você ainda não tem contatos";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scanează cod QR";
"vc_enter_public_key_explanation" = "Începe o conversație nouă introducând ID-ul sesiunii cuiva sau partajează-ți ID-ul sesiunii cu el.";
"vc_scan_qr_code_camera_access_explanation" = "Session are nevoie de permisiunea de acces la camera pentru a scana coduri QR";
"vc_scan_qr_code_grant_camera_access_button_title" = "Acordați acces la camera";
"vc_create_closed_group_title" = "Creează grup";
"vc_create_closed_group_text_field_hint" = "Introduceți un nume de grup";
"vc_create_closed_group_empty_state_message" = "Încă nu aveți persoane de contact";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Сканировать QR-код";
"vc_enter_public_key_explanation" = "Введите Session ID собеседника или отправьте ему ваш Session ID чтобы начать новую беседу.";
"vc_scan_qr_code_camera_access_explanation" = "Session нужен доступ к камере для сканирования QR-кодов";
"vc_scan_qr_code_grant_camera_access_button_title" = "Предоставить доступ к камере";
"vc_create_closed_group_title" = "Создать группу";
"vc_create_closed_group_text_field_hint" = "Введите название группы";
"vc_create_closed_group_empty_state_message" = "У вас еще нет контактов";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "QR කේතය පරිලෝකනය කරන්න";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "QR කේත පරිලෝකනය කිරීමට සැසියට කැමරා ප්‍රවේශය අවශ්‍යයි";
"vc_scan_qr_code_grant_camera_access_button_title" = "කැමරා ප්‍රවේශය ලබා දෙන්න";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "කණ්ඩායම් නමක් ඇතුළත් කරන්න";
"vc_create_closed_group_empty_state_message" = "ඔබට තවම සම්බන්ධතා කිසිවක් නැත";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skenovať QR kód";
"vc_enter_public_key_explanation" = "Začnite novú konverzáciu zadaním niekoho Session ID alebo zdieľajte s nimi svoje Session ID.";
"vc_scan_qr_code_camera_access_explanation" = "Session potrebuje prístup ku kamere na skenovanie QR kódov";
"vc_scan_qr_code_grant_camera_access_button_title" = "Povoliť prístup ku kamere";
"vc_create_closed_group_title" = "Vytvoriť skupinu";
"vc_create_closed_group_text_field_hint" = "Zadajte názov skupiny";
"vc_create_closed_group_empty_state_message" = "Zatiaľ nemáte žiadne kontakty";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Skeniraj QR kodo";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session needs camera access to scan QR codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "Grant Camera Access";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Enter a group name";
"vc_create_closed_group_empty_state_message" = "You don't have any contacts yet";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Scanna QR-kod";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Sessionen behöver kameraåtkomst för att skanna QR-koder";
"vc_scan_qr_code_grant_camera_access_button_title" = "Tillåt kameraåtkomst";
"vc_create_closed_group_title" = "Skapa grupp";
"vc_create_closed_group_text_field_hint" = "Ange ett gruppnamn";
"vc_create_closed_group_empty_state_message" = "Du har inga kontakter";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "สแกน QR โค้ด";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "เปิดใช้กล้องเพื่อสแกนรหัส QR โค้ด";
"vc_scan_qr_code_grant_camera_access_button_title" = "ให้สิทธิใช้กล้อง";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "ป้อนชื่อกลุ่ม";
"vc_create_closed_group_empty_state_message" = "คุณยังไม่มีผู้ติดต่อ";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "QR Kodunu Tara";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session uygulamasının QR kodlarını taramak için kamera erişiminize ihtiyacı var";
"vc_scan_qr_code_grant_camera_access_button_title" = "Kamera Erişimine İzin Verin";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Bir grup adı girin";
"vc_create_closed_group_empty_state_message" = "Henüz herhangi bir bağlantınız yok";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Сканувати QR-код";
"vc_enter_public_key_explanation" = "Почніть нову розмову, ввівши чийсь Session ID або поділіться з ним своїм Session ID.";
"vc_scan_qr_code_camera_access_explanation" = "Session потрібен дозвіл до камери, щоб сканувати QR-код";
"vc_scan_qr_code_grant_camera_access_button_title" = "Дозволити доступ до камери";
"vc_create_closed_group_title" = "Створити групу";
"vc_create_closed_group_text_field_hint" = "Введіть назву групи";
"vc_create_closed_group_empty_state_message" = "У вас ще немає жодних контактів";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "Quét mã QR";
"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them.";
"vc_scan_qr_code_camera_access_explanation" = "Session cần truy cập máy ảnh để quét mã QR ";
"vc_scan_qr_code_grant_camera_access_button_title" = "Cho phép truy cập máy ảnh";
"vc_create_closed_group_title" = "Create Group";
"vc_create_closed_group_text_field_hint" = "Nhập tên nhóm";
"vc_create_closed_group_empty_state_message" = "Bạn chưa có danh bạ nào ";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "扫描二维码";
"vc_enter_public_key_explanation" = "通过某人的 Session ID 或与他人分享你自己的 Session ID 来开始新的会话.";
"vc_scan_qr_code_camera_access_explanation" = "Session需要摄像头访问权限才能扫描二维码";
"vc_scan_qr_code_grant_camera_access_button_title" = "授予摄像头访问权限";
"vc_create_closed_group_title" = "创建群组";
"vc_create_closed_group_text_field_hint" = "输入群组名称";
"vc_create_closed_group_empty_state_message" = "您还没有任何联系人";

@ -289,7 +289,6 @@
"vc_create_private_chat_scan_qr_code_tab_title" = "掃描 QR Code";
"vc_enter_public_key_explanation" = "輸入他人的 Session ID 或分享您的 Session ID 以開始新會話。";
"vc_scan_qr_code_camera_access_explanation" = "Session 需要使用相機來掃描 QR Codes";
"vc_scan_qr_code_grant_camera_access_button_title" = "授與相機權限";
"vc_create_closed_group_title" = "建立群組";
"vc_create_closed_group_text_field_hint" = "請輸入群組名稱";
"vc_create_closed_group_empty_state_message" = "您尚未加入聯絡人";

@ -1,7 +1,5 @@
{
"urls": ["turn:freyr.getsession.org",
"turn:fenrir.getsession.org",
"turn:frigg.getsession.org",
"turn:angus.getsession.org",
"turn:hereford.getsession.org",
"turn:holstein.getsession.org",

@ -341,7 +341,7 @@ private final class ScanQRCodePlaceholderVC: UIViewController {
// Set up call to action button
let callToActionButton = UIButton()
callToActionButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
callToActionButton.setTitle("vc_scan_qr_code_grant_camera_access_button_title".localized(), for: .normal)
callToActionButton.setTitle("continue_2".localized(), for: .normal)
callToActionButton.setThemeTitleColor(.primary, for: .normal)
callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: .touchUpInside)

@ -513,7 +513,7 @@ private final class ScanQRCodePlaceholderVC: UIViewController {
// Call to action button
let callToActionButton = UIButton()
callToActionButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
callToActionButton.setTitle("vc_scan_qr_code_grant_camera_access_button_title".localized(), for: .normal)
callToActionButton.setTitle("continue_2".localized(), for: .normal)
callToActionButton.setThemeTitleColor(.primary, for: .normal)
callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: .touchUpInside)

@ -8,37 +8,27 @@ import SessionUIKit
import SignalUtilitiesKit
import SessionUtilitiesKit
class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsViewModel.Section, BlockedContactsViewModel.DataModel> {
// MARK: - Section
public enum Section: SessionTableSection {
case contacts
case loadMore
var style: SessionTableSectionStyle {
switch self {
case .contacts: return .none
case .loadMore: return .loadMore
}
}
}
// MARK: - Variables
public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource {
public static let pageSize: Int = 30
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let selectedContactIdsSubject: CurrentValueSubject<Set<String>, Never> = CurrentValueSubject([])
public private(set) var pagedDataObserver: PagedDatabaseObserver<Contact, TableItem>?
// MARK: - Initialization
override init() {
_pagedDataObserver = nil
super.init()
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
_pagedDataObserver = PagedDatabaseObserver(
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: Contact.self,
pageSize: BlockedContactsViewModel.pageSize,
idColumn: .id,
@ -64,22 +54,19 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
)
],
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
joinSQL: DataModel.optimisedJoinSQL,
filterSQL: DataModel.filterSQL,
orderSQL: DataModel.orderSQL,
dataQuery: DataModel.query(
filterSQL: DataModel.filterSQL,
orderSQL: DataModel.orderSQL
joinSQL: TableItem.optimisedJoinSQL,
filterSQL: TableItem.filterSQL,
orderSQL: TableItem.orderSQL,
dataQuery: TableItem.query(
filterSQL: TableItem.filterSQL,
orderSQL: TableItem.orderSQL
),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
PagedData.processAndTriggerUpdates(
updatedData: self?.process(data: updatedData, for: updatedPageInfo)
.mapToSessionTableViewData(for: self),
currentDataRetriever: { self?.tableData },
onDataChange: { updatedData, changeset in
self?.contactDataSubject.send((updatedData, changeset))
},
onUnobservedDataChange: { _, _ in }
valueSubject: self?.pendingTableDataSubject
)
}
)
@ -87,49 +74,46 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
// Run the initial query on a background thread so we don't block the push transition
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// The `.pageBefore` will query from a `0` offset loading the first page
self?._pagedDataObserver?.load(.pageBefore)
self?.pagedDataObserver?.load(.pageBefore)
}
}
// MARK: - Contact Data
// MARK: - Section
override var title: String { "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized() }
override var emptyStateTextPublisher: AnyPublisher<String?, Never> {
Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized())
.eraseToAnyPublisher()
public enum Section: SessionTableSection {
case contacts
case loadMore
public var style: SessionTableSectionStyle {
switch self {
case .contacts: return .none
case .loadMore: return .loadMore
}
}
}
private let contactDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> = CurrentValueSubject(([], StagedChangeset()))
private let selectedContactIdsSubject: CurrentValueSubject<Set<String>, Never> = CurrentValueSubject([])
private var _pagedDataObserver: PagedDatabaseObserver<Contact, DataModel>?
public override var pagedDataObserver: TransactionObserver? { _pagedDataObserver }
public override var observableTableData: ObservableData { _observableTableData }
private lazy var _observableTableData: ObservableData = contactDataSubject
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
// MARK: - Content
override var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
selectedContactIdsSubject
.prepend([])
.map { selectedContactIds in
SessionButton.Info(
style: .destructive,
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(),
isEnabled: !selectedContactIds.isEmpty,
onTap: { [weak self] in self?.unblockTapped() }
)
}
let title: String = "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized()
let emptyStateTextPublisher: AnyPublisher<String?, Never> = Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized())
.eraseToAnyPublisher()
}
// MARK: - Functions
lazy var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> = selectedContactIdsSubject
.prepend([])
.map { selectedContactIds in
SessionButton.Info(
style: .destructive,
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(),
isEnabled: !selectedContactIds.isEmpty,
onTap: { [weak self] in self?.unblockTapped() }
)
}
.eraseToAnyPublisher()
override func loadPageAfter() { _pagedDataObserver?.load(.pageAfter) }
// MARK: - Functions
private func process(
data: [DataModel],
data: [TableItem],
for pageInfo: PagedData.PageInfo
) -> [SectionModel] {
return [
@ -143,7 +127,7 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
return (lhsValue < rhsValue)
}
.map { [weak self] model -> SessionCell.Info<DataModel> in
.map { [weak self] model -> SessionCell.Info<TableItem> in
SessionCell.Info(
id: model,
leftAccessory: .profile(id: model.id, profile: model.profile),
@ -188,7 +172,7 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
guard
let section: BlockedContactsViewModel.SectionModel = self.tableData
.first(where: { section in section.model == .contacts }),
let info: SessionCell.Info<DataModel> = section.elements
let info: SessionCell.Info<TableItem> = section.elements
.first(where: { info in info.id.id == contactId })
else { return contactId }
@ -262,9 +246,9 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
self.transitionToScreen(confirmationModal, transitionType: .present)
}
// MARK: - DataModel
// MARK: - TableItem
public struct DataModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible {
public struct TableItem: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible {
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case rowId
@ -281,21 +265,21 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
static func query(
filterSQL: SQL,
orderSQL: SQL
) -> (([Int64]) -> any FetchRequest<DataModel>) {
return { rowIds -> any FetchRequest<DataModel> in
) -> (([Int64]) -> any FetchRequest<TableItem>) {
return { rowIds -> any FetchRequest<TableItem> in
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
/// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before
/// the `DataModel.profileKey` entry below otherwise the query will fail to
/// the `TableItem.profileKey` entry below otherwise the query will fail to
/// parse and might throw
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfile: Int = 2
let request: SQLRequest<DataModel> = """
let request: SQLRequest<TableItem> = """
SELECT
\(contact[.rowId]) AS \(DataModel.Columns.rowId),
\(contact[.rowId]) AS \(TableItem.Columns.rowId),
\(contact[.id]),
\(profile.allColumns)
@ -311,7 +295,7 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
Profile.numberOfSelectedColumns(db)
])
return ScopeAdapter.with(DataModel.self, [
return ScopeAdapter.with(TableItem.self, [
.profile: adapters[1]
])
}
@ -338,5 +322,4 @@ class BlockedContactsViewModel: SessionTableViewModel<NoNav, BlockedContactsView
return SQL("IFNULL(IFNULL(\(profile[.nickname]), \(profile[.name])), \(contact[.id])) ASC")
}()
}
}

@ -7,7 +7,20 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSettingsViewModel.Section, ConversationSettingsViewModel.Section> {
class ConversationSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = Section
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
// MARK: - Initialization
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
}
// MARK: - Section
public enum Section: SessionTableSection {
@ -38,29 +51,16 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
let shouldAutoPlayConsecutiveAudioMessages: Bool
}
override var title: String { "CONVERSATION_SETTINGS_TITLE".localized() }
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "CONVERSATION_SETTINGS_TITLE".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self] db -> State in
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [weak self] db -> State in
State(
trimOpenGroupMessagesOlderThanSixMonths: db[.trimOpenGroupMessagesOlderThanSixMonths],
shouldAutoPlayConsecutiveAudioMessages: db[.shouldAutoPlayConsecutiveAudioMessages]
)
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ConversationSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.withPrevious()
.map { (previous: State?, current: State) -> [SectionModel] in
.mapWithPrevious { [dependencies] previous, current -> [SectionModel] in
return [
SectionModel(
model: .messageTrimming,
@ -126,5 +126,4 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
)
]
}
.mapToSessionTableViewData(for: self)
}

@ -9,11 +9,24 @@ import SessionMessagingKit
import SessionUtilitiesKit
import SignalCoreKit
class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = Section
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
#if DEBUG
private var databaseKeyEncryptionPassword: String = ""
#endif
// MARK: - Initialization
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
}
// MARK: - Section
public enum Section: SessionTableSection {
@ -31,146 +44,132 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
// MARK: - Content
override var title: String { "HELP_TITLE".localized() }
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "HELP_TITLE".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { db -> [SectionModel] in
return [
SectionModel(
model: .report,
elements: [
SessionCell.Info(
id: .report,
title: "HELP_REPORT_BUG_TITLE".localized(),
subtitle: "HELP_REPORT_BUG_DESCRIPTION".localized(),
rightAccessory: .highlightingBackgroundLabel(
title: "HELP_REPORT_BUG_ACTION_TITLE".localized()
),
onTapView: { HelpViewModel.shareLogs(targetView: $0) }
)
]
),
SectionModel(
model: .translate,
elements: [
SessionCell.Info(
id: .translate,
title: "HELP_TRANSLATE_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://crowdin.com/project/session-ios") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
SectionModel(
model: .feedback,
elements: [
SessionCell.Info(
id: .feedback,
title: "HELP_FEEDBACK_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://getsession.org/survey") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
SectionModel(
model: .faq,
elements: [
SessionCell.Info(
id: .faq,
title: "HELP_FAQ_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://getsession.org/faq") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
SectionModel(
model: .support,
elements: [
SessionCell.Info(
id: .support,
title: "HELP_SUPPORT_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://sessionapp.zendesk.com/hc/en-us") else {
return
}
UIApplication.shared.open(url)
}
)
]
lazy var observation: TargetObservation = [
SectionModel(
model: .report,
elements: [
SessionCell.Info(
id: .report,
title: "HELP_REPORT_BUG_TITLE".localized(),
subtitle: "HELP_REPORT_BUG_DESCRIPTION".localized(),
rightAccessory: .highlightingBackgroundLabel(
title: "HELP_REPORT_BUG_ACTION_TITLE".localized()
),
onTapView: { HelpViewModel.shareLogs(targetView: $0) }
)
]
#if DEBUG
.appending(
SectionModel(
model: .exportDatabase,
elements: [
SessionCell.Info(
id: .support,
title: "Export Database",
rightAccessory: .icon(
UIImage(systemName: "square.and.arrow.up.trianglebadge.exclamationmark")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
styling: SessionCell.StyleInfo(
tintColor: .danger
),
onTapView: { [weak self] view in self?.exportDatabase(view) }
)
]
),
SectionModel(
model: .translate,
elements: [
SessionCell.Info(
id: .translate,
title: "HELP_TRANSLATE_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://crowdin.com/project/session-ios") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
SectionModel(
model: .feedback,
elements: [
SessionCell.Info(
id: .feedback,
title: "HELP_FEEDBACK_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://getsession.org/survey") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
SectionModel(
model: .faq,
elements: [
SessionCell.Info(
id: .faq,
title: "HELP_FAQ_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://getsession.org/faq") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
SectionModel(
model: .support,
elements: [
SessionCell.Info(
id: .support,
title: "HELP_SUPPORT_TITLE".localized(),
rightAccessory: .icon(
UIImage(systemName: "arrow.up.forward.app")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
onTap: {
guard let url: URL = URL(string: "https://sessionapp.zendesk.com/hc/en-us") else {
return
}
UIApplication.shared.open(url)
}
)
]
),
maybeExportDbSection
]
#if DEBUG
private lazy var maybeExportDbSection: SectionModel? = SectionModel(
model: .exportDatabase,
elements: [
SessionCell.Info(
id: .support,
title: "Export Database",
rightAccessory: .icon(
UIImage(systemName: "square.and.arrow.up.trianglebadge.exclamationmark")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
styling: SessionCell.StyleInfo(
tintColor: .danger
),
onTapView: { [weak self] view in self?.exportDatabase(view) }
)
]
)
#else
private let maybeExportDbSection: SectionModel? = nil
#endif
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[HelpViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
// MARK: - Functions

@ -7,18 +7,18 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSettingsViewModel.Section, Preferences.NotificationPreviewType> {
private let storage: Storage
private let scheduler: ValueObservationScheduler
class NotificationContentViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = Preferences.NotificationPreviewType
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
// MARK: - Initialization
init(
storage: Storage = Storage.shared,
scheduling scheduler: ValueObservationScheduler = Storage.defaultPublisherScheduler
) {
self.storage = storage
self.scheduler = scheduler
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
}
// MARK: - Section
@ -29,22 +29,13 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
// MARK: - Content
override var title: String { "NOTIFICATIONS_STYLE_CONTENT_TITLE".localized() }
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "NOTIFICATIONS_STYLE_CONTENT_TITLE".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [storage] db -> [SectionModel] in
let currentSelection: Preferences.NotificationPreviewType? = db[.preferencesNotificationPreviewType]
.defaulting(to: .defaultPreviewType)
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { db -> Preferences.NotificationPreviewType in
db[.preferencesNotificationPreviewType].defaulting(to: .defaultPreviewType)
}
.map { [weak self, dependencies] currentSelection -> [SectionModel] in
return [
SectionModel(
model: .content,
@ -56,8 +47,8 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
rightAccessory: .radio(
isSelected: { (currentSelection == previewType) }
),
onTap: { [weak self] in
storage.writeAsync { db in
onTap: {
dependencies.storage.writeAsync { db in
db[.preferencesNotificationPreviewType] = previewType
}
@ -68,8 +59,4 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
)
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[NotificationContentViewModel] Observation failed with error: \($0)") })
.publisher(in: storage, scheduling: scheduler)
.mapToSessionTableViewData(for: self)
}

@ -7,7 +7,18 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSettingsViewModel.Section, NotificationSettingsViewModel.Item> {
class NotificationSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
// MARK: - Initialization
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
}
// MARK: - Config
public enum Section: SessionTableSection {
@ -31,7 +42,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
}
}
public enum Item: Differentiable {
public enum TableItem: Differentiable {
case strategyUseFastMode
case strategyDeviceSettings
case styleSound
@ -48,19 +59,10 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
let previewType: Preferences.NotificationPreviewType
}
override var title: String { "NOTIFICATIONS_TITLE".localized() }
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "NOTIFICATIONS_TITLE".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { db -> State in
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { db -> State in
State(
isUsingFullAPNs: false, // Set later the the data flow
notificationSound: db[.defaultNotificationSound]
@ -70,10 +72,6 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
.defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType)
)
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[NotificationSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.manualRefreshFrom(forcedRefresh)
.map { dbState -> State in
State(
isUsingFullAPNs: UserDefaults.standard[.isUsingFullAPNs],
@ -82,8 +80,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
previewType: dbState.previewType
)
}
.withPrevious()
.map { (previous: State?, current: State) -> [SectionModel] in
.mapWithPrevious { [dependencies] previous, current -> [SectionModel] in
return [
SectionModel(
model: .strategy,
@ -181,5 +178,4 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
)
]
}
.mapToSessionTableViewData(for: self)
}

@ -8,17 +8,13 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewModel.NavButton, NotificationSettingsViewModel.Section, Preferences.Sound> {
// MARK: - Config
enum NavButton: Equatable {
case cancel
case save
}
class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = Preferences.Sound
public enum Section: SessionTableSection {
case content
}
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
// FIXME: Remove `threadId` once we ditch the per-thread notification sound
private let threadId: String?
@ -28,7 +24,8 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
// MARK: - Initialization
init(threadId: String? = nil) {
init(threadId: String? = nil, using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
self.threadId = threadId
}
@ -37,73 +34,70 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
self.audioPlayer = nil
}
// MARK: - Navigation
// MARK: - Config
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
Just([
NavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.dismissScreen()
}
]).eraseToAnyPublisher()
enum NavItem: Equatable {
case cancel
case save
}
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
currentSelection
.removeDuplicates()
.map { [weak self] currentSelection in (self?.storedSelection != currentSelection) }
.map { isChanged in
guard isChanged else { return [] }
return [
NavItem(
id: .save,
systemItem: .save,
accessibilityIdentifier: "Save button"
) { [weak self] in
self?.saveChanges()
self?.dismissScreen()
}
]
}
.eraseToAnyPublisher()
public enum Section: SessionTableSection {
case content
}
// MARK: - Content
// MARK: - Navigation
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in self?.dismissScreen() }
]
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = currentSelection
.removeDuplicates()
.map { [weak self] currentSelection in (self?.storedSelection != currentSelection) }
.map { isChanged in
guard isChanged else { return [] }
return [
SessionNavItem(
id: .save,
systemItem: .save,
accessibilityIdentifier: "Save button"
) { [weak self] in
self?.saveChanges()
self?.dismissScreen()
}
]
}
.eraseToAnyPublisher()
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
// MARK: - Content
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "NOTIFICATIONS_STYLE_SOUND_TITLE".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self] db -> [SectionModel] in
self?.storedSelection = try {
guard let threadId: String = self?.threadId else {
return db[.defaultNotificationSound]
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [threadId] db -> Preferences.Sound in
guard let threadId: String = threadId else {
return db[.defaultNotificationSound]
.defaulting(to: .defaultNotificationSound)
}
return try SessionThread
.filter(id: threadId)
.select(.notificationSound)
.asRequest(of: Preferences.Sound.self)
.fetchOne(db)
.defaulting(
to: db[.defaultNotificationSound]
.defaulting(to: .defaultNotificationSound)
}
return try SessionThread
.filter(id: threadId)
.select(.notificationSound)
.asRequest(of: Preferences.Sound.self)
.fetchOne(db)
.defaulting(
to: db[.defaultNotificationSound]
.defaulting(to: .defaultNotificationSound)
)
}()
self?.currentSelection.send(self?.currentSelection.value ?? self?.storedSelection)
)
}
.map { [weak self] storedSelection in
self?.storedSelection = storedSelection
self?.currentSelection.send(self?.currentSelection.value ?? storedSelection)
return [
SectionModel(
@ -127,11 +121,10 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
),
onTap: {
self?.currentSelection.send(sound)
self?.audioPlayer?.stop() // Stop the old sound immediately
// Play the sound (to prevent UI lag we dispatch this to the next
// run loop
DispatchQueue.main.async {
self?.audioPlayer?.stop()
// Play the sound (to prevent UI lag we dispatch after a short delay)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self?.audioPlayer = Preferences.Sound.audioPlayer(
for: sound,
behavior: .playback
@ -145,10 +138,6 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
)
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[NotificationSoundViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
// MARK: - Functions

@ -9,20 +9,24 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.NavButton, PrivacySettingsViewModel.Section, PrivacySettingsViewModel.Item> {
class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let editableState: EditableState<TableItem> = EditableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let shouldShowCloseButton: Bool
// MARK: - Initialization
init(shouldShowCloseButton: Bool = false) {
init(shouldShowCloseButton: Bool = false, using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
self.shouldShowCloseButton = shouldShowCloseButton
super.init()
}
// MARK: - Config
enum NavButton: Equatable {
enum NavItem: Equatable {
case close
}
@ -48,7 +52,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
var style: SessionTableSectionStyle { return .titleRoundedContent }
}
public enum Item: Differentiable {
public enum TableItem: Differentiable {
case screenLock
case communityMessageRequests
case screenshotNotifications
@ -60,21 +64,17 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
// MARK: - Navigation
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
guard self.shouldShowCloseButton else { return Just([]).eraseToAnyPublisher() }
return Just([
NavItem(
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = (!shouldShowCloseButton ? [] :
[
SessionNavItem(
id: .close,
image: UIImage(named: "X")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Close Button"
) { [weak self] in
self?.dismissScreen()
}
]).eraseToAnyPublisher()
}
) { [weak self] in self?.dismissScreen() }
]
)
// MARK: - Content
@ -87,19 +87,10 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
let areCallsEnabled: Bool
}
override var title: String { "PRIVACY_TITLE".localized() }
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "PRIVACY_TITLE".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self] db -> State in
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [weak self] db -> State in
State(
isScreenLockEnabled: db[.isScreenLockEnabled],
checkForCommunityMessageRequests: db[.checkForCommunityMessageRequests],
@ -109,11 +100,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
areCallsEnabled: db[.areCallsEnabled]
)
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[PrivacySettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.withPrevious()
.map { (previous: State?, current: State) -> [SectionModel] in
.mapWithPrevious { [dependencies] previous, current -> [SectionModel] in
return [
SectionModel(
model: .screenSecurity,
@ -312,5 +299,4 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
)
]
}
.mapToSessionTableViewData(for: self)
}

@ -303,7 +303,7 @@ private final class ScanQRCodePlaceholderVC : UIViewController {
// Set up call to action button
let callToActionButton = UIButton()
callToActionButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
callToActionButton.setTitle("vc_scan_qr_code_grant_camera_access_button_title".localized(), for: UIControl.State.normal)
callToActionButton.setTitle("continue_2".localized(), for: .normal)
callToActionButton.setThemeTitleColor(.primary, for: .normal)
callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: UIControl.Event.touchUpInside)

@ -10,7 +10,38 @@ import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, SettingsViewModel.Section, SettingsViewModel.Item> {
class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, EditableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let editableState: EditableState<TableItem> = EditableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let userSessionId: String
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImageDataPicked: { [weak self] resultImageData in
guard let oldDisplayName: String = self?.oldDisplayName else { return }
self?.updatedProfilePictureSelected(
name: oldDisplayName,
avatarUpdate: .uploadImageData(resultImageData)
)
}
)
fileprivate var oldDisplayName: String
private var editedDisplayName: String?
private var editProfilePictureModal: ConfirmationModal?
private var editProfilePictureModalInfo: ConfirmationModal.Info?
// MARK: - Initialization
init(using dependencies: Dependencies = Dependencies()) {
self.dependencies = dependencies
self.userSessionId = getUserHexEncodedPublicKey(using: dependencies)
self.oldDisplayName = Profile.fetchOrCreateCurrentUser(using: dependencies).name
}
// MARK: - Config
enum NavState {
@ -18,7 +49,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
case editing
}
enum NavButton: Equatable {
enum NavItem: Equatable {
case close
case qrCode
case cancel
@ -47,7 +78,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
}
}
public enum Item: Differentiable {
public enum TableItem: Differentiable {
case avatar
case profileName
@ -66,34 +97,6 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
case clearData
}
// MARK: - Variables
private let userSessionId: String
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImageDataPicked: { [weak self] resultImageData in
guard let oldDisplayName: String = self?.oldDisplayName else { return }
self?.updatedProfilePictureSelected(
name: oldDisplayName,
avatarUpdate: .uploadImageData(resultImageData)
)
}
)
fileprivate var oldDisplayName: String
private var editedDisplayName: String?
private var editProfilePictureModal: ConfirmationModal?
private var editProfilePictureModalInfo: ConfirmationModal.Info?
// MARK: - Initialization
override init() {
self.userSessionId = getUserHexEncodedPublicKey()
self.oldDisplayName = Profile.fetchOrCreateCurrentUser().name
super.init()
}
// MARK: - Navigation
lazy var navState: AnyPublisher<NavState, Never> = {
@ -116,58 +119,55 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
.eraseToAnyPublisher()
}()
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
navState
.map { navState -> [NavItem] in
switch navState {
case .standard:
return [
NavItem(
id: .close,
image: UIImage(named: "X")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Close button"
) { [weak self] in self?.dismissScreen() }
]
case .editing:
return [
NavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
}
.eraseToAnyPublisher()
}
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { navState -> [SessionNavItem<NavItem>] in
switch navState {
case .standard:
return [
SessionNavItem(
id: .close,
image: UIImage(named: "X")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Close button"
) { [weak self] in self?.dismissScreen() }
]
case .editing:
return [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
}
.eraseToAnyPublisher()
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
navState
.map { [weak self] navState -> [NavItem] in
switch navState {
case .standard:
return [
NavItem(
id: .qrCode,
image: UIImage(named: "QRCode")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Show QR code button",
action: { [weak self] in
self?.transitionToScreen(QRCodeVC())
}
)
]
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { [weak self] navState -> [SessionNavItem<NavItem>] in
switch navState {
case .standard:
return [
SessionNavItem(
id: .qrCode,
image: UIImage(named: "QRCode")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Show QR code button",
action: { [weak self] in
self?.transitionToScreen(QRCodeVC())
}
)
]
case .editing:
return [
NavItem(
SessionNavItem(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
@ -209,30 +209,20 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
avatarUpdate: .none
)
}
]
}
}
.eraseToAnyPublisher()
}
]
}
}
.eraseToAnyPublisher()
// MARK: - Content
override var title: String { "vc_settings_title".localized() }
public override var observableTableData: ObservableData { _observableTableData }
let title: String = "vc_settings_title".localized()
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
lazy var observation: TargetObservation = ObservationBuilder
.databaseObservation(self) { [weak self, dependencies] db -> Profile in
Profile.fetchOrCreateCurrentUser(db, using: dependencies)
}
.map { [weak self] profile -> [SectionModel] in
return [
SectionModel(
model: .profileInfo,
@ -329,7 +319,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
elements: [
SessionCell.Info(
id: .path,
leftAccessory: .customView(hashValue: "PathStatusView") {
leftAccessory: .customView(hashValue: "PathStatusView") { // stringlint:disable
// Need to ensure this view is the same size as the icons so
// wrap it in a larger view
let result: UIView = UIView()
@ -392,7 +382,9 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
),
title: "MESSAGE_REQUESTS_TITLE".localized(),
onTap: {
self?.transitionToScreen(MessageRequestsViewController())
self?.transitionToScreen(
SessionTableViewController(viewModel: MessageRequestsViewModel())
)
}
),
SessionCell.Info(
@ -480,15 +472,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
)
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[SettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
public override var footerView: AnyPublisher<UIView?, Never> {
Just(VersionFooterView())
.eraseToAnyPublisher()
}
public let footerView: AnyPublisher<UIView?, Never> = Just(VersionFooterView()).eraseToAnyPublisher()
// MARK: - Functions
@ -573,7 +558,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
DispatchQueue.main.async {
let picker: UIImagePickerController = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = [ "public.image" ]
picker.mediaTypes = [ "public.image" ] // stringlint:disable
picker.delegate = self?.imagePickerHandler
self?.transitionToScreen(picker, transitionType: .present)

@ -1,92 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
class BlockedContactCell: UITableViewCell {
// MARK: - Components
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
private let selectionView: RadioButton = {
let result: RadioButton = RadioButton(size: .medium)
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.font = .systemFont(ofSize: Values.mediumFontSize, weight: .bold)
return result
}()
// MARK: - Initializtion
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
// MARK: - Layout
private func setUpViewHierarchy() {
// Background color
themeBackgroundColor = .conversationButton_background
// Highlight color
let selectedBackgroundView = UIView()
selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
self.selectedBackgroundView = selectedBackgroundView
// Add the UI
contentView.addSubview(profilePictureView)
contentView.addSubview(selectionView)
setupLayout()
}
private func setupLayout() {
// Profile picture view
profilePictureView.center(.vertical, in: contentView)
profilePictureView.topAnchor
.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Values.mediumSpacing)
.isActive = true
profilePictureView.bottomAnchor
.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Values.mediumSpacing)
.isActive = true
profilePictureView.pin(.left, to: .left, of: contentView, withInset: Values.veryLargeSpacing)
selectionView.center(.vertical, in: contentView)
selectionView.topAnchor
.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Values.mediumSpacing)
.isActive = true
selectionView.bottomAnchor
.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Values.mediumSpacing)
.isActive = true
selectionView.pin(.left, to: .right, of: profilePictureView, withInset: Values.mediumSpacing)
selectionView.pin(.right, to: .right, of: contentView, withInset: -Values.veryLargeSpacing)
}
// MARK: - Content
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
profilePictureView.update(
publicKey: cellViewModel.id,
threadVariant: .contact,
customImageData: nil,
profile: cellViewModel.profile,
additionalProfile: nil
)
selectionView.text = (
cellViewModel.profile?.displayName() ??
Profile.truncated(id: cellViewModel.id, truncating: .middle)
)
selectionView.update(isSelected: isSelected)
}
}

@ -12,10 +12,12 @@ protocol SessionViewModelAccessible {
var viewModelType: AnyObject.Type { get }
}
class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable>: BaseVC, UITableViewDataSource, UITableViewDelegate, SessionViewModelAccessible {
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITableViewDelegate, SessionViewModelAccessible where ViewModel: (SessionTableViewModel & ObservableTableSource) {
typealias Section = ViewModel.Section
typealias TableItem = ViewModel.TableItem
typealias SectionModel = ViewModel.SectionModel
private let viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>
private let viewModel: ViewModel
private var hasLoadedInitialTableData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
@ -37,6 +39,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
result.register(view: SessionCell.self)
result.register(view: FullConversationCell.self)
result.registerHeaderFooterView(view: SessionHeaderView.self)
result.dataSource = self
result.delegate = self
@ -48,6 +51,20 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
return result
}()
private lazy var initialLoadLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textSecondary
result.text = viewModel.initialLoadMessage
result.textAlignment = .center
result.numberOfLines = 0
result.isHidden = (viewModel.initialLoadMessage == nil)
return result
}()
private lazy var emptyStateLabel: UILabel = {
let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false
@ -87,10 +104,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
// MARK: - Initialization
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
init(viewModel: ViewModel) {
self.viewModel = viewModel
Storage.shared.addObserver(viewModel.pagedDataObserver)
(viewModel as? (any PagedObservationSource))?.didInit(using: viewModel.dependencies)
super.init(nibName: nil, bundle: nil)
}
@ -116,6 +133,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
view.themeBackgroundColor = .backgroundPrimary
view.addSubview(tableView)
view.addSubview(initialLoadLabel)
view.addSubview(emptyStateLabel)
view.addSubview(fadeView)
view.addSubview(footerButton)
@ -159,7 +177,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
@objc func applicationDidBecomeActive(_ notification: Notification) {
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
DispatchQueue.main.async { [weak self] in
self?.startObservingChanges()
self?.startObservingChanges(didReturnFromBackground: true)
}
}
@ -170,6 +188,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
private func setupLayout() {
tableView.pin(to: view)
initialLoadLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
initialLoadLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
initialLoadLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
emptyStateLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
emptyStateLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
emptyStateLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
@ -184,9 +206,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
// MARK: - Updating
private func startObservingChanges() {
private func startObservingChanges(didReturnFromBackground: Bool = false) {
// Start observing for data changes
dataChangeCancellable = viewModel.observableTableData
dataChangeCancellable = viewModel.tableDataPublisher
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in
@ -202,7 +224,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
SNLog("Atempting recovery for database stream in '\(title)' settings with error: \(error)")
self?.dataStreamJustFailed = true
self?.startObservingChanges()
self?.startObservingChanges(didReturnFromBackground: didReturnFromBackground)
case .finished: break
}
@ -212,6 +234,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
self?.handleDataUpdates(updatedData, changeset: changeset)
}
)
// Some viewModel's may need to run custom logic after returning from the background so trigger that here
if didReturnFromBackground { viewModel.didReturnFromBackground() }
}
private func stopObservingChanges() {
@ -233,7 +258,8 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
// in from a frame of CGRect.zero)
guard hasLoadedInitialTableData else {
UIView.performWithoutAnimation {
// Update the empty state
// Update the initial/empty state
initialLoadLabel.isHidden = true
emptyStateLabel.isHidden = (itemCount > 0)
// Update the content
@ -302,7 +328,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
self?.isLoadingMore = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.loadPageAfter()
(self?.viewModel as? (any PagedObservationSource))?.loadPageAfter()
}
}
}
@ -310,13 +336,22 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
// MARK: - Binding
private func setupBinding() {
viewModel.isEditing
(viewModel as? (any NavigationItemSource))?.setupBindings(
viewController: self,
disposables: &disposables
)
(viewModel as? (any NavigatableStateHolder))?.navigatableState.setupBindings(
viewController: self,
disposables: &disposables
)
(viewModel as? ErasedEditableStateHolder)?.isEditing
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing in
.sink { [weak self, weak tableView] isEditing in
UIView.animate(withDuration: 0.25) {
self?.setEditing(isEditing, animated: true)
self?.tableView.visibleCells
tableView?.visibleCells
.compactMap { $0 as? SessionCell }
.filter { $0.interactionMode == .editable || $0.interactionMode == .alwaysEditing }
.enumerated()
@ -332,56 +367,12 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
)
}
self?.tableView.beginUpdates()
self?.tableView.endUpdates()
tableView?.beginUpdates()
tableView?.endUpdates()
}
}
.store(in: &disposables)
viewModel.leftNavItems
.receive(on: DispatchQueue.main)
.sink { [weak self] maybeItems in
self?.navigationItem.setLeftBarButtonItems(
maybeItems.map { items in
items.map { item -> DisposableBarButtonItem in
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
buttonItem.themeTintColor = .textPrimary
buttonItem.tapPublisher
.map { _ in item.id }
.sink(receiveValue: { _ in item.action?() })
.store(in: &buttonItem.disposables)
return buttonItem
}
},
animated: true
)
}
.store(in: &disposables)
viewModel.rightNavItems
.receive(on: DispatchQueue.main)
.sink { [weak self] maybeItems in
self?.navigationItem.setRightBarButtonItems(
maybeItems.map { items in
items.map { item -> DisposableBarButtonItem in
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
buttonItem.themeTintColor = .textPrimary
buttonItem.tapPublisher
.map { _ in item.id }
.sink(receiveValue: { _ in item.action?() })
.store(in: &buttonItem.disposables)
return buttonItem
}
},
animated: true
)
}
.store(in: &disposables)
viewModel.emptyStateTextPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] text in
@ -403,6 +394,8 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
self?.footerButton.setStyle(buttonInfo.style)
self?.footerButton.isEnabled = buttonInfo.isEnabled
self?.footerButton.accessibilityIdentifier = buttonInfo.accessibility?.identifier
self?.footerButton.accessibilityLabel = buttonInfo.accessibility?.label
}
self?.onFooterTap = buttonInfo?.onTap
@ -422,61 +415,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
)
}
.store(in: &disposables)
viewModel.showToast
.receive(on: DispatchQueue.main)
.sink { [weak self] text, color in
guard let view: UIView = self?.view else { return }
let toastController: ToastController = ToastController(text: text, background: color)
toastController.presentToastView(fromBottomOfView: view, inset: Values.largeSpacing)
}
.store(in: &disposables)
viewModel.transitionToScreen
.receive(on: DispatchQueue.main)
.sink { [weak self] viewController, transitionType in
switch transitionType {
case .push:
self?.navigationController?.pushViewController(viewController, animated: true)
case .present:
let presenter: UIViewController? = (self?.presentedViewController ?? self)
if UIDevice.current.isIPad {
viewController.popoverPresentationController?.permittedArrowDirections = []
viewController.popoverPresentationController?.sourceView = presenter?.view
viewController.popoverPresentationController?.sourceRect = (presenter?.view.bounds ?? UIScreen.main.bounds)
}
presenter?.present(viewController, animated: true)
}
}
.store(in: &disposables)
viewModel.dismissScreen
.receive(on: DispatchQueue.main)
.sink { [weak self] dismissType in
switch dismissType {
case .auto:
guard
let viewController: UIViewController = self,
(self?.navigationController?.viewControllers
.firstIndex(of: viewController))
.defaulting(to: 0) > 0
else {
self?.dismiss(animated: true)
return
}
self?.navigationController?.popViewController(animated: true)
case .dismiss: self?.dismiss(animated: true)
case .pop: self?.navigationController?.popViewController(animated: true)
case .popToRoot: self?.navigationController?.popToRootViewController(animated: true)
}
}
.store(in: &disposables)
}
@objc private func footerButtonTapped() {
@ -495,19 +433,36 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: SectionModel = viewModel.tableData[indexPath.section]
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
cell.update(with: info)
cell.update(
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
becomeFirstResponder: false,
animated: false
)
cell.textPublisher
.sink(receiveValue: { [weak self] text in
self?.viewModel.textChanged(text, for: info.id)
})
.store(in: &cell.disposables)
let info: SessionCell.Info<TableItem> = section.elements[indexPath.row]
let cell: UITableViewCell = tableView.dequeue(type: viewModel.cellType.viewType.self, for: indexPath)
switch (cell, info) {
case (let cell as SessionCell, _):
cell.update(with: info)
cell.update(
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
becomeFirstResponder: false,
animated: false
)
switch viewModel {
case let editableStateHolder as ErasedEditableStateHolder:
cell.textPublisher
.sink(receiveValue: { [weak editableStateHolder] text in
editableStateHolder?.textChanged(text, for: info.id)
})
.store(in: &cell.disposables)
default: break
}
case (let cell as FullConversationCell, let threadInfo as SessionCell.Info<SessionThreadViewModel>):
cell.accessibilityIdentifier = info.accessibility?.identifier
cell.isAccessibilityElement = (info.accessibility != nil)
cell.update(with: threadInfo.id)
default:
SNLog("[SessionTableViewController] Got invalid combination of cellType: \(viewModel.cellType) and tableData: \(SessionCell.Info<TableItem>.self)")
}
return cell
}
@ -547,18 +502,38 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
self.isLoadingMore = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.loadPageAfter()
(self?.viewModel as? (any PagedObservationSource))?.loadPageAfter()
}
default: break
}
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return viewModel.canEditRow(at: indexPath)
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return viewModel.leadingSwipeActionsConfiguration(forRowAt: indexPath, in: tableView, of: self)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return viewModel.trailingSwipeActionsConfiguration(forRowAt: indexPath, in: tableView, of: self)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: SectionModel = self.viewModel.tableData[indexPath.section]
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
let info: SessionCell.Info<TableItem> = section.elements[indexPath.row]
// Do nothing if the item is disabled
guard info.isEnabled else { return }
@ -580,7 +555,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
return cell
}
}()
let maybeOldSelection: (Int, SessionCell.Info<SettingItem>)? = section.elements
let maybeOldSelection: (Int, SessionCell.Info<TableItem>)? = section.elements
.enumerated()
.first(where: { index, info in
switch (info.leftAccessory, info.rightAccessory) {
@ -596,7 +571,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
// Update the old selection as well
if let oldSelection: (index: Int, info: SessionCell.Info<SettingItem>) = maybeOldSelection {
if let oldSelection: (index: Int, info: SessionCell.Info<TableItem>) = maybeOldSelection {
self?.manuallyReload(
indexPath: IndexPath(
row: oldSelection.index,
@ -628,7 +603,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
private func manuallyReload(
indexPath: IndexPath,
section: SectionModel,
info: SessionCell.Info<SettingItem>
info: SessionCell.Info<TableItem>
) {
// Try update the existing cell to have a nice animation instead of reloading the cell
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {

@ -1,6 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit.UIImage
import UIKit
import Combine
import GRDB
import DifferenceKit
@ -8,130 +8,53 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable> {
typealias SectionModel = ArraySection<Section, SessionCell.Info<SettingItem>>
typealias ObservableData = AnyPublisher<([SectionModel], StagedChangeset<[SectionModel]>), Error>
// MARK: - Input
private let _isEditing: CurrentValueSubject<Bool, Never> = CurrentValueSubject(false)
lazy var isEditing: AnyPublisher<Bool, Never> = _isEditing
.removeDuplicates()
.shareReplay(1)
private let _textChanged: PassthroughSubject<(text: String?, item: SettingItem), Never> = PassthroughSubject()
lazy var textChanged: AnyPublisher<(text: String?, item: SettingItem), Never> = _textChanged
.eraseToAnyPublisher()
// MARK: - Navigation
open var leftNavItems: AnyPublisher<[NavItem]?, Never> { Just(nil).eraseToAnyPublisher() }
open var rightNavItems: AnyPublisher<[NavItem]?, Never> { Just(nil).eraseToAnyPublisher() }
private let _forcedRefresh: PassthroughSubject<Void, Never> = PassthroughSubject()
lazy var forcedRefresh: AnyPublisher<Void, Never> = _forcedRefresh
.shareReplay(0)
private let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject()
lazy var showToast: AnyPublisher<(String, ThemeValue), Never> = _showToast
.shareReplay(0)
private let _transitionToScreen: PassthroughSubject<(UIViewController, TransitionType), Never> = PassthroughSubject()
lazy var transitionToScreen: AnyPublisher<(UIViewController, TransitionType), Never> = _transitionToScreen
.shareReplay(0)
private let _dismissScreen: PassthroughSubject<DismissType, Never> = PassthroughSubject()
lazy var dismissScreen: AnyPublisher<DismissType, Never> = _dismissScreen
.shareReplay(0)
// MARK: - Content
open var title: String { preconditionFailure("abstract class - override in subclass") }
open var emptyStateTextPublisher: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
open var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
open var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
Just(nil).eraseToAnyPublisher()
}
fileprivate var hasEmittedInitialData: Bool = false
public private(set) var tableData: [SectionModel] = []
open var observableTableData: ObservableData {
preconditionFailure("abstract class - override in subclass")
}
open var pagedDataObserver: TransactionObserver? { nil }
func updateTableData(_ updatedData: [SectionModel]) {
self.tableData = updatedData
}
func loadPageBefore() { preconditionFailure("abstract class - override in subclass") }
func loadPageAfter() { preconditionFailure("abstract class - override in subclass") }
protocol SessionTableViewModel: AnyObject, SectionedTableData {
var dependencies: Dependencies { get }
var title: String { get }
var subtitle: String? { get }
var initialLoadMessage: String? { get }
var cellType: SessionTableViewCellType { get }
var emptyStateTextPublisher: AnyPublisher<String?, Never> { get }
var state: TableDataState<Section, TableItem> { get }
var footerView: AnyPublisher<UIView?, Never> { get }
var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> { get }
// MARK: - Functions
func forceRefresh() {
_forcedRefresh.send(())
}
func setIsEditing(_ isEditing: Bool) {
_isEditing.send(isEditing)
}
func textChanged(_ text: String?, for item: SettingItem) {
_textChanged.send((text, item))
}
func canEditRow(at indexPath: IndexPath) -> Bool
func leadingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration?
func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration?
}
extension SessionTableViewModel {
var subtitle: String? { nil }
var initialLoadMessage: String? { nil }
var cellType: SessionTableViewCellType { .general }
var emptyStateTextPublisher: AnyPublisher<String?, Never> { Just(nil).eraseToAnyPublisher() }
var tableData: [SectionModel] { state.tableData }
var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> { Just(nil).eraseToAnyPublisher() }
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
_showToast.send((text, backgroundColor))
}
// MARK: - Functions
func dismissScreen(type: DismissType = .auto) {
_dismissScreen.send(type)
}
func updateTableData(_ updatedData: [SectionModel]) { state.updateTableData(updatedData) }
func transitionToScreen(_ viewController: UIViewController, transitionType: TransitionType = .push) {
_transitionToScreen.send((viewController, transitionType))
}
func canEditRow(at indexPath: IndexPath) -> Bool { false }
func leadingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? { nil }
func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? { nil }
}
// MARK: - Convenience
// MARK: - SessionTableViewCellType
extension Array {
func mapToSessionTableViewData<Nav, Section, Item>(
for viewModel: SessionTableViewModel<Nav, Section, Item>?
) -> [ArraySection<Section, SessionCell.Info<Item>>] where Element == ArraySection<Section, SessionCell.Info<Item>> {
// Update the data to include the proper position for each element
return self.map { section in
ArraySection(
model: section.model,
elements: section.elements.enumerated().map { index, element in
element.updatedPosition(for: index, count: section.elements.count)
}
)
enum SessionTableViewCellType: CaseIterable {
case general
case fullConversation
var viewType: UITableViewCell.Type {
switch self {
case .general: return SessionCell.self
case .fullConversation: return FullConversationCell.self
}
}
}
extension Publisher {
func mapToSessionTableViewData<Nav, Section, Item>(
for viewModel: SessionTableViewModel<Nav, Section, Item>
) -> AnyPublisher<(Output, StagedChangeset<Output>), Failure> where Output == [ArraySection<Section, SessionCell.Info<Item>>] {
return self
.map { [weak viewModel] updatedData -> (Output, StagedChangeset<Output>) in
let updatedDataWithPositions: Output = updatedData
.mapToSessionTableViewData(for: viewModel)
// Generate an updated changeset
let changeset = StagedChangeset(
source: (viewModel?.tableData ?? []),
target: updatedDataWithPositions
)
return (updatedDataWithPositions, changeset)
}
.filter { [weak viewModel] _, changeset in
viewModel?.hasEmittedInitialData == false || // Always emit at least once
!changeset.isEmpty // Do nothing if there were no changes
}
.handleEvents(receiveOutput: { [weak viewModel] _ in
viewModel?.hasEmittedInitialData = true
})
.eraseToAnyPublisher()
}
}

@ -0,0 +1,76 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import DifferenceKit
import SessionUtilitiesKit
// MARK: - EditableStateHolder
public protocol EditableStateHolder: AnyObject, TableData, ErasedEditableStateHolder {
var editableState: EditableState<TableItem> { get }
}
public extension EditableStateHolder {
var textChanged: AnyPublisher<(text: String?, item: TableItem), Never> { editableState.textChanged }
func setIsEditing(_ isEditing: Bool) {
editableState._isEditing.send(isEditing)
}
func textChanged(_ text: String?, for item: TableItem) {
editableState._textChanged.send((text, item))
}
}
// MARK: - ErasedEditableStateHolder
public protocol ErasedEditableStateHolder: AnyObject {
var isEditing: AnyPublisher<Bool, Never> { get }
func setIsEditing(_ isEditing: Bool)
func textChanged<Item>(_ text: String?, for item: Item)
}
public extension ErasedEditableStateHolder {
var isEditing: AnyPublisher<Bool, Never> { Just(false).eraseToAnyPublisher() }
func setIsEditing(_ isEditing: Bool) {}
func textChanged<Item>(_ text: String?, for item: Item) {}
}
public extension ErasedEditableStateHolder where Self: EditableStateHolder {
var isEditing: AnyPublisher<Bool, Never> { editableState.isEditing }
func setIsEditing(_ isEditing: Bool) {
editableState._isEditing.send(isEditing)
}
func textChanged<Item>(_ text: String?, for item: Item) {
guard let convertedItem: TableItem = item as? TableItem else { return }
editableState._textChanged.send((text, convertedItem))
}
}
// MARK: - EditableState
public struct EditableState<TableItem: Hashable & Differentiable> {
let isEditing: AnyPublisher<Bool, Never>
let textChanged: AnyPublisher<(text: String?, item: TableItem), Never>
// MARK: - Internal Variables
fileprivate let _isEditing: CurrentValueSubject<Bool, Never> = CurrentValueSubject(false)
fileprivate let _textChanged: PassthroughSubject<(text: String?, item: TableItem), Never> = PassthroughSubject()
// MARK: - Initialization
init() {
self.isEditing = _isEditing
.removeDuplicates()
.shareReplay(1)
self.textChanged = _textChanged
.eraseToAnyPublisher()
}
}

@ -0,0 +1,71 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import SessionUIKit
import SessionUtilitiesKit
// MARK: - NavigationItemSource
protocol NavigationItemSource {
associatedtype NavItem: Equatable
var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { get }
var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { get }
}
// MARK: - Defaults
extension NavigationItemSource {
var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { Just([]).eraseToAnyPublisher() }
var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { Just([]).eraseToAnyPublisher() }
}
// MARK: - Bindings
extension NavigationItemSource {
func setupBindings(
viewController: UIViewController,
disposables: inout Set<AnyCancellable>
) {
self.leftNavItems
.receive(on: DispatchQueue.main)
.sink { [weak viewController] items in
viewController?.navigationItem.setLeftBarButtonItems(
items.map { item -> DisposableBarButtonItem in
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
buttonItem.themeTintColor = .textPrimary
buttonItem.tapPublisher
.map { _ in item.id }
.sink(receiveValue: { _ in item.action?() })
.store(in: &buttonItem.disposables)
return buttonItem
},
animated: true
)
}
.store(in: &disposables)
self.rightNavItems
.receive(on: DispatchQueue.main)
.sink { [weak viewController] items in
viewController?.navigationItem.setRightBarButtonItems(
items.map { item -> DisposableBarButtonItem in
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
buttonItem.themeTintColor = .textPrimary
buttonItem.tapPublisher
.map { _ in item.id }
.sink(receiveValue: { _ in item.action?() })
.store(in: &buttonItem.disposables)
return buttonItem
},
animated: true
)
}
.store(in: &disposables)
}
}

@ -0,0 +1,111 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import SessionUIKit
import SessionUtilitiesKit
import SignalUtilitiesKit
// MARK: - NavigatableStateHolder
public protocol NavigatableStateHolder {
var navigatableState: NavigatableState { get }
}
public extension NavigatableStateHolder {
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
navigatableState._showToast.send((text, backgroundColor))
}
func dismissScreen(type: DismissType = .auto) {
navigatableState._dismissScreen.send(type)
}
func transitionToScreen(_ viewController: UIViewController, transitionType: TransitionType = .push) {
navigatableState._transitionToScreen.send((viewController, transitionType))
}
}
// MARK: - NavigatableState
public struct NavigatableState {
let showToast: AnyPublisher<(String, ThemeValue), Never>
let transitionToScreen: AnyPublisher<(UIViewController, TransitionType), Never>
let dismissScreen: AnyPublisher<DismissType, Never>
// MARK: - Internal Variables
fileprivate let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject()
fileprivate let _transitionToScreen: PassthroughSubject<(UIViewController, TransitionType), Never> = PassthroughSubject()
fileprivate let _dismissScreen: PassthroughSubject<DismissType, Never> = PassthroughSubject()
// MARK: - Initialization
init() {
self.showToast = _showToast.shareReplay(0)
self.transitionToScreen = _transitionToScreen.shareReplay(0)
self.dismissScreen = _dismissScreen.shareReplay(0)
}
// MARK: - Functions
public func setupBindings(
viewController: UIViewController,
disposables: inout Set<AnyCancellable>
) {
self.showToast
.receive(on: DispatchQueue.main)
.sink { [weak viewController] text, color in
guard let view: UIView = viewController?.view else { return }
let toastController: ToastController = ToastController(text: text, background: color)
toastController.presentToastView(fromBottomOfView: view, inset: Values.largeSpacing)
}
.store(in: &disposables)
self.transitionToScreen
.receive(on: DispatchQueue.main)
.sink { [weak viewController] targetViewController, transitionType in
switch transitionType {
case .push:
viewController?.navigationController?.pushViewController(targetViewController, animated: true)
case .present:
let presenter: UIViewController? = (viewController?.presentedViewController ?? viewController)
if UIDevice.current.isIPad {
targetViewController.popoverPresentationController?.permittedArrowDirections = []
targetViewController.popoverPresentationController?.sourceView = presenter?.view
targetViewController.popoverPresentationController?.sourceRect = (presenter?.view.bounds ?? UIScreen.main.bounds)
}
presenter?.present(targetViewController, animated: true)
}
}
.store(in: &disposables)
self.dismissScreen
.receive(on: DispatchQueue.main)
.sink { [weak viewController] dismissType in
switch dismissType {
case .auto:
guard
let viewController: UIViewController = viewController,
(viewController.navigationController?.viewControllers
.firstIndex(of: viewController))
.defaulting(to: 0) > 0
else {
viewController?.dismiss(animated: true)
return
}
viewController.navigationController?.popViewController(animated: true)
case .dismiss: viewController?.dismiss(animated: true)
case .pop: viewController?.navigationController?.popViewController(animated: true)
case .popToRoot: viewController?.navigationController?.popToRootViewController(animated: true)
}
}
.store(in: &disposables)
}
}

@ -0,0 +1,263 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Combine
import DifferenceKit
import SessionUtilitiesKit
// MARK: - ObservableTableSource
public protocol ObservableTableSource: AnyObject, SectionedTableData {
typealias TargetObservation = TableObservation<[SectionModel]>
typealias TargetPublisher = AnyPublisher<(([SectionModel], StagedChangeset<[SectionModel]>)), Error>
var dependencies: Dependencies { get }
var state: TableDataState<Section, TableItem> { get }
var observableState: ObservableTableSourceState<Section, TableItem> { get }
var observation: TargetObservation { get }
// MARK: - Functions
func didReturnFromBackground()
}
extension ObservableTableSource {
public var pendingTableDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> {
self.observableState.pendingTableDataSubject
}
public var observation: TargetObservation {
ObservationBuilder.changesetSubject(self.observableState.pendingTableDataSubject)
}
public var tableDataPublisher: TargetPublisher { self.observation.finalPublisher(self, using: dependencies) }
public func didReturnFromBackground() {}
public func forceRefresh() { self.observableState._forcedRefresh.send(()) }
}
// MARK: - State Manager (ObservableTableSource)
public class ObservableTableSourceState<Section: SessionTableSection, TableItem: Hashable & Differentiable>: SectionedTableData {
public let forcedRefresh: AnyPublisher<Void, Never>
public let pendingTableDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never>
// MARK: - Internal Variables
fileprivate var hasEmittedInitialData: Bool
fileprivate let _forcedRefresh: PassthroughSubject<Void, Never> = PassthroughSubject()
// MARK: - Initialization
init() {
self.hasEmittedInitialData = false
self.forcedRefresh = _forcedRefresh.shareReplay(0)
self.pendingTableDataSubject = CurrentValueSubject(([], StagedChangeset()))
}
}
// MARK: - TableObservation
public struct TableObservation<T> {
fileprivate let generatePublisher: (any ObservableTableSource, Dependencies) -> AnyPublisher<T, Error>
fileprivate let generatePublisherWithChangeset: ((any ObservableTableSource, Dependencies) -> AnyPublisher<Any, Error>)?
init(generatePublisher: @escaping (any ObservableTableSource, Dependencies) -> AnyPublisher<T, Error>) {
self.generatePublisher = generatePublisher
self.generatePublisherWithChangeset = nil
}
init(generatePublisherWithChangeset: @escaping (any ObservableTableSource, Dependencies) -> AnyPublisher<(T, StagedChangeset<T>), Error>) where T: Collection {
self.generatePublisher = { _, _ in Fail(error: StorageError.invalidData).eraseToAnyPublisher() }
self.generatePublisherWithChangeset = { source, dependencies in
generatePublisherWithChangeset(source, dependencies).map { $0 as Any }.eraseToAnyPublisher()
}
}
fileprivate func finalPublisher<S: ObservableTableSource>(
_ source: S,
using dependencies: Dependencies
) -> S.TargetPublisher {
typealias TargetData = (([S.SectionModel], StagedChangeset<[S.SectionModel]>))
switch (self, self.generatePublisherWithChangeset) {
case (_, .some(let generatePublisherWithChangeset)):
return generatePublisherWithChangeset(source, dependencies)
.tryMap { data -> TargetData in
guard let convertedData: TargetData = data as? TargetData else {
throw StorageError.invalidData
}
return convertedData
}
.eraseToAnyPublisher()
case (let validObservation as S.TargetObservation, _):
// Doing `removeDuplicates` in case the conversion from the original data to [SectionModel]
// can result in duplicate output even with some different inputs
return validObservation.generatePublisher(source, dependencies)
.removeDuplicates()
.mapToSessionTableViewData(for: source)
default: return Fail(error: StorageError.invalidData).eraseToAnyPublisher()
}
}
}
extension TableObservation: ExpressibleByArrayLiteral where T: Collection {
public init(arrayLiteral elements: T.Element?...) {
self.init(
generatePublisher: { _, _ in
guard let convertedElements: T = Array(elements.compactMap { $0 }) as? T else {
return Fail(error: StorageError.invalidData).eraseToAnyPublisher()
}
return Just(convertedElements)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
)
}
}
// MARK: - ObservationBuilder
public enum ObservationBuilder {
/// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
/// added
static func subject<T: Equatable>(_ subject: CurrentValueSubject<T, Error>) -> TableObservation<T> {
return TableObservation { _, _ in
return subject
.removeDuplicates()
.eraseToAnyPublisher()
}
}
/// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
/// added
static func subject<T: Equatable>(_ subject: CurrentValueSubject<T, Never>) -> TableObservation<T> {
return TableObservation { _, _ in
return subject
.removeDuplicates()
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
/// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips
/// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance
static func databaseObservation<S: ObservableTableSource, T: Equatable>(_ source: S, fetch: @escaping (Database) throws -> T) -> TableObservation<T> {
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
return TableObservation { viewModel, dependencies in
return ValueObservation
.trackingConstantRegion(fetch)
.removeDuplicates()
.handleEvents(didFail: { SNLog("[\(type(of: viewModel))] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.manualRefreshFrom(source.observableState.forcedRefresh)
}
}
/// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips
/// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance
static func databaseObservation<S: ObservableTableSource, T: Equatable>(_ source: S, fetch: @escaping (Database) throws -> [T]) -> TableObservation<[T]> {
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
return TableObservation { viewModel, dependencies in
return ValueObservation
.trackingConstantRegion(fetch)
.removeDuplicates()
.handleEvents(didFail: { SNLog("[\(type(of: viewModel))] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.manualRefreshFrom(source.observableState.forcedRefresh)
}
}
/// The `changesetSubject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
/// added
static func changesetSubject<T>(
_ subject: CurrentValueSubject<([T], StagedChangeset<[T]>), Never>
) -> TableObservation<[T]> {
return TableObservation { viewModel, dependencies in
subject
.withPrevious(([], StagedChangeset()))
.filter { prev, next in
/// Suppress events with no changes (these will be sent in order to clear out the `StagedChangeset` value as if we
/// don't do so then resubscribing will result in an attempt to apply an invalid changeset to the `tableView` resulting
/// in a crash)
!next.1.isEmpty
}
.map { _, current -> ([T], StagedChangeset<[T]>) in current }
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
}
// MARK: - Convenience Transforms
public extension TableObservation {
func map<R>(transform: @escaping (T) -> R) -> TableObservation<R> {
return TableObservation<R> { viewModel, dependencies in
self.generatePublisher(viewModel, dependencies).map(transform).eraseToAnyPublisher()
}
}
func mapWithPrevious<R>(transform: @escaping (T?, T) -> R) -> TableObservation<R> {
return TableObservation<R> { viewModel, dependencies in
self.generatePublisher(viewModel, dependencies)
.withPrevious()
.map(transform)
.eraseToAnyPublisher()
}
}
}
public extension Array {
func mapToSessionTableViewData<S: ObservableTableSource>(
for source: S?
) -> [ArraySection<S.Section, SessionCell.Info<S.TableItem>>] where Element == ArraySection<S.Section, SessionCell.Info<S.TableItem>> {
// Update the data to include the proper position for each element
return self.map { section in
ArraySection(
model: section.model,
elements: section.elements.enumerated().map { index, element in
element.updatedPosition(for: index, count: section.elements.count)
}
)
}
}
}
public extension Publisher {
func mapToSessionTableViewData<S: ObservableTableSource>(
for source: S
) -> AnyPublisher<(Output, StagedChangeset<Output>), Failure> where Output == [ArraySection<S.Section, SessionCell.Info<S.TableItem>>] {
return self
.map { [weak source] updatedData -> (Output, StagedChangeset<Output>) in
let updatedDataWithPositions: Output = updatedData
.mapToSessionTableViewData(for: source)
// Generate an updated changeset
let changeset = StagedChangeset(
source: (source?.state.tableData ?? []),
target: updatedDataWithPositions
)
return (updatedDataWithPositions, changeset)
}
.filter { [weak source] _, changeset in
source?.observableState.hasEmittedInitialData == false || // Always emit at least once
!changeset.isEmpty // Do nothing if there were no changes
}
.handleEvents(receiveOutput: { [weak source] _ in
source?.observableState.hasEmittedInitialData = true
})
.eraseToAnyPublisher()
}
}

@ -0,0 +1,27 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
protocol PagedObservationSource {
associatedtype PagedTable: TableRecord & ColumnExpressible & Identifiable
associatedtype PagedDataModel: FetchableRecordWithRowId & Identifiable
var pagedDataObserver: PagedDatabaseObserver<PagedTable, PagedDataModel>? { get }
func didInit(using dependencies: Dependencies)
func loadPageBefore()
func loadPageAfter()
}
extension PagedObservationSource {
public func didInit(using dependencies: Dependencies) {
dependencies.storage.addObserver(pagedDataObserver)
}
}
extension PagedObservationSource where PagedTable.ID: SQLExpressible {
func loadPageBefore() { pagedDataObserver?.load(.pageBefore) }
func loadPageAfter() { pagedDataObserver?.load(.pageAfter) }
}

@ -0,0 +1,87 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUtilitiesKit
public struct SessionNavItem<Id: Equatable>: Equatable {
let id: Id
let image: UIImage?
let style: UIBarButtonItem.Style
let systemItem: UIBarButtonItem.SystemItem?
let accessibilityIdentifier: String
let accessibilityLabel: String?
let action: (() -> Void)?
// MARK: - Initialization
public init(
id: Id,
systemItem: UIBarButtonItem.SystemItem?,
accessibilityIdentifier: String,
accessibilityLabel: String? = nil,
action: (() -> Void)? = nil
) {
self.id = id
self.image = nil
self.style = .plain
self.systemItem = systemItem
self.accessibilityIdentifier = accessibilityIdentifier
self.accessibilityLabel = accessibilityLabel
self.action = action
}
public init(
id: Id,
image: UIImage?,
style: UIBarButtonItem.Style,
accessibilityIdentifier: String,
accessibilityLabel: String? = nil,
action: (() -> Void)? = nil
) {
self.id = id
self.image = image
self.style = style
self.systemItem = nil
self.accessibilityIdentifier = accessibilityIdentifier
self.accessibilityLabel = accessibilityLabel
self.action = action
}
// MARK: - Functions
public func createBarButtonItem() -> DisposableBarButtonItem {
guard let systemItem: UIBarButtonItem.SystemItem = systemItem else {
return DisposableBarButtonItem(
image: image,
style: style,
target: nil,
action: nil,
accessibilityIdentifier: accessibilityIdentifier,
accessibilityLabel: accessibilityLabel
)
}
return DisposableBarButtonItem(
barButtonSystemItem: systemItem,
target: nil,
action: nil,
accessibilityIdentifier: accessibilityIdentifier,
accessibilityLabel: accessibilityLabel
)
}
// MARK: - Conformance
public static func == (
lhs: SessionNavItem<Id>,
rhs: SessionNavItem<Id>
) -> Bool {
return (
lhs.id == rhs.id &&
lhs.image == rhs.image &&
lhs.style == rhs.style &&
lhs.systemItem == rhs.systemItem &&
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier
)
}
}

@ -4,14 +4,16 @@ import Foundation
import DifferenceKit
import SessionUIKit
protocol SessionTableSection: Differentiable {
public protocol SessionTableSection: Differentiable, Equatable {
var title: String? { get }
var style: SessionTableSectionStyle { get }
var footer: String? { get }
}
extension SessionTableSection {
var title: String? { nil }
var style: SessionTableSectionStyle { .none }
public var title: String? { nil }
public var style: SessionTableSectionStyle { .none }
public var footer: String? { nil }
}
public enum SessionTableSectionStyle: Equatable, Hashable, Differentiable {

@ -1,91 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUtilitiesKit
public enum NoNav: Equatable {}
extension SessionTableViewModel {
public struct NavItem: Equatable {
let id: NavItemId
let image: UIImage?
let style: UIBarButtonItem.Style
let systemItem: UIBarButtonItem.SystemItem?
let accessibilityIdentifier: String
let accessibilityLabel: String?
let action: (() -> Void)?
// MARK: - Initialization
public init(
id: NavItemId,
systemItem: UIBarButtonItem.SystemItem?,
accessibilityIdentifier: String,
accessibilityLabel: String? = nil,
action: (() -> Void)? = nil
) {
self.id = id
self.image = nil
self.style = .plain
self.systemItem = systemItem
self.accessibilityIdentifier = accessibilityIdentifier
self.accessibilityLabel = accessibilityLabel
self.action = action
}
public init(
id: NavItemId,
image: UIImage?,
style: UIBarButtonItem.Style,
accessibilityIdentifier: String,
accessibilityLabel: String? = nil,
action: (() -> Void)? = nil
) {
self.id = id
self.image = image
self.style = style
self.systemItem = nil
self.accessibilityIdentifier = accessibilityIdentifier
self.accessibilityLabel = accessibilityLabel
self.action = action
}
// MARK: - Functions
public func createBarButtonItem() -> DisposableBarButtonItem {
guard let systemItem: UIBarButtonItem.SystemItem = systemItem else {
return DisposableBarButtonItem(
image: image,
style: style,
target: nil,
action: nil,
accessibilityIdentifier: accessibilityIdentifier,
accessibilityLabel: accessibilityLabel
)
}
return DisposableBarButtonItem(
barButtonSystemItem: systemItem,
target: nil,
action: nil,
accessibilityIdentifier: accessibilityIdentifier,
accessibilityLabel: accessibilityLabel
)
}
// MARK: - Conformance
public static func == (
lhs: SessionTableViewModel<NavItemId, Section, SettingItem>.NavItem,
rhs: SessionTableViewModel<NavItemId, Section, SettingItem>.NavItem
) -> Bool {
return (
lhs.id == rhs.id &&
lhs.image == rhs.image &&
lhs.style == rhs.style &&
lhs.systemItem == rhs.systemItem &&
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier
)
}
}
}

@ -0,0 +1,20 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import DifferenceKit
public protocol TableData {
associatedtype TableItem: Hashable & Differentiable
}
public protocol SectionedTableData: TableData {
associatedtype Section: SessionTableSection
typealias SectionModel = ArraySection<Section, SessionCell.Info<TableItem>>
}
public class TableDataState<Section: SessionTableSection, TableItem: Hashable & Differentiable>: SectionedTableData {
public private(set) var tableData: [SectionModel] = []
public func updateTableData(_ updatedData: [SectionModel]) { self.tableData = updatedData }
}

@ -10,23 +10,23 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API
[
_001_InitialSetupMigration.self,
_002_SetupStandardJobs.self
],
], // Initial DB Creation
[
_003_YDBToGRDBMigration.self
],
], // YDB to GRDB Migration
[
_004_RemoveLegacyYDB.self
],
], // Legacy DB removal
[
_005_FixDeletedMessageReadState.self,
_006_FixHiddenModAdminSupport.self,
_007_HomeQueryOptimisationIndexes.self
],
], // Add job priorities
[
_008_EmojiReacts.self,
_009_OpenGroupPermission.self,
_010_AddThreadIdToFTS.self
], // Add job priorities
], // Fix thread FTS
[
_011_AddPendingReadReceipts.self,
_012_AddFTSIfNeeded.self,

@ -8,6 +8,7 @@ import GRDB
import SignalCoreKit
import SessionUtilitiesKit
import SessionSnodeKit
import SessionUIKit
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "attachment" }
@ -788,6 +789,14 @@ extension Attachment {
public var isText: Bool { MIMETypeUtil.isText(contentType) }
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
public var documentFileName: String {
if let sourceFilename: String = sourceFilename { return sourceFilename }
if isImage { return "Image File" }
if isAudio { return "Audio File" }
if isVideo { return "Video File" }
return "File"
}
public var shortDescription: String {
if isImage { return "Image" }
if isAudio { return "Audio" }
@ -795,6 +804,15 @@ extension Attachment {
return "Document"
}
public var documentFileInfo: String {
switch duration {
case .some(let duration) where duration > 0:
return "\(Format.fileSize(byteCount)), \(Format.duration(duration))"
default: return Format.fileSize(byteCount)
}
}
public func readDataFromFile() throws -> Data? {
guard let filePath: String = self.originalFilePath else {
return nil

@ -352,9 +352,9 @@ public extension Profile {
nickname: String?,
customFallback: String? = nil
) -> String {
if let nickname: String = nickname { return nickname }
if let nickname: String = nickname, !nickname.isEmpty { return nickname }
guard let name: String = name, name != id else {
guard let name: String = name, name != id, !name.isEmpty else {
return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant))
}

@ -263,6 +263,25 @@ public class SignalAttachment: Equatable, Hashable {
return text
}
public func duration() -> TimeInterval? {
switch (isAudio, isVideo) {
case (true, _):
let audioPlayer: AVAudioPlayer? = try? AVAudioPlayer(data: dataSource.data())
return (audioPlayer?.duration).map { $0 > 0 ? $0 : nil }
case (_, true):
return dataUrl.map { url in
let asset: AVURLAsset = AVURLAsset(url: url, options: nil)
// According to the CMTime docs "value/timescale = seconds"
return (TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale))
}
default: return nil
}
}
// Returns the MIME type for this attachment or nil if no MIME type
// can be identified.

@ -263,7 +263,7 @@ public class Poller {
let lastHashes: [String] = namespacedResults
.compactMap { $0.value.data?.lastHash }
let otherKnownHashes: [String] = namespacedResults
.filter { $0.key.shouldDedupeMessages }
.filter { $0.key.shouldFetchSinceLastHash }
.compactMap { $0.value.data?.messages.map { $0.info.hash } }
.reduce([], +)
var messageCount: Int = 0

@ -78,6 +78,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
case textOnlyMessage
case mediaMessage
case audio
case voiceMessage
case genericAttachment
case typingIndicator
case dateHeader
@ -289,7 +290,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
)
)
{
return .audio
return (attachment.variant == .voiceMessage ? .voiceMessage : .audio)
}
if attachment.isVisualMedia {

@ -10,7 +10,7 @@ import SessionUtilitiesKit
fileprivate typealias ViewModel = SessionThreadViewModel
/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the
/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewModel` and the
/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each
/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places
///
@ -606,7 +606,7 @@ private struct GroupMemberInfo: Decodable, ColumnExpressible {
let threadMemberNames: String
}
// MARK: - HomeVC & MessageRequestsViewController
// MARK: - HomeVC & MessageRequestsViewModel
// MARK: --SessionThreadViewModel

@ -12,14 +12,18 @@ public enum SNSnodeKit: MigratableTarget { // Just to make the external API nice
[
_001_InitialSetupMigration.self,
_002_SetupStandardJobs.self
],
], // Initial DB Creation
[
_003_YDBToGRDBMigration.self
],
], // YDB to GRDB Migration
[
_004_FlagMessageHashAsDeletedOrInvalid.self
],
[] // Add job priorities
], // Legacy DB removal
[], // Add job priorities
[], // Fix thread FTS
[
_005_AddSnodeReveivedMessageInfoPrimaryKey.self
]
]
)
}

@ -40,7 +40,7 @@ enum _001_InitialSetupMigration: Migration {
}
try db.create(table: SnodeReceivedMessageInfo.self) { t in
t.column(.id, .integer)
t.deprecatedColumn(name: "id", .integer) // stringlint:disable
.notNull()
.primaryKey(autoincrement: true)
t.column(.key, .text)

@ -0,0 +1,72 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
enum _005_AddSnodeReveivedMessageInfoPrimaryKey: Migration {
static let target: TargetMigrations.Identifier = .snodeKit
static let identifier: String = "AddSnodeReveivedMessageInfoPrimaryKey" // stringlint:disable
static let needsConfigSync: Bool = false
static let fetchedTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self]
static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self]
/// This migration adds a flat to the `SnodeReceivedMessageInfo` so that when deleting interactions we can
/// ignore their hashes when subsequently trying to fetch new messages (which results in the storage server returning
/// messages from the beginning of time)
static let minExpectedRunDuration: TimeInterval = 0.2
static func migrate(_ db: Database) throws {
// SQLite doesn't support adding a new primary key after creation so we need to create a new table with
// the setup we want, copy data from the old table over, drop the old table and rename the new table
struct TmpSnodeReceivedMessageInfo: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
static var databaseTableName: String { "tmpSnodeReceivedMessageInfo" }
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression {
case key
case hash
case expirationDateMs
case wasDeletedOrInvalid
}
let key: String
let hash: String
let expirationDateMs: Int64
var wasDeletedOrInvalid: Bool?
}
try db.create(table: TmpSnodeReceivedMessageInfo.self) { t in
t.column(.key, .text).notNull()
t.column(.hash, .text).notNull()
t.column(.expirationDateMs, .integer).notNull()
t.column(.wasDeletedOrInvalid, .boolean)
t.primaryKey([.key, .hash])
}
// Insert into the new table, drop the old table and rename the new table to be the old one
let tmpInfo: TypedTableAlias<TmpSnodeReceivedMessageInfo> = TypedTableAlias()
let info: TypedTableAlias<SnodeReceivedMessageInfo> = TypedTableAlias()
try db.execute(literal: """
INSERT INTO \(tmpInfo)
SELECT \(info[.key]), \(info[.hash]), \(info[.expirationDateMs]), \(info[.wasDeletedOrInvalid])
FROM \(info)
""")
try db.drop(table: SnodeReceivedMessageInfo.self)
try db.rename(
table: TmpSnodeReceivedMessageInfo.databaseTableName,
to: SnodeReceivedMessageInfo.databaseTableName
)
// Need to create the indexes separately from creating 'TmpGroupMember' to ensure they
// have the correct names
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.key])
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.hash])
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.expirationDateMs])
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.wasDeletedOrInvalid])
Storage.update(progress: 1, for: self, in: target)
}
}

@ -9,17 +9,12 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case id
case key
case hash
case expirationDateMs
case wasDeletedOrInvalid
}
/// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into
/// the database yet this value will be `nil`
public var id: Int64? = nil
/// The key this message hash is associated to
///
/// This will be a combination of {address}.{port}.{publicKey} for new rows and just the {publicKey} for legacy rows
@ -41,12 +36,6 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
///
/// **Note:** When retrieving the `lastNotExpired` we will ignore any entries where this flag is true
public var wasDeletedOrInvalid: Bool?
// MARK: - Custom Database Interaction
public mutating func didInsert(_ inserted: InsertionSuccess) {
self.id = inserted.rowID
}
}
// MARK: - Convenience
@ -133,7 +122,7 @@ public extension SnodeReceivedMessageInfo {
)
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > SnodeAPI.currentOffsetTimestampMs())
.order(SnodeReceivedMessageInfo.Columns.id.desc)
.order(Column.rowID.desc)
.fetchOne(db)
// If we have a non-legacy hash then return it immediately (legacy hashes had a different
@ -146,7 +135,7 @@ public extension SnodeReceivedMessageInfo {
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false
)
.filter(SnodeReceivedMessageInfo.Columns.key == publicKey)
.order(SnodeReceivedMessageInfo.Columns.id.desc)
.order(Column.rowID.desc)
.fetchOne(db)
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save