parent
cf63384dce
commit
2cfc0cf28a
|
@ -52,10 +52,11 @@ public class Client {
|
||||||
self.session = session
|
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 {
|
guard let request = createURLRequest(request: request) else {
|
||||||
completion(.failure(Error.invalidRequest))
|
completion(.failure(Error.invalidRequest))
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = session.dataTask(with: request) { data, response, error in
|
let task = session.dataTask(with: request) { data, response, error in
|
||||||
|
@ -83,6 +84,7 @@ public class Client {
|
||||||
completion(.success(result, pagination))
|
completion(.success(result, pagination))
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||||
|
@ -276,12 +278,12 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// 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: [
|
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||||
"q" => query,
|
"q" => query,
|
||||||
"resolve" => resolve,
|
"resolve" => resolve,
|
||||||
"limit" => limit
|
"limit" => limit,
|
||||||
])
|
] + "types" => types?.map { $0.rawValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Statuses
|
// MARK: - Statuses
|
||||||
|
@ -314,13 +316,24 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: Bookmarks
|
// MARK: - Bookmarks
|
||||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
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 {
|
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 */; };
|
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
|
||||||
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
|
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
|
||||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.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 */; };
|
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; };
|
||||||
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
|
||||||
|
@ -753,6 +765,7 @@
|
||||||
D61099F82145698900432DC2 /* Relationship.swift */,
|
D61099F82145698900432DC2 /* Relationship.swift */,
|
||||||
D61099FA214569F600432DC2 /* Report.swift */,
|
D61099FA214569F600432DC2 /* Report.swift */,
|
||||||
D61099FC21456A1D00432DC2 /* SearchResults.swift */,
|
D61099FC21456A1D00432DC2 /* SearchResults.swift */,
|
||||||
|
D6E426B8253382B300C02E1C /* SearchResultType.swift */,
|
||||||
D61099FE21456A4C00432DC2 /* Status.swift */,
|
D61099FE21456A4C00432DC2 /* Status.swift */,
|
||||||
D6285B4E21EA695800FE4B39 /* StatusContentType.swift */,
|
D6285B4E21EA695800FE4B39 /* StatusContentType.swift */,
|
||||||
D6109A10214607D500432DC2 /* Timeline.swift */,
|
D6109A10214607D500432DC2 /* Timeline.swift */,
|
||||||
|
@ -987,6 +1000,7 @@
|
||||||
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
|
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
|
||||||
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
|
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
|
||||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||||
|
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
||||||
);
|
);
|
||||||
path = Compose;
|
path = Compose;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1259,6 +1273,8 @@
|
||||||
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
|
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
|
||||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
|
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
|
||||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||||
|
D6E426802532814100C02E1C /* MaybeLazyStack.swift */,
|
||||||
|
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||||
D67C57A721E2649B00C3118B /* Account Detail */,
|
D67C57A721E2649B00C3118B /* Account Detail */,
|
||||||
D626494023C122C800612E6E /* Asset Picker */,
|
D626494023C122C800612E6E /* Asset Picker */,
|
||||||
D61959D0241E842400A37B8E /* Draft Cell */,
|
D61959D0241E842400A37B8E /* Draft Cell */,
|
||||||
|
@ -1341,6 +1357,7 @@
|
||||||
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
|
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
|
||||||
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||||
|
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
|
||||||
D67B506B250B28FF00FAECFB /* Vendor */,
|
D67B506B250B28FF00FAECFB /* Vendor */,
|
||||||
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
||||||
D6757A7A2157E00100721E32 /* XCallbackURL */,
|
D6757A7A2157E00100721E32 /* XCallbackURL */,
|
||||||
|
@ -1366,6 +1383,7 @@
|
||||||
children = (
|
children = (
|
||||||
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
|
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
|
||||||
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
||||||
|
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
||||||
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = TuskerTests;
|
path = TuskerTests;
|
||||||
|
@ -1696,6 +1714,7 @@
|
||||||
D61099CB2144B20500432DC2 /* Request.swift in Sources */,
|
D61099CB2144B20500432DC2 /* Request.swift in Sources */,
|
||||||
D6109A05214572BF00432DC2 /* Scope.swift in Sources */,
|
D6109A05214572BF00432DC2 /* Scope.swift in Sources */,
|
||||||
D6109A11214607D500432DC2 /* Timeline.swift in Sources */,
|
D6109A11214607D500432DC2 /* Timeline.swift in Sources */,
|
||||||
|
D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */,
|
||||||
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */,
|
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */,
|
||||||
D61099E7214561FF00432DC2 /* Attachment.swift in Sources */,
|
D61099E7214561FF00432DC2 /* Attachment.swift in Sources */,
|
||||||
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */,
|
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */,
|
||||||
|
@ -1795,6 +1814,7 @@
|
||||||
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
|
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
|
||||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
|
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
|
||||||
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
|
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
|
||||||
|
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */,
|
||||||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
||||||
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
|
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
|
||||||
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
|
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
|
||||||
|
@ -1849,6 +1869,7 @@
|
||||||
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
|
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
|
||||||
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
|
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
|
||||||
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
|
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
|
||||||
|
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
|
||||||
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
||||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||||
|
@ -1910,11 +1931,13 @@
|
||||||
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
||||||
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
|
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
|
||||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
|
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
|
||||||
|
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
|
||||||
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
|
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
|
||||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
||||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
||||||
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
||||||
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||||
|
@ -1928,6 +1951,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
||||||
|
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
||||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
|
@ -44,6 +44,7 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
@Published private(set) var account: Account!
|
@Published private(set) var account: Account!
|
||||||
@Published private(set) var instance: Instance!
|
@Published private(set) var instance: Instance!
|
||||||
|
private(set) var customEmojis: [Emoji]?
|
||||||
|
|
||||||
var loggedIn: Bool {
|
var loggedIn: Bool {
|
||||||
accountInfo != nil
|
accountInfo != nil
|
||||||
|
@ -56,8 +57,9 @@ class MastodonController: ObservableObject {
|
||||||
self.transient = transient
|
self.transient = transient
|
||||||
}
|
}
|
||||||
|
|
||||||
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
|
@discardableResult
|
||||||
client.run(request, completion: completion)
|
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) {
|
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 {
|
var body: some View {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 50, height: 50)
|
|
||||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
|
||||||
.conditionally(url != nil) {
|
.conditionally(url != nil) {
|
||||||
$0.onAppear(perform: self.loadImage)
|
$0.onAppear(perform: self.loadImage)
|
||||||
}
|
}
|
||||||
|
@ -27,7 +25,7 @@ struct ComposeAvatarImageView: View {
|
||||||
|
|
||||||
private var image: Image {
|
private var image: Image {
|
||||||
if let avatarImage = avatarImage {
|
if let avatarImage = avatarImage {
|
||||||
return Image(uiImage: avatarImage)
|
return Image(uiImage: avatarImage).renderingMode(.original)
|
||||||
} else {
|
} else {
|
||||||
return placeholderImage
|
return placeholderImage
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import Pachyderm
|
||||||
|
|
||||||
struct ComposeCurrentAccount: View {
|
struct ComposeCurrentAccount: View {
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
|
|
||||||
var account: Account? {
|
var account: Account? {
|
||||||
mastodonController.account
|
mastodonController.account
|
||||||
|
@ -19,6 +20,8 @@ struct ComposeCurrentAccount: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
ComposeAvatarImageView(url: account?.avatar)
|
ComposeAvatarImageView(url: account?.avatar)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||||
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
|
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
|
||||||
|
|
||||||
if let id = account?.id,
|
if let id = account?.id,
|
||||||
|
|
|
@ -164,7 +164,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||||
// temporarily reset add'l safe area insets so we can access the default inset
|
// temporarily reset add'l safe area insets so we can access the default inset
|
||||||
additionalSafeAreaInsets = .zero
|
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()
|
updateAdditionalSafeAreaInsets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,8 @@ struct ComposeReplyView: View {
|
||||||
|
|
||||||
@State private var contentHeight: CGFloat?
|
@State private var contentHeight: CGFloat?
|
||||||
|
|
||||||
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
|
|
||||||
private let horizSpacing: CGFloat = 8
|
private let horizSpacing: CGFloat = 8
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -55,6 +57,8 @@ struct ComposeReplyView: View {
|
||||||
scrollOffset += stackPadding
|
scrollOffset += stackPadding
|
||||||
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
|
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
|
||||||
return ComposeAvatarImageView(url: status.account.avatar)
|
return ComposeAvatarImageView(url: status.account.avatar)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||||
.offset(x: 0, y: offset)
|
.offset(x: 0, y: offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,9 +27,12 @@ class ComposeUIState: ObservableObject {
|
||||||
@Published var draft: Draft
|
@Published var draft: Draft
|
||||||
@Published var isShowingSaveDraftSheet = false
|
@Published var isShowingSaveDraftSheet = false
|
||||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
|
@Published var autocompleteState: AutocompleteState? = nil
|
||||||
|
|
||||||
var composeDrawingMode: ComposeDrawingMode?
|
var composeDrawingMode: ComposeDrawingMode?
|
||||||
|
|
||||||
|
weak var autocompleteHandler: ComposeAutocompleteHandler?
|
||||||
|
|
||||||
init(draft: Draft) {
|
init(draft: Draft) {
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
}
|
}
|
||||||
|
@ -42,3 +45,11 @@ extension ComposeUIState {
|
||||||
case edit(id: UUID)
|
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
|
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||||
WrappedProgressView(value: postProgress, total: postTotalProgress)
|
WrappedProgressView(value: postProgress, total: postTotalProgress)
|
||||||
|
|
||||||
|
if let state = uiState.autocompleteState {
|
||||||
|
autocompleteSuggestions(state: state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear(perform: self.didAppear)
|
.onAppear(perform: self.didAppear)
|
||||||
.navigationBarTitle("Compose")
|
.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 {
|
func mainStack(outerMinY: CGFloat) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if let id = draft.inReplyToID,
|
if let id = draft.inReplyToID,
|
||||||
|
|
|
@ -66,6 +66,8 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||||
context.coordinator.textView = textView
|
context.coordinator.textView = textView
|
||||||
|
|
||||||
|
uiState.autocompleteHandler = context.coordinator
|
||||||
|
|
||||||
let visibilityAction: Selector?
|
let visibilityAction: Selector?
|
||||||
if #available(iOS 14.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
visibilityAction = nil
|
visibilityAction = nil
|
||||||
|
@ -167,7 +169,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
|
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, UITextViewDelegate {
|
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler {
|
||||||
weak var textView: UITextView?
|
weak var textView: UITextView?
|
||||||
var text: Binding<String>
|
var text: Binding<String>
|
||||||
var didChange: (UITextView) -> Void
|
var didChange: (UITextView) -> Void
|
||||||
|
@ -182,6 +184,8 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
text.wrappedValue = textView.text
|
text.wrappedValue = textView.text
|
||||||
didChange(textView)
|
didChange(textView)
|
||||||
|
|
||||||
|
updateAutocompleteState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
|
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
|
||||||
|
@ -213,5 +217,132 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
|
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
|
||||||
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: 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 SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
struct AccountDisplayNameLabel: View {
|
struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
|
||||||
let account: AccountMO
|
let account: Account
|
||||||
let fontSize: Int
|
let fontSize: Int
|
||||||
@State var text: Text
|
@State var text: Text
|
||||||
@State var emojiRequests = [ImageCache.Request]()
|
@State var emojiRequests = [ImageCache.Request]()
|
||||||
|
|
||||||
init(account: AccountMO, fontSize: Int) {
|
init(account: Account, fontSize: Int) {
|
||||||
self.account = account
|
self.account = account
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
self._text = State(initialValue: Text(verbatim: account.displayName))
|
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