Browse Source

Add Compose screen mention, hashtag, emoji completion

Closes #10
async
Shadowfacts 1 year ago
parent
commit
2cfc0cf28a
Signed by: shadowfacts GPG Key ID: 94A5AB95422746E5
  1. 25
      Pachyderm/Client.swift
  2. 15
      Pachyderm/Model/SearchResultType.swift
  3. 24
      Tusker.xcodeproj/project.pbxproj
  4. 22
      Tusker/Controllers/MastodonController.swift
  5. 62
      Tusker/FuzzyMatcher.swift
  6. 350
      Tusker/Screens/Compose/ComposeAutocompleteView.swift
  7. 4
      Tusker/Screens/Compose/ComposeAvatarImageView.swift
  8. 3
      Tusker/Screens/Compose/ComposeCurrentAccount.swift
  9. 4
      Tusker/Screens/Compose/ComposeHostingController.swift
  10. 4
      Tusker/Screens/Compose/ComposeReplyView.swift
  11. 11
      Tusker/Screens/Compose/ComposeUIState.swift
  12. 22
      Tusker/Screens/Compose/ComposeView.swift
  13. 133
      Tusker/Screens/Compose/MainComposeTextView.swift
  14. 7
      Tusker/Views/AccountDisplayNameLabel.swift
  15. 59
      Tusker/Views/CustomEmojiImageView.swift
  16. 59
      Tusker/Views/MaybeLazyStack.swift
  17. 25
      TuskerTests/FuzzyMatcherTests.swift

25
Pachyderm/Client.swift

@ -52,10 +52,11 @@ public class Client {
self.session = session
}
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
@discardableResult
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> 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<Result>(request: Request<Result>) -> URLRequest? {
@ -276,12 +278,12 @@ public class Client {
}
// MARK: - Search
public static func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
return Request<SearchResults>(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 {

15
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
}

24
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 = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = "<group>"; };
D6E426802532814100C02E1C /* MaybeLazyStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeLazyStack.swift; sourceTree = "<group>"; };
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = "<group>"; };
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = "<group>"; };
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; };
D6E426B8253382B300C02E1C /* SearchResultType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultType.swift; sourceTree = "<group>"; };
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; };
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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;

22
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<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
client.run(request, completion: completion)
@discardableResult
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> 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([])
}
}
}
}
}

62
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)
}
}

350
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> = 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"))
}
}

4
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
}

3
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,

4
Tusker/Screens/Compose/ComposeHostingController.swift

@ -164,7 +164,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
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()
}
}

4
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)
}

11
Tusker/Screens/Compose/ComposeUIState.swift

@ -27,9 +27,12 @@ class ComposeUIState: ObservableObject {
@Published var draft: Draft
@Published var isShowingSaveDraftSheet = false
@Published var attachmentsMissingDescriptions = Set<UUID>()
@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)
}
}

22
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,

133
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<String>
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..<characterBeforeCursorIndex, with: string)
self.textViewDidChange(textView)
}
private func updateAutocompleteState() {
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
uiState.autocompleteState = nil
return
}
let triggerChars: [Character] = ["@", ":", "#"]
if 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..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign {
uiState.autocompleteState = .mention(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
return
}
switch lastWord.first {
case "@":
uiState.autocompleteState = .mention(String(exceptFirst))
case ":":
uiState.autocompleteState = .emoji(String(exceptFirst))
case "#":
uiState.autocompleteState = .hashtag(String(exceptFirst))
default:
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> 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)
}

7
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<Account: AccountProtocol>: 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))

59
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()
// }
//}

59
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<Content: View>: 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<Content: View>: 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
}
}
}
}

25
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)
}
}
Loading…
Cancel
Save