Merge remote-tracking branch 'upstream/dev' into fix/media-interactions
This commit is contained in:
commit
de7d85f4cb
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -497,6 +497,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
|
||||
startObservingChanges()
|
||||
|
||||
/// If the view is removed and readded to the view hierarchy then `viewWillDisappear` will be called but `viewDidDisappear`
|
||||
/// **won't**, as a result `viewIsDisappearing` would never get set to `false` - do so here to handle this case
|
||||
viewIsDisappearing = false
|
||||
viewIsAppearing = true
|
||||
}
|
||||
|
||||
|
|
|
@ -49,11 +49,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
private var markAsReadPublisher: AnyPublisher<Void, Never>?
|
||||
|
||||
public lazy var blockedBannerMessage: String = {
|
||||
switch self.threadData.threadVariant {
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
|
||||
switch threadData.threadVariant {
|
||||
case .contact:
|
||||
let name: String = Profile.displayName(
|
||||
id: self.threadData.threadId,
|
||||
threadVariant: self.threadData.threadVariant
|
||||
id: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant
|
||||
)
|
||||
|
||||
return "\(name) is blocked. Unblock them?"
|
||||
|
@ -140,16 +142,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
|
||||
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
|
||||
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
|
||||
self.threadData = SessionThreadViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
|
||||
threadIsBlocked: initialData?.threadIsBlocked,
|
||||
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
||||
openGroupPermissions: initialData?.openGroupPermissions
|
||||
).populatingCurrentUserBlindedKeys(
|
||||
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
|
||||
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
|
||||
self._threadData = Atomic(
|
||||
SessionThreadViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
|
||||
threadIsBlocked: initialData?.threadIsBlocked,
|
||||
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
||||
openGroupPermissions: initialData?.openGroupPermissions
|
||||
).populatingCurrentUserBlindedKeys(
|
||||
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
|
||||
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
|
||||
)
|
||||
)
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
|
@ -179,8 +183,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
// MARK: - Thread Data
|
||||
|
||||
private var _threadData: Atomic<SessionThreadViewModel>
|
||||
|
||||
/// This value is the current state of the view
|
||||
public private(set) var threadData: SessionThreadViewModel
|
||||
public var threadData: SessionThreadViewModel { _threadData.wrappedValue }
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
|
@ -200,6 +206,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
|
||||
let oldThreadData: SessionThreadViewModel? = self?._threadData.wrappedValue
|
||||
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
|
@ -209,8 +216,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKeys(
|
||||
db,
|
||||
currentUserBlinded15PublicKeyForThisThread: self?.threadData.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKeyForThisThread: self?.threadData.currentUserBlinded25PublicKey
|
||||
currentUserBlinded15PublicKeyForThisThread: oldThreadData?.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKeyForThisThread: oldThreadData?.currentUserBlinded25PublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -219,7 +226,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||
self.threadData = updatedData
|
||||
self._threadData.mutate { $0 = updatedData }
|
||||
}
|
||||
|
||||
// MARK: - Interaction Data
|
||||
|
@ -393,6 +400,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
optimisticMessages: [MessageViewModel]?,
|
||||
initialUnreadInteractionId: Int64?
|
||||
) -> [SectionModel] {
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||
let sortedData: [MessageViewModel] = data
|
||||
.filter { $0.id != MessageViewModel.optimisticUpdateId } // Remove old optimistic updates
|
||||
|
@ -498,6 +506,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
) -> OptimisticMessageData {
|
||||
// Generate the optimistic data
|
||||
let optimisticMessageId: UUID = UUID()
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
|
||||
let interaction: Interaction = Interaction(
|
||||
threadId: threadData.threadId,
|
||||
|
@ -658,7 +667,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// MARK: - Mentions
|
||||
|
||||
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||
let threadData: SessionThreadViewModel = self.threadData
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
|
||||
return Storage.shared
|
||||
.read { db -> [MentionInfo] in
|
||||
|
@ -733,15 +742,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] target, timestampMs in
|
||||
let threadData: SessionThreadViewModel? = self?._threadData.wrappedValue
|
||||
|
||||
switch target {
|
||||
case .thread: self?.threadData.markAsRead(target: target)
|
||||
case .thread: threadData?.markAsRead(target: target)
|
||||
case .threadAndInteractions(let interactionId):
|
||||
guard
|
||||
timestampMs == nil ||
|
||||
(self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) ||
|
||||
(self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0)
|
||||
else {
|
||||
self?.threadData.markAsRead(target: .thread)
|
||||
threadData?.markAsRead(target: .thread)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -751,8 +762,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self?.lastInteractionTimestampMsMarkedAsRead = timestampMs
|
||||
}
|
||||
|
||||
self?.lastInteractionIdMarkedAsRead = (interactionId ?? self?.threadData.interactionId)
|
||||
self?.threadData.markAsRead(target: target)
|
||||
self?.lastInteractionIdMarkedAsRead = (interactionId ?? threadData?.interactionId)
|
||||
threadData?.markAsRead(target: target)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -791,7 +802,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public func trustContact() {
|
||||
guard self.threadData.threadVariant == .contact else { return }
|
||||
guard self._threadData.wrappedValue.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
|
@ -822,7 +833,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public func unblockContact() {
|
||||
guard self.threadData.threadVariant == .contact else { return }
|
||||
guard self._threadData.wrappedValue.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -617,7 +617,9 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
|
||||
switch section.model {
|
||||
case .messageRequests:
|
||||
let viewController: MessageRequestsViewController = MessageRequestsViewController()
|
||||
let viewController: SessionTableViewController = SessionTableViewController(
|
||||
viewModel: MessageRequestsViewModel()
|
||||
)
|
||||
self.navigationController?.pushViewController(viewController, animated: true)
|
||||
|
||||
case .threads:
|
||||
|
@ -776,7 +778,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
|
||||
let finalViewControllers: [UIViewController] = [
|
||||
self,
|
||||
(isMessageRequest ? MessageRequestsViewController() : nil),
|
||||
(isMessageRequest ? SessionTableViewController(viewModel: MessageRequestsViewModel()) : nil),
|
||||
ConversationVC(
|
||||
threadId: threadId,
|
||||
threadVariant: variant,
|
||||
|
|
|
@ -1,486 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
|
||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// MARK: - SessionUtilRespondingViewController
|
||||
|
||||
let isConversationList: Bool = true
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
init() {
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init() instead.")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var loadingConversationsLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "LOADING_CONVERSATIONS".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
|
||||
right: 0
|
||||
)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "MESSAGE_REQUESTS_EMPTY_TEXT".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: GradientView = {
|
||||
let result: GradientView = GradientView()
|
||||
result.themeBackgroundGradient = [
|
||||
.value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary
|
||||
]
|
||||
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .destructive, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
result.accessibilityIdentifier = "Clear all"
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
||||
// the dataSource has the correct data)
|
||||
view.addSubview(loadingConversationsLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
view.addSubview(clearAllButton)
|
||||
setupLayout()
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidBecomeActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidResignActive(_:)),
|
||||
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
self.viewHasAppeared = true
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
loadingConversationsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.veryLargeSpacing),
|
||||
loadingConversationsLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.massiveSpacing),
|
||||
loadingConversationsLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.massiveSpacing),
|
||||
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
||||
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
||||
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
|
||||
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
clearAllButton.bottomAnchor.constraint(
|
||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.smallSpacing
|
||||
),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
|
||||
// data to ensure everything is up to date
|
||||
if didReturnFromBackground {
|
||||
self.viewModel.pagedDataObserver?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
self.viewModel.onThreadChange = nil
|
||||
}
|
||||
|
||||
private func handleThreadUpdates(
|
||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
UIView.performWithoutAnimation {
|
||||
// Hide the 'loading conversations' label (now that we have received conversation data)
|
||||
loadingConversationsLabel.isHidden = true
|
||||
|
||||
// Show the empty state if there is no data
|
||||
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
|
||||
emptyStateLabel.isHidden = !clearAllButton.isHidden
|
||||
|
||||
// Update the content
|
||||
viewModel.updateThreadData(updatedData)
|
||||
tableView.reloadData()
|
||||
hasLoadedInitialThreadData = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Hide the 'loading conversations' label (now that we have received conversation data)
|
||||
loadingConversationsLabel.isHidden = true
|
||||
|
||||
// Show the empty state if there is no data
|
||||
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
|
||||
emptyStateLabel.isHidden = !clearAllButton.isHidden
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setCompletionBlock { [weak self] in
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
// Reload the table content (animate changes after the first load)
|
||||
tableView.reload(
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .bottom,
|
||||
insertRowsAnimation: .top,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
||||
) { [weak self] updatedData in
|
||||
self?.viewModel.updateThreadData(updatedData)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard
|
||||
self.hasLoadedInitialThreadData &&
|
||||
!self.isAutoLoadingNextPage &&
|
||||
!self.isLoadingMore
|
||||
else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||
self?.isAutoLoadingNextPage = false
|
||||
|
||||
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
|
||||
.enumerated()
|
||||
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
|
||||
.defaulting(to: [])
|
||||
let shouldLoadMore: Bool = sections
|
||||
.contains { section, headerRect in
|
||||
section == .loadMore &&
|
||||
headerRect != .zero &&
|
||||
(self?.tableView.bounds.contains(headerRect) == true)
|
||||
}
|
||||
|
||||
guard shouldLoadMore else { return }
|
||||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return viewModel.threadData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
return section.elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.accessibilityIdentifier = "Message request"
|
||||
cell.isAccessibilityElement = true
|
||||
cell.update(with: threadViewModel)
|
||||
return cell
|
||||
|
||||
default: preconditionFailure("Other sections should have no content")
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
loadingIndicator.themeTintColor = .textPrimary
|
||||
loadingIndicator.alpha = 0.5
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
let view: UIView = UIView()
|
||||
view.addSubview(loadingIndicator)
|
||||
loadingIndicator.center(in: view)
|
||||
|
||||
return view
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
|
||||
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let conversationVC: ConversationVC = ConversationVC(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
[
|
||||
(threadViewModel.threadVariant != .contact ? nil : .block),
|
||||
.delete
|
||||
].compactMap { $0 },
|
||||
for: .trailing,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView,
|
||||
threadViewModel: threadViewModel,
|
||||
viewController: self
|
||||
)
|
||||
)
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
|
||||
return
|
||||
}
|
||||
|
||||
let contactThreadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.filter { $0.threadVariant == .contact }
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let groupThreadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group }
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
MessageRequestsViewModel.clearAllRequests(
|
||||
contactThreadIds: contactThreadIds,
|
||||
groupThreadIds: groupThreadIds
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,35 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class MessageRequestsViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable {
|
||||
case threads
|
||||
case loadMore
|
||||
}
|
||||
class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource {
|
||||
typealias TableItem = SessionThreadViewModel
|
||||
typealias PagedTable = SessionThread
|
||||
typealias PagedDataModel = SessionThreadViewModel
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||
public let dependencies: Dependencies
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, SessionThreadViewModel> = ObservableTableSourceState()
|
||||
public let navigatableState: NavigatableState = NavigatableState()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
init(using dependencies: Dependencies = Dependencies()) {
|
||||
self.dependencies = dependencies
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies)
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: SessionThread.self,
|
||||
|
@ -101,14 +103,8 @@ public class MessageRequestsViewModel {
|
|||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
||||
currentDataRetriever: { self?.threadData },
|
||||
onDataChange: self?.onThreadChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
currentDataRetriever: { self?.tableData },
|
||||
valueSubject: self?.pendingTableDataSubject
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -120,35 +116,35 @@ public class MessageRequestsViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
}()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,92 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class BlockedContactCell: UITableViewCell {
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
|
||||
|
||||
private let selectionView: RadioButton = {
|
||||
let result: RadioButton = RadioButton(size: .medium)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize, weight: .bold)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initializtion
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Background color
|
||||
themeBackgroundColor = .conversationButton_background
|
||||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Add the UI
|
||||
contentView.addSubview(profilePictureView)
|
||||
contentView.addSubview(selectionView)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
// Profile picture view
|
||||
profilePictureView.center(.vertical, in: contentView)
|
||||
profilePictureView.topAnchor
|
||||
.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Values.mediumSpacing)
|
||||
.isActive = true
|
||||
profilePictureView.bottomAnchor
|
||||
.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Values.mediumSpacing)
|
||||
.isActive = true
|
||||
profilePictureView.pin(.left, to: .left, of: contentView, withInset: Values.veryLargeSpacing)
|
||||
|
||||
selectionView.center(.vertical, in: contentView)
|
||||
selectionView.topAnchor
|
||||
.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: Values.mediumSpacing)
|
||||
.isActive = true
|
||||
selectionView.bottomAnchor
|
||||
.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Values.mediumSpacing)
|
||||
.isActive = true
|
||||
selectionView.pin(.left, to: .right, of: profilePictureView, withInset: Values.mediumSpacing)
|
||||
selectionView.pin(.right, to: .right, of: contentView, withInset: -Values.veryLargeSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
public func update(with cellViewModel: BlockedContactsViewModel.DataModel, isSelected: Bool) {
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.id,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: nil
|
||||
)
|
||||
selectionView.text = (
|
||||
cellViewModel.profile?.displayName() ??
|
||||
Profile.truncated(id: cellViewModel.id, truncating: .middle)
|
||||
)
|
||||
selectionView.update(isSelected: isSelected)
|
||||
}
|
||||
}
|
|
@ -12,10 +12,12 @@ protocol SessionViewModelAccessible {
|
|||
var viewModelType: AnyObject.Type { get }
|
||||
}
|
||||
|
||||
class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable>: BaseVC, UITableViewDataSource, UITableViewDelegate, SessionViewModelAccessible {
|
||||
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
|
||||
class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITableViewDelegate, SessionViewModelAccessible where ViewModel: (SessionTableViewModel & ObservableTableSource) {
|
||||
typealias Section = ViewModel.Section
|
||||
typealias TableItem = ViewModel.TableItem
|
||||
typealias SectionModel = ViewModel.SectionModel
|
||||
|
||||
private let viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>
|
||||
private let viewModel: ViewModel
|
||||
private var hasLoadedInitialTableData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
|
@ -37,6 +39,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
result.showsVerticalScrollIndicator = false
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.register(view: SessionCell.self)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.registerHeaderFooterView(view: SessionHeaderView.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
@ -48,6 +51,20 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var initialLoadLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.text = viewModel.initialLoadMessage
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = (viewModel.initialLoadMessage == nil)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -87,10 +104,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
|
||||
init(viewModel: ViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
(viewModel as? (any PagedObservationSource))?.didInit(using: viewModel.dependencies)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
@ -116,6 +133,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(initialLoadLabel)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
view.addSubview(footerButton)
|
||||
|
@ -159,7 +177,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges()
|
||||
self?.startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,6 +188,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
private func setupLayout() {
|
||||
tableView.pin(to: view)
|
||||
|
||||
initialLoadLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
|
||||
initialLoadLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
|
||||
initialLoadLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
|
||||
|
||||
emptyStateLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
|
||||
emptyStateLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
|
||||
emptyStateLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
|
||||
|
@ -184,9 +206,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges() {
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
// Start observing for data changes
|
||||
dataChangeCancellable = viewModel.observableTableData
|
||||
dataChangeCancellable = viewModel.tableDataPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
@ -202,7 +224,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
SNLog("Atempting recovery for database stream in '\(title)' settings with error: \(error)")
|
||||
self?.dataStreamJustFailed = true
|
||||
self?.startObservingChanges()
|
||||
self?.startObservingChanges(didReturnFromBackground: didReturnFromBackground)
|
||||
|
||||
case .finished: break
|
||||
}
|
||||
|
@ -212,6 +234,9 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
self?.handleDataUpdates(updatedData, changeset: changeset)
|
||||
}
|
||||
)
|
||||
|
||||
// Some viewModel's may need to run custom logic after returning from the background so trigger that here
|
||||
if didReturnFromBackground { viewModel.didReturnFromBackground() }
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
|
@ -233,7 +258,8 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialTableData else {
|
||||
UIView.performWithoutAnimation {
|
||||
// Update the empty state
|
||||
// Update the initial/empty state
|
||||
initialLoadLabel.isHidden = true
|
||||
emptyStateLabel.isHidden = (itemCount > 0)
|
||||
|
||||
// Update the content
|
||||
|
@ -302,7 +328,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.loadPageAfter()
|
||||
(self?.viewModel as? (any PagedObservationSource))?.loadPageAfter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -310,13 +336,22 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
// MARK: - Binding
|
||||
|
||||
private func setupBinding() {
|
||||
viewModel.isEditing
|
||||
(viewModel as? (any NavigationItemSource))?.setupBindings(
|
||||
viewController: self,
|
||||
disposables: &disposables
|
||||
)
|
||||
(viewModel as? (any NavigatableStateHolder))?.navigatableState.setupBindings(
|
||||
viewController: self,
|
||||
disposables: &disposables
|
||||
)
|
||||
|
||||
(viewModel as? ErasedEditableStateHolder)?.isEditing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEditing in
|
||||
.sink { [weak self, weak tableView] isEditing in
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self?.setEditing(isEditing, animated: true)
|
||||
|
||||
self?.tableView.visibleCells
|
||||
tableView?.visibleCells
|
||||
.compactMap { $0 as? SessionCell }
|
||||
.filter { $0.interactionMode == .editable || $0.interactionMode == .alwaysEditing }
|
||||
.enumerated()
|
||||
|
@ -332,56 +367,12 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
)
|
||||
}
|
||||
|
||||
self?.tableView.beginUpdates()
|
||||
self?.tableView.endUpdates()
|
||||
tableView?.beginUpdates()
|
||||
tableView?.endUpdates()
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.leftNavItems
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] maybeItems in
|
||||
self?.navigationItem.setLeftBarButtonItems(
|
||||
maybeItems.map { items in
|
||||
items.map { item -> DisposableBarButtonItem in
|
||||
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
|
||||
buttonItem.themeTintColor = .textPrimary
|
||||
|
||||
buttonItem.tapPublisher
|
||||
.map { _ in item.id }
|
||||
.sink(receiveValue: { _ in item.action?() })
|
||||
.store(in: &buttonItem.disposables)
|
||||
|
||||
return buttonItem
|
||||
}
|
||||
},
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.rightNavItems
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] maybeItems in
|
||||
self?.navigationItem.setRightBarButtonItems(
|
||||
maybeItems.map { items in
|
||||
items.map { item -> DisposableBarButtonItem in
|
||||
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
|
||||
buttonItem.themeTintColor = .textPrimary
|
||||
|
||||
buttonItem.tapPublisher
|
||||
.map { _ in item.id }
|
||||
.sink(receiveValue: { _ in item.action?() })
|
||||
.store(in: &buttonItem.disposables)
|
||||
|
||||
return buttonItem
|
||||
}
|
||||
},
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.emptyStateTextPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] text in
|
||||
|
@ -403,6 +394,8 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
self?.footerButton.setTitle(buttonInfo.title, for: .normal)
|
||||
self?.footerButton.setStyle(buttonInfo.style)
|
||||
self?.footerButton.isEnabled = buttonInfo.isEnabled
|
||||
self?.footerButton.accessibilityIdentifier = buttonInfo.accessibility?.identifier
|
||||
self?.footerButton.accessibilityLabel = buttonInfo.accessibility?.label
|
||||
}
|
||||
|
||||
self?.onFooterTap = buttonInfo?.onTap
|
||||
|
@ -422,61 +415,6 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.showToast
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] text, color in
|
||||
guard let view: UIView = self?.view else { return }
|
||||
|
||||
let toastController: ToastController = ToastController(text: text, background: color)
|
||||
toastController.presentToastView(fromBottomOfView: view, inset: Values.largeSpacing)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.transitionToScreen
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] viewController, transitionType in
|
||||
switch transitionType {
|
||||
case .push:
|
||||
self?.navigationController?.pushViewController(viewController, animated: true)
|
||||
|
||||
case .present:
|
||||
let presenter: UIViewController? = (self?.presentedViewController ?? self)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
viewController.popoverPresentationController?.permittedArrowDirections = []
|
||||
viewController.popoverPresentationController?.sourceView = presenter?.view
|
||||
viewController.popoverPresentationController?.sourceRect = (presenter?.view.bounds ?? UIScreen.main.bounds)
|
||||
}
|
||||
|
||||
presenter?.present(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
viewModel.dismissScreen
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] dismissType in
|
||||
switch dismissType {
|
||||
case .auto:
|
||||
guard
|
||||
let viewController: UIViewController = self,
|
||||
(self?.navigationController?.viewControllers
|
||||
.firstIndex(of: viewController))
|
||||
.defaulting(to: 0) > 0
|
||||
else {
|
||||
self?.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
|
||||
case .dismiss: self?.dismiss(animated: true)
|
||||
case .pop: self?.navigationController?.popViewController(animated: true)
|
||||
case .popToRoot: self?.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
}
|
||||
|
||||
@objc private func footerButtonTapped() {
|
||||
|
@ -495,19 +433,36 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section: SectionModel = viewModel.tableData[indexPath.section]
|
||||
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
|
||||
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
|
||||
cell.update(with: info)
|
||||
cell.update(
|
||||
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
|
||||
becomeFirstResponder: false,
|
||||
animated: false
|
||||
)
|
||||
cell.textPublisher
|
||||
.sink(receiveValue: { [weak self] text in
|
||||
self?.viewModel.textChanged(text, for: info.id)
|
||||
})
|
||||
.store(in: &cell.disposables)
|
||||
let info: SessionCell.Info<TableItem> = section.elements[indexPath.row]
|
||||
let cell: UITableViewCell = tableView.dequeue(type: viewModel.cellType.viewType.self, for: indexPath)
|
||||
|
||||
switch (cell, info) {
|
||||
case (let cell as SessionCell, _):
|
||||
cell.update(with: info)
|
||||
cell.update(
|
||||
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
|
||||
becomeFirstResponder: false,
|
||||
animated: false
|
||||
)
|
||||
|
||||
switch viewModel {
|
||||
case let editableStateHolder as ErasedEditableStateHolder:
|
||||
cell.textPublisher
|
||||
.sink(receiveValue: { [weak editableStateHolder] text in
|
||||
editableStateHolder?.textChanged(text, for: info.id)
|
||||
})
|
||||
.store(in: &cell.disposables)
|
||||
default: break
|
||||
}
|
||||
|
||||
case (let cell as FullConversationCell, let threadInfo as SessionCell.Info<SessionThreadViewModel>):
|
||||
cell.accessibilityIdentifier = info.accessibility?.identifier
|
||||
cell.isAccessibilityElement = (info.accessibility != nil)
|
||||
cell.update(with: threadInfo.id)
|
||||
|
||||
default:
|
||||
SNLog("[SessionTableViewController] Got invalid combination of cellType: \(viewModel.cellType) and tableData: \(SessionCell.Info<TableItem>.self)")
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
@ -547,18 +502,38 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.loadPageAfter()
|
||||
(self?.viewModel as? (any PagedObservationSource))?.loadPageAfter()
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return viewModel.canEditRow(at: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return viewModel.leadingSwipeActionsConfiguration(forRowAt: indexPath, in: tableView, of: self)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return viewModel.trailingSwipeActionsConfiguration(forRowAt: indexPath, in: tableView, of: self)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
let section: SectionModel = self.viewModel.tableData[indexPath.section]
|
||||
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
|
||||
let info: SessionCell.Info<TableItem> = section.elements[indexPath.row]
|
||||
|
||||
// Do nothing if the item is disabled
|
||||
guard info.isEnabled else { return }
|
||||
|
@ -580,7 +555,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
return cell
|
||||
}
|
||||
}()
|
||||
let maybeOldSelection: (Int, SessionCell.Info<SettingItem>)? = section.elements
|
||||
let maybeOldSelection: (Int, SessionCell.Info<TableItem>)? = section.elements
|
||||
.enumerated()
|
||||
.first(where: { index, info in
|
||||
switch (info.leftAccessory, info.rightAccessory) {
|
||||
|
@ -596,7 +571,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
|
||||
|
||||
// Update the old selection as well
|
||||
if let oldSelection: (index: Int, info: SessionCell.Info<SettingItem>) = maybeOldSelection {
|
||||
if let oldSelection: (index: Int, info: SessionCell.Info<TableItem>) = maybeOldSelection {
|
||||
self?.manuallyReload(
|
||||
indexPath: IndexPath(
|
||||
row: oldSelection.index,
|
||||
|
@ -628,7 +603,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
|
|||
private func manuallyReload(
|
||||
indexPath: IndexPath,
|
||||
section: SectionModel,
|
||||
info: SessionCell.Info<SettingItem>
|
||||
info: SessionCell.Info<TableItem>
|
||||
) {
|
||||
// Try update the existing cell to have a nice animation instead of reloading the cell
|
||||
if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit.UIImage
|
||||
import UIKit
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
|
@ -8,130 +8,53 @@ import SessionUIKit
|
|||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable> {
|
||||
typealias SectionModel = ArraySection<Section, SessionCell.Info<SettingItem>>
|
||||
typealias ObservableData = AnyPublisher<([SectionModel], StagedChangeset<[SectionModel]>), Error>
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import DifferenceKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - EditableStateHolder
|
||||
|
||||
public protocol EditableStateHolder: AnyObject, TableData, ErasedEditableStateHolder {
|
||||
var editableState: EditableState<TableItem> { get }
|
||||
}
|
||||
|
||||
public extension EditableStateHolder {
|
||||
var textChanged: AnyPublisher<(text: String?, item: TableItem), Never> { editableState.textChanged }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool) {
|
||||
editableState._isEditing.send(isEditing)
|
||||
}
|
||||
|
||||
func textChanged(_ text: String?, for item: TableItem) {
|
||||
editableState._textChanged.send((text, item))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ErasedEditableStateHolder
|
||||
|
||||
public protocol ErasedEditableStateHolder: AnyObject {
|
||||
var isEditing: AnyPublisher<Bool, Never> { get }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool)
|
||||
func textChanged<Item>(_ text: String?, for item: Item)
|
||||
}
|
||||
|
||||
public extension ErasedEditableStateHolder {
|
||||
var isEditing: AnyPublisher<Bool, Never> { Just(false).eraseToAnyPublisher() }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool) {}
|
||||
func textChanged<Item>(_ text: String?, for item: Item) {}
|
||||
}
|
||||
|
||||
public extension ErasedEditableStateHolder where Self: EditableStateHolder {
|
||||
var isEditing: AnyPublisher<Bool, Never> { editableState.isEditing }
|
||||
|
||||
func setIsEditing(_ isEditing: Bool) {
|
||||
editableState._isEditing.send(isEditing)
|
||||
}
|
||||
|
||||
func textChanged<Item>(_ text: String?, for item: Item) {
|
||||
guard let convertedItem: TableItem = item as? TableItem else { return }
|
||||
|
||||
editableState._textChanged.send((text, convertedItem))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EditableState
|
||||
|
||||
public struct EditableState<TableItem: Hashable & Differentiable> {
|
||||
let isEditing: AnyPublisher<Bool, Never>
|
||||
let textChanged: AnyPublisher<(text: String?, item: TableItem), Never>
|
||||
|
||||
// MARK: - Internal Variables
|
||||
|
||||
fileprivate let _isEditing: CurrentValueSubject<Bool, Never> = CurrentValueSubject(false)
|
||||
fileprivate let _textChanged: PassthroughSubject<(text: String?, item: TableItem), Never> = PassthroughSubject()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.isEditing = _isEditing
|
||||
.removeDuplicates()
|
||||
.shareReplay(1)
|
||||
self.textChanged = _textChanged
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - NavigationItemSource
|
||||
|
||||
protocol NavigationItemSource {
|
||||
associatedtype NavItem: Equatable
|
||||
|
||||
var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { get }
|
||||
var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { get }
|
||||
}
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
extension NavigationItemSource {
|
||||
var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { Just([]).eraseToAnyPublisher() }
|
||||
var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> { Just([]).eraseToAnyPublisher() }
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
extension NavigationItemSource {
|
||||
func setupBindings(
|
||||
viewController: UIViewController,
|
||||
disposables: inout Set<AnyCancellable>
|
||||
) {
|
||||
self.leftNavItems
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] items in
|
||||
viewController?.navigationItem.setLeftBarButtonItems(
|
||||
items.map { item -> DisposableBarButtonItem in
|
||||
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
|
||||
buttonItem.themeTintColor = .textPrimary
|
||||
|
||||
buttonItem.tapPublisher
|
||||
.map { _ in item.id }
|
||||
.sink(receiveValue: { _ in item.action?() })
|
||||
.store(in: &buttonItem.disposables)
|
||||
|
||||
return buttonItem
|
||||
},
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
self.rightNavItems
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] items in
|
||||
viewController?.navigationItem.setRightBarButtonItems(
|
||||
items.map { item -> DisposableBarButtonItem in
|
||||
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
|
||||
buttonItem.themeTintColor = .textPrimary
|
||||
|
||||
buttonItem.tapPublisher
|
||||
.map { _ in item.id }
|
||||
.sink(receiveValue: { _ in item.action?() })
|
||||
.store(in: &buttonItem.disposables)
|
||||
|
||||
return buttonItem
|
||||
},
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
// MARK: - NavigatableStateHolder
|
||||
|
||||
public protocol NavigatableStateHolder {
|
||||
var navigatableState: NavigatableState { get }
|
||||
}
|
||||
|
||||
public extension NavigatableStateHolder {
|
||||
func showToast(text: String, backgroundColor: ThemeValue = .backgroundPrimary) {
|
||||
navigatableState._showToast.send((text, backgroundColor))
|
||||
}
|
||||
|
||||
func dismissScreen(type: DismissType = .auto) {
|
||||
navigatableState._dismissScreen.send(type)
|
||||
}
|
||||
|
||||
func transitionToScreen(_ viewController: UIViewController, transitionType: TransitionType = .push) {
|
||||
navigatableState._transitionToScreen.send((viewController, transitionType))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NavigatableState
|
||||
|
||||
public struct NavigatableState {
|
||||
let showToast: AnyPublisher<(String, ThemeValue), Never>
|
||||
let transitionToScreen: AnyPublisher<(UIViewController, TransitionType), Never>
|
||||
let dismissScreen: AnyPublisher<DismissType, Never>
|
||||
|
||||
// MARK: - Internal Variables
|
||||
|
||||
fileprivate let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject()
|
||||
fileprivate let _transitionToScreen: PassthroughSubject<(UIViewController, TransitionType), Never> = PassthroughSubject()
|
||||
fileprivate let _dismissScreen: PassthroughSubject<DismissType, Never> = PassthroughSubject()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.showToast = _showToast.shareReplay(0)
|
||||
self.transitionToScreen = _transitionToScreen.shareReplay(0)
|
||||
self.dismissScreen = _dismissScreen.shareReplay(0)
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func setupBindings(
|
||||
viewController: UIViewController,
|
||||
disposables: inout Set<AnyCancellable>
|
||||
) {
|
||||
self.showToast
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] text, color in
|
||||
guard let view: UIView = viewController?.view else { return }
|
||||
|
||||
let toastController: ToastController = ToastController(text: text, background: color)
|
||||
toastController.presentToastView(fromBottomOfView: view, inset: Values.largeSpacing)
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
self.transitionToScreen
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] targetViewController, transitionType in
|
||||
switch transitionType {
|
||||
case .push:
|
||||
viewController?.navigationController?.pushViewController(targetViewController, animated: true)
|
||||
|
||||
case .present:
|
||||
let presenter: UIViewController? = (viewController?.presentedViewController ?? viewController)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
targetViewController.popoverPresentationController?.permittedArrowDirections = []
|
||||
targetViewController.popoverPresentationController?.sourceView = presenter?.view
|
||||
targetViewController.popoverPresentationController?.sourceRect = (presenter?.view.bounds ?? UIScreen.main.bounds)
|
||||
}
|
||||
|
||||
presenter?.present(targetViewController, animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
|
||||
self.dismissScreen
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewController] dismissType in
|
||||
switch dismissType {
|
||||
case .auto:
|
||||
guard
|
||||
let viewController: UIViewController = viewController,
|
||||
(viewController.navigationController?.viewControllers
|
||||
.firstIndex(of: viewController))
|
||||
.defaulting(to: 0) > 0
|
||||
else {
|
||||
viewController?.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
viewController.navigationController?.popViewController(animated: true)
|
||||
|
||||
case .dismiss: viewController?.dismiss(animated: true)
|
||||
case .pop: viewController?.navigationController?.popViewController(animated: true)
|
||||
case .popToRoot: viewController?.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposables)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,263 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Combine
|
||||
import DifferenceKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - ObservableTableSource
|
||||
|
||||
public protocol ObservableTableSource: AnyObject, SectionedTableData {
|
||||
typealias TargetObservation = TableObservation<[SectionModel]>
|
||||
typealias TargetPublisher = AnyPublisher<(([SectionModel], StagedChangeset<[SectionModel]>)), Error>
|
||||
|
||||
var dependencies: Dependencies { get }
|
||||
var state: TableDataState<Section, TableItem> { get }
|
||||
var observableState: ObservableTableSourceState<Section, TableItem> { get }
|
||||
var observation: TargetObservation { get }
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
func didReturnFromBackground()
|
||||
}
|
||||
|
||||
extension ObservableTableSource {
|
||||
public var pendingTableDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> {
|
||||
self.observableState.pendingTableDataSubject
|
||||
}
|
||||
public var observation: TargetObservation {
|
||||
ObservationBuilder.changesetSubject(self.observableState.pendingTableDataSubject)
|
||||
}
|
||||
|
||||
public var tableDataPublisher: TargetPublisher { self.observation.finalPublisher(self, using: dependencies) }
|
||||
|
||||
public func didReturnFromBackground() {}
|
||||
public func forceRefresh() { self.observableState._forcedRefresh.send(()) }
|
||||
}
|
||||
|
||||
// MARK: - State Manager (ObservableTableSource)
|
||||
|
||||
public class ObservableTableSourceState<Section: SessionTableSection, TableItem: Hashable & Differentiable>: SectionedTableData {
|
||||
public let forcedRefresh: AnyPublisher<Void, Never>
|
||||
public let pendingTableDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never>
|
||||
|
||||
// MARK: - Internal Variables
|
||||
|
||||
fileprivate var hasEmittedInitialData: Bool
|
||||
fileprivate let _forcedRefresh: PassthroughSubject<Void, Never> = PassthroughSubject()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.hasEmittedInitialData = false
|
||||
self.forcedRefresh = _forcedRefresh.shareReplay(0)
|
||||
self.pendingTableDataSubject = CurrentValueSubject(([], StagedChangeset()))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TableObservation
|
||||
|
||||
public struct TableObservation<T> {
|
||||
fileprivate let generatePublisher: (any ObservableTableSource, Dependencies) -> AnyPublisher<T, Error>
|
||||
fileprivate let generatePublisherWithChangeset: ((any ObservableTableSource, Dependencies) -> AnyPublisher<Any, Error>)?
|
||||
|
||||
init(generatePublisher: @escaping (any ObservableTableSource, Dependencies) -> AnyPublisher<T, Error>) {
|
||||
self.generatePublisher = generatePublisher
|
||||
self.generatePublisherWithChangeset = nil
|
||||
}
|
||||
|
||||
init(generatePublisherWithChangeset: @escaping (any ObservableTableSource, Dependencies) -> AnyPublisher<(T, StagedChangeset<T>), Error>) where T: Collection {
|
||||
self.generatePublisher = { _, _ in Fail(error: StorageError.invalidData).eraseToAnyPublisher() }
|
||||
self.generatePublisherWithChangeset = { source, dependencies in
|
||||
generatePublisherWithChangeset(source, dependencies).map { $0 as Any }.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func finalPublisher<S: ObservableTableSource>(
|
||||
_ source: S,
|
||||
using dependencies: Dependencies
|
||||
) -> S.TargetPublisher {
|
||||
typealias TargetData = (([S.SectionModel], StagedChangeset<[S.SectionModel]>))
|
||||
|
||||
switch (self, self.generatePublisherWithChangeset) {
|
||||
case (_, .some(let generatePublisherWithChangeset)):
|
||||
return generatePublisherWithChangeset(source, dependencies)
|
||||
.tryMap { data -> TargetData in
|
||||
guard let convertedData: TargetData = data as? TargetData else {
|
||||
throw StorageError.invalidData
|
||||
}
|
||||
|
||||
return convertedData
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case (let validObservation as S.TargetObservation, _):
|
||||
// Doing `removeDuplicates` in case the conversion from the original data to [SectionModel]
|
||||
// can result in duplicate output even with some different inputs
|
||||
return validObservation.generatePublisher(source, dependencies)
|
||||
.removeDuplicates()
|
||||
.mapToSessionTableViewData(for: source)
|
||||
|
||||
default: return Fail(error: StorageError.invalidData).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TableObservation: ExpressibleByArrayLiteral where T: Collection {
|
||||
public init(arrayLiteral elements: T.Element?...) {
|
||||
self.init(
|
||||
generatePublisher: { _, _ in
|
||||
guard let convertedElements: T = Array(elements.compactMap { $0 }) as? T else {
|
||||
return Fail(error: StorageError.invalidData).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(convertedElements)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ObservationBuilder
|
||||
|
||||
public enum ObservationBuilder {
|
||||
/// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
|
||||
/// added
|
||||
static func subject<T: Equatable>(_ subject: CurrentValueSubject<T, Error>) -> TableObservation<T> {
|
||||
return TableObservation { _, _ in
|
||||
return subject
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `subject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
|
||||
/// added
|
||||
static func subject<T: Equatable>(_ subject: CurrentValueSubject<T, Never>) -> TableObservation<T> {
|
||||
return TableObservation { _, _ in
|
||||
return subject
|
||||
.removeDuplicates()
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips
|
||||
/// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
static func databaseObservation<S: ObservableTableSource, T: Equatable>(_ source: S, fetch: @escaping (Database) throws -> T) -> TableObservation<T> {
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
return TableObservation { viewModel, dependencies in
|
||||
return ValueObservation
|
||||
.trackingConstantRegion(fetch)
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[\(type(of: viewModel))] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.manualRefreshFrom(source.observableState.forcedRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ValueObserveration` will trigger whenever any of the data fetched in the closure is updated, please see the following link for tips
|
||||
/// to help optimise performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
static func databaseObservation<S: ObservableTableSource, T: Equatable>(_ source: S, fetch: @escaping (Database) throws -> [T]) -> TableObservation<[T]> {
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
return TableObservation { viewModel, dependencies in
|
||||
return ValueObservation
|
||||
.trackingConstantRegion(fetch)
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[\(type(of: viewModel))] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.manualRefreshFrom(source.observableState.forcedRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `changesetSubject` will emit immediately when there is a subscriber and store the most recent value to be emitted whenever a new subscriber is
|
||||
/// added
|
||||
static func changesetSubject<T>(
|
||||
_ subject: CurrentValueSubject<([T], StagedChangeset<[T]>), Never>
|
||||
) -> TableObservation<[T]> {
|
||||
return TableObservation { viewModel, dependencies in
|
||||
subject
|
||||
.withPrevious(([], StagedChangeset()))
|
||||
.filter { prev, next in
|
||||
/// Suppress events with no changes (these will be sent in order to clear out the `StagedChangeset` value as if we
|
||||
/// don't do so then resubscribing will result in an attempt to apply an invalid changeset to the `tableView` resulting
|
||||
/// in a crash)
|
||||
!next.1.isEmpty
|
||||
}
|
||||
.map { _, current -> ([T], StagedChangeset<[T]>) in current }
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Transforms
|
||||
|
||||
public extension TableObservation {
|
||||
func map<R>(transform: @escaping (T) -> R) -> TableObservation<R> {
|
||||
return TableObservation<R> { viewModel, dependencies in
|
||||
self.generatePublisher(viewModel, dependencies).map(transform).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
func mapWithPrevious<R>(transform: @escaping (T?, T) -> R) -> TableObservation<R> {
|
||||
return TableObservation<R> { viewModel, dependencies in
|
||||
self.generatePublisher(viewModel, dependencies)
|
||||
.withPrevious()
|
||||
.map(transform)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array {
|
||||
func mapToSessionTableViewData<S: ObservableTableSource>(
|
||||
for source: S?
|
||||
) -> [ArraySection<S.Section, SessionCell.Info<S.TableItem>>] where Element == ArraySection<S.Section, SessionCell.Info<S.TableItem>> {
|
||||
// Update the data to include the proper position for each element
|
||||
return self.map { section in
|
||||
ArraySection(
|
||||
model: section.model,
|
||||
elements: section.elements.enumerated().map { index, element in
|
||||
element.updatedPosition(for: index, count: section.elements.count)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Publisher {
|
||||
func mapToSessionTableViewData<S: ObservableTableSource>(
|
||||
for source: S
|
||||
) -> AnyPublisher<(Output, StagedChangeset<Output>), Failure> where Output == [ArraySection<S.Section, SessionCell.Info<S.TableItem>>] {
|
||||
return self
|
||||
.map { [weak source] updatedData -> (Output, StagedChangeset<Output>) in
|
||||
let updatedDataWithPositions: Output = updatedData
|
||||
.mapToSessionTableViewData(for: source)
|
||||
|
||||
// Generate an updated changeset
|
||||
let changeset = StagedChangeset(
|
||||
source: (source?.state.tableData ?? []),
|
||||
target: updatedDataWithPositions
|
||||
)
|
||||
|
||||
return (updatedDataWithPositions, changeset)
|
||||
}
|
||||
.filter { [weak source] _, changeset in
|
||||
source?.observableState.hasEmittedInitialData == false || // Always emit at least once
|
||||
!changeset.isEmpty // Do nothing if there were no changes
|
||||
}
|
||||
.handleEvents(receiveOutput: { [weak source] _ in
|
||||
source?.observableState.hasEmittedInitialData = true
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol PagedObservationSource {
|
||||
associatedtype PagedTable: TableRecord & ColumnExpressible & Identifiable
|
||||
associatedtype PagedDataModel: FetchableRecordWithRowId & Identifiable
|
||||
|
||||
var pagedDataObserver: PagedDatabaseObserver<PagedTable, PagedDataModel>? { get }
|
||||
|
||||
func didInit(using dependencies: Dependencies)
|
||||
func loadPageBefore()
|
||||
func loadPageAfter()
|
||||
}
|
||||
|
||||
extension PagedObservationSource {
|
||||
public func didInit(using dependencies: Dependencies) {
|
||||
dependencies.storage.addObserver(pagedDataObserver)
|
||||
}
|
||||
}
|
||||
|
||||
extension PagedObservationSource where PagedTable.ID: SQLExpressible {
|
||||
func loadPageBefore() { pagedDataObserver?.load(.pageBefore) }
|
||||
func loadPageAfter() { pagedDataObserver?.load(.pageAfter) }
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public struct SessionNavItem<Id: Equatable>: Equatable {
|
||||
let id: Id
|
||||
let image: UIImage?
|
||||
let style: UIBarButtonItem.Style
|
||||
let systemItem: UIBarButtonItem.SystemItem?
|
||||
let accessibilityIdentifier: String
|
||||
let accessibilityLabel: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
id: Id,
|
||||
systemItem: UIBarButtonItem.SystemItem?,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = nil
|
||||
self.style = .plain
|
||||
self.systemItem = systemItem
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public init(
|
||||
id: Id,
|
||||
image: UIImage?,
|
||||
style: UIBarButtonItem.Style,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = image
|
||||
self.style = style
|
||||
self.systemItem = nil
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func createBarButtonItem() -> DisposableBarButtonItem {
|
||||
guard let systemItem: UIBarButtonItem.SystemItem = systemItem else {
|
||||
return DisposableBarButtonItem(
|
||||
image: image,
|
||||
style: style,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
return DisposableBarButtonItem(
|
||||
barButtonSystemItem: systemItem,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Conformance
|
||||
|
||||
public static func == (
|
||||
lhs: SessionNavItem<Id>,
|
||||
rhs: SessionNavItem<Id>
|
||||
) -> Bool {
|
||||
return (
|
||||
lhs.id == rhs.id &&
|
||||
lhs.image == rhs.image &&
|
||||
lhs.style == rhs.style &&
|
||||
lhs.systemItem == rhs.systemItem &&
|
||||
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier
|
||||
)
|
||||
}
|
||||
}
|
|
@ -4,14 +4,16 @@ import Foundation
|
|||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
|
||||
protocol SessionTableSection: Differentiable {
|
||||
public protocol SessionTableSection: Differentiable, Equatable {
|
||||
var title: String? { get }
|
||||
var style: SessionTableSectionStyle { get }
|
||||
var footer: String? { get }
|
||||
}
|
||||
|
||||
extension SessionTableSection {
|
||||
var title: String? { nil }
|
||||
var style: SessionTableSectionStyle { .none }
|
||||
public var title: String? { nil }
|
||||
public var style: SessionTableSectionStyle { .none }
|
||||
public var footer: String? { nil }
|
||||
}
|
||||
|
||||
public enum SessionTableSectionStyle: Equatable, Hashable, Differentiable {
|
||||
|
|
|
@ -1,91 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public enum NoNav: Equatable {}
|
||||
|
||||
extension SessionTableViewModel {
|
||||
public struct NavItem: Equatable {
|
||||
let id: NavItemId
|
||||
let image: UIImage?
|
||||
let style: UIBarButtonItem.Style
|
||||
let systemItem: UIBarButtonItem.SystemItem?
|
||||
let accessibilityIdentifier: String
|
||||
let accessibilityLabel: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
id: NavItemId,
|
||||
systemItem: UIBarButtonItem.SystemItem?,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = nil
|
||||
self.style = .plain
|
||||
self.systemItem = systemItem
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public init(
|
||||
id: NavItemId,
|
||||
image: UIImage?,
|
||||
style: UIBarButtonItem.Style,
|
||||
accessibilityIdentifier: String,
|
||||
accessibilityLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.image = image
|
||||
self.style = style
|
||||
self.systemItem = nil
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func createBarButtonItem() -> DisposableBarButtonItem {
|
||||
guard let systemItem: UIBarButtonItem.SystemItem = systemItem else {
|
||||
return DisposableBarButtonItem(
|
||||
image: image,
|
||||
style: style,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
return DisposableBarButtonItem(
|
||||
barButtonSystemItem: systemItem,
|
||||
target: nil,
|
||||
action: nil,
|
||||
accessibilityIdentifier: accessibilityIdentifier,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Conformance
|
||||
|
||||
public static func == (
|
||||
lhs: SessionTableViewModel<NavItemId, Section, SettingItem>.NavItem,
|
||||
rhs: SessionTableViewModel<NavItemId, Section, SettingItem>.NavItem
|
||||
) -> Bool {
|
||||
return (
|
||||
lhs.id == rhs.id &&
|
||||
lhs.image == rhs.image &&
|
||||
lhs.style == rhs.style &&
|
||||
lhs.systemItem == rhs.systemItem &&
|
||||
lhs.accessibilityIdentifier == rhs.accessibilityIdentifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import DifferenceKit
|
||||
|
||||
public protocol TableData {
|
||||
associatedtype TableItem: Hashable & Differentiable
|
||||
}
|
||||
|
||||
public protocol SectionedTableData: TableData {
|
||||
associatedtype Section: SessionTableSection
|
||||
|
||||
typealias SectionModel = ArraySection<Section, SessionCell.Info<TableItem>>
|
||||
}
|
||||
|
||||
public class TableDataState<Section: SessionTableSection, TableItem: Hashable & Differentiable>: SectionedTableData {
|
||||
public private(set) var tableData: [SectionModel] = []
|
||||
|
||||
public func updateTableData(_ updatedData: [SectionModel]) { self.tableData = updatedData }
|
||||
}
|
|
@ -10,23 +10,23 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API
|
|||
[
|
||||
_001_InitialSetupMigration.self,
|
||||
_002_SetupStandardJobs.self
|
||||
],
|
||||
], // Initial DB Creation
|
||||
[
|
||||
_003_YDBToGRDBMigration.self
|
||||
],
|
||||
], // YDB to GRDB Migration
|
||||
[
|
||||
_004_RemoveLegacyYDB.self
|
||||
],
|
||||
], // Legacy DB removal
|
||||
[
|
||||
_005_FixDeletedMessageReadState.self,
|
||||
_006_FixHiddenModAdminSupport.self,
|
||||
_007_HomeQueryOptimisationIndexes.self
|
||||
],
|
||||
], // Add job priorities
|
||||
[
|
||||
_008_EmojiReacts.self,
|
||||
_009_OpenGroupPermission.self,
|
||||
_010_AddThreadIdToFTS.self
|
||||
], // Add job priorities
|
||||
], // Fix thread FTS
|
||||
[
|
||||
_011_AddPendingReadReceipts.self,
|
||||
_012_AddFTSIfNeeded.self,
|
||||
|
|
|
@ -352,9 +352,9 @@ public extension Profile {
|
|||
nickname: String?,
|
||||
customFallback: String? = nil
|
||||
) -> String {
|
||||
if let nickname: String = nickname { return nickname }
|
||||
if let nickname: String = nickname, !nickname.isEmpty { return nickname }
|
||||
|
||||
guard let name: String = name, name != id else {
|
||||
guard let name: String = name, name != id, !name.isEmpty else {
|
||||
return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant))
|
||||
}
|
||||
|
||||
|
|
|
@ -263,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
|
||||
|
|
|
@ -10,7 +10,7 @@ import SessionUtilitiesKit
|
|||
|
||||
fileprivate typealias ViewModel = SessionThreadViewModel
|
||||
|
||||
/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the
|
||||
/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewModel` and the
|
||||
/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each
|
||||
/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places
|
||||
///
|
||||
|
@ -606,7 +606,7 @@ private struct GroupMemberInfo: Decodable, ColumnExpressible {
|
|||
let threadMemberNames: String
|
||||
}
|
||||
|
||||
// MARK: - HomeVC & MessageRequestsViewController
|
||||
// MARK: - HomeVC & MessageRequestsViewModel
|
||||
|
||||
// MARK: --SessionThreadViewModel
|
||||
|
||||
|
|
|
@ -12,14 +12,18 @@ public enum SNSnodeKit: MigratableTarget { // Just to make the external API nice
|
|||
[
|
||||
_001_InitialSetupMigration.self,
|
||||
_002_SetupStandardJobs.self
|
||||
],
|
||||
], // Initial DB Creation
|
||||
[
|
||||
_003_YDBToGRDBMigration.self
|
||||
],
|
||||
], // YDB to GRDB Migration
|
||||
[
|
||||
_004_FlagMessageHashAsDeletedOrInvalid.self
|
||||
],
|
||||
[] // Add job priorities
|
||||
], // Legacy DB removal
|
||||
[], // Add job priorities
|
||||
[], // Fix thread FTS
|
||||
[
|
||||
_005_AddSnodeReveivedMessageInfoPrimaryKey.self
|
||||
]
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ enum _001_InitialSetupMigration: Migration {
|
|||
}
|
||||
|
||||
try db.create(table: SnodeReceivedMessageInfo.self) { t in
|
||||
t.column(.id, .integer)
|
||||
t.deprecatedColumn(name: "id", .integer) // stringlint:disable
|
||||
.notNull()
|
||||
.primaryKey(autoincrement: true)
|
||||
t.column(.key, .text)
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
enum _005_AddSnodeReveivedMessageInfoPrimaryKey: Migration {
|
||||
static let target: TargetMigrations.Identifier = .snodeKit
|
||||
static let identifier: String = "AddSnodeReveivedMessageInfoPrimaryKey" // stringlint:disable
|
||||
static let needsConfigSync: Bool = false
|
||||
static let fetchedTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self]
|
||||
static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [SnodeReceivedMessageInfo.self]
|
||||
|
||||
/// This migration adds a flat to the `SnodeReceivedMessageInfo` so that when deleting interactions we can
|
||||
/// ignore their hashes when subsequently trying to fetch new messages (which results in the storage server returning
|
||||
/// messages from the beginning of time)
|
||||
static let minExpectedRunDuration: TimeInterval = 0.2
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
// SQLite doesn't support adding a new primary key after creation so we need to create a new table with
|
||||
// the setup we want, copy data from the old table over, drop the old table and rename the new table
|
||||
struct TmpSnodeReceivedMessageInfo: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
|
||||
static var databaseTableName: String { "tmpSnodeReceivedMessageInfo" }
|
||||
|
||||
typealias Columns = CodingKeys
|
||||
enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case key
|
||||
case hash
|
||||
case expirationDateMs
|
||||
case wasDeletedOrInvalid
|
||||
}
|
||||
|
||||
let key: String
|
||||
let hash: String
|
||||
let expirationDateMs: Int64
|
||||
var wasDeletedOrInvalid: Bool?
|
||||
}
|
||||
|
||||
try db.create(table: TmpSnodeReceivedMessageInfo.self) { t in
|
||||
t.column(.key, .text).notNull()
|
||||
t.column(.hash, .text).notNull()
|
||||
t.column(.expirationDateMs, .integer).notNull()
|
||||
t.column(.wasDeletedOrInvalid, .boolean)
|
||||
|
||||
t.primaryKey([.key, .hash])
|
||||
}
|
||||
|
||||
// Insert into the new table, drop the old table and rename the new table to be the old one
|
||||
let tmpInfo: TypedTableAlias<TmpSnodeReceivedMessageInfo> = TypedTableAlias()
|
||||
let info: TypedTableAlias<SnodeReceivedMessageInfo> = TypedTableAlias()
|
||||
try db.execute(literal: """
|
||||
INSERT INTO \(tmpInfo)
|
||||
SELECT \(info[.key]), \(info[.hash]), \(info[.expirationDateMs]), \(info[.wasDeletedOrInvalid])
|
||||
FROM \(info)
|
||||
""")
|
||||
|
||||
try db.drop(table: SnodeReceivedMessageInfo.self)
|
||||
try db.rename(
|
||||
table: TmpSnodeReceivedMessageInfo.databaseTableName,
|
||||
to: SnodeReceivedMessageInfo.databaseTableName
|
||||
)
|
||||
|
||||
// Need to create the indexes separately from creating 'TmpGroupMember' to ensure they
|
||||
// have the correct names
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.key])
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.hash])
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.expirationDateMs])
|
||||
try db.createIndex(on: SnodeReceivedMessageInfo.self, columns: [.wasDeletedOrInvalid])
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target)
|
||||
}
|
||||
}
|
|
@ -9,17 +9,12 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
|
|||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case id
|
||||
case key
|
||||
case hash
|
||||
case expirationDateMs
|
||||
case wasDeletedOrInvalid
|
||||
}
|
||||
|
||||
/// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into
|
||||
/// the database yet this value will be `nil`
|
||||
public var id: Int64? = nil
|
||||
|
||||
/// The key this message hash is associated to
|
||||
///
|
||||
/// This will be a combination of {address}.{port}.{publicKey} for new rows and just the {publicKey} for legacy rows
|
||||
|
@ -41,12 +36,6 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
|
|||
///
|
||||
/// **Note:** When retrieving the `lastNotExpired` we will ignore any entries where this flag is true
|
||||
public var wasDeletedOrInvalid: Bool?
|
||||
|
||||
// MARK: - Custom Database Interaction
|
||||
|
||||
public mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
self.id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
@ -133,7 +122,7 @@ public extension SnodeReceivedMessageInfo {
|
|||
)
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace))
|
||||
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > SnodeAPI.currentOffsetTimestampMs())
|
||||
.order(SnodeReceivedMessageInfo.Columns.id.desc)
|
||||
.order(Column.rowID.desc)
|
||||
.fetchOne(db)
|
||||
|
||||
// If we have a non-legacy hash then return it immediately (legacy hashes had a different
|
||||
|
@ -146,7 +135,7 @@ public extension SnodeReceivedMessageInfo {
|
|||
SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == false
|
||||
)
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == publicKey)
|
||||
.order(SnodeReceivedMessageInfo.Columns.id.desc)
|
||||
.order(Column.rowID.desc)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
[]
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
[]
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import DifferenceKit
|
||||
|
||||
extension String: Differentiable {}
|
Loading…
Reference in New Issue