From 2cfc0cf28adda2f8c99715cc7cd0f362e8fe0800 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 11 Oct 2020 22:14:45 -0400 Subject: [PATCH] Add Compose screen mention, hashtag, emoji completion Closes #10 --- Pachyderm/Client.swift | 25 +- Pachyderm/Model/SearchResultType.swift | 15 + Tusker.xcodeproj/project.pbxproj | 24 ++ Tusker/Controllers/MastodonController.swift | 22 +- Tusker/FuzzyMatcher.swift | 62 ++++ .../Compose/ComposeAutocompleteView.swift | 350 ++++++++++++++++++ .../Compose/ComposeAvatarImageView.swift | 4 +- .../Compose/ComposeCurrentAccount.swift | 3 + .../Compose/ComposeHostingController.swift | 4 +- Tusker/Screens/Compose/ComposeReplyView.swift | 4 + Tusker/Screens/Compose/ComposeUIState.swift | 11 + Tusker/Screens/Compose/ComposeView.swift | 22 ++ .../Screens/Compose/MainComposeTextView.swift | 133 ++++++- Tusker/Views/AccountDisplayNameLabel.swift | 7 +- Tusker/Views/CustomEmojiImageView.swift | 59 +++ Tusker/Views/MaybeLazyStack.swift | 59 +++ TuskerTests/FuzzyMatcherTests.swift | 25 ++ 17 files changed, 813 insertions(+), 16 deletions(-) create mode 100644 Pachyderm/Model/SearchResultType.swift create mode 100644 Tusker/FuzzyMatcher.swift create mode 100644 Tusker/Screens/Compose/ComposeAutocompleteView.swift create mode 100644 Tusker/Views/CustomEmojiImageView.swift create mode 100644 Tusker/Views/MaybeLazyStack.swift create mode 100644 TuskerTests/FuzzyMatcherTests.swift diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index 6fd43c60..7595dc54 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -52,10 +52,11 @@ public class Client { self.session = session } - public func run(_ request: Request, completion: @escaping Callback) { + @discardableResult + public func run(_ request: Request, completion: @escaping Callback) -> URLSessionTask? { guard let request = createURLRequest(request: request) else { completion(.failure(Error.invalidRequest)) - return + return nil } let task = session.dataTask(with: request) { data, response, error in @@ -83,6 +84,7 @@ public class Client { completion(.success(result, pagination)) } task.resume() + return task } func createURLRequest(request: Request) -> URLRequest? { @@ -276,12 +278,12 @@ public class Client { } // MARK: - Search - public static func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request { + public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request { return Request(method: .get, path: "/api/v2/search", queryParameters: [ "q" => query, "resolve" => resolve, - "limit" => limit - ]) + "limit" => limit, + ] + "types" => types?.map { $0.rawValue }) } // MARK: - Statuses @@ -314,13 +316,24 @@ public class Client { } - // MARK: Bookmarks + // MARK: - Bookmarks public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> { var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks") request.range = range return request } + // MARK: - Trends + public static func getTrends(limit: Int? = nil) -> Request<[Hashtag]> { + let parameters: [Parameter] + if let limit = limit { + parameters = ["limit" => limit] + } else { + parameters = [] + } + return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters) + } + } extension Client { diff --git a/Pachyderm/Model/SearchResultType.swift b/Pachyderm/Model/SearchResultType.swift new file mode 100644 index 00000000..7c3eac95 --- /dev/null +++ b/Pachyderm/Model/SearchResultType.swift @@ -0,0 +1,15 @@ +// +// SearchResultType.swift +// Pachyderm +// +// Created by Shadowfacts on 10/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation + +public enum SearchResultType: String { + case accounts + case hashtags + case statuses +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 787eb0c7..0a3bc3c1 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -263,6 +263,12 @@ D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; }; D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; }; + D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; }; + D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426802532814100C02E1C /* MaybeLazyStack.swift */; }; + D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; }; + D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; }; + D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; }; + D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B8253382B300C02E1C /* SearchResultType.swift */; }; D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; }; D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; @@ -591,6 +597,12 @@ D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = ""; }; D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = ""; }; D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = ""; }; + D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = ""; }; + D6E426802532814100C02E1C /* MaybeLazyStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeLazyStack.swift; sourceTree = ""; }; + D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = ""; }; + D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = ""; }; + D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = ""; }; + D6E426B8253382B300C02E1C /* SearchResultType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultType.swift; sourceTree = ""; }; D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = ""; }; D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = ""; }; D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = ""; }; @@ -753,6 +765,7 @@ D61099F82145698900432DC2 /* Relationship.swift */, D61099FA214569F600432DC2 /* Report.swift */, D61099FC21456A1D00432DC2 /* SearchResults.swift */, + D6E426B8253382B300C02E1C /* SearchResultType.swift */, D61099FE21456A4C00432DC2 /* Status.swift */, D6285B4E21EA695800FE4B39 /* StatusContentType.swift */, D6109A10214607D500432DC2 /* Timeline.swift */, @@ -987,6 +1000,7 @@ D622757724EE133700B82A16 /* ComposeAssetPicker.swift */, D62275A524F1C81800B82A16 /* ComposeReplyView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, + D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, ); path = Compose; sourceTree = ""; @@ -1259,6 +1273,8 @@ D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */, D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, + D6E426802532814100C02E1C /* MaybeLazyStack.swift */, + D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D67C57A721E2649B00C3118B /* Account Detail */, D626494023C122C800612E6E /* Asset Picker */, D61959D0241E842400A37B8E /* Draft Cell */, @@ -1341,6 +1357,7 @@ D64D8CA82463B494006B0BAA /* CachedDictionary.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, + D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */, D67B506B250B28FF00FAECFB /* Vendor */, D6F1F84E2193B9BE00F5FE67 /* Caching */, D6757A7A2157E00100721E32 /* XCallbackURL */, @@ -1366,6 +1383,7 @@ children = ( D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */, D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */, + D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */, D6D4DDE6212518A200E1C4BB /* Info.plist */, ); path = TuskerTests; @@ -1696,6 +1714,7 @@ D61099CB2144B20500432DC2 /* Request.swift in Sources */, D6109A05214572BF00432DC2 /* Scope.swift in Sources */, D6109A11214607D500432DC2 /* Timeline.swift in Sources */, + D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */, D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */, D61099E7214561FF00432DC2 /* Attachment.swift in Sources */, D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */, @@ -1795,6 +1814,7 @@ D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, + D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */, D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */, @@ -1849,6 +1869,7 @@ D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */, D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */, D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */, + D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */, D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, @@ -1910,11 +1931,13 @@ D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */, + D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */, D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */, D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */, + D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, @@ -1928,6 +1951,7 @@ buildActionMask = 2147483647; files = ( D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, + D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index 308e3986..010b6db8 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -44,6 +44,7 @@ class MastodonController: ObservableObject { @Published private(set) var account: Account! @Published private(set) var instance: Instance! + private(set) var customEmojis: [Emoji]? var loggedIn: Bool { accountInfo != nil @@ -56,8 +57,9 @@ class MastodonController: ObservableObject { self.transient = transient } - func run(_ request: Request, completion: @escaping Client.Callback) { - client.run(request, completion: completion) + @discardableResult + func run(_ request: Request, completion: @escaping Client.Callback) -> URLSessionTask? { + return client.run(request, completion: completion) } func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) { @@ -128,4 +130,20 @@ class MastodonController: ObservableObject { } } + func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { + if let emojis = self.customEmojis { + completion(emojis) + } else { + let request = Client.getCustomEmoji() + run(request) { (response) in + if case let .success(emojis, _) = response { + self.customEmojis = emojis + completion(emojis) + } else { + completion([]) + } + } + } + } + } diff --git a/Tusker/FuzzyMatcher.swift b/Tusker/FuzzyMatcher.swift new file mode 100644 index 00000000..32c397e9 --- /dev/null +++ b/Tusker/FuzzyMatcher.swift @@ -0,0 +1,62 @@ +// +// FuzzyMatcher.swift +// Tusker +// +// Created by Shadowfacts on 10/10/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation + +struct FuzzyMatcher { + + private init() {} + + /// Rudimentary string fuzzy matching algorithm. + /// + /// Operates on UTF-8 code points, so attempting to match strings which include characters composed of + /// multiple code points may produce unexpected results. + /// + /// Scoring is as follows: + /// +2 points for every char in `pattern` that occurs in `str` sequentially + /// -2 points for every char in `pattern` that does not occur in `str` sequentially + /// -1 point for every char in `str` skipped between matching chars from the `pattern` + static func match(pattern: String, str: String) -> (matched: Bool, score: Int) { + let pattern = pattern.lowercased() + let str = str.lowercased() + + var patternIndex = pattern.utf8.startIndex + var lastStrMatchIndex: String.UTF8View.Index? + var strIndex = str.utf8.startIndex + + var score = 0 + + while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex { + let patternChar = pattern.utf8[patternIndex] + let strChar = str.utf8[strIndex] + if patternChar == strChar { + let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex) + if distance > 1 { + score -= distance - 1 + } + + patternIndex = pattern.utf8.index(after: patternIndex) + lastStrMatchIndex = strIndex + strIndex = str.utf8.index(after: strIndex) + + score += 2 + } else { + strIndex = str.utf8.index(after: strIndex) + + if strIndex >= str.utf8.endIndex { + patternIndex = pattern.utf8.index(after: patternIndex) + strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex) + score -= 2 + } + } + } + + return (score > 0, score) + } + +} diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift new file mode 100644 index 00000000..7cf05b00 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeAutocompleteView.swift @@ -0,0 +1,350 @@ +// +// ComposeAutocompleteView.swift +// Tusker +// +// Created by Shadowfacts on 10/10/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import CoreData +import Pachyderm + +struct ComposeAutocompleteView: View { + let autocompleteState: ComposeUIState.AutocompleteState + + @Environment(\.colorScheme) var colorScheme: ColorScheme + + private var backgroundColor: Color { + Color(white: colorScheme == .light ? 0.98 : 0.15) + } + + private var borderColor: Color { + Color(white: colorScheme == .light ? 0.85 : 0.25) + } + + var body: some View { + suggestionsView + // animate changes of the scroll view items + .animation(.default) + .background(backgroundColor) + .overlay(borderColor.frame(height: 0.5), alignment: .top) + } + + @ViewBuilder + private var suggestionsView: some View { + switch autocompleteState { + case .mention(_): + ComposeAutocompleteMentionsView() + case .emoji(_): + ComposeAutocompleteEmojisView() + case .hashtag(_): + ComposeAutocompleteHashtagsView() + } + } +} + +fileprivate extension View { + @ViewBuilder + func iOS13OnlyPadding() -> some View { + // on iOS 13, if the scroll view content's height changes after the view is added to the hierarchy, + // it doesn't appear on screen until interactive keyboard dismissal is started and then cancelled :S + if #available(iOS 14.0, *) { + self + } else { + self.frame(height: 46) + } + } +} + +struct ComposeAutocompleteMentionsView: View { + @EnvironmentObject private var mastodonController: MastodonController + @EnvironmentObject private var uiState: ComposeUIState + @ObservedObject private var preferences = Preferences.shared + + // can't use AccountProtocol because of associated type requirements + @State private var accounts: [EitherAccount] = [] + + @State private var searchRequest: URLSessionTask? + + var body: some View { + ScrollView(.horizontal) { + // can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang + HStack(spacing: 8) { + ForEach(accounts, id: \.id) { (account) in + Button { + uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct) ") + } label: { + HStack(spacing: 4) { + ComposeAvatarImageView(url: account.avatar) + .frame(width: 30, height: 30) + .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30) + + VStack(alignment: .leading) { + switch account { + case let .pachyderm(underlying): + AccountDisplayNameLabel(account: underlying, fontSize: 14) + .foregroundColor(Color(UIColor.label)) + case let .coreData(underlying): + AccountDisplayNameLabel(account: underlying, fontSize: 14) + .foregroundColor(Color(UIColor.label)) + } + + Text(verbatim: "@\(account.acct)") + .font(.system(size: 12)) + .foregroundColor(Color(UIColor.label)) + } + } + } + .frame(height: 30) + .padding(.vertical, 8) + .animation(.linear(duration: 0.1)) + } + + Spacer() + } + .padding(.horizontal, 8) + .iOS13OnlyPadding() + } + .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) + .onDisappear { + searchRequest?.cancel() + } + } + + private func queryChanged(_ state: ComposeUIState.AutocompleteState?) { + guard case let .mention(query) = state, + !query.isEmpty else { + accounts = [] + return + } + + let localSearchWorkItem = DispatchWorkItem { + // todo: there's got to be something more efficient than this :/ + let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" + let request: NSFetchRequest = AccountMO.fetchRequest() + request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery) + + if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) { + loadAccounts(results.map { .coreData($0) }, query: query) + } + } + + // we only want to search locally if the search API call takes more than .25sec or it fails + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem) + + if let oldRequest = searchRequest { + oldRequest.cancel() + } + + let apiRequest = Client.searchForAccount(query: query) + searchRequest = mastodonController.run(apiRequest) { (response) in + guard case let .success(accounts, _) = response else { return } + + localSearchWorkItem.cancel() + + // if the query has changed, don't bother loading the now-outdated results + if case .mention(query) = uiState.autocompleteState { + self.loadAccounts(accounts.map { .pachyderm($0) }, query: query) + } + } + } + + private func loadAccounts(_ accounts: [EitherAccount], query: String) { + // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself + let ignoreDomain = !query.contains("@") + + self.accounts = + accounts.map { (account: EitherAccount) -> (EitherAccount, (matched: Bool, score: Int)) in + let fuzzyStr = ignoreDomain ? String(account.acct.split(separator: "@").first!) : account.acct + let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) + return res + } + .filter(\.1.matched) + // todo: it would be nice to prioritize followee/follower accounts, but relationships aren't cached + .sorted { $0.1.score > $1.1.score } + .map(\.0) + } + + private enum EitherAccount { + case pachyderm(Account) + case coreData(AccountMO) + + var id: String { + switch self { + case let .pachyderm(account): + return account.id + case let .coreData(account): + return account.id + } + } + + var acct: String { + switch self { + case let .pachyderm(account): + return account.acct + case let .coreData(account): + return account.acct + } + } + + var avatar: URL { + switch self { + case let .pachyderm(account): + return account.avatar + case let .coreData(account): + return account.avatar + } + } + } +} + +struct ComposeAutocompleteEmojisView: View { + @EnvironmentObject private var mastodonController: MastodonController + @EnvironmentObject private var uiState: ComposeUIState + + @State private var emojis: [Emoji] = [] + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(emojis, id: \.shortcode) { (emoji) in + Button { + uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode): ") + } label: { + HStack(spacing: 4) { + CustomEmojiImageView(emoji: emoji) + .frame(height: 30) + Text(verbatim: ":\(emoji.shortcode):") + .foregroundColor(Color(UIColor.label)) + } + } + .frame(height: 30) + .padding(.vertical, 8) + .animation(.linear(duration: 0.1)) + } + + Spacer() + } + .padding(.horizontal, 8) + .iOS13OnlyPadding() + } + .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) + } + + private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { + guard case let .emoji(query) = autocompleteState, + !query.isEmpty else { + emojis = [] + return + } + + mastodonController.getCustomEmojis { (emojis) in + guard case .emoji(query) = self.uiState.autocompleteState else { return } + + self.emojis = + emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in + (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) + } + .filter(\.1.matched) + .sorted { $0.1.score > $1.1.score } + .map(\.0) + } + } +} + +struct ComposeAutocompleteHashtagsView: View { + @EnvironmentObject private var mastodonController: MastodonController + @EnvironmentObject private var uiState: ComposeUIState + + @State private var hashtags: [Hashtag] = [] + @State private var trendingRequest: URLSessionTask? + @State private var searchRequest: URLSessionTask? + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(hashtags, id: \.name) { (hashtag) in + Button { + uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name) ") + } label: { + Text(verbatim: "#\(hashtag.name)") + .foregroundColor(Color(UIColor.label)) + } + .frame(height: 30) + .padding(.vertical, 8) + .animation(.linear(duration: 0.1)) + } + + Spacer() + } + .padding(.horizontal, 8) + .iOS13OnlyPadding() + } + .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) + .onDisappear { + trendingRequest?.cancel() + } + } + + private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { + guard case let .hashtag(query) = autocompleteState, + !query.isEmpty else { + hashtags = [] + return + } + + let onlySavedTagsWorkItem = DispatchWorkItem { + self.updateHashtags(searchResults: [], trendingTags: [], query: query) + } + + // we only want to do the local-only search if the trends API call takes more than .25sec or it fails + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem) + + var trendingTags: [Hashtag] = [] + var searchedTags: [Hashtag] = [] + + let group = DispatchGroup() + + group.enter() + trendingRequest = mastodonController.run(Client.getTrends()) { (response) in + defer { group.leave() } + guard case let .success(trends, _) = response else { return } + trendingTags = trends + } + + group.enter() + searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in + defer { group.leave() } + guard case let .success(results, _) = response else { return } + searchedTags = results.hashtags + } + + group.notify(queue: .main) { + onlySavedTagsWorkItem.cancel() + + // if the query has changed, don't bother loading the now-outdated results + if case .hashtag(query) = self.uiState.autocompleteState { + self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query) + } + } + } + + private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { + let savedTags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) + + hashtags = (searchResults + savedTags + trendingTags) + .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in + return (tag, FuzzyMatcher.match(pattern: query, str: tag.name)) + } + .filter(\.1.matched) + .sorted { $0.1.score > $1.1.score } + .map(\.0) + } +} + +struct ComposeAutocompleteView_Previews: PreviewProvider { + static var previews: some View { + ComposeAutocompleteView(autocompleteState: .mention("shadowfacts")) + } +} diff --git a/Tusker/Screens/Compose/ComposeAvatarImageView.swift b/Tusker/Screens/Compose/ComposeAvatarImageView.swift index 292c206b..8004869d 100644 --- a/Tusker/Screens/Compose/ComposeAvatarImageView.swift +++ b/Tusker/Screens/Compose/ComposeAvatarImageView.swift @@ -17,8 +17,6 @@ struct ComposeAvatarImageView: View { var body: some View { image .resizable() - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) .conditionally(url != nil) { $0.onAppear(perform: self.loadImage) } @@ -27,7 +25,7 @@ struct ComposeAvatarImageView: View { private var image: Image { if let avatarImage = avatarImage { - return Image(uiImage: avatarImage) + return Image(uiImage: avatarImage).renderingMode(.original) } else { return placeholderImage } diff --git a/Tusker/Screens/Compose/ComposeCurrentAccount.swift b/Tusker/Screens/Compose/ComposeCurrentAccount.swift index 1a723d40..41e393d1 100644 --- a/Tusker/Screens/Compose/ComposeCurrentAccount.swift +++ b/Tusker/Screens/Compose/ComposeCurrentAccount.swift @@ -11,6 +11,7 @@ import Pachyderm struct ComposeCurrentAccount: View { @EnvironmentObject var mastodonController: MastodonController + @ObservedObject private var preferences = Preferences.shared var account: Account? { mastodonController.account @@ -19,6 +20,8 @@ struct ComposeCurrentAccount: View { var body: some View { HStack(alignment: .top) { ComposeAvatarImageView(url: account?.avatar) + .frame(width: 50, height: 50) + .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) .accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar")) if let id = account?.id, diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 5a966577..954da8eb 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -164,7 +164,9 @@ class ComposeHostingController: UIHostingController { let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect // temporarily reset add'l safe area insets so we can access the default inset additionalSafeAreaInsets = .zero - keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height + // there are a few extra points that come from somewhere, it seems to be four + // and without it, the autocomplete suggestions are cut off :S + keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height + 4 updateAdditionalSafeAreaInsets() } } diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift index 0be41d93..81697a0f 100644 --- a/Tusker/Screens/Compose/ComposeReplyView.swift +++ b/Tusker/Screens/Compose/ComposeReplyView.swift @@ -15,6 +15,8 @@ struct ComposeReplyView: View { @State private var contentHeight: CGFloat? + @ObservedObject private var preferences = Preferences.shared + private let horizSpacing: CGFloat = 8 var body: some View { @@ -55,6 +57,8 @@ struct ComposeReplyView: View { scrollOffset += stackPadding let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding) return ComposeAvatarImageView(url: status.account.avatar) + .frame(width: 50, height: 50) + .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) .offset(x: 0, y: offset) } diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift index 0b65e2ef..5a3bcae9 100644 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ b/Tusker/Screens/Compose/ComposeUIState.swift @@ -27,9 +27,12 @@ class ComposeUIState: ObservableObject { @Published var draft: Draft @Published var isShowingSaveDraftSheet = false @Published var attachmentsMissingDescriptions = Set() + @Published var autocompleteState: AutocompleteState? = nil var composeDrawingMode: ComposeDrawingMode? + weak var autocompleteHandler: ComposeAutocompleteHandler? + init(draft: Draft) { self.draft = draft } @@ -42,3 +45,11 @@ extension ComposeUIState { case edit(id: UUID) } } + +extension ComposeUIState { + enum AutocompleteState { + case mention(String) + case emoji(String) + case hashtag(String) + } +} diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index 7524dd53..07bbf63d 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -65,6 +65,10 @@ struct ComposeView: View { // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 WrappedProgressView(value: postProgress, total: postTotalProgress) + + if let state = uiState.autocompleteState { + autocompleteSuggestions(state: state) + } } .onAppear(perform: self.didAppear) .navigationBarTitle("Compose") @@ -78,6 +82,24 @@ struct ComposeView: View { } } + @ViewBuilder + func autocompleteSuggestions(state: ComposeUIState.AutocompleteState) -> some View { + // on iOS 13, the transition causes SwiftUI to hang on the main thread when the view appears, so it's disabled + if #available(iOS 14.0, *) { + VStack(spacing: 0) { + Spacer() + ComposeAutocompleteView(autocompleteState: state) + } + .transition(.move(edge: .bottom)) + .animation(.default) + } else { + VStack(spacing: 0) { + Spacer() + ComposeAutocompleteView(autocompleteState: state) + } + } + } + func mainStack(outerMinY: CGFloat) -> some View { VStack(alignment: .leading, spacing: 8) { if let id = draft.inReplyToID, diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 7ec9a423..917a88ec 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -66,6 +66,8 @@ struct MainComposeWrappedTextView: UIViewRepresentable { textView.textContainer.lineBreakMode = .byWordWrapping context.coordinator.textView = textView + uiState.autocompleteHandler = context.coordinator + let visibilityAction: Selector? if #available(iOS 14.0, *) { visibilityAction = nil @@ -167,7 +169,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable { return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) } - class Coordinator: NSObject, UITextViewDelegate { + class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler { weak var textView: UITextView? var text: Binding var didChange: (UITextView) -> Void @@ -182,6 +184,8 @@ struct MainComposeWrappedTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text didChange(textView) + + updateAutocompleteState() } @objc func formatButtonPressed(_ sender: UIBarButtonItem) { @@ -213,5 +217,132 @@ struct MainComposeWrappedTextView: UIViewRepresentable { @objc func keyboardDidHide(_ notification: Foundation.Notification) { uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification) } + + func textViewDidEndEditing(_ textView: UITextView) { + updateAutocompleteState() + } + + func textViewDidBeginEditing(_ textView: UITextView) { + updateAutocompleteState() + } + + func textViewDidChangeSelection(_ textView: UITextView) { + updateAutocompleteState() + } + + func autocomplete(with string: String) { + guard let textView = textView, + let text = textView.text, + let (lastWordStartIndex, _) = findAutocompleteLastWord() else { + return + } + + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) + + textView.text.replaceSubrange(lastWordStartIndex.. text.startIndex { + // if the character before the "word" beginning is a valid part of a "word", + // we aren't able to autocomplete + let c = text[text.index(before: lastWordStartIndex)] + if isPermittedForAutocomplete(c) || triggerChars.contains(c) { + uiState.autocompleteState = nil + return + } + } + + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) + + if lastWordStartIndex >= text.startIndex { + let lastWord = text[lastWordStartIndex.. Bool { + return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" + } + + private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? { + guard let textView = textView, + textView.isFirstResponder, + textView.selectedRange.length == 0, + textView.selectedRange.upperBound > 0, + let text = textView.text, + text.count > 0 else { + return nil + } + + let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) + + var lastWordStartIndex = text.index(before: characterBeforeCursorIndex) + var foundFirstAtSign = false + while true { + let c = textView.text[lastWordStartIndex] + + if !isPermittedForAutocomplete(c) { + if foundFirstAtSign { + if c != "@" { + // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it + lastWordStartIndex = text.index(after: lastWordStartIndex) + } + break + } else { + if c == "@" { + foundFirstAtSign = true + } else if c != "." { + // periods are allowed for domain names in mentions + break + } + } + } + + if lastWordStartIndex > text.startIndex { + lastWordStartIndex = text.index(before: lastWordStartIndex) + } else { + break + } + } + + return (lastWordStartIndex, foundFirstAtSign) + } } } + +protocol ComposeAutocompleteHandler: class { + func autocomplete(with string: String) +} diff --git a/Tusker/Views/AccountDisplayNameLabel.swift b/Tusker/Views/AccountDisplayNameLabel.swift index 05c4161b..c7c2c156 100644 --- a/Tusker/Views/AccountDisplayNameLabel.swift +++ b/Tusker/Views/AccountDisplayNameLabel.swift @@ -7,16 +7,17 @@ // import SwiftUI +import Pachyderm private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) -struct AccountDisplayNameLabel: View { - let account: AccountMO +struct AccountDisplayNameLabel: View { + let account: Account let fontSize: Int @State var text: Text @State var emojiRequests = [ImageCache.Request]() - init(account: AccountMO, fontSize: Int) { + init(account: Account, fontSize: Int) { self.account = account self.fontSize = fontSize self._text = State(initialValue: Text(verbatim: account.displayName)) diff --git a/Tusker/Views/CustomEmojiImageView.swift b/Tusker/Views/CustomEmojiImageView.swift new file mode 100644 index 00000000..bb72967b --- /dev/null +++ b/Tusker/Views/CustomEmojiImageView.swift @@ -0,0 +1,59 @@ +// +// CustomEmojiImageView.swift +// Tusker +// +// Created by Shadowfacts on 10/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct CustomEmojiImageView: View { + let emoji: Emoji + @State private var request: ImageCache.Request? + @State private var image: UIImage? + + var body: some View { + imageView + .onAppear(perform: self.loadImage) + .onDisappear(perform: self.cancelRequest) + } + + @ViewBuilder + private var imageView: some View { + if let image = image { + Image(uiImage: image) + .renderingMode(.original) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "smiley.fill") + } + } + + private func loadImage() { + request = ImageCache.emojis.get(emoji.url) { (data) in + if let data = data, let image = UIImage(data: data) { + DispatchQueue.main.async { + self.request = nil + self.image = image + } + } else { + DispatchQueue.main.async { + self.request = nil + } + } + } + } + + private func cancelRequest() { + request?.cancel() + } +} + +//struct CustomEmojiImageView_Previews: PreviewProvider { +// static var previews: some View { +// CustomEmojiImageView() +// } +//} diff --git a/Tusker/Views/MaybeLazyStack.swift b/Tusker/Views/MaybeLazyStack.swift new file mode 100644 index 00000000..2e2a67da --- /dev/null +++ b/Tusker/Views/MaybeLazyStack.swift @@ -0,0 +1,59 @@ +// +// MaybeLazyStack.swift +// Tusker +// +// Created by Shadowfacts on 10/10/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct MaybeLazyVStack: View { + private let alignment: HorizontalAlignment + private let spacing: CGFloat? + private let content: Content + + init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + @ViewBuilder + var body: some View { + if #available(iOS 14.0, *) { + LazyVStack(alignment: alignment, spacing: spacing) { + content + } + } else { + VStack(alignment: alignment, spacing: spacing) { + content + } + } + } +} + +struct MaybeLazyHStack: View { + private let alignment: VerticalAlignment + private let spacing: CGFloat? + private let content: Content + + init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + @ViewBuilder + var body: some View { + if #available(iOS 14.0, *) { + LazyHStack(alignment: alignment, spacing: spacing) { + content + } + } else { + HStack(alignment: alignment, spacing: spacing) { + content + } + } + } +} diff --git a/TuskerTests/FuzzyMatcherTests.swift b/TuskerTests/FuzzyMatcherTests.swift new file mode 100644 index 00000000..175c57bc --- /dev/null +++ b/TuskerTests/FuzzyMatcherTests.swift @@ -0,0 +1,25 @@ +// +// FuzzyMatcherTests.swift +// TuskerTests +// +// Created by Shadowfacts on 10/11/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import Tusker + +class FuzzyMatcherTests: XCTestCase { + + func testExample() throws { + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6) + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4) + XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6) + + XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2) + XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1) + + XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score) + } + +}