diff --git a/Localization/app.json b/Localization/app.json index 21304d564..f91e428ff 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 c3555be88..0ef6f6752 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 */; }; @@ -312,7 +314,7 @@ 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 /* AutoCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */; }; + 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 */; }; @@ -417,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 */; }; @@ -734,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 = ""; }; @@ -857,7 +867,7 @@ 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 /* AutoCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompletionViewController.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 = ""; }; @@ -962,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 = ""; }; @@ -1368,6 +1384,7 @@ DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, + DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */, ); path = Section; sourceTree = ""; @@ -1428,6 +1445,7 @@ DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DB6D9F8326358EEC008423CD /* SettingsItem.swift */, + DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, ); path = Item; sourceTree = ""; @@ -1760,6 +1778,7 @@ DB49A61325FF2C5600B98345 /* EmojiService.swift */, DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */, DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */, + DB040ED026538E3C00BEE9D8 /* Trie.swift */, ); path = EmojiService; sourceTree = ""; @@ -1777,6 +1796,7 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( + DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, @@ -1880,13 +1900,17 @@ path = MastodonSDK; sourceTree = ""; }; - DB6F5E36264E78EA009108F4 /* AutoCompletion */ = { + DB6F5E36264E78EA009108F4 /* AutoComplete */ = { isa = PBXGroup; children = ( - DB6F5E34264E78E7009108F4 /* AutoCompletionViewController.swift */, - DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */, + DBBF1DC02652402000E5B703 /* View */, + DBBF1DC326524D3100E5B703 /* Cell */, + DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */, + DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */, + DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */, + DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */, ); - path = AutoCompletion; + path = AutoComplete; sourceTree = ""; }; DB72602125E36A2500235243 /* ServerRules */ = { @@ -1909,7 +1933,7 @@ DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( - DB6F5E36264E78EA009108F4 /* AutoCompletion */, + DB6F5E36264E78EA009108F4 /* AutoComplete */, DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, @@ -2319,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 = ( @@ -2863,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 */, @@ -3015,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 */, @@ -3036,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 */, @@ -3063,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 */, @@ -3075,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 */, @@ -3086,7 +3131,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, - DB6F5E35264E78E7009108F4 /* AutoCompletionViewController.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 */, @@ -3154,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 */, @@ -3161,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 */, @@ -3188,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 */, diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index cad5ea618..296feea28 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" } }, diff --git a/Mastodon/Diffiable/Item/AutoCompleteItem.swift b/Mastodon/Diffiable/Item/AutoCompleteItem.swift new file mode 100644 index 000000000..5fac45f4b --- /dev/null +++ b/Mastodon/Diffiable/Item/AutoCompleteItem.swift @@ -0,0 +1,54 @@ +// +// 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) + 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 000000000..0d5caf872 --- /dev/null +++ b/Mastodon/Diffiable/Section/AutoCompleteSection.swift @@ -0,0 +1,89 @@ +// +// 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 + 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 33465fc3f..0c4ae8832 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -262,7 +262,7 @@ extension ComposeStatusSection { } -protocol CustomEmojiReplacableTextInput: AnyObject { +protocol CustomEmojiReplaceableTextInput: AnyObject { var inputView: UIView? { get set } func reloadInputViews() @@ -273,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) } @@ -290,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/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8f6c13f9e..5d324c40d 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 index 43aad4dfe..c390ea513 100644 --- a/Mastodon/Helper/MastodonRegex.swift +++ b/Mastodon/Helper/MastodonRegex.swift @@ -17,4 +17,9 @@ enum MastodonRegex { /// 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 c9ed556c3..2d3993618 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 c9ed556c3..2d3993618 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 000000000..4d6e3a031 --- /dev/null +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift @@ -0,0 +1,105 @@ +// +// AutoCompleteViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-14. +// + +import os.log +import UIKit +import Combine + +final class AutoCompleteViewController: UIViewController { + + static let chevronViewHeight: CGFloat = 24 + + var viewModel: AutoCompleteViewModel! + var disposeBag = Set() + + 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.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) + } + + 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 000000000..742188726 --- /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 000000000..f57328f70 --- /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 000000000..dc6ab1789 --- /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 000000000..324f5354e --- /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 000000000..de409dfc9 --- /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/AutoCompletion/AutoCompleteTopChevronView.swift b/Mastodon/Scene/Compose/AutoCompletion/AutoCompleteTopChevronView.swift deleted file mode 100644 index 61f247b46..000000000 --- a/Mastodon/Scene/Compose/AutoCompletion/AutoCompleteTopChevronView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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) - - var chevronMinX: CGFloat = 0 - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - func _init() { - backgroundColor = Asset.Colors.Background.secondarySystemBackground.color - } - - override func draw(_ rect: CGRect) { - let bezierPath = UIBezierPath() - let bottomY = rect.height - let topY = 0 - let count = Int(ceil(rect.width / CGFloat(SawToothView.widthUint))) - bezierPath.move(to: CGPoint(x: 0, y: bottomY)) - for n in 0 ..< count { - bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 0.5) * Double(SawToothView.widthUint)), y: CGFloat(topY))) - bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 1) * Double(SawToothView.widthUint)), y: CGFloat(bottomY))) - } - bezierPath.addLine(to: CGPoint(x: 0, y: bottomY)) - bezierPath.close() - Asset.Colors.Background.systemBackground.color.setFill() - bezierPath.fill() - bezierPath.lineWidth = 0 - bezierPath.stroke() - } - -} diff --git a/Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift b/Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift deleted file mode 100644 index aadec1cd7..000000000 --- a/Mastodon/Scene/Compose/AutoCompletion/AutoCompletionViewController.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// AutoCompletionViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-5-14. -// - -import UIKit - -final class AutoCompletionViewController: UIViewController { - - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - -} - -extension AutoCompletionViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .red.withAlphaComponent(0.5) - } - -} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index a738fe210..c895566a8 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -15,7 +15,7 @@ import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { - static let minAutoCompletionVisibleHeight: CGFloat = 100 + static let minAutoCompleteVisibleHeight: CGFloat = 100 weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -42,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)) @@ -95,9 +95,12 @@ final class ComposeViewController: UIViewController, NeedsDependency { return documentPickerController }() - private(set) lazy var autoCompletionViewController: AutoCompletionViewController = { - let viewController = AutoCompletionViewController() - viewController.context = context + private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { + let viewController = AutoCompleteViewController() + viewController.viewModel = AutoCompleteViewModel(context: context) + viewModel.customEmojiViewModel + .assign(to: \.value, on: viewController.viewModel.customEmojiViewModel) + .store(in: &disposeBag) return viewController }() @@ -199,16 +202,16 @@ extension ComposeViewController { Publishers.CombineLatest3( keyboardEventPublishers, viewModel.isCustomEmojiComposing, - viewModel.autoCompletion + viewModel.autoCompleteInfo ) - .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompletion in + .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompleteInfo in guard let self = self else { return } let (isShow, state, endFrame) = keyboardEvents let extraMargin: CGFloat = { var margin = self.composeToolbarView.frame.height - if autoCompletion != nil { - margin += ComposeViewController.minAutoCompletionVisibleHeight + if autoCompleteInfo != nil { + margin += ComposeViewController.minAutoCompleteVisibleHeight } return margin }() @@ -218,6 +221,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 { @@ -229,7 +243,17 @@ 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 + extraMargin - endFrame.minY guard padding > 0 else { @@ -251,22 +275,26 @@ extension ComposeViewController { .store(in: &disposeBag) // bind auto-complete - viewModel.autoCompletion + viewModel.autoCompleteInfo .receive(on: DispatchQueue.main) - .sink { [weak self] autoCompletion in + .sink { [weak self] info in guard let self = self else { return } guard let textEditorView = self.textEditorView() else { return } - if self.autoCompletionViewController.view.superview == nil { - self.autoCompletionViewController.view.frame = self.view.bounds - self.autoCompletionViewController.willMove(toParent: self) + if self.autoCompleteViewController.view.superview == nil { + self.autoCompleteViewController.view.frame = self.view.bounds // add to container view. seealso: `viewDidLayoutSubviews()` - textEditorView.superview!.addSubview(self.autoCompletionViewController.view) - self.autoCompletionViewController.didMove(toParent: self) - self.autoCompletionViewController.view.isHidden = true + 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.autoCompletionViewController.view.isHidden = autoCompletion == nil - guard let autoCompletion = autoCompletion else { return } - self.autoCompletionViewController.view.frame.origin.y = autoCompletion.textBoundingRect.maxY + 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) @@ -412,11 +440,11 @@ extension ComposeViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - // pin autoCompletionViewController frame to window - if let containerView = autoCompletionViewController.view.superview { - let viewFrameInWindow = containerView.convert(autoCompletionViewController.view.frame, to: nil) + // 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 { - autoCompletionViewController.view.frame.origin.x = -viewFrameInWindow.origin.x + autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x } } } @@ -770,20 +798,20 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { extension ComposeViewController: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { - guard var autoCompeletion = ComposeViewController.scanAutoCompletion(textEditorView: textEditorView) else { - viewModel.autoCompletion.value = nil + 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 textBoundingRectInWindow = textEditorView.convert(textBoundingRect, to: nil) let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value - guard textBoundingRectInWindow.size != .zero else { + guard textBoundingRect.size != .zero else { viewModel.autoCompleteRetryLayoutTimes.value += 1 // avoid infinite loop guard retryLayoutTimes < 3 else { return } @@ -794,22 +822,33 @@ extension ComposeViewController: TextEditorViewChangeObserver { 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 - viewModel.autoCompletion.value = autoCompeletion + autoCompeletion.symbolBoundingRect = symbolBoundingRect + viewModel.autoCompleteInfo.value = autoCompeletion } - struct AutoCompletion { + 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 scanAutoCompletion(textEditorView: TextEditorView) -> AutoCompletion? { + private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? { let text = textEditorView.text let cursorLocation = textEditorView.selectedRange.location let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation) @@ -819,14 +858,14 @@ extension ComposeViewController: TextEditorViewChangeObserver { var index = text.index(text.startIndex, offsetBy: cursorLocation - 1) while index > text.startIndex { let char = text[index] - if char == "@" || char == "#" { + if char == "@" || char == "#" || char == ":" { return index } index = text.index(before: index) } assert(index == text.startIndex) let char = text[index] - if char == "@" || char == "#" { + if char == "@" || char == "#" || char == ":" { return index } else { return nil @@ -836,23 +875,30 @@ extension ComposeViewController: TextEditorViewChangeObserver { guard let highlighStartIndex = _highlighStartIndex else { return nil } let scanRange = NSRange(highlighStartIndex..= cursorIndex else { return nil } + let symbolRange = highlighStartIndex..(Void()) // use CurrentValueSubject to make intial event emit let repliedToCellFrame = CurrentValueSubject(.zero) let autoCompleteRetryLayoutTimes = CurrentValueSubject(0) - let autoCompletion = CurrentValueSubject(nil) + let autoCompleteInfo = CurrentValueSubject(nil) // output var diffableDataSource: UICollectionViewDiffableDataSource! @@ -61,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 000000000..2dc03bb84 --- /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 02a45d922..fec205d6f 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 d1b8494d7..fb6e5ec01 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 000000000..0eb490ae6 --- /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 8dd978a8c..81869153f 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 +