Merge remote-tracking branch 'upstream/dev' into fix/media-interactions

This commit is contained in:
Morgan Pretty 2023-10-13 15:03:57 +11:00
commit de7d85f4cb
50 changed files with 2000 additions and 1932 deletions

View File

@ -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 */; };
@ -482,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 */; };
@ -646,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 */; };
@ -679,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 */; };
@ -717,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 */; };
@ -1242,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>"; };
@ -1632,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>"; };
@ -1756,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>"; };
@ -1786,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>"; };
@ -1823,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>"; };
@ -2396,7 +2408,6 @@
children = (
FD716E6F28505E5100C96BF4 /* Views */,
FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */,
7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */,
);
path = "Message Requests";
sourceTree = "<group>";
@ -3575,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 */,
@ -3668,6 +3680,7 @@
FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */,
FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */,
FD39353528F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift */,
FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -3820,7 +3833,6 @@
FD37E9D028A1F2EB003AE748 /* ThemeSelectionView.swift */,
FD37E9DA28A244E9003AE748 /* ThemePreviewView.swift */,
FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */,
FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */,
FD39352B28F382920084DADA /* VersionFooterView.swift */,
);
path = Views;
@ -4001,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>";
@ -5761,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 */,
@ -5818,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 */,
@ -6120,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 */,
@ -6131,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 */,
@ -6143,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 */,
@ -6218,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 */,
@ -6234,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 */,
@ -6269,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 */,
@ -6299,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 */,

View File

@ -2469,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

View File

@ -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
}

View File

@ -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

View File

@ -9,27 +9,14 @@ import SessionMessagingKit
import SessionUtilitiesKit
import SessionSnodeKit
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadDisappearingMessagesSettingsViewModel.NavButton, ThreadDisappearingMessagesSettingsViewModel.Section, ThreadDisappearingMessagesSettingsViewModel.Item> {
// MARK: - Config
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = String
enum NavButton: Equatable {
case cancel
case save
}
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
public enum Section: SessionTableSection {
case content
}
public struct Item: Equatable, Hashable, Differentiable {
let title: String
public var differenceIdentifier: String { title }
}
// MARK: - Variables
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: - Config
enum NavItem: Equatable {
case cancel
case save
}
public enum Section: SessionTableSection {
case content
}
// MARK: - Navigation
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
Just([
NavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in self?.dismissScreen() }
]).eraseToAnyPublisher()
}
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in self?.dismissScreen() }
]
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()
}
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
override var title: String { "DISAPPEARING_MESSAGES".localized() }
let title: String = "DISAPPEARING_MESSAGES".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, 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

View File

@ -11,47 +11,13 @@ import SignalUtilitiesKit
import SessionUtilitiesKit
import SessionSnodeKit
class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.NavButton, ThreadSettingsViewModel.Section, ThreadSettingsViewModel.Setting> {
// MARK: - Config
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()
enum NavState {
case standard
case editing
}
enum NavButton: Equatable {
case edit
case cancel
case done
}
public enum Section: SessionTableSection {
case conversationInfo
case content
}
public enum Setting: Differentiable {
case avatar
case nickname
case sessionId
case copyThreadId
case allMedia
case searchConversation
case addToOpenGroup
case disappearingMessages
case disappearingMessagesDuration
case editGroup
case leaveGroup
case notificationSound
case notificationMentionsOnly
case notificationMute
case blockUser
}
// MARK: - Variables
private let dependencies: Dependencies
private let threadId: String
private let threadVariant: SessionThread.Variant
private let didTriggerSearch: () -> ()
@ -82,6 +48,43 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
}
// MARK: - Config
enum NavState {
case standard
case editing
}
enum NavItem: Equatable {
case edit
case cancel
case done
}
public enum Section: SessionTableSection {
case conversationInfo
case content
}
public enum TableItem: Differentiable {
case avatar
case nickname
case sessionId
case copyThreadId
case allMedia
case searchConversation
case addToOpenGroup
case disappearingMessages
case disappearingMessagesDuration
case editGroup
case leaveGroup
case notificationSound
case notificationMentionsOnly
case notificationMute
case blockUser
}
// 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()
}
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 [] }
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)
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) }
]
return [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
}
.eraseToAnyPublisher()
}
]
}
.eraseToAnyPublisher()
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 [
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))
)
}
}
]
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

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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
// MARK: - Section
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
didSet {
// 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
}
}
}

View File

@ -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

View File

@ -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()
}
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()
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() }
)
public enum Section: SessionTableSection {
case contacts
case loadMore
public var style: SessionTableSectionStyle {
switch self {
case .contacts: return .none
case .loadMore: return .loadMore
}
.eraseToAnyPublisher()
}
}
// MARK: - Content
let title: String = "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized()
let emptyStateTextPublisher: AnyPublisher<String?, Never> = Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized())
.eraseToAnyPublisher()
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()
// MARK: - Functions
override func loadPageAfter() { _pagedDataObserver?.load(.pageAfter) }
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")
}()
}
}

View File

@ -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() }
let title: String = "CONVERSATION_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] 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)
}

View File

@ -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() }
let title: String = "HELP_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 { 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

View File

@ -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() }
let title: String = "NOTIFICATIONS_STYLE_CONTENT_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 { [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)
}

View File

@ -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() }
let title: String = "NOTIFICATIONS_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 { 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)
}

View File

@ -8,17 +8,13 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewModel.NavButton, NotificationSettingsViewModel.Section, Preferences.Sound> {
// MARK: - Config
class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
typealias TableItem = Preferences.Sound
enum NavButton: Equatable {
case cancel
case save
}
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: - Config
enum NavItem: Equatable {
case cancel
case save
}
public enum Section: SessionTableSection {
case content
}
// MARK: - Navigation
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
Just([
NavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.dismissScreen()
}
]).eraseToAnyPublisher()
}
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in self?.dismissScreen() }
]
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()
}
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()
// MARK: - Content
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
let title: String = "NOTIFICATIONS_STYLE_SOUND_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] 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

View File

@ -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() }
let title: String = "PRIVACY_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] 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)
}

View File

@ -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() }
let title: String = "vc_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] 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)

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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>
protocol SessionTableViewModel: AnyObject, SectionedTableData {
var dependencies: Dependencies { get }
// 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") }
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 showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
_showToast.send((text, backgroundColor))
}
func dismissScreen(type: DismissType = .auto) {
_dismissScreen.send(type)
}
func transitionToScreen(_ viewController: UIViewController, transitionType: TransitionType = .push) {
_transitionToScreen.send((viewController, transitionType))
}
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?
}
// MARK: - Convenience
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() }
// MARK: - Functions
func updateTableData(_ updatedData: [SectionModel]) { state.updateTableData(updatedData) }
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 }
}
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)
}
)
// MARK: - SessionTableViewCellType
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()
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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) }
}

View File

@ -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
)
}
}

View File

@ -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 {

View File

@ -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
)
}
}
}

View File

@ -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 }
}

View File

@ -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,

View File

@ -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))
}

View File

@ -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

View File

@ -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

View File

@ -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
]
]
)
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -1,4 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
@ -40,12 +42,9 @@ public extension SnodeAPI {
public var shouldFetchSinceLastHash: Bool { true }
/// This flag indicates whether we should dedupe messages from the specified namespace, when `true` we will
/// store a `SnodeReceivedMessageInfo` record for the message and check for a matching record whenever
/// we receive a message from this namespace
///
/// **Note:** An additional side-effect of this flag is that when we poll for messages from the specified namespace
/// we will always retrieve **all** messages from the namespace (instead of just new messages since the last one
/// we have seen)
/// attempt to `insert` a `SnodeReceivedMessageInfo` record (which will fail if we had already processed this
/// message previously), when `false` we will still `upsert` a record so we don't run into the unique constraint allowing
/// re-processing of a previously processed message
public var shouldDedupeMessages: Bool {
switch self {
case .`default`, .legacyClosedGroup: return true

View File

@ -41,7 +41,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
)
@TestState var cancellables: [AnyCancellable]! = [
viewModel.observableTableData
viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -68,9 +68,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
.to(
equal(
SessionCell.Info(
id: ThreadDisappearingMessagesSettingsViewModel.Item(
title: "DISAPPEARING_MESSAGES_OFF".localized()
),
id: "DISAPPEARING_MESSAGES_OFF".localized(),
position: .top,
title: "DISAPPEARING_MESSAGES_OFF".localized(),
rightAccessory: .radio(
@ -87,7 +85,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
.to(
equal(
SessionCell.Info(
id: ThreadDisappearingMessagesSettingsViewModel.Item(title: title),
id: title,
position: .bottom,
title: title,
rightAccessory: .radio(
@ -116,7 +114,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
using: dependencies
)
cancellables.append(
viewModel.observableTableData
viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -128,9 +126,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
.to(
equal(
SessionCell.Info(
id: ThreadDisappearingMessagesSettingsViewModel.Item(
title: "DISAPPEARING_MESSAGES_OFF".localized()
),
id: "DISAPPEARING_MESSAGES_OFF".localized(),
position: .top,
title: "DISAPPEARING_MESSAGES_OFF".localized(),
rightAccessory: .radio(
@ -147,7 +143,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
.to(
equal(
SessionCell.Info(
id: ThreadDisappearingMessagesSettingsViewModel.Item(title: title),
id: title,
position: .bottom,
title: title,
rightAccessory: .radio(
@ -160,7 +156,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
// MARK: -- has no right bar button
it("has no right bar button") {
var items: [ParentType.NavItem]?
var items: [SessionNavItem<ThreadDisappearingMessagesSettingsViewModel.NavItem>]!
cancellables.append(
viewModel.rightNavItems
@ -176,7 +172,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
// MARK: -- when changed from the previous setting
context("when changed from the previous setting") {
@TestState var items: [ParentType.NavItem]?
@TestState var items: [SessionNavItem<ThreadDisappearingMessagesSettingsViewModel.NavItem>]!
beforeEach {
cancellables.append(
@ -195,7 +191,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
it("shows the save button") {
expect(items)
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadDisappearingMessagesSettingsViewModel.NavItem>(
id: .save,
systemItem: .save,
accessibilityIdentifier: "Save button"
@ -210,7 +206,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
var didDismissScreen: Bool = false
cancellables.append(
viewModel.dismissScreen
viewModel.navigatableState.dismissScreen
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -218,14 +214,14 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
)
)
items?.first?.action?()
items.first?.action?()
expect(didDismissScreen).to(beTrue())
}
// MARK: ------ saves the updated config
it("saves the updated config") {
items?.first?.action?()
items.first?.action?()
let updatedConfig: DisappearingMessagesConfiguration? = mockStorage.read { db in
try DisappearingMessagesConfiguration.fetchOne(db, id: "TestId")
@ -240,7 +236,3 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec {
}
}
}
// MARK: - Test Types
fileprivate typealias ParentType = SessionTableViewModel<ThreadDisappearingMessagesSettingsViewModel.NavButton, ThreadDisappearingMessagesSettingsViewModel.Section, ThreadDisappearingMessagesSettingsViewModel.Item>

View File

@ -57,7 +57,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
)
@TestState var disposables: [AnyCancellable]! = [
viewModel.observableTableData
viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -149,7 +149,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
using: dependencies
)
disposables.append(
viewModel.observableTableData
viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -171,7 +171,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue()).to(equal([]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
@ -193,7 +193,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
context("when entering edit mode") {
beforeEach {
viewModel.navState.sinkAndStore(in: &disposables)
viewModel.rightNavItems.firstValue()??.first?.action?()
viewModel.rightNavItems.firstValue()?.first?.action?()
viewModel.textChanged("TestNew", for: .nickname)
}
@ -204,7 +204,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
@ -212,7 +212,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
@ -223,7 +223,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
// MARK: ------ when cancelling edit mode
context("when cancelling edit mode") {
beforeEach {
viewModel.leftNavItems.firstValue()??.first?.action?()
viewModel.leftNavItems.firstValue()?.first?.action?()
}
// MARK: -------- exits editing mode
@ -234,7 +234,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue()).to(equal([]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
@ -258,7 +258,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
// MARK: ------ when saving edit mode
context("when saving edit mode") {
beforeEach {
viewModel.rightNavItems.firstValue()??.first?.action?()
viewModel.rightNavItems.firstValue()?.first?.action?()
}
// MARK: -------- exits editing mode
@ -269,7 +269,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue()).to(equal([]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
@ -318,7 +318,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue()).to(equal([]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
@ -330,7 +330,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
context("when entering edit mode") {
beforeEach {
viewModel.navState.sinkAndStore(in: &disposables)
viewModel.rightNavItems.firstValue()??.first?.action?()
viewModel.rightNavItems.firstValue()?.first?.action?()
viewModel.textChanged("TestUserNew", for: .nickname)
}
@ -341,7 +341,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
@ -349,7 +349,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
@ -360,7 +360,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
// MARK: ------ when cancelling edit mode
context("when cancelling edit mode") {
beforeEach {
viewModel.leftNavItems.firstValue()??.first?.action?()
viewModel.leftNavItems.firstValue()?.first?.action?()
}
// MARK: -------- exits editing mode
@ -371,7 +371,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue()).to(equal([]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
@ -393,7 +393,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
// MARK: ------ when saving edit mode
context("when saving edit mode") {
beforeEach {
viewModel.rightNavItems.firstValue()??.first?.action?()
viewModel.rightNavItems.firstValue()?.first?.action?()
}
// MARK: -------- exits editing mode
@ -404,7 +404,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
expect(viewModel.leftNavItems.firstValue()).to(equal([]))
expect(viewModel.rightNavItems.firstValue())
.to(equal([
ParentType.NavItem(
SessionNavItem<ThreadSettingsViewModel.NavItem>(
id: .edit,
systemItem: .edit,
accessibilityIdentifier: "Edit button"
@ -446,7 +446,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
using: dependencies
)
disposables.append(
viewModel.observableTableData
viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -491,7 +491,7 @@ class ThreadSettingsViewModelSpec: QuickSpec {
using: dependencies
)
disposables.append(
viewModel.observableTableData
viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -517,7 +517,3 @@ class ThreadSettingsViewModelSpec: QuickSpec {
}
}
}
// MARK: - Test Types
fileprivate typealias ParentType = SessionTableViewModel<ThreadSettingsViewModel.NavButton, ThreadSettingsViewModel.Section, ThreadSettingsViewModel.Setting>

View File

@ -22,12 +22,14 @@ class NotificationContentViewModelSpec: QuickSpec {
SNUIKit.self
]
)
@TestState var viewModel: NotificationContentViewModel! = NotificationContentViewModel(
@TestState var dependencies: Dependencies! = Dependencies(
storage: mockStorage,
scheduling: .immediate
scheduler: .immediate
)
@TestState var dataChangeCancellable: AnyCancellable? = viewModel.observableTableData
@TestState var viewModel: NotificationContentViewModel! = NotificationContentViewModel(
using: dependencies
)
@TestState var dataChangeCancellable: AnyCancellable? = viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -88,8 +90,8 @@ class NotificationContentViewModelSpec: QuickSpec {
mockStorage.write { db in
db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType.nameNoPreview
}
viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate)
dataChangeCancellable = viewModel.observableTableData
viewModel = NotificationContentViewModel(using: dependencies)
dataChangeCancellable = viewModel.tableDataPublisher
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },
@ -141,7 +143,7 @@ class NotificationContentViewModelSpec: QuickSpec {
it("dismisses the screen") {
var didDismissScreen: Bool = false
dismissCancellable = viewModel.dismissScreen
dismissCancellable = viewModel.navigatableState.dismissScreen
.receive(on: ImmediateScheduler.shared)
.sink(
receiveCompletion: { _ in },

View File

@ -17,23 +17,35 @@ public final class SessionButton: UIButton {
case large
}
public struct Info {
public struct Info: Equatable {
public let style: Style
public let title: String
public let isEnabled: Bool
public let accessibility: Accessibility?
public let onTap: () -> ()
public init(
style: Style,
title: String,
isEnabled: Bool,
accessibility: Accessibility? = nil,
onTap: @escaping () -> ()
) {
self.style = style
self.title = title
self.isEnabled = isEnabled
self.accessibility = accessibility
self.onTap = onTap
}
public static func == (lhs: SessionButton.Info, rhs: SessionButton.Info) -> Bool {
return (
lhs.style == rhs.style &&
lhs.title == rhs.title &&
lhs.isEnabled == rhs.isEnabled &&
lhs.accessibility == rhs.accessibility
)
}
}
private let style: Style

View File

@ -95,11 +95,7 @@ public class TopBannerController: UIViewController {
view.addSubview(contentStackView)
contentStackView.addArrangedSubview(bannerContainer)
child.willMove(toParent: self)
addChild(child)
contentStackView.addArrangedSubview(child.view)
child.didMove(toParent: self)
attachChild()
bannerContainer.addSubview(bannerLabel)
bannerContainer.addSubview(closeButton)
@ -155,6 +151,13 @@ public class TopBannerController: UIViewController {
// MARK: - Functions
public func attachChild() {
child.willMove(toParent: self)
addChild(child)
contentStackView.addArrangedSubview(child.view)
child.didMove(toParent: self)
}
public static func show(warning: Warning, inWindowFor view: UIView? = nil) {
guard Thread.isMainThread else {
DispatchQueue.main.async {

View File

@ -13,10 +13,12 @@ public enum SNUIKit: MigratableTarget {
// SNUIKit migrations
[], // Initial DB Creation
[], // YDB to GRDB Migration
[], // YDB Removal
[], // Legacy DB removal
[
_001_ThemePreferences.self
] // Add job priorities
], // Add job priorities
[], // Fix thread FTS
[]
]
)
}

View File

@ -1,4 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import UIKit
import GRDB
@ -203,7 +205,7 @@ public enum ThemeManager {
func updateIfNeeded(viewController: UIViewController?) {
guard let viewController: UIViewController = viewController else { return }
guard
let navController: UINavigationController = ((viewController as? UINavigationController) ?? viewController.navigationController),
let navController: UINavigationController = retrieveNavigationController(from: viewController),
let superview: UIView = navController.view.superview,
!navController.isNavigationBarHidden
else {
@ -218,9 +220,19 @@ public enum ThemeManager {
applyNavigationStylingIfNeeded(to: viewController)
// Re-attach to the UI
navController.view.removeFromSuperview()
superview.addSubview(navController.view)
let wasFirstResponder: Bool = (navController.topViewController?.isFirstResponder == true)
switch navController.parent {
case let topBannerController as TopBannerController:
navController.view.removeFromSuperview()
topBannerController.attachChild()
default:
navController.view.removeFromSuperview()
superview.addSubview(navController.view)
}
navController.topViewController?.setNeedsStatusBarAppearanceUpdate()
if wasFirstResponder { navController.topViewController?.becomeFirstResponder() }
// Recurse through the rest of the UI
updateIfNeeded(viewController:
@ -263,6 +275,16 @@ public enum ThemeManager {
}
}
private static func retrieveNavigationController(from viewController: UIViewController) -> UINavigationController? {
switch viewController {
case let navController as UINavigationController: return navController
case let topBannerController as TopBannerController:
return (topBannerController.children.first as? UINavigationController)
default: return viewController.navigationController
}
}
public static func applyWindowStyling() {
guard Thread.isMainThread else {
return DispatchQueue.main.async { applyWindowStyling() }

View File

@ -124,6 +124,20 @@ public extension AnyPublisher {
}
}
extension AnyPublisher: ExpressibleByArrayLiteral where Output: Collection {
public init(arrayLiteral elements: Output.Element...) {
guard let convertedElements: Output = Array(elements) as? Output else {
SNLog("Failed to convery array literal to Publisher due to invalid type conversation of \(type(of: Output.self))")
guard let empty: Output = [] as? Output else { preconditionFailure("Invalid type") }
self = Just(empty).setFailureType(to: Failure.self).eraseToAnyPublisher()
return
}
self = Just(convertedElements).setFailureType(to: Failure.self).eraseToAnyPublisher()
}
}
// MARK: - Data Decoding
public extension Publisher where Output == Data, Failure == Error {

View File

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

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import DifferenceKit
@ -1073,6 +1074,46 @@ public enum PagedData {
}
}
public static func processAndTriggerUpdates<SectionModel: DifferentiableSection>(
updatedData: [SectionModel]?,
currentDataRetriever: @escaping (() -> [SectionModel]?),
valueSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never>?
) {
guard let updatedData: [SectionModel] = updatedData else { return }
// Note: While it would be nice to generate the changeset on a background thread it introduces
// a multi-threading issue where a data change can come in while the table is processing multiple
// updates resulting in the data being in a partially updated state (which makes the subsequent
// table reload crash due to inconsistent state)
let performUpdates = {
guard let currentData: [SectionModel] = currentDataRetriever() else { return }
let changeset: StagedChangeset<[SectionModel]> = StagedChangeset(
source: currentData,
target: updatedData
)
// No need to do anything if there were no changes
guard !changeset.isEmpty else { return }
// Need to send an event with the changes and then a second event to clear out the `StagedChangeset`
// value otherwise resubscribing will result with the changes coming through a second time
valueSubject?.send((updatedData, changeset))
valueSubject?.send((updatedData, StagedChangeset()))
}
// No need to dispatch to the next run loop if we are alread on the main thread
guard !Thread.isMainThread else {
performUpdates()
return
}
// Run any changes on the main thread (as they will generally trigger UI updates)
DispatchQueue.main.async {
performUpdates()
}
}
// MARK: - Internal Functions
fileprivate static func totalCount(

View File

@ -0,0 +1,6 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import DifferenceKit
extension String: Differentiable {}