forked from shadowfacts/Tusker
parent
cf63384dce
commit
2cfc0cf28a
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,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))
|
||||
|
|
|
@ -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()
|
||||
// }
|
||||
//}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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…
Reference in New Issue