diff --git a/Localization/app.json b/Localization/app.json index 21304d56..f91e428f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -340,6 +340,10 @@ "private": "Followers only", "direct": "Only people I mention" }, + "auto_complete": { + "single_people_talking": "%ld people talking", + "multiple_people_talking": "%ld people talking" + }, "accessibility": { "append_attachment": "Append attachment", "append_poll": "Append poll", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index df5d04df..0ef6f675 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -189,6 +189,8 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; + DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; }; + DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; @@ -206,8 +208,6 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; }; - DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; @@ -311,6 +311,11 @@ DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; + DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */; }; + DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; }; + DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */; }; + DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -414,6 +419,12 @@ DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; }; DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; + DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */; }; + DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */; }; + DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */; }; + DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */; }; + DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */; }; + DBBF1DCB2652539E00E5B703 /* AutoCompleteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; }; @@ -527,7 +538,7 @@ files = ( DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, - DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */, + DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -731,6 +742,8 @@ DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; + DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = ""; }; + DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; @@ -853,6 +866,9 @@ DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; + DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = ""; }; + DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -956,6 +972,12 @@ DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = ""; }; + DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTableViewCell.swift; sourceTree = ""; }; + DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AutoCompleteViewModel+Diffable.swift"; sourceTree = ""; }; + DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AutoCompleteViewModel+State.swift"; sourceTree = ""; }; + DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteSection.swift; sourceTree = ""; }; + DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteItem.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -993,7 +1015,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */, + DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -1362,6 +1384,7 @@ DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, + DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */, ); path = Section; sourceTree = ""; @@ -1422,6 +1445,7 @@ DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DB6D9F8326358EEC008423CD /* SettingsItem.swift */, + DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, ); path = Item; sourceTree = ""; @@ -1754,6 +1778,7 @@ DB49A61325FF2C5600B98345 /* EmojiService.swift */, DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */, DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */, + DB040ED026538E3C00BEE9D8 /* Trie.swift */, ); path = EmojiService; sourceTree = ""; @@ -1771,6 +1796,7 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( + DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, @@ -1874,6 +1900,19 @@ path = MastodonSDK; sourceTree = ""; }; + DB6F5E36264E78EA009108F4 /* AutoComplete */ = { + isa = PBXGroup; + children = ( + DBBF1DC02652402000E5B703 /* View */, + DBBF1DC326524D3100E5B703 /* Cell */, + DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */, + DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */, + DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */, + DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */, + ); + path = AutoComplete; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -1894,6 +1933,7 @@ DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( + DB6F5E36264E78EA009108F4 /* AutoComplete */, DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, @@ -2199,6 +2239,7 @@ isa = PBXGroup; children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, ); @@ -2302,6 +2343,22 @@ path = View; sourceTree = ""; }; + DBBF1DC02652402000E5B703 /* View */ = { + isa = PBXGroup; + children = ( + DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */, + ); + path = View; + sourceTree = ""; + }; + DBBF1DC326524D3100E5B703 /* Cell */ = { + isa = PBXGroup; + children = ( + DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { isa = PBXGroup; children = ( @@ -2400,7 +2457,7 @@ 2D939AC725EE14620076FA61 /* CropViewController */, DB9A487D2603456B008B817C /* UITextView+Placeholder */, DBB525072611EAC0002F1F29 /* Tabman */, - DB35B0B22643D821006AC73B /* TwitterTextEditor */, + DB6F5E31264E7410009108F4 /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2589,7 +2646,7 @@ DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, - DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2846,6 +2903,7 @@ 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, + DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */, DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, @@ -2998,6 +3056,7 @@ 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, + DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, @@ -3019,6 +3078,7 @@ DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, + DBBF1DCB2652539E00E5B703 /* AutoCompleteItem.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, @@ -3035,6 +3095,7 @@ 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, + DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, @@ -3045,6 +3106,7 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, + DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, @@ -3057,6 +3119,7 @@ DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */, 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, + DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, @@ -3068,6 +3131,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */, 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, @@ -3135,6 +3199,7 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */, DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */, 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */, 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */, @@ -3142,6 +3207,7 @@ 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, + DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, @@ -3160,6 +3226,7 @@ DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, + DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, @@ -3168,6 +3235,7 @@ DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, + DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, @@ -3938,14 +4006,6 @@ minimumVersion = 0.1.1; }; }; - DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/twitter/TwitterTextEditor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; @@ -3970,6 +4030,14 @@ minimumVersion = 4.2.2; }; }; + DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/TwitterTextEditor.git"; + requirement = { + branch = "feature/expose-layout"; + kind = branch; + }; + }; DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; @@ -4031,11 +4099,6 @@ package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; - DB35B0B22643D821006AC73B /* TwitterTextEditor */ = { - isa = XCSwiftPackageProductDependency; - package = DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; - productName = TwitterTextEditor; - }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; @@ -4056,6 +4119,11 @@ package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; productName = AlamofireImage; }; + DB6F5E31264E7410009108F4 /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38325ae9..296feea2 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,7 +69,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, @@ -138,11 +138,11 @@ }, { "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/twitter/TwitterTextEditor", + "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor.git", "state": { - "branch": null, - "revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a", - "version": "1.1.0" + "branch": "feature/expose-layout", + "revision": "c208329b23dcb3c8c7192de34776440d625a26a4", + "version": null } }, { diff --git a/Mastodon/Diffiable/Item/AutoCompleteItem.swift b/Mastodon/Diffiable/Item/AutoCompleteItem.swift new file mode 100644 index 00000000..ee296ba7 --- /dev/null +++ b/Mastodon/Diffiable/Item/AutoCompleteItem.swift @@ -0,0 +1,55 @@ +// +// AutoCompleteItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import Foundation +import MastodonSDK + +enum AutoCompleteItem { + case hashtag(tag: Mastodon.Entity.Tag) + case hashtagV1(tag: String) + case account(account: Mastodon.Entity.Account) + case emoji(emoji: Mastodon.Entity.Emoji) + case bottomLoader +} + +extension AutoCompleteItem: Equatable { + static func == (lhs: AutoCompleteItem, rhs: AutoCompleteItem) -> Bool { + switch (lhs, rhs) { + case (.hashtag(let tagLeft), hashtag(let tagRight)): + return tagLeft.name == tagRight.name + case (.hashtagV1(let tagLeft), hashtagV1(let tagRight)): + return tagLeft == tagRight + case (.account(let accountLeft), account(let accountRight)): + return accountLeft.id == accountRight.id + case (.emoji(let emojiLeft), .emoji(let emojiRight)): + return emojiLeft.shortcode == emojiRight.shortcode + case (.bottomLoader, .bottomLoader): + return true + default: + return false + } + } +} + +extension AutoCompleteItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .hashtag(let tag): + hasher.combine(tag.name) + hasher.combine(tag.url) + case .hashtagV1(let tag): + hasher.combine(tag) + case .account(let account): + hasher.combine(account.id) + case .emoji(let emoji): + hasher.combine(emoji.shortcode) + hasher.combine(emoji.url) + case .bottomLoader: + hasher.combine(String(describing: AutoCompleteItem.bottomLoader.self)) + } + } +} diff --git a/Mastodon/Diffiable/Section/AutoCompleteSection.swift b/Mastodon/Diffiable/Section/AutoCompleteSection.swift new file mode 100644 index 00000000..39aa6e9c --- /dev/null +++ b/Mastodon/Diffiable/Section/AutoCompleteSection.swift @@ -0,0 +1,90 @@ +// +// AutoCompleteSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import UIKit +import MastodonSDK + +enum AutoCompleteSection: Equatable, Hashable { + case main +} + +extension AutoCompleteSection { + + static func tableViewDiffableDataSource( + for tableView: UITableView + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + switch item { + case .hashtag(let hashtag): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell + configureHashtag(cell: cell, hashtag: hashtag) + return cell + case .hashtagV1(let hashtagName): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell + configureHashtag(cell: cell, hashtagName: hashtagName) + return cell + case .account(let account): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell + configureAccount(cell: cell, account: account) + return cell + case .emoji(let emoji): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell + configureEmoji(cell: cell, emoji: emoji) + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell + } + } + } + +} + +extension AutoCompleteSection { + + private static func configureHashtag(cell: AutoCompleteTableViewCell, hashtag: Mastodon.Entity.Tag) { + cell.titleLabel.text = "#" + hashtag.name + cell.subtitleLabel.text = { + let count = (hashtag.history ?? []) + .sorted(by: { $0.day > $1.day }) + .prefix(2) + .compactMap { Int($0.accounts) } + .reduce(0, +) + if count > 1 { + return L10n.Scene.Compose.AutoComplete.multiplePeopleTalking(count) + } else { + return L10n.Scene.Compose.AutoComplete.singlePeopleTalking(count) + } + }() + cell.avatarImageView.isHidden = true + } + + private static func configureHashtag(cell: AutoCompleteTableViewCell, hashtagName: String) { + cell.titleLabel.text = "#" + hashtagName + cell.subtitleLabel.text = " " + cell.avatarImageView.isHidden = true + } + + private static func configureAccount(cell: AutoCompleteTableViewCell, account: Mastodon.Entity.Account) { + cell.titleLabel.text = { + guard !account.displayName.isEmpty else { return account.username } + return account.displayName + }() + cell.subtitleLabel.text = "@" + account.acct + cell.avatarImageView.isHidden = false + cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: account.avatar))) + } + + private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji) { + cell.titleLabel.text = ":" + emoji.shortcode + ":" + cell.subtitleLabel.text = " " + cell.avatarImageView.isHidden = false + cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: emoji.url))) + } + +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 2b7aecae..0c4ae883 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -37,6 +37,7 @@ extension ComposeStatusSection { repliedToCellFrameSubscriber: CurrentValueSubject, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + textEditorViewChangeObserver: TextEditorViewChangeObserver, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, @@ -45,6 +46,7 @@ extension ComposeStatusSection { UICollectionViewDiffableDataSource(collectionView: collectionView) { [ weak customEmojiPickerInputViewModel, weak textEditorViewTextAttributesDelegate, + weak textEditorViewChangeObserver, weak composeStatusAttachmentTableViewCellDelegate, weak composeStatusPollOptionCollectionViewCellDelegate, weak composeStatusNewPollOptionCollectionViewCellDelegate, @@ -92,6 +94,7 @@ extension ComposeStatusSection { } ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate + cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay cell.composeContent .removeDuplicates() .receive(on: DispatchQueue.main) @@ -259,7 +262,7 @@ extension ComposeStatusSection { } -protocol CustomEmojiReplacableTextInput: AnyObject { +protocol CustomEmojiReplaceableTextInput: AnyObject { var inputView: UIView? { get set } func reloadInputViews() @@ -270,14 +273,14 @@ protocol CustomEmojiReplacableTextInput: AnyObject { } class CustomEmojiReplacableTextInputReference { - weak var value: CustomEmojiReplacableTextInput? + weak var value: CustomEmojiReplaceableTextInput? - init(value: CustomEmojiReplacableTextInput? = nil) { + init(value: CustomEmojiReplaceableTextInput? = nil) { self.value = value } } -extension TextEditorView: CustomEmojiReplacableTextInput { +extension TextEditorView: CustomEmojiReplaceableTextInput { func insertText(_ text: String) { try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil) } @@ -287,14 +290,14 @@ extension TextEditorView: CustomEmojiReplacableTextInput { } } -extension UITextField: CustomEmojiReplacableTextInput { } -extension UITextView: CustomEmojiReplacableTextInput { } +extension UITextField: CustomEmojiReplaceableTextInput { } +extension UITextView: CustomEmojiReplaceableTextInput { } extension ComposeStatusSection { static func configureCustomEmojiPicker( viewModel: CustomEmojiPickerInputViewModel?, - customEmojiReplacableTextInput: CustomEmojiReplacableTextInput, + customEmojiReplacableTextInput: CustomEmojiReplaceableTextInput, disposeBag: inout Set ) { guard let viewModel = viewModel else { return } diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 4e1d855b..c7e35c11 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -152,41 +152,4 @@ extension ActiveLabel { return elements } -// public override func accessibilityElementCount() -> Int { -// return 1 + activeEntities.count -// } -// -// public override func accessibilityElement(at index: Int) -> Any? { -// if index == 0 { -// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self) -// element.accessibilityTraits = .staticText -// element.accessibilityLabel = accessibilityLabel -// element.accessibilityFrame = superview!.convert(frame, to: nil) -// element.index = index -// return element -// } -// -// let index = index - 1 -// guard index < activeEntities.count else { return nil } -// let eneity = activeEntities[index] -// guard let element = eneity.accessibilityElement(in: self) else { return nil } -// -// var glyphRange = NSRange() -// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange) -// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) -// element.accessibilityFrame = self.convert(rect, to: nil) -// element.accessibilityContainer = self -// -// return element -// } -// -// public override func index(ofAccessibilityElement element: Any) -> Int { -// guard let element = element as? ActiveLabelAccessibilityElement, -// let index = element.index else { -// return NSNotFound -// } -// -// return index -// } - } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8f6c13f9..5d324c40 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -376,6 +376,16 @@ internal enum L10n { /// video internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") } + internal enum AutoComplete { + /// %ld people talking + internal static func multiplePeopleTalking(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.AutoComplete.MultiplePeopleTalking", p1) + } + /// %ld people talking + internal static func singlePeopleTalking(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.AutoComplete.SinglePeopleTalking", p1) + } + } internal enum ContentWarning { /// Write an accurate warning here... internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder") diff --git a/Mastodon/Helper/MastodonRegex.swift b/Mastodon/Helper/MastodonRegex.swift new file mode 100644 index 00000000..c390ea51 --- /dev/null +++ b/Mastodon/Helper/MastodonRegex.swift @@ -0,0 +1,25 @@ +// +// MastodonRegex.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import Foundation + +enum MastodonRegex { + /// mention, hashtag. + /// @... + /// #... + static let highlightPattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))" + /// emoji + /// :shortcode: + /// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect + /// precondition :\B with following space + static let emojiPattern = "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))" + /// mention, hashtag, emoji + /// @… + /// #… + /// :… + static let autoCompletePattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)" +} diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index c9ed556c..2d399361 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -125,6 +125,8 @@ uploaded to Mastodon."; "Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; "Scene.Compose.Attachment.Photo" = "photo"; "Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking"; +"Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking"; "Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; "Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c9ed556c..2d399361 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -125,6 +125,8 @@ uploaded to Mastodon."; "Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; "Scene.Compose.Attachment.Photo" = "photo"; "Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking"; +"Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking"; "Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; "Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift new file mode 100644 index 00000000..acd7396f --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift @@ -0,0 +1,116 @@ +// +// AutoCompleteViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import os.log +import UIKit +import Combine + +protocol AutoCompleteViewControllerDelegate: AnyObject { + func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem) +} + +final class AutoCompleteViewController: UIViewController { + + static let chevronViewHeight: CGFloat = 24 + + var viewModel: AutoCompleteViewModel! + var disposeBag = Set() + + weak var delegate: AutoCompleteViewControllerDelegate? + + let chevronView = AutoCompleteTopChevronView() + let containerBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemBackground.color + return view + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(AutoCompleteTableViewCell.self, forCellReuseIdentifier: String(describing: AutoCompleteTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight + tableView.verticalScrollIndicatorInsets.top = AutoCompleteViewController.chevronViewHeight + return tableView + }() + +} + +extension AutoCompleteViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + + chevronView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(chevronView) + NSLayoutConstraint.activate([ + chevronView.topAnchor.constraint(equalTo: view.topAnchor), + chevronView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + chevronView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + chevronView.heightAnchor.constraint(equalToConstant: AutoCompleteViewController.chevronViewHeight) + ]) + + containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerBackgroundView) + NSLayoutConstraint.activate([ + containerBackgroundView.topAnchor.constraint(equalTo: chevronView.topAnchor), + containerBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + containerBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + containerBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + view.bringSubviewToFront(chevronView) + containerBackgroundView.preservesSuperviewLayoutMargins = true + containerBackgroundView.isUserInteractionEnabled = true + + tableView.translatesAutoresizingMaskIntoConstraints = false + containerBackgroundView.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: containerBackgroundView.topAnchor), + tableView.leadingAnchor.constraint(equalTo: containerBackgroundView.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: containerBackgroundView.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: containerBackgroundView.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource(for: tableView) + + // bind to layout chevron + viewModel.symbolBoundingRect + .receive(on: DispatchQueue.main) + .sink { [weak self] symbolBoundingRect in + guard let self = self else { return } + self.chevronView.chevronMinX = symbolBoundingRect.midX - 0.5 * AutoCompleteTopChevronView.chevronSize.width + self.chevronView.setNeedsLayout() + self.containerBackgroundView.layer.mask = self.chevronView.invertMask(in: self.view.bounds) + } + .store(in: &disposeBag) + } + +} + +// MARK: - UITableViewDelegate +extension AutoCompleteViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + tableView.deselectRow(at: indexPath, animated: true) + + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + delegate?.autoCompleteViewController(self, didSelectItem: item) + } + + func tableView(_ tableView: UITableView, didHighlightRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } + +} diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+Diffable.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+Diffable.swift new file mode 100644 index 00000000..74218872 --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+Diffable.swift @@ -0,0 +1,22 @@ +// +// AutoCompleteViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import UIKit + +extension AutoCompleteViewModel { + + func setupDiffableDataSource( + for tableView: UITableView + ) { + diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } + +} diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift new file mode 100644 index 00000000..4e59ce08 --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel+State.swift @@ -0,0 +1,206 @@ +// +// AutoCompleteViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension AutoCompleteViewModel { + class State: GKState { + weak var viewModel: AutoCompleteViewModel? + + init(viewModel: AutoCompleteViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension AutoCompleteViewModel.State { + class Initial: AutoCompleteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Loading.Type: + return !viewModel.inputText.value.isEmpty + default: + return false + } + } + } + + class Loading: AutoCompleteViewModel.State { + + var previoursSearchText = "" + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Loading.Type: + return previoursSearchText != viewModel.inputText.value + case is Fail.Type: + return true + case is Idle.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + let searchText = viewModel.inputText.value + let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default + if searchText != previoursSearchText { + reset(searchText: searchText) + } + + switch searchType { + case .emoji: + Loading.fetchLocalEmoji( + searchText: searchText, + viewModel: viewModel, + stateMachine: stateMachine + ) + default: + Loading.queryRemoteEnitity( + searchText: searchText, + viewModel: viewModel, + stateMachine: stateMachine + ) + } + } + + private static func fetchLocalEmoji( + searchText: String, + viewModel: AutoCompleteViewModel, + stateMachine: GKStateMachine + ) { + guard let customEmojiViewModel = viewModel.customEmojiViewModel.value else { + stateMachine.enter(Fail.self) + return + } + + guard let emojiTrie = customEmojiViewModel.emojiTrie.value else { + stateMachine.enter(Fail.self) + return + } + + let searchPattern = ArraySlice(String(searchText.dropFirst())) + let passthroughs = emojiTrie.passthrough(searchPattern) + let matchingEmojis = passthroughs + .map { $0.values } // [Set] + .map { set in set.compactMap { $0 as? Mastodon.Entity.Emoji } } // [[Emoji]] + .flatMap { $0 } // [Emoji] + let items: [AutoCompleteItem] = matchingEmojis.map { emoji in + AutoCompleteItem.emoji(emoji: emoji) + } + stateMachine.enter(Idle.self) + viewModel.autoCompleteItems.value = items + } + + private static func queryRemoteEnitity( + searchText: String, + viewModel: AutoCompleteViewModel, + stateMachine: GKStateMachine + ) { + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let domain = activeMastodonAuthenticationBox.domain + + let searchText = viewModel.inputText.value + let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default + + let q = String(searchText.dropFirst()) + let query = Mastodon.API.V2.Search.Query( + q: q, + type: searchType.mastodonSearchType, + maxID: nil, + offset: nil, + following: nil + ) + viewModel.context.apiService.search( + domain: domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto-complete fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + guard viewModel.inputText.value == searchText else { return } // discard if not matching + + var items: [AutoCompleteItem] = [] + items.append(contentsOf: response.value.accounts.map { AutoCompleteItem.account(account: $0) }) + items.append(contentsOf: response.value.hashtags.map { AutoCompleteItem.hashtag(tag: $0) }) + stateMachine.enter(Idle.self) + viewModel.autoCompleteItems.value = items + } + .store(in: &viewModel.disposeBag) + } + + private func reset(searchText: String) { + let previoursSearchType = AutoCompleteViewModel.SearchType(inputText: previoursSearchText) + previoursSearchText = searchText + let currentSearchType = AutoCompleteViewModel.SearchType(inputText: searchText) + // reset when search type change + if previoursSearchType != currentSearchType { + viewModel?.autoCompleteItems.value = [] + } + } + + } + + class Idle: AutoCompleteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + } + + class Fail: AutoCompleteViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + +} diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel.swift new file mode 100644 index 00000000..dc6ab178 --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewModel.swift @@ -0,0 +1,107 @@ +// +// AutoCompleteViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import UIKit +import Combine +import GameplayKit +import MastodonSDK + +final class AutoCompleteViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let inputText = CurrentValueSubject("") // contains "@" or "#" prefix + let symbolBoundingRect = CurrentValueSubject(.zero) + let customEmojiViewModel = CurrentValueSubject(nil) + + // output + var autoCompleteItems = CurrentValueSubject<[AutoCompleteItem], Never>([]) + var diffableDataSource: UITableViewDiffableDataSource! + private(set) lazy var stateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Loading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext) { + self.context = context + + autoCompleteItems + .receive(on: DispatchQueue.main) + .sink { [weak self] items in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Loading, is State.Fail: + if items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .main) + } + case is State.Idle: + // TODO: handle no results + break + default: + break + } + } + + diffableDataSource.defaultRowAnimation = .fade + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) + + inputText + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] inputText in + guard let self = self else { return } + self.stateMachine.enter(State.Loading.self) + } + .store(in: &disposeBag) + } + +} + +extension AutoCompleteViewModel { + enum SearchType { + case accounts + case hashtags + case emoji + case `default` + + public var mastodonSearchType: Mastodon.API.V2.Search.SearchType? { + switch self { + case .accounts: return .accounts + case .hashtags: return .hashtags + case .emoji: return nil + case .default: return .default + } + } + + init?(inputText: String) { + let prefix = inputText.first ?? Character("_") + switch prefix { + case "@": self = .accounts + case "#": self = .hashtags + case ":": self = .emoji + default: return nil + } + } + } +} diff --git a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift new file mode 100644 index 00000000..324f5354 --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift @@ -0,0 +1,156 @@ +// +// AutoCompleteTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import UIKit + +final class AutoCompleteTableViewCell: UITableViewCell { + + static let avatarImageSize = CGSize(width: 42, height: 42) + static let avatarImageCornerRadius: CGFloat = 4 + static let avatarToLabelSpacing: CGFloat = 12 + + let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 12 + return stackView + }() + + let contentStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fillEqually + return stackView + }() + + let avatarImageView = UIImageView() + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + label.textColor = Asset.Colors.Label.highlight.color + label.text = "Title" + return label + }() + + let subtitleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) + label.textColor = Asset.Colors.Label.secondary.color + label.text = "subtitle" + return label + }() + + override func prepareForReuse() { + super.prepareForReuse() + avatarImageView.af.cancelImageRequest() + avatarImageView.kf.cancelDownloadTask() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + // workaround for hitTest trigger highlighted issue + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if self.isHighlighted { + self.setHighlighted(false, animated: true) + } + } + } + +} + +extension AutoCompleteTableViewCell { + + private func _init() { + backgroundColor = .clear + + let topPaddingView = UIView() + let bottomPaddingView = UIView() + + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: contentView.topAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + topPaddingView.heightAnchor.constraint(equalToConstant: 12).priority(.defaultHigh), + ]) + + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + ]) + + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.heightAnchor.constraint(equalToConstant: AutoCompleteTableViewCell.avatarImageSize.height).priority(.required - 1), + avatarImageView.widthAnchor.constraint(equalToConstant: AutoCompleteTableViewCell.avatarImageSize.width).priority(.required - 1), + ]) + containerStackView.addArrangedSubview(contentStackView) + contentStackView.addArrangedSubview(titleLabel) + contentStackView.addArrangedSubview(subtitleLabel) + + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: contentStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0), + ]) + } + +} + +// MARK: - AvatarConfigurableView +extension AutoCompleteTableViewCell: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { avatarImageSize } + static var configurableAvatarImageCornerRadius: CGFloat { avatarImageCornerRadius } + var configurableAvatarImageView: UIImageView? { avatarImageView } + var configurableAvatarButton: UIButton? { nil } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AutoCompleteTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview() { + let cell = AutoCompleteTableViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 66)) + UIViewPreview() { + let cell = AutoCompleteTableViewCell() + return cell + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 66)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/AutoComplete/View/AutoCompleteTopChevronView.swift b/Mastodon/Scene/Compose/AutoComplete/View/AutoCompleteTopChevronView.swift new file mode 100644 index 00000000..de409dfc --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/View/AutoCompleteTopChevronView.swift @@ -0,0 +1,178 @@ +// +// AutoCompleteTopChevronView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import UIKit + +final class AutoCompleteTopChevronView: UIView { + + static let chevronSize = CGSize(width: 20, height: 12) + + private let shadowView = UIView() + private let shadowLayer = CAShapeLayer() + private let maskLayer = CAShapeLayer() + + var chevronMinX: CGFloat = 0 + var topViewBackgroundColor = Asset.Scene.Compose.background.color { + didSet { setNeedsLayout() } + } + var bottomViewBackgroundColor = Asset.Colors.Background.systemBackground.color { + didSet { setNeedsLayout() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AutoCompleteTopChevronView { + + var standardizedChevronMinX: CGFloat { + min(max(chevronMinX, 0), bounds.width - AutoCompleteTopChevronView.chevronSize.width) + } + + var edgePoints: [CGPoint] { + [ + CGPoint(x: standardizedChevronMinX, y: bounds.maxY), + CGPoint(x: standardizedChevronMinX + 0.5 * AutoCompleteTopChevronView.chevronSize.width, y: bounds.maxY - AutoCompleteTopChevronView.chevronSize.height), + CGPoint(x: standardizedChevronMinX + AutoCompleteTopChevronView.chevronSize.width, y: bounds.maxY), + CGPoint(x: bounds.width, y: bounds.maxY), + ] + } + +} + +extension AutoCompleteTopChevronView { + + private func _init() { + clipsToBounds = false + backgroundColor = .clear + isUserInteractionEnabled = false + + shadowView.translatesAutoresizingMaskIntoConstraints = false + addSubview(shadowView) + NSLayoutConstraint.activate([ + shadowView.topAnchor.constraint(equalTo: topAnchor), + shadowView.leadingAnchor.constraint(equalTo: leadingAnchor), + shadowView.trailingAnchor.constraint(equalTo: trailingAnchor), + shadowView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + shadowLayer.fillColor = topViewBackgroundColor.cgColor + shadowView.layer.addSublayer(shadowLayer) + } + + override func layoutSubviews() { + super.layoutSubviews() + + // 1. setup shadow with chevron + shadowLayer.path = { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: bounds.maxY)) + // bottom edge + for point in edgePoints { + path.addLine(to: point) + } + // up egde + path.addLine(to: CGPoint(x: bounds.maxX, y: 0)) + path.addLine(to: CGPoint(x: 0, y: 0)) + path.close() + return path.cgPath + }() + shadowLayer.fillColor = topViewBackgroundColor.cgColor + + // 2. setup mask to clip shadow + maskLayer.path = { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: bounds.maxY)) + // up edge + for point in edgePoints { + path.addLine(to: CGPoint(x: point.x, y: point.y - 2)) // move up 2pt + } + // bottom egde + path.addLine(to: CGPoint(x: bounds.maxX, y: 2 * bounds.maxY)) + path.addLine(to: CGPoint(x: 0, y: 2 * bounds.maxY)) + path.close() + return path.cgPath + }() + maskLayer.fillColor = UIColor.red.cgColor + shadowView.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.25)) + shadowView.layer.mask = maskLayer + + layer.mask = maskLayer + } + +} + +extension AutoCompleteTopChevronView { + func invertMask(in rect: CGRect) -> CAShapeLayer { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: bounds.maxY)) + // top edge + for point in edgePoints { + path.addLine(to: point) + } + // bottom edge + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: 0, y: rect.maxY)) + path.close() + + let mask = CAShapeLayer() + mask.fillColor = UIColor.red.cgColor + mask.path = path.cgPath + + return mask + } +} + + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AutoCompleteTopChevronView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let view = AutoCompleteTopChevronView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: 375), + view.heightAnchor.constraint(equalToConstant: 100), + ]) + view.chevronMinX = 10 + return view + } + .background(Color(Asset.Scene.Compose.background.color)) + .padding(20) + .previewLayout(.fixed(width: 375 + 40, height: 100 + 40)) + UIViewPreview(width: 375) { + let view = AutoCompleteTopChevronView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: 375), + view.heightAnchor.constraint(equalToConstant: 100), + ]) + view.chevronMinX = 10 + return view + } + .background(Color(Asset.Scene.Compose.background.color)) + .preferredColorScheme(.dark) + .padding(20) + .previewLayout(.fixed(width: 375 + 40, height: 100 + 40)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 5ec2a9ee..dfcb47fe 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -18,6 +18,7 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { let statusContentWarningEditorView = StatusContentWarningEditorView() + let textEditorViewContainerView = UIView() let textEditorView: TextEditorView = { let textEditorView = TextEditorView() textEditorView.font = .preferredFont(forTextStyle: .body) @@ -27,6 +28,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { textEditorView.keyboardType = .twitter return textEditorView }() + + // input + weak var textEditorViewChangeObserver: TextEditorViewChangeObserver? // output let composeContent = PassthroughSubject() @@ -75,13 +79,23 @@ extension ComposeStatusContentCollectionViewCell { statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - textEditorView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(textEditorView) + textEditorViewContainerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textEditorViewContainerView) NSLayoutConstraint.activate([ - textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10), + textEditorViewContainerView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + textEditorViewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + textEditorViewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor, constant: 10), + ]) + textEditorViewContainerView.preservesSuperviewLayoutMargins = true + + textEditorView.translatesAutoresizingMaskIntoConstraints = false + textEditorViewContainerView.addSubview(textEditorView) + NSLayoutConstraint.activate([ + textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), + textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor), + textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor), + textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) @@ -98,6 +112,10 @@ extension ComposeStatusContentCollectionViewCell { // MARK: - TextEditorViewChangeObserver extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + defer { + textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) guard changeResult.isTextChanged else { return } composeContent.send(textEditorView.text) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 2a0d46a1..b0d445d7 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -15,6 +15,8 @@ import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { + static let minAutoCompleteVisibleHeight: CGFloat = 100 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -40,9 +42,9 @@ final class ComposeViewController: UIViewController, NeedsDependency { return barButtonItem }() - let collectionView: UICollectionView = { + let collectionView: ComposeCollectionView = { let collectionViewLayout = ComposeViewController.createLayout() - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) @@ -93,6 +95,16 @@ final class ComposeViewController: UIViewController, NeedsDependency { return documentPickerController }() + private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { + let viewController = AutoCompleteViewController() + viewController.viewModel = AutoCompleteViewModel(context: context) + viewController.delegate = self + viewModel.customEmojiViewModel + .assign(to: \.value, on: viewController.viewModel.customEmojiViewModel) + .store(in: &disposeBag) + return viewController + }() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -166,6 +178,7 @@ extension ComposeViewController { dependency: self, customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: self, + textEditorViewChangeObserver: self, composeStatusAttachmentTableViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self, composeStatusNewPollOptionCollectionViewCellDelegate: self, @@ -181,26 +194,27 @@ extension ComposeViewController { dependency: self ) - // respond scrollView overlap change - //view.layoutIfNeeded() // update layout when keyboard show/dismiss - Publishers.CombineLatest4( - KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), - KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(), - viewModel.isCustomEmojiComposing.eraseToAnyPublisher() + let keyboardEventPublishers = Publishers.CombineLatest3( + KeyboardResponderService.shared.isShow, + KeyboardResponderService.shared.state, + KeyboardResponderService.shared.endFrame ) - .sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in + Publishers.CombineLatest3( + keyboardEventPublishers, + viewModel.isCustomEmojiComposing, + viewModel.autoCompleteInfo + ) + .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompleteInfo in guard let self = self else { return } - + + let (isShow, state, endFrame) = keyboardEvents let extraMargin: CGFloat = { - if self.view.safeAreaInsets.bottom == .zero { - // needs extra margin for zero inset device to workaround UIKit issue - return self.composeToolbarView.frame.height - } else { - // default some magic 16 extra margin - return 16 + var margin = self.composeToolbarView.frame.height + if autoCompleteInfo != nil { + margin += ComposeViewController.minAutoCompleteVisibleHeight } + return margin }() // update keyboard background color @@ -208,6 +222,17 @@ extension ComposeViewController { guard isShow, state == .dock else { self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + + if let superView = self.autoCompleteViewController.tableView.superview { + let autoCompleteTableViewBottomInset: CGFloat = { + let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil) + let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY + return max(0, padding) + }() + self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset + self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset + } + UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom if self.view.window != nil { @@ -219,29 +244,60 @@ extension ComposeViewController { } // isShow AND dock state self.systemKeyboardHeight = endFrame.height - + + // adjust inset for auto-complete + let autoCompleteTableViewBottomInset: CGFloat = { + let tableViewFrameInWindow = self.autoCompleteViewController.tableView.superview!.convert(self.autoCompleteViewController.tableView.frame, to: nil) + let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY + return max(0, padding) + }() + self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset + self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset + + // adjust inset for collectionView let contentFrame = self.view.convert(self.collectionView.frame, to: nil) - let padding = contentFrame.maxY - endFrame.minY + let padding = contentFrame.maxY + extraMargin - endFrame.minY guard padding > 0 else { self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin - UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom - self.view.layoutIfNeeded() - } + self.updateKeyboardBackground(isKeyboardDisplay: false) return } - self.collectionView.contentInset.bottom = padding + extraMargin - self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin + self.collectionView.contentInset.bottom = padding + self.collectionView.verticalScrollIndicatorInsets.bottom = padding UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = padding + self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.view.layoutIfNeeded() } self.updateKeyboardBackground(isKeyboardDisplay: isShow) }) .store(in: &disposeBag) + + // bind auto-complete + viewModel.autoCompleteInfo + .receive(on: DispatchQueue.main) + .sink { [weak self] info in + guard let self = self else { return } + guard let textEditorView = self.textEditorView() else { return } + if self.autoCompleteViewController.view.superview == nil { + self.autoCompleteViewController.view.frame = self.view.bounds + // add to container view. seealso: `viewDidLayoutSubviews()` + textEditorView.superview!.addSubview(self.autoCompleteViewController.view) + self.addChild(self.autoCompleteViewController) + self.autoCompleteViewController.didMove(toParent: self) + self.autoCompleteViewController.view.isHidden = true + self.collectionView.autoCompleteViewController = self.autoCompleteViewController + } + self.autoCompleteViewController.view.isHidden = info == nil + guard let info = info else { return } + let symbolBoundingRectInContainer = textEditorView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) + self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY + self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer + self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) + } + .store(in: &disposeBag) // bind publish bar button state viewModel.isPublishBarButtonItemEnabled @@ -382,6 +438,18 @@ extension ComposeViewController { viewModel.traitCollectionDidChangePublisher.send() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // pin autoCompleteViewController frame to window + if let containerView = autoCompleteViewController.view.superview { + let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: nil) + if viewFrameInWindow.origin.x != 0 { + autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x + } + } + } + } extension ComposeViewController { @@ -600,10 +668,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))") - // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect - // precondition :\B with following space - let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") + let highlightMatches = string.matches(pattern: MastodonRegex.highlightPattern) + let emojiMatches = string.matches(pattern: MastodonRegex.emojiPattern) // only accept http/https scheme let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") @@ -729,6 +795,115 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } +// MARK: - TextEditorViewChangeObserver +extension ComposeViewController: TextEditorViewChangeObserver { + + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + guard var autoCompeletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else { + viewModel.autoCompleteInfo.value = nil + return + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompeletion.toHighlightEndString), String(autoCompeletion.toCursorString)) + + // get layout text bounding rect + var glyphRange = NSRange() + textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange) + let textContainer = textEditorView.layoutManager.textContainers[0] + let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value + guard textBoundingRect.size != .zero else { + viewModel.autoCompleteRetryLayoutTimes.value += 1 + // avoid infinite loop + guard retryLayoutTimes < 3 else { return } + // needs retry calculate layout when the rect position changing + DispatchQueue.main.async { + self.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) + } + return + } + viewModel.autoCompleteRetryLayoutTimes.value = 0 + + // get symbol bounding rect + textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange) + let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // set bounding rect and trigger layout + autoCompeletion.textBoundingRect = textBoundingRect + autoCompeletion.symbolBoundingRect = symbolBoundingRect + viewModel.autoCompleteInfo.value = autoCompeletion + } + + struct AutoCompleteInfo { + // model + let inputText: Substring + // range + let symbolRange: Range + let symbolString: Substring + let toCursorRange: Range + let toCursorString: Substring + let toHighlightEndRange: Range + let toHighlightEndString: Substring + // geometry + var textBoundingRect: CGRect = .zero + var symbolBoundingRect: CGRect = .zero + } + + private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? { + let text = textEditorView.text + let cursorLocation = textEditorView.selectedRange.location + let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation) + guard cursorLocation > 0, !text.isEmpty else { return nil } + + let _highlighStartIndex: String.Index? = { + var index = text.index(text.startIndex, offsetBy: cursorLocation - 1) + while index > text.startIndex { + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } + index = text.index(before: index) + } + assert(index == text.startIndex) + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } else { + return nil + } + }() + + guard let highlighStartIndex = _highlighStartIndex else { return nil } + let scanRange = NSRange(highlighStartIndex..= cursorIndex else { return nil } + let symbolRange = highlighStartIndex.. let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make intial event emit let repliedToCellFrame = CurrentValueSubject(.zero) + let autoCompleteRetryLayoutTimes = CurrentValueSubject(0) + let autoCompleteInfo = CurrentValueSubject(nil) // output var diffableDataSource: UICollectionViewDiffableDataSource! @@ -59,8 +61,8 @@ final class ComposeViewModel { let characterCount = CurrentValueSubject(0) let collectionViewState = CurrentValueSubject(.fold) - // for hashtag: #' ' - // for mention: @' ' + // for hashtag: "# " + // for mention: "@ " private(set) var preInsertedContent: String? // custom emojis diff --git a/Mastodon/Scene/Compose/View/ComposeCollectionView.swift b/Mastodon/Scene/Compose/View/ComposeCollectionView.swift new file mode 100644 index 00000000..2dc03bb8 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ComposeCollectionView.swift @@ -0,0 +1,28 @@ +// +// ComposeCollectionView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-17. +// + +import UIKit + +final class ComposeCollectionView: UICollectionView { + + weak var autoCompleteViewController: AutoCompleteViewController? + + // adjust hitTest for auto-complete + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let autoCompleteViewController = autoCompleteViewController else { + return super.hitTest(point, with: event) + } + + let thePoint = convert(point, to: autoCompleteViewController.view) + if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) { + return hitView + } else { + return super.hitTest(point, with: event) + } + } + +} diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift index 02a45d92..fec205d6 100644 --- a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift @@ -30,7 +30,7 @@ extension CustomEmojiPickerInputViewModel { }) } - func append(customEmojiReplacableTextInput textInput: CustomEmojiReplacableTextInput) { + func append(customEmojiReplacableTextInput textInput: CustomEmojiReplaceableTextInput) { removeEmptyReferences() let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift index d1b8494d..fb6e5ec0 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -33,6 +33,7 @@ extension EmojiService { }() let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:]) + let emojiTrie = CurrentValueSubject?, Never>(nil) private var learnedEmoji: Set = Set() @@ -44,6 +45,19 @@ extension EmojiService { .map { Dictionary(grouping: $0, by: { $0.shortcode }) } .assign(to: \.value, on: emojiDict) .store(in: &disposeBag) + + emojis + .map { emojis -> Trie? in + guard !emojis.isEmpty else { return nil } + var trie: Trie = Trie() + for emoji in emojis { + let key = emoji.shortcode.lowercased() + trie.inserted(Array(key).slice, value: emoji) + } + return trie + } + .assign(to: \.value, on: emojiTrie) + .store(in: &disposeBag) } func emoji(shortcode: String) -> Mastodon.Entity.Emoji? { diff --git a/Mastodon/Service/EmojiService/Trie.swift b/Mastodon/Service/EmojiService/Trie.swift new file mode 100644 index 00000000..0eb490ae --- /dev/null +++ b/Mastodon/Service/EmojiService/Trie.swift @@ -0,0 +1,115 @@ +// +// AutoCompleteViewModel+Trie.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-18. +// + +import Foundation + +struct Trie { + let isElement: Bool + let valueSet: NSMutableSet + var children: [Element: Trie] +} + +extension Trie { + init() { + isElement = false + valueSet = NSMutableSet() + children = [:] + } + + init(_ key: ArraySlice, value: Any) { + if let (head, tail) = key.decomposed { + let children = [head: Trie(tail, value: value)] + self = Trie(isElement: false, valueSet: NSMutableSet(), children: children) + } else { + self = Trie(isElement: true, valueSet: NSMutableSet(object: value), children: [:]) + } + } +} + +extension Trie { + var elements: [[Element]] { + var result: [[Element]] = isElement ? [[]] : [] + for (key, value) in children { + result += value.elements.map { [key] + $0 } + } + return result + } +} + +//extension Array { +// var slice: ArraySlice { +// return ArraySlice(self) +// } +//} + +extension ArraySlice { + var decomposed: (Element, ArraySlice)? { + return isEmpty ? nil : (self[startIndex], self.dropFirst()) + } +} + +extension Trie { + func lookup(key: ArraySlice) -> Bool { + guard let (head, tail) = key.decomposed else { return isElement } + guard let subtrie = children[head] else { return false } + return subtrie.lookup(key: tail) + } + + func lookup(key: ArraySlice) -> Trie? { + guard let (head, tail) = key.decomposed else { return self } + guard let remainder = children[head] else { return nil } + return remainder.lookup(key: tail) + } +} + +extension Trie { + func complete(key: ArraySlice) -> [[Element]] { + return lookup(key: key)?.elements ?? [] + } +} + +extension Trie { + mutating func inserted(_ key: ArraySlice, value: Any) { + guard let (head, tail) = key.decomposed else { + self.valueSet.add(value) + return + } + + if var nextTrie = children[head] { + nextTrie.inserted(tail, value: value) + children[head] = nextTrie + } else { + children[head] = Trie(tail, value: value) + } + } +} + +extension Trie { + func passthrough(_ key: ArraySlice) -> [Trie] { + guard let (head, tail) = key.decomposed else { + return [self] + } + + let passthroughed = children[head]?.passthrough(tail) ?? [] + if isElement { + return passthroughed + [self] + } else { + return passthroughed + } + } + + var values: NSSet { + let valueSet = NSMutableSet(set: self.valueSet) + for (key, value) in children { + valueSet.addObjects(from: Array(value.values)) + } + + return valueSet + } + +} + diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 8dd978a8..81869153 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -73,3 +73,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } +#if DEBUG +class TestWindow: UIWindow { + + override func sendEvent(_ event: UIEvent) { + event.allTouches?.forEach({ (touch) in + let location = touch.location(in: self) + let view = hitTest(location, with: event) + print(view.debugDescription) + }) + + super.sendEvent(event) + } +} +#endif +