diff --git a/Mixin.xcodeproj/project.pbxproj b/Mixin.xcodeproj/project.pbxproj index 14b76c8486..f4aecbfdbe 100644 --- a/Mixin.xcodeproj/project.pbxproj +++ b/Mixin.xcodeproj/project.pbxproj @@ -623,6 +623,9 @@ 7C7579DF29DC61890002DA0B /* TransferToPhoneQRCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7579DD29DC61890002DA0B /* TransferToPhoneQRCodeViewController.swift */; }; 7C7579E029DC61890002DA0B /* TransferToPhoneQRCodeView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7C7579DE29DC61890002DA0B /* TransferToPhoneQRCodeView.xib */; }; 7C7635B826A13461006101DB /* HomeAppsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7635B726A13461006101DB /* HomeAppsConstants.swift */; }; + 7C7DBAD62A2F3464008D4B0E /* DeviceTransferDateSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7DBAD52A2F3464008D4B0E /* DeviceTransferDateSelectionViewController.swift */; }; + 7C7DBAD82A2F34CC008D4B0E /* DeviceTransferConversationSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7DBAD72A2F34CC008D4B0E /* DeviceTransferConversationSelectionViewController.swift */; }; + 7C7DBADA2A2F391C008D4B0E /* DeviceTransferFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7DBAD92A2F391C008D4B0E /* DeviceTransferFilter.swift */; }; 7C8CC5A4280D347A00F7CBDF /* PreviewWallpaperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8CC5A2280D347A00F7CBDF /* PreviewWallpaperViewController.swift */; }; 7C8CC5A7280D40E900F7CBDF /* PreviewWallpaperCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8CC5A6280D40E900F7CBDF /* PreviewWallpaperCell.swift */; }; 7C8FA78D27687D1500855AFD /* DeleteAccountSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8FA78C27687D1500855AFD /* DeleteAccountSettingViewController.swift */; }; @@ -658,11 +661,14 @@ 7CDBA58E28F7B6CB00AC3777 /* TransferSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDBA58D28F7B6CB00AC3777 /* TransferSearchViewController.swift */; }; 7CDF316C29890FB200421808 /* ConversationFontSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDF316B29890FB200421808 /* ConversationFontSet.swift */; }; 7CDF316E29891B1200421808 /* PresentationFontSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDF316D29891B1200421808 /* PresentationFontSize.swift */; }; + 7CDFFF6F2A30760500E0870E /* DeviceTransferSelectedConversationWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDFFF6E2A30760500E0870E /* DeviceTransferSelectedConversationWindow.swift */; }; + 7CDFFF712A30761900E0870E /* DeviceTransferSelectedConversationWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CDFFF702A30761900E0870E /* DeviceTransferSelectedConversationWindow.xib */; }; 7CE2DC9A28587DE100AF00AE /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7B8BB58F234F36C000991ACB /* Colors.xcassets */; }; 7CE2DE102858B52000AF00AE /* WallpaperImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE2DE0F2858B52000AF00AE /* WallpaperImageView.swift */; }; 7CE3A25C2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE3A25B2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift */; }; 7CE5E7A8269BDA29000B7904 /* HomeAppsPinTipsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE5E7A6269BDA29000B7904 /* HomeAppsPinTipsViewController.swift */; }; 7CE5E7A9269BDA29000B7904 /* HomeAppsPinTipsView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CE5E7A7269BDA29000B7904 /* HomeAppsPinTipsView.xib */; }; + 7CE78FB12A30883200FEB942 /* DeviceTransferDateSelectionView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CE78FB02A30883200FEB942 /* DeviceTransferDateSelectionView.xib */; }; 7CEB735429DB24F3006FB5B2 /* RestoreChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB735229DB24F3006FB5B2 /* RestoreChatViewController.swift */; }; 7CEB735529DB24F3006FB5B2 /* RestoreChatView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7CEB735329DB24F3006FB5B2 /* RestoreChatView.xib */; }; 7CEB735829DB272F006FB5B2 /* RestoreChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB735629DB272F006FB5B2 /* RestoreChatTableViewCell.swift */; }; @@ -766,6 +772,7 @@ 949569A8263B13BF00E043FE /* TranscriptMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949569A7263B13BF00E043FE /* TranscriptMessageViewModel.swift */; }; 949A3686261D9C5C004251B2 /* post.css in Resources */ = {isa = PBXBuildFile; fileRef = 949A3685261D9C5C004251B2 /* post.css */; }; 94A1B1DF25BFD7CB0098586D /* LinkLocatingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A1B1DE25BFD7CB0098586D /* LinkLocatingTextView.swift */; }; + 94B309012A3C910E004C96A4 /* DeviceTransferConversationSelectionToolbarView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 94B309002A3C910E004C96A4 /* DeviceTransferConversationSelectionToolbarView.xib */; }; 94B7643525EA8E9B00EDF9C6 /* CacheableAssetFileDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B7643425EA8E9B00EDF9C6 /* CacheableAssetFileDescription.swift */; }; 94B7B6DC26B43562000B0AC5 /* SilentNotificationMessagePreviewView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 94B7B6DB26B43562000B0AC5 /* SilentNotificationMessagePreviewView.xib */; }; 94B7B6DE26B43581000B0AC5 /* SilentNotificationMessagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B7B6DD26B43580000B0AC5 /* SilentNotificationMessagePreviewViewController.swift */; }; @@ -1696,6 +1703,9 @@ 7C7579DD29DC61890002DA0B /* TransferToPhoneQRCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferToPhoneQRCodeViewController.swift; sourceTree = ""; }; 7C7579DE29DC61890002DA0B /* TransferToPhoneQRCodeView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TransferToPhoneQRCodeView.xib; sourceTree = ""; }; 7C7635B726A13461006101DB /* HomeAppsConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAppsConstants.swift; sourceTree = ""; }; + 7C7DBAD52A2F3464008D4B0E /* DeviceTransferDateSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferDateSelectionViewController.swift; sourceTree = ""; }; + 7C7DBAD72A2F34CC008D4B0E /* DeviceTransferConversationSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferConversationSelectionViewController.swift; sourceTree = ""; }; + 7C7DBAD92A2F391C008D4B0E /* DeviceTransferFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferFilter.swift; sourceTree = ""; }; 7C8CC5A2280D347A00F7CBDF /* PreviewWallpaperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWallpaperViewController.swift; sourceTree = ""; }; 7C8CC5A6280D40E900F7CBDF /* PreviewWallpaperCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWallpaperCell.swift; sourceTree = ""; }; 7C8FA78C27687D1500855AFD /* DeleteAccountSettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountSettingViewController.swift; sourceTree = ""; }; @@ -1732,10 +1742,13 @@ 7CDBA58D28F7B6CB00AC3777 /* TransferSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferSearchViewController.swift; sourceTree = ""; }; 7CDF316B29890FB200421808 /* ConversationFontSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationFontSet.swift; sourceTree = ""; }; 7CDF316D29891B1200421808 /* PresentationFontSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationFontSize.swift; sourceTree = ""; }; + 7CDFFF6E2A30760500E0870E /* DeviceTransferSelectedConversationWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTransferSelectedConversationWindow.swift; sourceTree = ""; }; + 7CDFFF702A30761900E0870E /* DeviceTransferSelectedConversationWindow.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DeviceTransferSelectedConversationWindow.xib; sourceTree = ""; }; 7CE2DE0F2858B52000AF00AE /* WallpaperImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperImageView.swift; sourceTree = ""; }; 7CE3A25B2771A8AB006BE765 /* DeleteAccountVerifyCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountVerifyCodeViewController.swift; sourceTree = ""; }; 7CE5E7A6269BDA29000B7904 /* HomeAppsPinTipsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAppsPinTipsViewController.swift; sourceTree = ""; }; 7CE5E7A7269BDA29000B7904 /* HomeAppsPinTipsView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HomeAppsPinTipsView.xib; sourceTree = ""; }; + 7CE78FB02A30883200FEB942 /* DeviceTransferDateSelectionView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DeviceTransferDateSelectionView.xib; sourceTree = ""; }; 7CEB735229DB24F3006FB5B2 /* RestoreChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreChatViewController.swift; sourceTree = ""; }; 7CEB735329DB24F3006FB5B2 /* RestoreChatView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RestoreChatView.xib; sourceTree = ""; }; 7CEB735629DB272F006FB5B2 /* RestoreChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreChatTableViewCell.swift; sourceTree = ""; }; @@ -1845,6 +1858,7 @@ 949569A7263B13BF00E043FE /* TranscriptMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptMessageViewModel.swift; sourceTree = ""; }; 949A3685261D9C5C004251B2 /* post.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = post.css; sourceTree = ""; }; 94A1B1DE25BFD7CB0098586D /* LinkLocatingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkLocatingTextView.swift; sourceTree = ""; }; + 94B309002A3C910E004C96A4 /* DeviceTransferConversationSelectionToolbarView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DeviceTransferConversationSelectionToolbarView.xib; sourceTree = ""; }; 94B7643425EA8E9B00EDF9C6 /* CacheableAssetFileDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheableAssetFileDescription.swift; sourceTree = ""; }; 94B7B6DB26B43562000B0AC5 /* SilentNotificationMessagePreviewView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SilentNotificationMessagePreviewView.xib; sourceTree = ""; }; 94B7B6DD26B43580000B0AC5 /* SilentNotificationMessagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SilentNotificationMessagePreviewViewController.swift; sourceTree = ""; }; @@ -2669,6 +2683,8 @@ 7CEB735329DB24F3006FB5B2 /* RestoreChatView.xib */, 7CEB735629DB272F006FB5B2 /* RestoreChatTableViewCell.swift */, 7CEB735729DB272F006FB5B2 /* RestoreChatTableViewCell.xib */, + 7CE78FB02A30883200FEB942 /* DeviceTransferDateSelectionView.xib */, + 94B309002A3C910E004C96A4 /* DeviceTransferConversationSelectionToolbarView.xib */, ); path = View; sourceTree = ""; @@ -2786,6 +2802,9 @@ 7C7579DD29DC61890002DA0B /* TransferToPhoneQRCodeViewController.swift */, 7C7579DB29DC60960002DA0B /* TransferToPhoneViewController.swift */, 7C7579D529DC573F0002DA0B /* DeviceTransferProgressViewController.swift */, + 7C7DBAD72A2F34CC008D4B0E /* DeviceTransferConversationSelectionViewController.swift */, + 7C7DBAD52A2F3464008D4B0E /* DeviceTransferDateSelectionViewController.swift */, + 7C7DBAD92A2F391C008D4B0E /* DeviceTransferFilter.swift */, ); path = DeviceTransfer; sourceTree = ""; @@ -3173,6 +3192,8 @@ 7C0FAAC827E07A0A008D4021 /* ExpiredMessageTimePickerWindow.xib */, 7C47352828571CC900ECD293 /* AccessPhoneContactHintWindow.swift */, 7C47352A28571D0300ECD293 /* AccessPhoneContactHintWindow.xib */, + 7CDFFF6E2A30760500E0870E /* DeviceTransferSelectedConversationWindow.swift */, + 7CDFFF702A30761900E0870E /* DeviceTransferSelectedConversationWindow.xib */, ); path = Windows; sourceTree = ""; @@ -4177,6 +4198,7 @@ 7C7579E029DC61890002DA0B /* TransferToPhoneQRCodeView.xib in Resources */, 94E8913925C019F000F1E5D4 /* Pods-Mixin-acknowledgements.plist in Resources */, 7BD3880024DC66F900A3035C /* Localizable.strings in Resources */, + 7CDFFF712A30761900E0870E /* DeviceTransferSelectedConversationWindow.xib in Resources */, 94B7B6DC26B43562000B0AC5 /* SilentNotificationMessagePreviewView.xib in Resources */, 7C5DFE32284F3071008733FC /* UserCenterTableHeaderView.xib in Resources */, 7C8FA78F2768822800855AFD /* DeleteAccountTableHeaderView.xib in Resources */, @@ -4260,6 +4282,7 @@ DFB18FFC2330E71E0021CAF3 /* AnnouncementView.xib in Resources */, 7BF52B232199AC2E007A5A74 /* NoTransactionFooterView.xib in Resources */, 7CE5E7A9269BDA29000B7904 /* HomeAppsPinTipsView.xib in Resources */, + 94B309012A3C910E004C96A4 /* DeviceTransferConversationSelectionToolbarView.xib in Resources */, 7BD639CB2282B17B00B7B3A6 /* PeerView.xib in Resources */, 7C4C03A928532D2F003DE0C0 /* PhoneContactCell.xib in Resources */, 7BE73BFE230A8D0300B97FC6 /* group_separator_2.png in Resources */, @@ -4297,6 +4320,7 @@ 948E6CB628AF95CC00DFF5DF /* TIPIntroView.xib in Resources */, 9BB351691FB1A94100EDDD2C /* ConversationDateHeaderView.xib in Resources */, 7B84358B20D8F88200237242 /* QuotePreviewView.xib in Resources */, + 7CE78FB12A30883200FEB942 /* DeviceTransferDateSelectionView.xib in Resources */, 7B51DDAE223A3818008ACDBB /* MobileNumberView.xib in Resources */, 7C53049B28FE753400567CF6 /* AuthorizationScopeGroupCell.xib in Resources */, E05F1B9E23BB546F00A2569D /* AppReceptionView.xib in Resources */, @@ -5105,6 +5129,7 @@ 7B0BE46421A59AE400B91A1E /* RefreshTopAssetsJob.swift in Sources */, 7BC1466F230D12B70060FE19 /* CurrencyCell.swift in Sources */, 7CB0955B26CF4DDC0049F4C7 /* PinMessageCell.swift in Sources */, + 7C7DBAD82A2F34CC008D4B0E /* DeviceTransferConversationSelectionViewController.swift in Sources */, 7B1D61132512773F00BD6B15 /* ExternalSharingConfirmationViewController.swift in Sources */, 7B9D825422F1BFC40099381E /* LargeModernNetworkOperationButton.swift in Sources */, 941CAE00276A3F17008F42D6 /* WebPImageDecoderInternal.swift in Sources */, @@ -5139,6 +5164,7 @@ 7B57186E24D9375300682D86 /* MixinAPIError+Description.swift in Sources */, E0320FA523CC3D1600A651D4 /* MessageTagLabel.swift in Sources */, 7B333A8623406AD000FDA848 /* GalleryTransitionFromMessageCellView.swift in Sources */, + 7C7DBADA2A2F391C008D4B0E /* DeviceTransferFilter.swift in Sources */, 7B54F95922B243EA00908A9D /* EmergencyContactVerifyPinViewController.swift in Sources */, 7B68F78B2191741300B79978 /* BiometryType.swift in Sources */, 7BD0532820A41F6A00C36F69 /* RangeExtension.swift in Sources */, @@ -5354,6 +5380,7 @@ 7B5BABE31FB57B0300341FE6 /* ImagePickerController.swift in Sources */, 7B1ABAE021186BF2009FAA6C /* StickerInputViewController.swift in Sources */, 7CEB736029DBC639006FB5B2 /* DeviceTransferUser.swift in Sources */, + 7C7DBAD62A2F3464008D4B0E /* DeviceTransferDateSelectionViewController.swift in Sources */, 7BA9D9EE226F114700255943 /* HomeSearchViewController.swift in Sources */, DFAD89A1241B6D6400836EDD /* JobService.swift in Sources */, 94E1D44129E906AB00511267 /* DeviceTransferHeader.swift in Sources */, @@ -5362,6 +5389,7 @@ 7BA1768E244ACE2E007D50FD /* PickerCell.swift in Sources */, 7B81BF2522893F5D00266A77 /* GroupParticipantsViewController.swift in Sources */, 7B2C8B2D2524D26900347AC3 /* ClipSwitcherViewController.swift in Sources */, + 7CDFFF6F2A30760500E0870E /* DeviceTransferSelectedConversationWindow.swift in Sources */, 7B34A19722C0B59200665F41 /* MixinNavigationAnimating.swift in Sources */, DFE1F9811FAB40A800537D43 /* CornerButton.swift in Sources */, 7C359DCE26A6C15A001D3AE4 /* StickerStorePreviewCell.swift in Sources */, diff --git a/Mixin/Resources/en.lproj/Localizable.strings b/Mixin/Resources/en.lproj/Localizable.strings index 2b435608ab..9dc7331ebe 100644 --- a/Mixin/Resources/en.lproj/Localizable.strings +++ b/Mixin/Resources/en.lproj/Localizable.strings @@ -55,7 +55,9 @@ "alert_key_group_transcript_message" = "%@ sent a transcript"; "alert_key_group_video_message" = "%@ sent a video"; "all" = "All"; +"all_chats" = "All chats"; "all_conversations" = "All Conversations"; +"all_dates" = "All dates"; "all_photos" = "All Photos"; "all_signer_failure" = "All node failure, perhaps PIN does not match what was set when it was last terminated unexpectedly"; "all_transactions" = "All Transactions"; @@ -179,6 +181,8 @@ "chat_text_size" = "Chat Text Size"; "chat_waiting" = "Waiting for %1$@ to get online and establish an encrypted session. %2$@."; "chats" = "CHATS"; +"chats_count_one" = "1 chat"; +"chats_count" = "%d chats"; "choose_network" = "Choose Network"; "choose_network_tip" = "Please ensure the network you choose to deposit that Mixin supports. Otherwise, your assets may be lost."; "choose_photo" = "Choose Photo"; @@ -299,6 +303,8 @@ "deposit_tip_eos" = "This address supports all base on EOS tokens."; "deposit_tip_eth" = "This address supports all ERC-20 tokens, such as ETH, XIN, etc."; "deposit_tip_trx" = "This address supports all TRC-10 and TRC-20 tokens."; +"deselect_all" = "Deselect All"; +"designated_time_period" = "Designated time period"; "desktop_on_hint" = "You have your desktop logged in"; "desktop_upgrade" = "Please upgrade Mixin Messenger Desktop to the latest version."; "detect_qr_tip" = "Detected a Mixin QR code, tap to recognize"; @@ -454,6 +460,8 @@ "invite_to_group_via_link" = "Invite to Group via Link"; "invited_by_stranger" = "The inviter is not in your contacts"; "invites_you_to_a_voice_call" = "invites you to a voice call"; +"items_selected_one" = "1 Item Selected"; +"items_selected_count" = "%d Items Selected"; "join_group" = "Join Group"; "joined_in" = "Joined in %@"; "keep_running_foreground" = "Please do not close the screen and keep Mixin running in the foreground"; @@ -465,6 +473,10 @@ "large_amount_confirmation_with_symbol" = "Large Amount Confirmation(%@)"; "last_active_time" = "Last active %@"; "last_backup_hint" = "Last backup on %@, total size %@."; +"last_month" = "Last month"; +"last_month_count" = "Last %d months"; +"last_year" = "Last year"; +"last_year_count" = "Last %d years"; "later" = "Later"; "learn_more" = "Learn More"; "light" = "Light"; @@ -512,6 +524,7 @@ "mixin_messenger_desktop" = "Mixin Messenger Desktop"; "mixin_server_encounters_errors" = "Mixin server encounters errors"; "mobile_contacts" = "Mobile Contacts"; +"month" = "Month"; "monthly" = "Monthly"; "more" = "More"; "move_and_scale" = "Move and Scale"; @@ -675,6 +688,7 @@ "receive_money" = "Receive Money"; "receiver" = "Receiver"; "receivers" = "Receivers"; +"recent" = "Recent"; "recent_chats" = "CHATS"; "recent_searches" = "Recent searches"; "refresh" = "Refresh"; @@ -749,6 +763,7 @@ "security" = "Security"; "select" = "Select"; "select_a_country_or_region" = "Select a Country or Region"; +"select_all" = "Select All"; "select_emergency_contact" = "Select Emergency Contact"; "select_more_photos" = "Select More Photos"; "selected_count" = "%@ Selected"; @@ -823,6 +838,7 @@ "show" = "Show"; "show_asset" = "Show asset"; "show_in_chat" = "Show in chat"; +"show_selected" = "Show Selected(%d)"; "sign_in" = "Sign in"; "sign_with_emergency_contact" = "Sign in with emergency contact"; "sign_with_phone_number" = "Sign in with phone number"; @@ -1007,6 +1023,7 @@ "withdrawal_network_fee" = "Network fee: "; "withdrawal_no_memo" = "No Memo"; "withdrawal_to" = "Withdraw to %@"; +"year" = "Year"; "you" = "You"; "you_deleted_this_message" = "You deleted this message"; "you_have_a_new_message" = "You have a new message"; diff --git a/Mixin/Resources/es.lproj/Localizable.strings b/Mixin/Resources/es.lproj/Localizable.strings index b69dddb4e6..abc42ff2d4 100644 --- a/Mixin/Resources/es.lproj/Localizable.strings +++ b/Mixin/Resources/es.lproj/Localizable.strings @@ -55,7 +55,9 @@ "alert_key_group_transcript_message" = "%@ ha enviado una transcripción"; "alert_key_group_video_message" = "%@ ha enviado un vídeo"; "all" = "Todo"; +"all_chats" = "All chats"; "all_conversations" = "Todas las conversaciones"; +"all_dates" = "All dates"; "all_photos" = "Todas las fotos"; "all_signer_failure" = "Falla de todos los nodos, tal vez el PIN no coincida con lo que se configuró la última vez que finalizó inesperadamente"; "all_transactions" = "Todas las transacciones"; @@ -179,6 +181,8 @@ "chat_text_size" = "Tamaño del texto del chat"; "chat_waiting" = "Esperando a que %1$@ se conecte y establezca una sesión cifrada. %2$@."; "chats" = "CHATS"; +"chats_count_one" = "1 chat"; +"chats_count" = "%d chats"; "choose_network" = "Elige Red"; "choose_network_tip" = "Asegúrate de que la red que elijas para depositar sea compatible con Mixin. De lo contrario, tus bienes pueden perderse."; "choose_photo" = "Escoge una foto"; @@ -299,6 +303,8 @@ "deposit_tip_eos" = "Esta dirección es compatible con todos los tokens basados en EOS."; "deposit_tip_eth" = "Esta dirección admite todos los tokens ERC-20, como ETH, XIN, etc."; "deposit_tip_trx" = "Esta dirección admite todos los tokens TRC-10 y TRC-20."; +"deselect_all" = "Deselect All"; +"designated_time_period" = "Designated time period"; "desktop_on_hint" = "Tienes tu escritorio conectado"; "desktop_upgrade" = "Actualiza Mixin Messenger Desktop a la última versión."; "detect_qr_tip" = "Se ha detectado un código QR de Mixin, toca para reconocer"; @@ -454,6 +460,8 @@ "invite_to_group_via_link" = "Invitar al grupo a través de un enlace"; "invited_by_stranger" = "El anfitrión no está en tus contactos."; "invites_you_to_a_voice_call" = "te invita a una llamada de voz"; +"items_selected_one" = "1 Item Selected"; +"items_selected_count" = "%d Items Selected"; "join_group" = "Únete al grupo"; "joined_in" = "Se ha unido en %@"; "keep_running_foreground" = "Please do not close the screen and keep Mixin running in the foreground"; @@ -465,6 +473,10 @@ "large_amount_confirmation_with_symbol" = "Confirmación de gran cantidad(%@)"; "last_active_time" = "Último Activo %@"; "last_backup_hint" = "Última copia de seguridad en %@, tamaño total %@."; +"last_month" = "Last month"; +"last_month_count" = "Last %d months"; +"last_year" = "Last year"; +"last_year_count" = "Last %d years"; "later" = "Más tarde"; "learn_more" = "Obtener más información"; "light" = "Claro"; @@ -512,6 +524,7 @@ "mixin_messenger_desktop" = "Mixin Messenger de Escritorio"; "mixin_server_encounters_errors" = "El servidor Mixin encuentra errores"; "mobile_contacts" = "Contactos móviles"; +"month" = "Month"; "monthly" = "Mensual"; "more" = "Más"; "move_and_scale" = "Mover y escalar"; @@ -675,6 +688,7 @@ "receive_money" = "Recibir dinero"; "receiver" = "Receptor"; "receivers" = "Receptores"; +"recent" = "Recent"; "recent_chats" = "CHATS"; "recent_searches" = "Búsquedas recientes"; "refresh" = "Actualizar"; @@ -749,6 +763,7 @@ "security" = "Seguridad"; "select" = "Seleccionar"; "select_a_country_or_region" = "Seleccionar un país o región"; +"select_all" = "Select All"; "select_emergency_contact" = "Seleccionar contacto de emergencia"; "select_more_photos" = "Seleccionar más fotos"; "selected_count" = "%@ Seleccionado"; @@ -823,6 +838,7 @@ "show" = "Espectáculo"; "show_asset" = "Mostrar activo"; "show_in_chat" = "Mostrar en el chat"; +"show_selected" = "Show Selected(%d)"; "sign_in" = "Iniciar sesión"; "sign_with_emergency_contact" = "Iniciar sesión con contacto de emergencia"; "sign_with_phone_number" = "Iniciar sesión con número de teléfono"; @@ -1007,6 +1023,7 @@ "withdrawal_network_fee" = "Tarifa de red: "; "withdrawal_no_memo" = "Sin memorando"; "withdrawal_to" = "Retirar a %@"; +"year" = "Year"; "you" = "Tú"; "you_deleted_this_message" = "Has borrado este mensaje"; "you_have_a_new_message" = "Tienes un nuevo mensaje"; diff --git a/Mixin/Resources/ja.lproj/Localizable.strings b/Mixin/Resources/ja.lproj/Localizable.strings index cd48d98549..5c21274329 100644 --- a/Mixin/Resources/ja.lproj/Localizable.strings +++ b/Mixin/Resources/ja.lproj/Localizable.strings @@ -55,7 +55,9 @@ "alert_key_group_transcript_message" = "%@がメッセージ履歴を共有しました"; "alert_key_group_video_message" = "%@が動画を送信しました"; "all" = "すべて"; +"all_chats" = "All chats"; "all_conversations" = "すべてのチャットルーム"; +"all_dates" = "All dates"; "all_photos" = "全ての画像"; "all_signer_failure" = "All node failure, perhaps PIN does not match what was set when it was last terminated unexpectedly"; "all_transactions" = "もらった・あげたコイン💰"; @@ -179,6 +181,8 @@ "chat_text_size" = "Chat Text Size"; "chat_waiting" = "オンラインで暗号化されたやりとりを開始するまであと%1$@。%2$@"; "chats" = "チャット"; +"chats_count_one" = "1 chat"; +"chats_count" = "%d chats"; "choose_network" = "Choose Network"; "choose_network_tip" = "Please ensure the network you choose to deposit that Mixin supports. Otherwise, your assets may be lost."; "choose_photo" = "画像を選ぶ"; @@ -299,6 +303,8 @@ "deposit_tip_eos" = "このアドレスはEOSベースの全てのトークンをサポートしています"; "deposit_tip_eth" = "このアドレスはETHやXINなど全てのERC-20トークンをサポートしています"; "deposit_tip_trx" = "このアドレスは全てのTRC-10/TRC-20トークンをサポートしています"; +"deselect_all" = "Deselect All"; +"designated_time_period" = "Designated time period"; "desktop_on_hint" = "デスクトップにログインしています"; "desktop_upgrade" = "デスクトップ版Mixinを最新バージョンにアップデートしてください"; "detect_qr_tip" = "Mixin QRコードを検出しました、タップしてアクセスします"; @@ -454,6 +460,8 @@ "invite_to_group_via_link" = "リンクを使って招待する"; "invited_by_stranger" = "招待者はあなたの連絡先に存在しません"; "invites_you_to_a_voice_call" = "グループ通話に招待されました"; +"items_selected_one" = "1 Item Selected"; +"items_selected_count" = "%d Items Selected"; "join_group" = "グループに参加"; "joined_in" = "%@からMixinを利用しています"; "keep_running_foreground" = "Please do not close the screen and keep Mixin running in the foreground"; @@ -465,6 +473,10 @@ "large_amount_confirmation_with_symbol" = "送金時に通知を行う金額の設定(%@)"; "last_active_time" = "直近のアクティビティ%@"; "last_backup_hint" = "最後に行ったバックアップ %@, 合計サイズ %@."; +"last_month" = "Last month"; +"last_month_count" = "Last %d months"; +"last_year" = "Last year"; +"last_year_count" = "Last %d years"; "later" = "後で"; "learn_more" = "こちら"; "light" = "ダーク"; @@ -512,6 +524,7 @@ "mixin_messenger_desktop" = "Mixin デスクトップ"; "mixin_server_encounters_errors" = "Mixinのサーバーにエラーが発生しています"; "mobile_contacts" = "連絡先"; +"month" = "Month"; "monthly" = "月"; "more" = "もっとみる"; "move_and_scale" = "移動と拡大縮小"; @@ -675,6 +688,7 @@ "receive_money" = "仮想通貨を受け取る"; "receiver" = "受取人"; "receivers" = "受取人"; +"recent" = "Recent"; "recent_chats" = "チャット"; "recent_searches" = "最近の検索"; "refresh" = "更新"; @@ -749,6 +763,7 @@ "security" = "セキュリティ"; "select" = "選択"; "select_a_country_or_region" = "国と地域を選択"; +"select_all" = "Select All"; "select_emergency_contact" = "緊急連絡先を選択"; "select_more_photos" = "さらに選択"; "selected_count" = "%@を選択しています。"; @@ -823,6 +838,7 @@ "show" = "表示"; "show_asset" = "資産を表示する"; "show_in_chat" = "チャット内で表示"; +"show_selected" = "Show Selected(%d)"; "sign_in" = "ログイン"; "sign_with_emergency_contact" = "緊急連絡先でログイン"; "sign_with_phone_number" = "電話番号でログイン"; @@ -1007,6 +1023,7 @@ "withdrawal_network_fee" = "ネットワーク手数料:"; "withdrawal_no_memo" = "メモなし"; "withdrawal_to" = "%@へ出金"; +"year" = "Year"; "you" = "自分"; "you_deleted_this_message" = "このメッセージを削除しました。"; "you_have_a_new_message" = "新しいメッセージがあります"; diff --git a/Mixin/Resources/ru.lproj/Localizable.strings b/Mixin/Resources/ru.lproj/Localizable.strings index 8e718abc42..bcd26c7f2d 100644 --- a/Mixin/Resources/ru.lproj/Localizable.strings +++ b/Mixin/Resources/ru.lproj/Localizable.strings @@ -55,7 +55,9 @@ "alert_key_group_transcript_message" = "%@ отправил стенограмму"; "alert_key_group_video_message" = "%@ отправил видео"; "all" = "Все"; +"all_chats" = "All chats"; "all_conversations" = "Все разговоры"; +"all_dates" = "All dates"; "all_photos" = "Все фотографии"; "all_signer_failure" = "All node failure, perhaps PIN does not match what was set when it was last terminated unexpectedly"; "all_transactions" = "Все транзакции"; @@ -179,6 +181,8 @@ "chat_text_size" = "Chat Text Size"; "chat_waiting" = "Ожидание, пока %1$@ подключится к сети и установит зашифрованный сеанс. %2$@."; "chats" = "ЧАТЫ"; +"chats_count_one" = "1 chat"; +"chats_count" = "%d chats"; "choose_network" = "Choose Network"; "choose_network_tip" = "Please ensure the network you choose to deposit that Mixin supports. Otherwise, your assets may be lost."; "choose_photo" = "Выбрать фото"; @@ -299,6 +303,8 @@ "deposit_tip_eos" = "Этот адрес поддерживает всю базу токенов EOS."; "deposit_tip_eth" = "Этот адрес поддерживает все токены ERC-20, такие как ETH, XIN и т. д."; "deposit_tip_trx" = "Этот адрес поддерживает все токены TRC-10 и TRC-20."; +"deselect_all" = "Deselect All"; +"designated_time_period" = "Designated time period"; "desktop_on_hint" = "Вы вошли в свой рабочий стол"; "desktop_upgrade" = "Пожалуйста, обновите Mixin Messenger Desktop до последней версии."; "detect_qr_tip" = "Обнаружен QR-код Mixin, нажмите, чтобы распознать"; @@ -454,6 +460,8 @@ "invite_to_group_via_link" = "Пригласить в группу по ссылке"; "invited_by_stranger" = "Приглашающего нет в ваших контактах"; "invites_you_to_a_voice_call" = "приглашает вас на голосовой вызов"; +"items_selected_one" = "1 Item Selected"; +"items_selected_count" = "%d Items Selected"; "join_group" = "Присоединиться к группе"; "joined_in" = "Присоединился к %@"; "keep_running_foreground" = "Please do not close the screen and keep Mixin running in the foreground"; @@ -465,6 +473,10 @@ "large_amount_confirmation_with_symbol" = "Подтверждение крупной суммы(%@)"; "last_active_time" = "Последнее посещение %@"; "last_backup_hint" = "Последняя резервная копия %@, общий размер %@."; +"last_month" = "Last month"; +"last_month_count" = "Last %d months"; +"last_year" = "Last year"; +"last_year_count" = "Last %d years"; "later" = "Потом"; "learn_more" = "Узнать больше"; "light" = "Светлое"; @@ -512,6 +524,7 @@ "mixin_messenger_desktop" = "Рабочий стол Mixin Messenger"; "mixin_server_encounters_errors" = "Сервер Mixin обнаруживает ошибки"; "mobile_contacts" = "Мобильные контакты"; +"month" = "Month"; "monthly" = "Ежемесячно"; "more" = "Больше"; "move_and_scale" = "Cдвиг и масштаб"; @@ -675,6 +688,7 @@ "receive_money" = "Получить деньги"; "receiver" = "Получатель"; "receivers" = "Получатели"; +"recent" = "Recent"; "recent_chats" = "ЧАТЫ"; "recent_searches" = "Недавние поиски"; "refresh" = "Обновить"; @@ -749,6 +763,7 @@ "security" = "Безопасность"; "select" = "Выбрать"; "select_a_country_or_region" = "Выберите страну или регион"; +"select_all" = "Select All"; "select_emergency_contact" = "Выберите экстренный контакт"; "select_more_photos" = "Выберите больше фотографий"; "selected_count" = "%@ Выбрано"; @@ -823,6 +838,7 @@ "show" = "Показать"; "show_asset" = "Показать актив"; "show_in_chat" = "Показать в чате"; +"show_selected" = "Show Selected(%d)"; "sign_in" = "Войти"; "sign_with_emergency_contact" = "Войти через контакт для экстренных случаев"; "sign_with_phone_number" = "Войти через номер телефона"; @@ -1007,6 +1023,7 @@ "withdrawal_network_fee" = "Комиссия за сеть: "; "withdrawal_no_memo" = "Нет памятки"; "withdrawal_to" = "Вывод на %@"; +"year" = "Year"; "you" = "Вы"; "you_deleted_this_message" = "Вы удалили это сообщение"; "you_have_a_new_message" = "У вас новое сообщение"; diff --git a/Mixin/Resources/zh-Hans.lproj/Localizable.strings b/Mixin/Resources/zh-Hans.lproj/Localizable.strings index bb95de0fd3..d1b1c26e48 100644 --- a/Mixin/Resources/zh-Hans.lproj/Localizable.strings +++ b/Mixin/Resources/zh-Hans.lproj/Localizable.strings @@ -55,7 +55,9 @@ "alert_key_group_transcript_message" = "%@分享一个聊天记录"; "alert_key_group_video_message" = "%@发送一个视频"; "all" = "全部"; +"all_chats" = "所有聊天"; "all_conversations" = "所有会话"; +"all_dates" = "所有日期"; "all_photos" = "所有照片"; "all_signer_failure" = "所有节点失败,也许 PIN 跟上次意外退出时设置的不一致"; "all_transactions" = "所有交易记录"; @@ -179,6 +181,8 @@ "chat_text_size" = "聊天字体大小"; "chat_waiting" = "等待%1$@上线后建立加密会话。%2$@。"; "chats" = "会话"; +"chats_count_one" = "1 个聊天"; +"chats_count" = "%d 个聊天"; "choose_network" = "选择充值网络"; "choose_network_tip" = "请选择 Mixin 支持的网络进行充值,否则您的充值将不会到账。"; "choose_photo" = "选择照片"; @@ -299,6 +303,8 @@ "deposit_tip_eos" = "支持所有基于 EOS 发行的代币。"; "deposit_tip_eth" = "支持所有符合 ERC-20 标准的代币,例如 ETH、XIN 等。"; "deposit_tip_trx" = "支持 TRX 和所有符合 TRC-10、TRC-20 标准的代币。"; +"deselect_all" = "取消所有选择"; +"designated_time_period" = "指定时间段"; "desktop_on_hint" = "桌面版已登入。"; "desktop_upgrade" = "请升级 Mixin Messenger 桌面端至最新版!"; "detect_qr_tip" = "检测到一个 Mixin 二维码,点击识别"; @@ -454,6 +460,8 @@ "invite_to_group_via_link" = "群邀请链接"; "invited_by_stranger" = "邀请人不是你的联系人"; "invites_you_to_a_voice_call" = "发起了语音通话"; +"items_selected_one" = "已选择 1 个"; +"items_selected_count" = "已选择 %d 个"; "join_group" = "加入群组"; "joined_in" = "%@ 加入"; "keep_running_foreground" = "不要关闭屏幕并保持 Mixin 在前台运行"; @@ -465,6 +473,10 @@ "large_amount_confirmation_with_symbol" = "大额转账确认(%@)"; "last_active_time" = "最后登入于 %@"; "last_backup_hint" = "上次备份是 %@,占用空间 %@。"; +"last_month" = "最近一个月"; +"last_month_count" = "最近 %d 个月"; +"last_year" = "最近一年"; +"last_year_count" = "最近 %d 年"; "later" = "稍后"; "learn_more" = "了解更多"; "light" = "浅色"; @@ -512,6 +524,7 @@ "mixin_messenger_desktop" = "Mixin Messenger 桌面"; "mixin_server_encounters_errors" = "服务器出错,请稍后重试"; "mobile_contacts" = "通讯录"; +"month" = "月"; "monthly" = "每月"; "more" = "更多"; "move_and_scale" = "移动和缩放"; @@ -675,6 +688,7 @@ "receive_money" = "我的收款码"; "receiver" = "至"; "receivers" = "交易接收人"; +"recent" = "最近"; "recent_chats" = "最近聊天"; "recent_searches" = "最近搜索"; "refresh" = "刷新"; @@ -749,6 +763,7 @@ "security" = "安全"; "select" = "选择"; "select_a_country_or_region" = "选择一个国家或地区"; +"select_all" = "选择所有"; "select_emergency_contact" = "选择紧急联系人"; "select_more_photos" = "选择更多照片"; "selected_count" = "选择了 %@ 个消息"; @@ -823,6 +838,7 @@ "show" = "显示"; "show_asset" = "显示资产"; "show_in_chat" = "在聊天中展示"; +"show_selected" = "显示选择(%d)"; "sign_in" = "登录"; "sign_with_emergency_contact" = "通过紧急联系人登录"; "sign_with_phone_number" = "通过手机号登录"; @@ -1007,6 +1023,7 @@ "withdrawal_network_fee" = "网络手续费:"; "withdrawal_no_memo" = "点击不使用 Memo(备注)"; "withdrawal_to" = "提现到 %@"; +"year" = "年"; "you" = "你"; "you_deleted_this_message" = "你撤回了一条消息"; "you_have_a_new_message" = "你收到了一条消息"; diff --git a/Mixin/Resources/zh-Hant.lproj/Localizable.strings b/Mixin/Resources/zh-Hant.lproj/Localizable.strings index f7f9d2463b..8db0fa3697 100644 --- a/Mixin/Resources/zh-Hant.lproj/Localizable.strings +++ b/Mixin/Resources/zh-Hant.lproj/Localizable.strings @@ -56,7 +56,9 @@ "alert_key_group_video_message" = "%@傳送一個影片"; "all" = "全部"; "all_conversations" = "所有會話"; +"all_chats" = "所有聊天"; "all_photos" = "所有照片"; +"all_dates" = "所有日期"; "all_signer_failure" = "所有節點失敗,也許 PIN 跟上次意外退出時設定的不一致"; "all_transactions" = "所有交易記錄"; "allow" = "允許"; @@ -179,6 +181,8 @@ "chat_text_size" = "聊天字型大小"; "chat_waiting" = "等待%1$@上線後建立加密會話。%2$@。"; "chats" = "會話"; +"chats_count_one" = "1 個聊天"; +"chats_count" = "%d 個聊天"; "choose_network" = "選擇充值網路"; "choose_network_tip" = "請選擇 Mixin 支援的網路進行充值,否則您的充值將不會到賬。"; "choose_photo" = "選擇照片"; @@ -299,6 +303,8 @@ "deposit_tip_eos" = "支援所有基於 EOS 發行的代幣。"; "deposit_tip_eth" = "支援所有符合 ERC-20 標準的代幣,例如 ETH、XIN 等。"; "deposit_tip_trx" = "支援 TRX 和所有符合 TRC-10、TRC-20 標準的代幣。"; +"deselect_all" = "取消所有選擇"; +"designated_time_period" = "指定時間段"; "desktop_on_hint" = "桌面版已登入。"; "desktop_upgrade" = "請升級 Mixin Messenger 桌面端至最新版!"; "detect_qr_tip" = "檢測到一個 Mixin 二維碼,點選識別"; @@ -454,6 +460,8 @@ "invite_to_group_via_link" = "群邀請連結"; "invited_by_stranger" = "邀請人不是你的聯絡人"; "invites_you_to_a_voice_call" = "發起了語音通話"; +"items_selected_one" = "已選擇 1 個"; +"items_selected_count" = "已選擇 %d 個"; "join_group" = "加入群組"; "joined_in" = "%@ 加入"; "keep_running_foreground" = "不要關閉螢幕並保持 Mixin 在前臺執行"; @@ -465,6 +473,10 @@ "large_amount_confirmation_with_symbol" = "大額轉賬確認(%@)"; "last_active_time" = "最後登入於 %@"; "last_backup_hint" = "上次備份是 %@,佔用空間 %@。"; +"last_month" = "最近一個月"; +"last_month_count" = "最近 %d 個月"; +"last_year" = "最近一年"; +"last_year_count" = "最近 %d 年"; "later" = "稍後"; "learn_more" = "瞭解更多"; "light" = "淺色"; @@ -512,6 +524,7 @@ "mixin_messenger_desktop" = "Mixin Messenger 桌面"; "mixin_server_encounters_errors" = "伺服器出錯,請稍後重試"; "mobile_contacts" = "通訊錄"; +"month" = "月"; "monthly" = "每月"; "more" = "更多"; "move_and_scale" = "移動和縮放"; @@ -675,6 +688,7 @@ "receive_money" = "我的收款碼"; "receiver" = "至"; "receivers" = "交易接收人"; +"recent" = "最近"; "recent_chats" = "最近聊天"; "recent_searches" = "最近搜尋"; "refresh" = "重新整理"; @@ -749,6 +763,7 @@ "security" = "安全"; "select" = "選擇"; "select_a_country_or_region" = "選擇一個國家或地區"; +"select_all" = "選擇所有"; "select_emergency_contact" = "選擇緊急聯絡人"; "select_more_photos" = "選擇更多照片"; "selected_count" = "選擇了 %@ 個訊息"; @@ -823,6 +838,7 @@ "show" = "顯示"; "show_asset" = "顯示資產"; "show_in_chat" = "在聊天中展示"; +"show_selected" = "顯示選擇(%d)"; "sign_in" = "登入"; "sign_with_emergency_contact" = "透過緊急聯絡人登入"; "sign_with_phone_number" = "透過手機號登入"; @@ -1007,6 +1023,7 @@ "withdrawal_network_fee" = "網路手續費:"; "withdrawal_no_memo" = "點選不使用 Memo(備註)"; "withdrawal_to" = "提現到 %@"; +"year" = "年"; "you" = "你"; "you_deleted_this_message" = "你撤回了一條訊息"; "you_have_a_new_message" = "你收到了一條訊息"; diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift b/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift index 8415f60e5e..2d4811513a 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferServer.swift @@ -25,6 +25,7 @@ final class DeviceTransferServer { // Access on main queue @Published private(set) var lastConnectionRejectedReason: ConnectionRejectedReason? + private let filter: DeviceTransferFilter private let queue = Queue(label: "one.mixin.messenger.DeviceTransferServer") private let dataLoaderQueue = Queue(label: "one.mixin.messenger.DeviceTransferServer.Loader") private let speedInspector = NetworkSpeedInspector() @@ -41,8 +42,9 @@ final class DeviceTransferServer { Unmanaged.passUnretained(self).toOpaque() } - init() { - Logger.general.info(category: "DeviceTransferServer", message: "\(opaquePointer) init") + init(filter: DeviceTransferFilter) { + self.filter = filter + Logger.general.info(category: "DeviceTransferServer", message: "\(opaquePointer) init with filter conversation: \(filter.conversation), time: \(filter.time)") } deinit { @@ -320,7 +322,7 @@ extension DeviceTransferServer { Logger.general.warn(category: "DeviceTransferServer", message: "Not transfering due to invalid state") return } - let dataSource = DeviceTransferServerDataSource(key: key, remotePlatform: remotePlatform) + let dataSource = DeviceTransferServerDataSource(key: key, filter: filter, remotePlatform: remotePlatform) let count = dataSource.totalCount() let start = DeviceTransferCommand(action: .start(count)) do { diff --git a/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift b/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift index da676c6142..3a96849943 100644 --- a/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift +++ b/Mixin/Service/DeviceTransfer/DeviceTransferServerDataSource.swift @@ -7,11 +7,13 @@ final class DeviceTransferServerDataSource { private let limit = 100 private let fileChunkSize = 600000 * kCCBlockSizeAES128 // About 9.1 MiB private let key: DeviceTransferKey + private let filter: DeviceTransferFilter private let remotePlatform: DeviceTransferPlatform private let fileContentBuffer: UnsafeMutablePointer - init(key: DeviceTransferKey, remotePlatform: DeviceTransferPlatform) { + init(key: DeviceTransferKey, filter: DeviceTransferFilter, remotePlatform: DeviceTransferPlatform) { self.key = key + self.filter = filter self.remotePlatform = remotePlatform self.fileContentBuffer = .allocate(capacity: fileChunkSize) } @@ -27,26 +29,47 @@ extension DeviceTransferServerDataSource { func totalCount() -> Int { assert(!Queue.main.isCurrent) - let messagesCount = MessageDAO.shared.messagesCount() - let attachmentsCount = attachmentsCount() - let total = ConversationDAO.shared.conversationsCount() - + ParticipantDAO.shared.participantsCount() + let messageRowID: Int? + let pinMessageRowID: Int? + if let createdAt = filter.earliestCreatedAt { + messageRowID = MessageDAO.shared.messageRowID(createdAt: createdAt) + pinMessageRowID = PinMessageDAO.shared.messageRowID(createdAt: createdAt) + } else { + messageRowID = nil + pinMessageRowID = nil + } + let conversationIDs: [String]? + switch filter.conversation { + case .all: + conversationIDs = nil + case .byDatabase(let ids), .byApplication(let ids): + conversationIDs = Array(ids) + } + let messagesCount = MessageDAO.shared.messagesCount(matching: conversationIDs, after: messageRowID) + let attachmentsCount = filter.isPassthrough + ? allAttachmentsCount() + : MessageDAO.shared.mediaMessagesCount(matching: conversationIDs, after: messageRowID) + let transcriptMessageCount = filter.isPassthrough + ? TranscriptMessageDAO.shared.transcriptMessagesCount() + : MessageDAO.shared.transcriptMessageCount(matching: conversationIDs, after: messageRowID) + let total = ConversationDAO.shared.conversationsCount(matching: conversationIDs) + + ParticipantDAO.shared.participantsCount(matching: conversationIDs) + UserDAO.shared.usersCount() + AppDAO.shared.appsCount() + AssetDAO.shared.assetsCount() + SnapshotDAO.shared.snapshotsCount() + StickerDAO.shared.stickersCount() - + PinMessageDAO.shared.pinMessagesCount() - + TranscriptMessageDAO.shared.transcriptMessagesCount() + + PinMessageDAO.shared.pinMessagesCount(matching: conversationIDs, after: pinMessageRowID) + + transcriptMessageCount + messagesCount - + MessageMentionDAO.shared.messageMentionsCount() + + MessageMentionDAO.shared.messageMentionsCount(matching: conversationIDs) + ExpiredMessageDAO.shared.expiredMessagesCount() + attachmentsCount - Logger.general.info(category: "DeviceTransferServerDataSource", message: "Total: \(total), Messages: \(messagesCount), attachments: \(attachmentsCount)") + Logger.general.info(category: "DeviceTransferServerDataSource", message: "Total: \(total), Messages: \(messagesCount), Attachments: \(attachmentsCount), TranscriptMessages: \(transcriptMessageCount)") return total } - private func attachmentsCount() -> Int { + private func allAttachmentsCount() -> Int { let folders = AttachmentContainer.Category.allCases.map(\.pathComponent) + ["Transcript"] let count = folders.reduce(0) { previousCount, folder in let folderURL = AttachmentContainer.url.appendingPathComponent(folder) @@ -87,6 +110,7 @@ extension DeviceTransferServerDataSource { let type: DeviceTransferRecordType let primaryID: String? let secondaryID: String? + let rowID: Int? } private struct TransferItem { @@ -111,18 +135,56 @@ extension DeviceTransferServerDataSource { } + private struct QueryResult { + + static let empty = QueryResult(databaseItemCount: 0, + transferItems: [], + dependenciesCount: 0, + lastPrimaryID: nil, + lastSecondaryID: nil, + lastRowID: nil) + + let databaseItemCount: Int + let transferItems: [TransferItem] + + // Some items in `transferItems` are dependencies of other items. + // Currently, there is only one case. When a non-passthrough filter is applied, + // transcript messages are not sent as a whole before sending messages. + // Instead, they are sent in batches along with the messages. In this case, + // `transferItems` includes two types of items: first, the dependent + // transcript messages, followed by the messages that depend on them. + // To separately count the quantity of these two types of items, it is necessary + // to specifically indicate the number of dependent items here. + let dependenciesCount: Int + + let lastPrimaryID: String? + let lastSecondaryID: String? + let lastRowID: Int? + + } + // Only throw fatal errors, like encryption failure for now func enumerateItems(using block: (_ data: Data, _ stop: inout Bool) -> Void) throws { - var nextLocation: Location? = Location(type: .allCases[0], primaryID: nil, secondaryID: nil) + let applicationFilteringIDs = filter.conversation.applicationFilteringIDs + let databaseFilteringIDs = filter.conversation.databaseFilteringIDs + let isPassthroughFilter = filter.isPassthrough + + var nextLocation: Location? = Location(type: .allCases[0], primaryID: nil, secondaryID: nil, rowID: nil) var recordCount = 0 + var dependenciesCount = 0 // See QueryResult.dependenciesCount var fileCount = 0 + while let location = nextLocation { - let (databaseItemCount, transferItems, nextPrimaryID, nextSecondaryID) = items(on: location) - if transferItems.isEmpty { - Logger.general.info(category: "DeviceTransferServerDataSource", message: "\(location.type) is empty") + let result = queryItems(on: location, + applicationFilteringIDs: applicationFilteringIDs, + databaseFilteringIDs: databaseFilteringIDs) + if result.transferItems.isEmpty { + Logger.general.info(category: "DeviceTransferServerDataSource", + message: "\(location.type) is empty, passthrough: \(isPassthroughFilter)") } - recordCount += transferItems.count - for item in transferItems { + recordCount += (result.transferItems.count - result.dependenciesCount) + dependenciesCount += result.dependenciesCount + for item in result.transferItems { var stop = false block(item.outputData, &stop) if stop { @@ -134,33 +196,42 @@ extension DeviceTransferServerDataSource { } } } - if databaseItemCount < limit { + if result.databaseItemCount < limit { if let nextType = DeviceTransferRecordType.allCases.element(after: location.type) { - nextLocation = Location(type: nextType, primaryID: nil, secondaryID: nil) + nextLocation = Location(type: nextType, primaryID: nil, secondaryID: nil, rowID: nil) } else { nextLocation = nil } - Logger.general.info(category: "DeviceTransferServerDataSource", message: "Send \(location.type) \(recordCount)") + Logger.general.info(category: "DeviceTransferServerDataSource", message: "Send \(recordCount) \(location.type)") + if dependenciesCount != 0 { + Logger.general.info(category: "DeviceTransferServerDataSource", message: "Send \(dependenciesCount) dependencies") + } recordCount = 0 + dependenciesCount = 0 } else { - nextLocation = Location(type: location.type, primaryID: nextPrimaryID, secondaryID: nextSecondaryID) + nextLocation = Location(type: location.type, + primaryID: result.lastPrimaryID, + secondaryID: result.lastSecondaryID, + rowID: result.lastRowID) } } Logger.general.info(category: "DeviceTransferServerDataSource", message: "Send file \(fileCount)") } - private func items(on location: Location) -> (databaseItemCount: Int, items: [TransferItem], nextPrimaryID: String?, nextSecondaryID: String?) { - let transferItems: [TransferItem] - let nextPrimaryID: String? - let nextSecondaryID: String? - let databaseItemCount: Int + private func queryItems( + on location: Location, + applicationFilteringIDs: Set?, + databaseFilteringIDs: Set? + ) -> QueryResult { switch location.type { case .conversation: - let conversations = ConversationDAO.shared.conversations(limit: limit, after: location.primaryID) - databaseItemCount = conversations.count - nextPrimaryID = conversations.last?.conversationId - nextSecondaryID = nil - transferItems = conversations.compactMap { conversation in + let conversations = ConversationDAO.shared.conversations(limit: limit, + after: location.primaryID, + matching: databaseFilteringIDs) + let transferItems: [TransferItem] = conversations.compactMap { conversation in + if let applicationFilteringIDs, !applicationFilteringIDs.contains(conversation.conversationId) { + return nil + } let deviceTransferConversation = DeviceTransferConversation(conversation: conversation, to: remotePlatform) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferConversation, key: key) @@ -170,12 +241,21 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: conversations.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: conversations.last?.conversationId, + lastSecondaryID: nil, + lastRowID: nil) case .participant: - let participants = ParticipantDAO.shared.participants(limit: limit, after: location.primaryID, with: location.secondaryID) - databaseItemCount = participants.count - nextPrimaryID = participants.last?.conversationId - nextSecondaryID = participants.last?.userId - transferItems = participants.compactMap { participant in + let participants = ParticipantDAO.shared.participants(limit: limit, + after: location.primaryID, + with: location.secondaryID, + matching: databaseFilteringIDs) + let transferItems: [TransferItem] = participants.compactMap { participant in + if let applicationFilteringIDs, !applicationFilteringIDs.contains(participant.conversationId) { + return nil + } let deviceTransferParticipant = DeviceTransferParticipant(participant: participant) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferParticipant, key: key) @@ -185,12 +265,15 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: participants.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: participants.last?.conversationId, + lastSecondaryID: participants.last?.userId, + lastRowID: nil) case .user: let users = UserDAO.shared.users(limit: limit, after: location.primaryID) - databaseItemCount = users.count - nextPrimaryID = users.last?.userId - nextSecondaryID = nil - transferItems = users.compactMap { user in + let transferItems: [TransferItem] = users.compactMap { user in let deviceTransferUser = DeviceTransferUser(user: user) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferUser, key: key) @@ -200,12 +283,15 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: users.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: users.last?.userId, + lastSecondaryID: nil, + lastRowID: nil) case .app: let apps = AppDAO.shared.apps(limit: limit, after: location.primaryID) - databaseItemCount = apps.count - nextPrimaryID = apps.last?.appId - nextSecondaryID = nil - transferItems = apps.compactMap { app in + let transferItems: [TransferItem] = apps.compactMap { app in let deviceTransferApp = DeviceTransferApp(app: app) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferApp, key: key) @@ -215,12 +301,15 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: apps.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: apps.last?.appId, + lastSecondaryID: nil, + lastRowID: nil) case .asset: let assets = AssetDAO.shared.assets(limit: limit, after: location.primaryID) - databaseItemCount = assets.count - nextPrimaryID = assets.last?.assetId - nextSecondaryID = nil - transferItems = assets.compactMap { asset in + let transferItems: [TransferItem] = assets.compactMap { asset in let deviceTransferAsset = DeviceTransferAsset(asset: asset) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferAsset, key: key) @@ -230,12 +319,15 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: assets.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: assets.last?.assetId, + lastSecondaryID: nil, + lastRowID: nil) case .snapshot: let snapshots = SnapshotDAO.shared.snapshots(limit: limit, after: location.primaryID) - databaseItemCount = snapshots.count - nextPrimaryID = snapshots.last?.snapshotId - nextSecondaryID = nil - transferItems = snapshots.compactMap { snapshot in + let transferItems: [TransferItem] = snapshots.compactMap { snapshot in let deviceTransferSnapshot = DeviceTransferSnapshot(snapshot: snapshot) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferSnapshot, key: key) @@ -245,12 +337,15 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: snapshots.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: snapshots.last?.snapshotId, + lastSecondaryID: nil, + lastRowID: nil) case .sticker: let stickers = StickerDAO.shared.stickers(limit: limit, after: location.primaryID) - databaseItemCount = stickers.count - nextPrimaryID = stickers.last?.stickerId - nextSecondaryID = nil - transferItems = stickers.compactMap { sticker in + let transferItems: [TransferItem] = stickers.compactMap { sticker in let deviceTransferSticker = DeviceTransferSticker(sticker: sticker) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferSticker, key: key) @@ -260,12 +355,41 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: stickers.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: stickers.last?.stickerId, + lastSecondaryID: nil, + lastRowID: nil) case .pinMessage: - let pinMessages = PinMessageDAO.shared.pinMessages(limit: limit, after: location.primaryID) - databaseItemCount = pinMessages.count - nextPrimaryID = pinMessages.last?.messageId - nextSecondaryID = nil - transferItems = pinMessages.compactMap { pinMessage in + let rowID: Int + if let id = location.rowID { + rowID = id + } else if let createdAt = filter.earliestCreatedAt { + if let firstRowID = PinMessageDAO.shared.messageRowID(createdAt: createdAt) { + rowID = firstRowID - 1 + } else { + return .empty + } + } else { + rowID = -1 + } + let pinMessages = PinMessageDAO.shared.pinMessages(limit: limit, + after: rowID, + matching: databaseFilteringIDs) + let lastRowID: Int? + if let messageID = pinMessages.last?.messageId { + lastRowID = PinMessageDAO.shared.messageRowID(messageID: messageID) + } else { + lastRowID = nil + } + let transferItems: [TransferItem] = pinMessages.compactMap { pinMessage in + if let applicationFilteringIDs, !applicationFilteringIDs.contains(pinMessage.conversationId) { + return nil + } + if let earliestCreatedAt = filter.earliestCreatedAt, pinMessage.createdAt < earliestCreatedAt { + return nil + } let deviceTransferPinMessage = DeviceTransferPinMessage(pinMessage: pinMessage) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferPinMessage, key: key) @@ -275,54 +399,92 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: pinMessages.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: nil, + lastSecondaryID: nil, + lastRowID: lastRowID) case .transcriptMessage: - let transcriptMessages = TranscriptMessageDAO.shared.transcriptMessages(limit: limit, after: location.primaryID, with: location.secondaryID) - databaseItemCount = transcriptMessages.count - nextPrimaryID = transcriptMessages.last?.transcriptId - nextSecondaryID = transcriptMessages.last?.messageId - transferItems = transcriptMessages.compactMap { transcriptMessage in - let deviceTransferTranscriptMessage = DeviceTransferTranscriptMessage(transcriptMessage: transcriptMessage, to: remotePlatform) - do { - let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferTranscriptMessage, key: key) - if let mediaURL = transcriptMessage.mediaUrl, !mediaURL.isEmpty, transcriptMessage.mediaStatus == MediaStatus.DONE.rawValue { - let url = AttachmentContainer.url(transcriptId: transcriptMessage.transcriptId, filename: mediaURL) - let attachment = TransferItem.Attachment(messageID: transcriptMessage.messageId, url: url) - return TransferItem(rawItem: transcriptMessage, outputData: outputData, attachment: attachment) - } else { - return TransferItem(rawItem: transcriptMessage, outputData: outputData, attachment: nil) - } - } catch { - Logger.general.error(category: "DeviceTransferServerDataSource", message: "Failed to output: \(error)") - return nil - } + if filter.isPassthrough { + let transcriptMessages = TranscriptMessageDAO.shared.transcriptMessages(limit: limit, after: location.primaryID, with: location.secondaryID) + let transferItems = transcriptTransferItems(for: transcriptMessages) + return QueryResult(databaseItemCount: transcriptMessages.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: transcriptMessages.last?.transcriptId, + lastSecondaryID: transcriptMessages.last?.messageId, + lastRowID: nil) + } else { + return .empty } case .message: - let messages = MessageDAO.shared.messages(limit: limit, after: location.primaryID) - databaseItemCount = messages.count - nextPrimaryID = messages.last?.messageId - nextSecondaryID = nil - transferItems = messages.compactMap { message in + let rowID: Int + if let id = location.rowID { + rowID = id + } else if let createdAt = filter.earliestCreatedAt { + if let firstRowID = MessageDAO.shared.messageRowID(createdAt: createdAt) { + rowID = firstRowID - 1 + } else { + return .empty + } + } else { + rowID = -1 + } + let messages = MessageDAO.shared.messages(limit: limit, + after: rowID, + matching: databaseFilteringIDs) + let lastRowID: Int? + if let messageID = messages.last?.messageId { + lastRowID = MessageDAO.shared.messageRowID(messageID: messageID) + } else { + lastRowID = nil + } + var messageItems = [TransferItem]() + var transcriptMessageItems = [TransferItem]() + for message in messages { + if let applicationFilteringIDs, !applicationFilteringIDs.contains(message.conversationId) { + continue + } + if let earliestCreatedAt = filter.earliestCreatedAt, message.createdAt < earliestCreatedAt { + continue + } let deviceTransferMessage = DeviceTransferMessage(message: message, to: remotePlatform) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferMessage, key: key) + let attachment: TransferItem.Attachment? if let mediaURL = message.mediaUrl, !mediaURL.isEmpty, message.mediaStatus == MediaStatus.DONE.rawValue, let category = AttachmentContainer.Category(messageCategory: message.category) { let url = AttachmentContainer.url(for: category, filename: mediaURL) - let attachment = TransferItem.Attachment(messageID: message.messageId, url: url) - return TransferItem(rawItem: message, outputData: outputData, attachment: attachment) + attachment = TransferItem.Attachment(messageID: message.messageId, url: url) } else { - return TransferItem(rawItem: message, outputData: outputData, attachment: nil) + attachment = nil + } + if let item = TransferItem(rawItem: message, outputData: outputData, attachment: attachment) { + messageItems.append(item) } } catch { - Logger.general.error(category: "DeviceTransferServerDataSource", message: "Failed to output: \(error)") - return nil + Logger.general.error(category: "DeviceTransferServerDataSource", message: "Failed to output message: \(error)") + } + // TranscriptMessage + if !filter.isPassthrough && message.category.hasSuffix("_TRANSCRIPT") { + let transcriptMessages = TranscriptMessageDAO.shared.transcriptMessages(transcriptId: message.messageId) + transcriptMessageItems = transcriptTransferItems(for: transcriptMessages) } } + return QueryResult(databaseItemCount: messages.count, + transferItems: transcriptMessageItems + messageItems, + dependenciesCount: transcriptMessageItems.count, + lastPrimaryID: nil, + lastSecondaryID: nil, + lastRowID: lastRowID) case .messageMention: - let messageMentions = MessageMentionDAO.shared.messageMentions(limit: limit, after: location.primaryID) - databaseItemCount = messageMentions.count - nextPrimaryID = messageMentions.last?.messageId - nextSecondaryID = nil - transferItems = messageMentions.compactMap { messageMention in + let messageMentions = MessageMentionDAO.shared.messageMentions(limit: limit, + after: location.primaryID, + matching: databaseFilteringIDs) + let transferItems: [TransferItem] = messageMentions.compactMap { messageMention in + if let applicationFilteringIDs, !applicationFilteringIDs.contains(messageMention.conversationId) { + return nil + } let deviceTransferMessageMention = DeviceTransferMessageMention(messageMention: messageMention) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferMessageMention, key: key) @@ -332,12 +494,15 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: messageMentions.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: messageMentions.last?.messageId, + lastSecondaryID: nil, + lastRowID: nil) case .expiredMessage: let expiredMessages = ExpiredMessageDAO.shared.expiredMessages(limit: limit, after: location.primaryID) - databaseItemCount = expiredMessages.count - nextPrimaryID = expiredMessages.last?.messageId - nextSecondaryID = nil - transferItems = expiredMessages.compactMap { expiredMessage in + let transferItems: [TransferItem] = expiredMessages.compactMap { expiredMessage in let deviceTransferExpiredMessage = DeviceTransferExpiredMessage(expiredMessage: expiredMessage) do { let outputData = try DeviceTransferProtocol.output(type: location.type, data: deviceTransferExpiredMessage, key: key) @@ -347,8 +512,13 @@ extension DeviceTransferServerDataSource { return nil } } + return QueryResult(databaseItemCount: expiredMessages.count, + transferItems: transferItems, + dependenciesCount: 0, + lastPrimaryID: expiredMessages.last?.messageId, + lastSecondaryID: nil, + lastRowID: nil) } - return (databaseItemCount, transferItems, nextPrimaryID, nextSecondaryID) } // Only throw fatal errors, like encryption failure for now @@ -430,4 +600,23 @@ extension DeviceTransferServerDataSource { return true } + private func transcriptTransferItems(for transcriptMessages: [TranscriptMessage]) -> [TransferItem] { + transcriptMessages.compactMap { transcriptMessage in + let deviceTransferTranscriptMessage = DeviceTransferTranscriptMessage(transcriptMessage: transcriptMessage, to: remotePlatform) + do { + let outputData = try DeviceTransferProtocol.output(type: .transcriptMessage, data: deviceTransferTranscriptMessage, key: key) + if let mediaURL = transcriptMessage.mediaUrl, !mediaURL.isEmpty, transcriptMessage.mediaStatus == MediaStatus.DONE.rawValue { + let url = AttachmentContainer.url(transcriptId: transcriptMessage.transcriptId, filename: mediaURL) + let attachment = TransferItem.Attachment(messageID: transcriptMessage.messageId, url: url) + return TransferItem(rawItem: transcriptMessage, outputData: outputData, attachment: attachment) + } else { + return TransferItem(rawItem: transcriptMessage, outputData: outputData, attachment: nil) + } + } catch { + Logger.general.error(category: "DeviceTransferServerDataSource", message: "Failed to output: \(error)") + return nil + } + } + } + } diff --git a/Mixin/UserInterface/Controllers/Common/PeerViewController.swift b/Mixin/UserInterface/Controllers/Common/PeerViewController.swift index 4efc01543d..e5ece7d2b0 100644 --- a/Mixin/UserInterface/Controllers/Common/PeerViewController.swift +++ b/Mixin/UserInterface/Controllers/Common/PeerViewController.swift @@ -14,6 +14,7 @@ class PeerViewController - + - + @@ -17,6 +17,7 @@ + @@ -26,24 +27,25 @@ - + - + - + + @@ -57,7 +59,6 @@ - diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferConversationSelectionViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferConversationSelectionViewController.swift new file mode 100644 index 0000000000..93efeb365c --- /dev/null +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferConversationSelectionViewController.swift @@ -0,0 +1,224 @@ +import UIKit +import AVFoundation +import MixinServices + +class DeviceTransferConversationSelectionViewController: PeerViewController { + + @IBOutlet weak var toggleAllSelectionButton: UIButton! + @IBOutlet weak var showSelectedButton: UIButton! + + private let filter: DeviceTransferFilter! + + private var toolbarView: UIView! + + private var selectedConversationIDs: Set = [] { + didSet { + container?.rightButton.isEnabled = !selectedConversationIDs.isEmpty + let title = !models.isEmpty && models.count == selectedConversationIDs.count + ? R.string.localizable.deselect_all() + : R.string.localizable.select_all() + toggleAllSelectionButton.setTitle(title, for: .normal) + let color = selectedConversationIDs.isEmpty ? R.color.text_accessory() : R.color.theme() + showSelectedButton.setTitleColor(color, for: .normal) + showSelectedButton.setTitle(R.string.localizable.show_selected(selectedConversationIDs.count), for: .normal) + showSelectedButton.isEnabled = !selectedConversationIDs.isEmpty + } + } + + init(filter: DeviceTransferFilter) { + self.filter = filter + let nib = R.nib.peerView + super.init(nibName: nib.name, bundle: nib.bundle) + } + + required init?(coder: NSCoder) { + fatalError("Storyboard not supported") + } + + class func instance(filter: DeviceTransferFilter) -> UIViewController { + let controller = DeviceTransferConversationSelectionViewController(filter: filter) + return ContainerViewController.instance(viewController: controller, title: R.string.localizable.conversations()) + } + + override func viewDidLoad() { + super.viewDidLoad() + tableViewBottomConstraint.isActive = false + toolbarView = R.nib.deviceTransferConversationSelectionToolbarView(owner: self) + view.addSubview(toolbarView) + toolbarView.snp.makeConstraints { make in + make.height.equalTo(50) + make.leading.trailing.equalToSuperview() + make.top.equalTo(tableView.snp.bottom) + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) + } + tableView.allowsMultipleSelection = true + showSelectedButton.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 16, weight: .regular) + } + + override func initData() { + initDataOperation.addExecutionBlock { [weak self] in + guard let self else { + return + } + let conversations = ConversationDAO.shared.conversationList() + .compactMap(MessageReceiver.init) + let selections: Set + switch filter.conversation { + case .all: + selections = Set(conversations.map(\.conversationId)) + case .byDatabase(let ids), .byApplication(let ids): + selections = ids + } + DispatchQueue.main.sync { + self.models = conversations + self.selectedConversationIDs = selections + self.tableView.reloadData() + self.reloadTableViewSelections() + } + } + queue.addOperation(initDataOperation) + } + + override func search(keyword: String) { + queue.operations + .filter({ $0 != initDataOperation }) + .forEach({ $0.cancel() }) + let op = BlockOperation() + let receivers = self.models + op.addExecutionBlock { [unowned op, weak self] in + guard self != nil, !op.isCancelled else { + return + } + let uniqueReceivers = Set(receivers.compactMap({ $0 })) + let searchResults = uniqueReceivers + .filter { $0.matches(lowercasedKeyword: keyword) } + .map { MessageReceiverSearchResult(receiver: $0, keyword: keyword) } + DispatchQueue.main.sync { + guard let weakSelf = self, !op.isCancelled else { + return + } + weakSelf.searchingKeyword = keyword + weakSelf.searchResults = searchResults + weakSelf.tableView.reloadData() + weakSelf.reloadTableViewSelections() + } + } + queue.addOperation(op) + } + + override func configure(cell: CheckmarkPeerCell, at indexPath: IndexPath) { + if isSearching { + cell.render(result: searchResults[indexPath.row]) + } else { + cell.render(receiver: models[indexPath.row]) + } + } + + override func reloadTableViewSelections() { + super.reloadTableViewSelections() + if isSearching { + for (index, result) in searchResults.enumerated() { + guard selectedConversationIDs.contains(result.receiver.conversationId) else { + continue + } + let indexPath = IndexPath(row: index, section: 0) + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } + } else { + updateSelectedRows() + } + } + + // MARK: - UITableViewDataSource + override func numberOfSections(in tableView: UITableView) -> Int { + 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return isSearching ? searchResults.count : models.count + } + + // MARK: - UITableViewDelegate + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let receiver = messageReceiver(at: indexPath) + selectedConversationIDs.insert(receiver.conversationId) + if !isSearching { + updateSelectedRows() + } + } + + override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + let receiver = messageReceiver(at: indexPath) + selectedConversationIDs.remove(receiver.conversationId) + if !isSearching { + if let row = models.firstIndex(where: { $0.conversationId == receiver.conversationId }) { + tableView.deselectRow(at: IndexPath(row: row, section: 0), animated: false) + } + } + } + + @IBAction func operationAllAction(_ sender: Any) { + if selectedConversationIDs.count == models.count { + selectedConversationIDs.removeAll() + for index in 0.. String? { + R.string.localizable.save() + } + + func barRightButtonTappedAction() { + if selectedConversationIDs.count == models.count { + filter.conversation = .all + } else { + filter.replaceSelectedConversations(with: selectedConversationIDs) + } + navigationController?.popViewController(animated: true) + } + +} + +extension DeviceTransferConversationSelectionViewController { + + private func messageReceiver(at indexPath: IndexPath) -> MessageReceiver { + if isSearching { + return searchResults[indexPath.row].receiver + } else { + return models[indexPath.row] + } + } + + private func updateSelectedRows() { + assert(!isSearching) + for (row, receiver) in models.enumerated() where selectedConversationIDs.contains(receiver.conversationId) { + tableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .none) + } + } + +} diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferDateSelectionViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferDateSelectionViewController.swift new file mode 100644 index 0000000000..18424164f1 --- /dev/null +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferDateSelectionViewController.swift @@ -0,0 +1,114 @@ +import UIKit + +class DeviceTransferDateSelectionViewController: UIViewController { + + @IBOutlet weak var allDateCheckmark: UIImageView! + @IBOutlet weak var lastDateCheckmark: UIImageView! + @IBOutlet weak var textField: UITextField! + @IBOutlet weak var segmentedControl: UISegmentedControl! + + private let filter: DeviceTransferFilter! + + init(filter: DeviceTransferFilter) { + self.filter = filter + let nib = R.nib.deviceTransferDateSelectionView + super.init(nibName: nib.name, bundle: nib.bundle) + } + + required init?(coder: NSCoder) { + fatalError("Storyboard not supported") + } + + class func instance(filter: DeviceTransferFilter) -> UIViewController { + let controller = DeviceTransferDateSelectionViewController(filter: filter) + return ContainerViewController.instance(viewController: controller, title: R.string.localizable.date()) + } + + override func viewDidLoad() { + super.viewDidLoad() + segmentedControl.setTitle(R.string.localizable.month(), forSegmentAt: 0) + segmentedControl.setTitle(R.string.localizable.year(), forSegmentAt: 1) + switch filter.time { + case .all: + updateAllDateSelection() + case .lastMonths(let months): + updateLastDateSelection() + textField.text = "\(months)" + segmentedControl.selectedSegmentIndex = 0 + case .lastYears(let years): + updateLastDateSelection() + textField.text = "\(years)" + segmentedControl.selectedSegmentIndex = 1 + } + } + + @IBAction func selectAllDateAction(_ sender: Any) { + updateAllDateSelection() + } + + @IBAction func selectLastDateAction(_ sender: Any) { + updateLastDateSelection() + } + + @IBAction func dateUnitChangedAction(_ sender: Any) { + updateSaveButton() + } + + @IBAction func textChangedAction(_ sender: Any) { + updateSaveButton() + } + + @IBAction func editingBeginAction(_ sender: Any) { + updateLastDateSelection() + } + +} + +extension DeviceTransferDateSelectionViewController: ContainerViewControllerDelegate { + + func textBarRightButton() -> String? { + R.string.localizable.save() + } + + func barRightButtonTappedAction() { + if !allDateCheckmark.isHidden { + filter.time = .all + } else if let count = textField.text?.intValue { + if segmentedControl.selectedSegmentIndex == 0 { + filter.time = .lastMonths(count) + } else { + filter.time = .lastYears(count) + } + } + navigationController?.popViewController(animated: true) + } + +} + +extension DeviceTransferDateSelectionViewController { + + private func updateAllDateSelection() { + allDateCheckmark.isHidden = false + lastDateCheckmark.isHidden = true + segmentedControl.isEnabled = false + container?.rightButton.isEnabled = true + textField.resignFirstResponder() + } + + private func updateLastDateSelection() { + allDateCheckmark.isHidden = true + lastDateCheckmark.isHidden = false + segmentedControl.isEnabled = true + textField.becomeFirstResponder() + updateSaveButton() + } + + private func updateSaveButton() { + if let count = textField.text?.intValue, count > 0 { + container?.rightButton.isEnabled = true + } else { + container?.rightButton.isEnabled = false + } + } + +} diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferFilter.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferFilter.swift new file mode 100644 index 0000000000..41f7dc5622 --- /dev/null +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/DeviceTransferFilter.swift @@ -0,0 +1,154 @@ +import Foundation +import MixinServices + +class DeviceTransferFilter { + + static let filterDidChangeNotification = Notification.Name("one.mixin.messenger.DeviceTransferFilter.Change") + + private(set) var earliestCreatedAt: String? + + var conversation: Conversation { + didSet { + NotificationCenter.default.post(name: Self.filterDidChangeNotification, object: self) + } + } + + var time: Time { + didSet { + earliestCreatedAt = time.utcString + NotificationCenter.default.post(name: Self.filterDidChangeNotification, object: self) + } + } + + var isPassthrough: Bool { + switch (time, conversation) { + case (.all, .all): + return true + default: + return false + } + } + + private init(conversation: Conversation, time: Time) { + self.conversation = conversation + self.time = time + } + + static func passthrough() -> DeviceTransferFilter { + DeviceTransferFilter(conversation: .all, time: .all) + } + + func replaceSelectedConversations(with ids: Set) { + if ids.count > UserDatabaseDAO.deviceTransferStride { + conversation = .byApplication(ids) + } else { + conversation = .byDatabase(ids) + } + } + +} + +extension DeviceTransferFilter { + + enum Conversation { + + // There are two mechanisms for filtering Conversations/Messages: + // 1. When the number of selected conversations is less than `deviceTransferStride`, + // SQL statements are in charge of filtering. + // 2. When the number of selected conversations is greater than `deviceTransferStride`, + // SQL queries do not apply any filter, the filter is applied at the application layer. + + case all + case byDatabase(Set) + case byApplication(Set) + + var title: String { + switch self { + case .all: + return R.string.localizable.all_chats() + case .byDatabase(let ids), .byApplication(let ids): + if ids.count == 1 { + return R.string.localizable.chats_count_one() + } else { + return R.string.localizable.chats_count(ids.count) + } + } + } + + var databaseFilteringIDs: Set? { + switch self { + case .all, .byApplication: + return nil + case .byDatabase(let ids): + return ids + } + } + + var applicationFilteringIDs: Set? { + switch self { + case .byApplication(let ids): + return ids + case .all, .byDatabase: + return nil + } + } + + } + +} + +extension DeviceTransferFilter { + + enum Time { + + case all + case lastMonths(Int) + case lastYears(Int) + + var title: String { + switch self { + case .all: + return R.string.localizable.all_dates() + case .lastMonths(let count): + if count == 1 { + return R.string.localizable.last_month() + } else { + return R.string.localizable.last_month_count(count) + } + case .lastYears(let count): + if count == 1 { + return R.string.localizable.last_year() + } else { + return R.string.localizable.last_year_count(count) + } + } + } + + var utcString: String? { + let calendar = Calendar.current + let now = Date() + let target: Date + switch self { + case .all: + return nil + case .lastMonths(let months): + if let added = calendar.date(byAdding: .month, value: -months, to: now) { + target = added + } else { + Logger.general.error(category: "DeviceTransferFilter", message: "Unable to add month: \(months), calendar: \(calendar)") + return nil + } + case .lastYears(let years): + if let added = calendar.date(byAdding: .year, value: -years, to: now) { + target = added + } else { + Logger.general.error(category: "DeviceTransferFilter", message: "Unable to add year: \(years), calendar: \(calendar)") + return nil + } + } + return calendar.startOfDay(for: target).toUTCString() + } + + } + +} diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromCloudViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromCloudViewController.swift index e90b618a4a..ee974f187f 100644 --- a/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromCloudViewController.swift +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/RestoreFromCloudViewController.swift @@ -38,7 +38,7 @@ extension RestoreFromCloudViewController: UITableViewDelegate { section.setAccessory(.busy, forRowAt: indexPath.row) DispatchQueue.global().async { if let lastMessageCreatedAt = MessageDAO.shared.lastMessageCreatedAt() { - let messageCount = MessageDAO.shared.messagesCount() + let messageCount = MessageDAO.shared.messagesCount(matching: nil, after: nil) let formattedCount = NumberFormatter.decimal.string(from: NSNumber(value: messageCount)) ?? "\(messageCount)" let createdAt = DateFormatter.dateFull.string(from: lastMessageCreatedAt.toUTCDate()) DispatchQueue.main.async { [weak self] in diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToDesktopViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToDesktopViewController.swift index ac2901649e..f1800dff16 100644 --- a/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToDesktopViewController.swift +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToDesktopViewController.swift @@ -4,11 +4,21 @@ import MixinServices class TransferToDesktopViewController: DeviceTransferSettingViewController { - private let section = SettingsRadioSection(rows: [ - SettingsRow(title: R.string.localizable.transfer_now(), titleStyle: .highlighted) + private lazy var actionSection = SettingsRadioSection(rows: [ + SettingsRow(title: R.string.localizable.transfer_now(), titleStyle: .highlighted), + ]) + private lazy var conversationFilterRow = SettingsRow(title: R.string.localizable.conversations(), + subtitle: DeviceTransferFilter.Conversation.all.title, + accessory: .disclosure) + private lazy var dateFilterRow = SettingsRow(title: R.string.localizable.date(), + subtitle: DeviceTransferFilter.Time.all.title, + accessory: .disclosure) + private lazy var dataSource = SettingsDataSource(sections: [ + actionSection, + SettingsRadioSection(rows: [conversationFilterRow, dateFilterRow]) ]) - private lazy var dataSource = SettingsDataSource(sections: [section]) + private let filter: DeviceTransferFilter = .passthrough() private var observers: Set = [] private var server: DeviceTransferServer? @@ -20,6 +30,7 @@ class TransferToDesktopViewController: DeviceTransferSettingViewController { dataSource.tableViewDelegate = self dataSource.tableView = tableView NotificationCenter.default.addObserver(self, selector: #selector(deviceTransfer(_:)), name: ReceiveMessageService.deviceTransferNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(updateFilterRows), name: DeviceTransferFilter.filterDidChangeNotification, object: filter) } class func instance() -> UIViewController { @@ -33,48 +44,18 @@ extension TransferToDesktopViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if AppGroupUserDefaults.Account.isDesktopLoggedIn { - guard ReachabilityManger.shared.isReachableOnEthernetOrWiFi else { - Logger.general.info(category: "TransferToDesktop", message: "Network is not reachable") - alert(R.string.localizable.devices_on_same_network()) - return - } - guard WebSocketService.shared.isRealConnected else { - Logger.general.info(category: "TransferToDesktop", message: "WebSocket is not connected") - alert(R.string.localizable.unable_connect_to_desktop()) - return - } - tableView.isUserInteractionEnabled = false - let section = SettingsRadioSection(footer: R.string.localizable.open_desktop_to_confirm(), - rows: [SettingsRow(title: R.string.localizable.waiting(), titleStyle: .normal)]) - section.setAccessory(.busy, forRowAt: indexPath.row) - dataSource.replaceSection(at: indexPath.section, with: section, animation: .automatic) - let server = DeviceTransferServer() - server.$state - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - self?.server(server, didChangeToState: state) - } - .store(in: &observers) - server.$lastConnectionRejectedReason - .sink { [weak self] reason in - if let self, let reason { - self.server(server, didRejectConnection: reason) - } - } - .store(in: &observers) - self.server = server - server.startListening() { [weak self] error in - guard let self else { - return - } - Logger.general.info(category: "TransferToDesktop", message: "Failed to start listening: \(error)") - self.alert(R.string.localizable.connection_establishment_failed()) { _ in - self.navigationController?.popViewController(animated: true) - } + switch indexPath.section { + case 0: + prepareTransfer() + default: + let controller: UIViewController + switch indexPath.row { + case 0: + controller = DeviceTransferConversationSelectionViewController.instance(filter: filter) + default: + controller = DeviceTransferDateSelectionViewController.instance(filter: filter) } - } else { - alert(R.string.localizable.login_desktop_first()) + navigationController?.pushViewController(controller, animated: true) } } @@ -94,7 +75,7 @@ extension TransferToDesktopViewController { } Logger.general.info(category: "TransferToDesktop", message: "Command: \(command))") tableView.isUserInteractionEnabled = true - dataSource.replaceSection(at: 0, with: section, animation: .automatic) + dataSource.replaceSection(at: 0, with: actionSection, animation: .automatic) server?.stopListening() } @@ -127,14 +108,14 @@ extension TransferToDesktopViewController { Logger.general.info(category: "TransferToDesktop", message: "Send push command: \(success)") if !success, let self { self.alert(R.string.localizable.unable_connect_to_desktop()) - self.dataSource.replaceSection(at: 0, with: self.section, animation: .automatic) + self.dataSource.replaceSection(at: 0, with: self.actionSection, animation: .automatic) self.tableView.isUserInteractionEnabled = true } } case .transfer: observers.forEach({ $0.cancel() }) tableView.isUserInteractionEnabled = true - dataSource.replaceSection(at: 0, with: section, animation: .automatic) + dataSource.replaceSection(at: 0, with: actionSection, animation: .automatic) let progress = DeviceTransferProgressViewController(connection: .server(server, .desktop)) navigationController?.pushViewController(progress, animated: true) case let .closed(reason): @@ -151,7 +132,7 @@ extension TransferToDesktopViewController { private func server(_ server: DeviceTransferServer, didRejectConnection reason: DeviceTransferServer.ConnectionRejectedReason) { tableView.isUserInteractionEnabled = true - dataSource.replaceSection(at: 0, with: section, animation: .automatic) + dataSource.replaceSection(at: 0, with: actionSection, animation: .automatic) let title: String switch reason { case .mismatchedUser: @@ -167,3 +148,58 @@ extension TransferToDesktopViewController { } } + +extension TransferToDesktopViewController { + + private func prepareTransfer() { + guard AppGroupUserDefaults.Account.isDesktopLoggedIn else { + alert(R.string.localizable.login_desktop_first()) + return + } + guard ReachabilityManger.shared.isReachableOnEthernetOrWiFi else { + Logger.general.info(category: "TransferToDesktop", message: "Network is not reachable") + alert(R.string.localizable.devices_on_same_network()) + return + } + guard WebSocketService.shared.isRealConnected else { + Logger.general.info(category: "TransferToDesktop", message: "WebSocket is not connected") + alert(R.string.localizable.unable_connect_to_desktop()) + return + } + tableView.isUserInteractionEnabled = false + let section = SettingsRadioSection(footer: R.string.localizable.open_desktop_to_confirm(), + rows: [SettingsRow(title: R.string.localizable.waiting(), titleStyle: .normal)]) + section.setAccessory(.busy, forRowAt: 0) + dataSource.replaceSection(at: 0, with: section, animation: .automatic) + let server = DeviceTransferServer(filter: filter) + server.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.server(server, didChangeToState: state) + } + .store(in: &observers) + server.$lastConnectionRejectedReason + .sink { [weak self] reason in + if let self, let reason { + self.server(server, didRejectConnection: reason) + } + } + .store(in: &observers) + self.server = server + server.startListening() { [weak self] error in + guard let self else { + return + } + Logger.general.info(category: "TransferToDesktop", message: "Failed to start listening: \(error)") + self.alert(R.string.localizable.connection_establishment_failed()) { _ in + self.navigationController?.popViewController(animated: true) + } + } + } + + @objc private func updateFilterRows() { + conversationFilterRow.subtitle = filter.conversation.title + dateFilterRow.subtitle = filter.time.title + } + +} diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneQRCodeViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneQRCodeViewController.swift index 7e4123019c..ffeaebd82d 100644 --- a/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneQRCodeViewController.swift +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneQRCodeViewController.swift @@ -9,12 +9,28 @@ class TransferToPhoneQRCodeViewController: UIViewController { @IBOutlet weak var imageViewHeightConstraint: NSLayoutConstraint! @IBOutlet weak var imageViewWidthConstraint: NSLayoutConstraint! + private let filter: DeviceTransferFilter + private let userID = myUserId + private var observers: Set = [] private var server: DeviceTransferServer? private var hasTrasferStarted = false private var isListening = false - private let userID = myUserId + init(filter: DeviceTransferFilter) { + self.filter = filter + let nib = R.nib.transferToPhoneQRCodeView + super.init(nibName: nib.name, bundle: nib.bundle) + } + + required init?(coder: NSCoder) { + fatalError("Storyboard not supported") + } + + class func instance(filter: DeviceTransferFilter) -> UIViewController { + let vc = TransferToPhoneQRCodeViewController(filter: filter) + return ContainerViewController.instance(viewController: vc, title: R.string.localizable.waiting_for_other_device()) + } override func viewDidLoad() { super.viewDidLoad() @@ -37,11 +53,6 @@ class TransferToPhoneQRCodeViewController: UIViewController { } } - class func instance() -> UIViewController { - let vc = TransferToPhoneQRCodeViewController() - return ContainerViewController.instance(viewController: vc, title: R.string.localizable.waiting_for_other_device()) - } - } extension TransferToPhoneQRCodeViewController: ContainerViewControllerDelegate { @@ -59,7 +70,7 @@ extension TransferToPhoneQRCodeViewController { isListening = false observers.forEach { $0.cancel() } observers.removeAll() - let server = DeviceTransferServer() + let server = DeviceTransferServer(filter: filter) server.$state .receive(on: DispatchQueue.main) .sink { [weak self] state in diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneViewController.swift b/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneViewController.swift index 88a693342a..3873f64eb0 100644 --- a/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneViewController.swift +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/TransferToPhoneViewController.swift @@ -3,16 +3,27 @@ import MixinServices class TransferToPhoneViewController: DeviceTransferSettingViewController { + private lazy var conversationFilterRow = SettingsRow(title: R.string.localizable.conversations(), + subtitle: DeviceTransferFilter.Conversation.all.title, + accessory: .disclosure) + private lazy var dateFilterRow = SettingsRow(title: R.string.localizable.date(), + subtitle: DeviceTransferFilter.Time.all.title, + accessory: .disclosure) + private lazy var dataSource = SettingsDataSource(sections: [ - SettingsSection(rows: [SettingsRow(title: R.string.localizable.transfer_now(), titleStyle: .highlighted)]) + SettingsSection(rows: [SettingsRow(title: R.string.localizable.transfer_now(), titleStyle: .highlighted)]), + SettingsRadioSection(rows: [conversationFilterRow, dateFilterRow]) ]) + private let filter: DeviceTransferFilter = .passthrough() + override func viewDidLoad() { super.viewDidLoad() tableHeaderView.imageView.image = R.image.setting.ic_transfer_phone() tableHeaderView.label.text = R.string.localizable.transfer_hint() dataSource.tableViewDelegate = self dataSource.tableView = tableView + NotificationCenter.default.addObserver(self, selector: #selector(updateFilterRows), name: DeviceTransferFilter.filterDidChangeNotification, object: filter) } class func instance() -> UIViewController { @@ -26,13 +37,33 @@ extension TransferToPhoneViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard ReachabilityManger.shared.isReachableOnEthernetOrWiFi else { - Logger.general.info(category: "TransferToPhone", message: "Network is not reachable") - alert(R.string.localizable.devices_on_same_network()) - return + let controller: UIViewController + switch indexPath.section { + case 0: + guard ReachabilityManger.shared.isReachableOnEthernetOrWiFi else { + Logger.general.info(category: "TransferToPhone", message: "Network is not reachable") + alert(R.string.localizable.devices_on_same_network()) + return + } + controller = TransferToPhoneQRCodeViewController.instance(filter: filter) + default: + switch indexPath.row { + case 0: + controller = DeviceTransferConversationSelectionViewController.instance(filter: filter) + default: + controller = DeviceTransferDateSelectionViewController.instance(filter: filter) + } } - let controller = TransferToPhoneQRCodeViewController.instance() navigationController?.pushViewController(controller, animated: true) } } + +extension TransferToPhoneViewController { + + @objc private func updateFilterRows() { + conversationFilterRow.subtitle = filter.conversation.title + dateFilterRow.subtitle = filter.time.title + } + +} diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/View/DeviceTransferConversationSelectionToolbarView.xib b/Mixin/UserInterface/Controllers/DeviceTransfer/View/DeviceTransferConversationSelectionToolbarView.xib new file mode 100644 index 0000000000..44e647644d --- /dev/null +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/View/DeviceTransferConversationSelectionToolbarView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mixin/UserInterface/Controllers/DeviceTransfer/View/DeviceTransferDateSelectionView.xib b/Mixin/UserInterface/Controllers/DeviceTransfer/View/DeviceTransferDateSelectionView.xib new file mode 100644 index 0000000000..fb97ec22f9 --- /dev/null +++ b/Mixin/UserInterface/Controllers/DeviceTransfer/View/DeviceTransferDateSelectionView.xib @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mixin/UserInterface/Windows/DeviceTransferSelectedConversationWindow.swift b/Mixin/UserInterface/Windows/DeviceTransferSelectedConversationWindow.swift new file mode 100644 index 0000000000..f223f4b274 --- /dev/null +++ b/Mixin/UserInterface/Windows/DeviceTransferSelectedConversationWindow.swift @@ -0,0 +1,85 @@ +import UIKit + +class DeviceTransferSelectedConversationWindow: BottomSheetView { + + typealias DeleteHandler = (_ conversationID: String) -> Void + + @IBOutlet weak var label: UILabel! + @IBOutlet weak var tableView: UITableView! + + @IBOutlet weak var tableViewHeightConstraint: NSLayoutConstraint! + + private let rowHeight: CGFloat = 70.0 + private let maxTableViewHeight: CGFloat = 500.0 + + private var onDelete: DeleteHandler? + private var selections = [MessageReceiver]() { + didSet { + if selections.count == 1 { + label.text = R.string.localizable.items_selected_one() + } else { + label.text = R.string.localizable.items_selected_count(selections.count) + } + let height = CGFloat(selections.count) * rowHeight + tableViewHeightConstraint.constant = min(height, maxTableViewHeight) + } + } + + override func awakeFromNib() { + super.awakeFromNib() + tableView.dataSource = self + tableView.delegate = self + tableView.register(CheckmarkPeerCell.nib, forCellReuseIdentifier: CheckmarkPeerCell.reuseIdentifier) + } + + class func instance() -> DeviceTransferSelectedConversationWindow { + R.nib.deviceTransferSelectedConversationWindow(owner: self)! + } + + func render(selections: [MessageReceiver], onDelete: @escaping DeleteHandler) { + self.selections = selections + self.onDelete = onDelete + self.tableView.reloadData() + } + + @IBAction func dismissAction(_ sender: Any) { + dismissPopupController(animated: true) + } + +} + +extension DeviceTransferSelectedConversationWindow: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + selections.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: CheckmarkPeerCell.reuseIdentifier, for: indexPath) as! CheckmarkPeerCell + cell.render(receiver: selections[indexPath.row]) + return cell + } + +} + +extension DeviceTransferSelectedConversationWindow: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + onDelete?(selections[indexPath.row].conversationId) + if selections.count == 1 { + dismissPopupController(animated: true) + } else { + selections.remove(at: indexPath.row) + tableView.reloadData() + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cell.isSelected = true + } + +} diff --git a/Mixin/UserInterface/Windows/DeviceTransferSelectedConversationWindow.xib b/Mixin/UserInterface/Windows/DeviceTransferSelectedConversationWindow.xib new file mode 100644 index 0000000000..8b500b2614 --- /dev/null +++ b/Mixin/UserInterface/Windows/DeviceTransferSelectedConversationWindow.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift b/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift index 986108d201..023a52644e 100644 --- a/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/AppDAO.swift @@ -38,9 +38,9 @@ public final class AppDAO: UserDatabaseDAO { public func apps(limit: Int, after appId: String?) -> [App] { var sql = "SELECT * FROM apps" if let appId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM apps WHERE app_id = '\(appId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM apps WHERE app_id = '\(appId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift b/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift index 87de4889f4..52a4e64974 100644 --- a/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/AssetDAO.swift @@ -107,9 +107,9 @@ public final class AssetDAO: UserDatabaseDAO { public func assets(limit: Int, after assetId: String?) -> [Asset] { var sql = "SELECT * FROM assets" if let assetId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM assets WHERE asset_id = '\(assetId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM assets WHERE asset_id = '\(assetId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift b/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift index 45026fb0e4..0f0ad69d9d 100644 --- a/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/ConversationDAO.swift @@ -659,18 +659,41 @@ public final class ConversationDAO: UserDatabaseDAO { } } - public func conversations(limit: Int, after conversationId: String?) -> [Conversation] { + public func conversations(limit: Int, after conversationId: String?, matching conversationIDs: Set?) -> [Conversation] { var sql = "SELECT * FROM conversations" + + var conditions: [String] = [] if let conversationId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM conversations WHERE conversation_id = '\(conversationId)'), 0)" + conditions.append("rowid > IFNULL((SELECT rowid FROM conversations WHERE conversation_id = '\(conversationId)'), 0)") + } + if let conversationIDs { + let ids = conversationIDs.joined(separator: "', '") + conditions.append("conversation_id IN ('\(ids)')") } - sql += " ORDER BY ROWID LIMIT ?" + if !conditions.isEmpty { + sql += " WHERE " + conditions.joined(separator: " AND ") + } + + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } - public func conversationsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM conversations") - return count ?? 0 + public func conversationsCount(matching conversationIDs: [String]?) -> Int { + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. [ExpiredMessage] { var sql = "SELECT * FROM expired_messages" if let messageId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM expired_messages WHERE message_id = '\(messageId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM expired_messages WHERE message_id = '\(messageId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift index 9611c9d115..a5c5e11e04 100644 --- a/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/MessageDAO.swift @@ -980,18 +980,100 @@ extension MessageDAO { silentNotification: silentNotification) } - public func messages(limit: Int, after messageId: String?) -> [Message] { - var sql = "SELECT * FROM messages" - if let messageId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM messages WHERE id = '\(messageId)'), 0)" + public func messages( + limit: Int, + after rowID: Int, + matching conversationIDs: Set? + ) -> [Message] { + var sql = "SELECT * FROM messages WHERE rowid > ?" + if let conversationIDs { + let ids = conversationIDs.joined(separator: "', '") + sql += " AND conversation_id IN ('\(ids)')" } - sql += " ORDER BY ROWID LIMIT ?" - return db.select(with: sql, arguments: [limit]) + sql += " ORDER BY rowid ASC LIMIT ?" + return db.select(with: sql, arguments: [rowID, limit]) } - public func messagesCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM messages") - return count ?? 0 + public func messageRowID(createdAt: String) -> Int? { + db.select(with: "SELECT ROWID FROM messages WHERE created_at >= ? ORDER BY rowid ASC LIMIT 1", arguments: [createdAt]) + } + + public func messageRowID(messageID: String) -> Int? { + db.select(with: "SELECT ROWID FROM messages WHERE id = ?", arguments: [messageID]) + } + + public func messagesCount(matching conversationIDs: [String]?, after rowID: Int?) -> Int { + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. Int { + let categories = MessageCategory.allMediaCategories.map(\.rawValue).joined(separator: "', '") + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. Int { + let categories = MessageCategory.transcriptCategories.map(\.rawValue).joined(separator: "', '") + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. String? { diff --git a/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift b/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift index 3b7540ab5f..0d72c0031b 100644 --- a/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/MessageMentionDAO.swift @@ -15,18 +15,44 @@ public final class MessageMentionDAO: UserDatabaseDAO { return db.select(with: sql, arguments: [conversationId]) } - public func messageMentions(limit: Int, after messageId: String?) -> [MessageMention] { + public func messageMentions( + limit: Int, + after messageId: String?, + matching conversationIDs: Set? + ) -> [MessageMention] { var sql = "SELECT * FROM message_mentions" + + var conditions: [String] = [] if let messageId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM message_mentions WHERE message_id = '\(messageId)'), 0)" + conditions.append("rowid > IFNULL((SELECT rowid FROM message_mentions WHERE message_id = '\(messageId)'), 0)") } - sql += " ORDER BY ROWID LIMIT ?" + if let conversationIDs { + let ids = conversationIDs.joined(separator: "', '") + conditions.append("conversation_id IN ('\(ids)')") + } + if !conditions.isEmpty { + sql += " WHERE " + conditions.joined(separator: " AND ") + } + + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } - public func messageMentionsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM message_mentions") - return count ?? 0 + public func messageMentionsCount(matching conversationIDs: [String]?) -> Int { + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. [Participant] { + public func participants( + limit: Int, + after conversationId: String?, + with userId: String?, + matching conversationIDs: Set? + ) -> [Participant] { var sql = "SELECT * FROM participants" + + var conditions: [String] = [] if let conversationId, let userId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM participants WHERE conversation_id = '\(conversationId)' AND user_id = '\(userId)'), 0)" + conditions.append("rowid > IFNULL((SELECT rowid FROM participants WHERE conversation_id = '\(conversationId)' AND user_id = '\(userId)'), 0)") } - sql += " ORDER BY ROWID LIMIT ?" + if let conversationIDs { + let ids = conversationIDs.joined(separator: "', '") + conditions.append("conversation_id IN ('\(ids)')") + } + if !conditions.isEmpty { + sql += " WHERE " + conditions.joined(separator: " AND ") + } + + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } - public func participantsCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM participants") - return count ?? 0 + public func participantsCount(matching conversationIDs: [String]?) -> Int { + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. [PinMessage] { - var sql = "SELECT * FROM pin_messages" - if let messageId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM pin_messages WHERE message_id = '\(messageId)'), 0)" + public func pinMessages( + limit: Int, + after rowID: Int, + matching conversationIDs: Set? + ) -> [PinMessage] { + var sql = "SELECT * FROM pin_messages WHERE rowid > ?" + if let conversationIDs { + let ids = conversationIDs.joined(separator: "', '") + sql += " AND conversation_id IN ('\(ids)')" } - sql += " ORDER BY ROWID LIMIT ?" - return db.select(with: sql, arguments: [limit]) + sql += " ORDER BY rowid ASC LIMIT ?" + return db.select(with: sql, arguments: [rowID, limit]) } - public func pinMessagesCount() -> Int { - let count: Int? = db.select(with: "SELECT COUNT(*) FROM pin_messages") - return count ?? 0 + public func pinMessagesCount(matching conversationIDs: [String]?, after rowID: Int?) -> Int { + if let conversationIDs { + var totalCount = 0 + for i in stride(from: 0, to: conversationIDs.count, by: Self.deviceTransferStride) { + let endIndex = min(i + Self.deviceTransferStride, conversationIDs.count) + let ids = Array(conversationIDs[i.. Int? { + db.select(with: "SELECT rowid FROM pin_messages WHERE created_at >= ? ORDER BY rowid ASC LIMIT 1", arguments: [createdAt]) + } + + public func messageRowID(messageID: String) -> Int? { + db.select(with: "SELECT rowid FROM pin_messages WHERE message_id = ?", arguments: [messageID]) } public func save(pinMessage: PinMessage) { diff --git a/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift b/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift index 3b03d67012..e8a4a7cc11 100644 --- a/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/SnapshotDAO.swift @@ -119,9 +119,9 @@ public final class SnapshotDAO: UserDatabaseDAO { public func snapshots(limit: Int, after snapshotId: String?) -> [Snapshot] { var sql = "SELECT * FROM snapshots" if let snapshotId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM snapshots WHERE snapshot_id = '\(snapshotId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM snapshots WHERE snapshot_id = '\(snapshotId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift b/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift index e17f27c9dc..a056d15198 100644 --- a/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/StickerDAO.swift @@ -166,9 +166,9 @@ public final class StickerDAO: UserDatabaseDAO { public func stickers(limit: Int, after stickerId: String?) -> [Sticker] { var sql = "SELECT * FROM stickers" if let stickerId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM stickers WHERE sticker_id = '\(stickerId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM stickers WHERE sticker_id = '\(stickerId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift b/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift index 8eef4a5c9a..4506f2a1b3 100644 --- a/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/TranscriptMessageDAO.swift @@ -93,9 +93,9 @@ public final class TranscriptMessageDAO: UserDatabaseDAO { public func transcriptMessages(limit: Int, after transcriptId: String?, with messageId: String?) -> [TranscriptMessage] { var sql = "SELECT * FROM transcript_messages" if let transcriptId, let messageId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM transcript_messages WHERE transcript_id = '\(transcriptId)' AND message_id = '\(messageId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM transcript_messages WHERE transcript_id = '\(transcriptId)' AND message_id = '\(messageId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift b/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift index 28fc0a20aa..f87e6a2b98 100644 --- a/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift +++ b/MixinServices/MixinServices/Database/User/DAO/UserDAO.swift @@ -224,9 +224,9 @@ public final class UserDAO: UserDatabaseDAO { public func users(limit: Int, after userId: String?) -> [User] { var sql = "SELECT * FROM users" if let userId { - sql += " WHERE ROWID > IFNULL((SELECT ROWID FROM users WHERE user_id = '\(userId)'), 0)" + sql += " WHERE rowid > IFNULL((SELECT rowid FROM users WHERE user_id = '\(userId)'), 0)" } - sql += " ORDER BY ROWID LIMIT ?" + sql += " ORDER BY rowid ASC LIMIT ?" return db.select(with: sql, arguments: [limit]) } diff --git a/MixinServices/MixinServices/Database/User/Model/Message.swift b/MixinServices/MixinServices/Database/User/Model/Message.swift index be9fef9fab..aaa408c6c0 100644 --- a/MixinServices/MixinServices/Database/User/Model/Message.swift +++ b/MixinServices/MixinServices/Database/User/Model/Message.swift @@ -370,6 +370,12 @@ public enum MessageCategory: String, Decodable { public static let allMediaCategoriesString: Set = Set(allMediaCategories.map(\.rawValue)) + public static let transcriptCategories: [MessageCategory] = [ + .PLAIN_TRANSCRIPT, + .SIGNAL_TRANSCRIPT, + .ENCRYPTED_TRANSCRIPT + ] + public static let endCallCategories: [MessageCategory] = [ .WEBRTC_AUDIO_END, .WEBRTC_AUDIO_BUSY, diff --git a/MixinServices/MixinServices/Database/User/UserDatabaseDAO.swift b/MixinServices/MixinServices/Database/User/UserDatabaseDAO.swift index 0aa6e30f71..428fdcb141 100644 --- a/MixinServices/MixinServices/Database/User/UserDatabaseDAO.swift +++ b/MixinServices/MixinServices/Database/User/UserDatabaseDAO.swift @@ -7,3 +7,14 @@ public class UserDatabaseDAO { } } + +extension UserDatabaseDAO { + + // SQLite has a limitation on the number of parameters, and the maximum limit is `SQLITE_MAX_VARIABLE_NUMBER` + // Before version 3.32.0 (2020-05-22), this limit was 999, and it was increased to 32766 afterward + // Since users may choose to transfer more than 999 conversations during the device transfer process, in cases + // where the limit is exceeded, it is necessary to query the relevant content in pages, with each page + // not exceeding this quantity. + public static let deviceTransferStride = 900 + +}