Add support of follow/unfollow of tags

This commit is contained in:
Marcin Czachursk 2023-01-25 15:39:04 +01:00
parent 2c22651cac
commit 0daf234784
9 changed files with 225 additions and 33 deletions

View File

@ -25,3 +25,4 @@ public typealias Offset = Int
public typealias Scope = String
public typealias Scopes = [Scope]
public typealias Token = String
public typealias Hashtag = String

View File

@ -0,0 +1,40 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
public extension MastodonClientAuthenticated {
func tag(hashtag: String) async throws -> Tag {
let request = try Self.request(
for: baseURL,
target: Mastodon.Tags.tag(hashtag),
withBearerToken: token
)
return try await downloadJson(Tag.self, request: request)
}
func follow(hashtag: String) async throws -> Tag {
let request = try Self.request(
for: baseURL,
target: Mastodon.Tags.follow(hashtag),
withBearerToken: token
)
return try await downloadJson(Tag.self, request: request)
}
func unfollow(hashtag: String) async throws -> Tag {
let request = try Self.request(
for: baseURL,
target: Mastodon.Tags.unfollow(hashtag),
withBearerToken: token
)
return try await downloadJson(Tag.self, request: request)
}
}

View File

@ -0,0 +1,54 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
extension Mastodon {
public enum Tags {
case tag(Hashtag)
case follow(Hashtag)
case unfollow(Hashtag)
}
}
extension Mastodon.Tags: TargetType {
fileprivate var apiPath: String { return "/api/v1/tags" }
/// The path to be appended to `baseURL` to form the full `URL`.
public var path: String {
switch self {
case .tag(let hashtag):
return "\(apiPath)/\(hashtag)"
case .follow(let hashtag):
return "\(apiPath)/\(hashtag)/follow"
case .unfollow(let hashtag):
return "\(apiPath)/\(hashtag)/unfollow"
}
}
/// The HTTP method used in the request.
public var method: Method {
switch self {
case .tag:
return .get
case .follow, .unfollow:
return .post
}
}
/// The parameters to be incoded in the request.
public var queryItems: [(String, String)]? {
return nil
}
public var headers: [String: String]? {
[:].contentTypeApplicationJson
}
public var httpBody: Data? {
nil
}
}

View File

@ -111,6 +111,7 @@
F8B1E6512973FB7E00EE0D10 /* ToastrService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B1E6502973FB7E00EE0D10 /* ToastrService.swift */; };
F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14391296AF0B3001FE31D /* String+Exif.swift */; };
F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14393296AF21B001FE31D /* Double+Round.swift */; };
F8C7EDBF298169EE002843BC /* TagsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C7EDBE298169EE002843BC /* TagsService.swift */; };
F8CC95CE2970761D00C9C2AC /* TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CC95CD2970761D00C9C2AC /* TintColor.swift */; };
/* End PBXBuildFile section */
@ -218,6 +219,7 @@
F8B1E6502973FB7E00EE0D10 /* ToastrService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastrService.swift; sourceTree = "<group>"; };
F8C14391296AF0B3001FE31D /* String+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Exif.swift"; sourceTree = "<group>"; };
F8C14393296AF21B001FE31D /* Double+Round.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Round.swift"; sourceTree = "<group>"; };
F8C7EDBE298169EE002843BC /* TagsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsService.swift; sourceTree = "<group>"; };
F8CC95CD2970761D00C9C2AC /* TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintColor.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -448,6 +450,7 @@
F88E4D45297E89DF0057491A /* TrendsService.swift */,
F88E4D49297EA0490057491A /* RouterPath.swift */,
F88E4D59297ECEE60057491A /* SearchService.swift */,
F8C7EDBE298169EE002843BC /* TagsService.swift */,
);
path = Services;
sourceTree = "<group>";
@ -606,6 +609,7 @@
F8984E4D296B648000A2610F /* UIImage+Blurhash.swift in Sources */,
F897978A2968314A00B22335 /* LoadingIndicator.swift in Sources */,
F8210DE52966E160001D9973 /* Color+SystemColors.swift in Sources */,
F8C7EDBF298169EE002843BC /* TagsService.swift in Sources */,
F88FAD2B295F43B8009B20C9 /* AccountData+CoreDataProperties.swift in Sources */,
F85D4975296407F100751DF7 /* HomeTimelineService.swift in Sources */,
F80048062961850500E6868A /* StatusData+CoreDataProperties.swift in Sources */,

View File

@ -0,0 +1,40 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import MastodonKit
public class TagsService {
public static let shared = TagsService()
private init() { }
public func tag(accountData: AccountData?, hashTag: String) async throws -> Tag? {
guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else {
return nil
}
let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken)
return try await client.tag(hashtag: hashTag)
}
public func follow(accountData: AccountData?, hashTag: String) async throws -> Tag? {
guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else {
return nil
}
let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken)
return try await client.follow(hashtag: hashTag)
}
public func unfollow(accountData: AccountData?, hashTag: String) async throws -> Tag? {
guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else {
return nil
}
let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken)
return try await client.unfollow(hashtag: hashTag)
}
}

View File

@ -17,7 +17,7 @@ struct SignInView: View {
var body: some View {
VStack {
// TODO: Rebild signin.
// TODO: Rebuild signin page.
HStack {
TextField(
"Server address",

View File

@ -24,6 +24,7 @@ struct StatusesView: View {
@State private var allItemsLoaded = false
@State private var firstLoadFinished = false
@State private var tag: Tag?
@State private var statusViewModels: [StatusViewModel] = []
private let defaultLimit = 20
@ -79,9 +80,17 @@ struct StatusesView: View {
}
}
}
.toolbar {
// TODO: It seems like pixelfed is not supporting the endpoints.
// self.getTrailingToolbar()
}
.task {
do {
try await self.loadStatuses()
if case .hashtag(let hashtag) = self.listType {
await self.loadTag(hashtag: hashtag)
}
} catch {
ErrorService.shared.handle(error, message: "Loading statuses failed.", showToastr: !Task.isCancelled)
}
@ -205,4 +214,50 @@ struct StatusesView: View {
return "#\(tag)"
}
}
@ToolbarContentBuilder
private func getTrailingToolbar() -> some ToolbarContent {
if case .hashtag(let hashtag) = self.listType {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
if self.tag?.following == true {
await self.follow(hashtag: hashtag)
} else {
await self.unfollow(hashtag: hashtag)
}
}
} label: {
Image(systemName: self.tag?.following == true ? "number.square.fill" : "number.square")
.tint(.mainTextColor)
}
}
}
}
private func loadTag(hashtag: String) async {
do {
self.tag = try await TagsService.shared.tag(accountData: self.applicationState.accountData, hashTag: hashtag)
} catch {
ErrorService.shared.handle(error, message: "Error during loading tag from server.", showToastr: false)
}
}
private func follow(hashtag: String) async {
do {
self.tag = try await TagsService.shared.follow(accountData: self.applicationState.accountData, hashTag: hashtag)
ToastrService.shared.showSuccess("You are following the tag.", imageSystemName: "number.square.fill")
} catch {
ErrorService.shared.handle(error, message: "Error during following tag.", showToastr: true)
}
}
private func unfollow(hashtag: String) async {
do {
self.tag = try await TagsService.shared.unfollow(accountData: self.applicationState.accountData, hashTag: hashtag)
ToastrService.shared.showSuccess("Tag has been unfollowed.", imageSystemName: "number.square")
} catch {
ErrorService.shared.handle(error, message: "Error during unfollowing tag.", showToastr: true)
}
}
}

View File

@ -9,20 +9,19 @@ import MastodonKit
struct CommentBody: View {
@EnvironmentObject var applicationState: ApplicationState
@EnvironmentObject var routerPath: RouterPath
@State var statusViewModel: StatusViewModel
private let contentWidth = Int(UIScreen.main.bounds.width) - 60
var body: some View {
HStack (alignment: .top) {
NavigationLink(value: RouteurDestinations.userProfile(
accountId: self.statusViewModel.account.id,
accountDisplayName: self.statusViewModel.account.displayName,
accountUserName: self.statusViewModel.account.acct)
) {
UserAvatar(accountAvatar: self.statusViewModel.account.avatar, size: .comment)
}
UserAvatar(accountAvatar: self.statusViewModel.account.avatar, size: .comment)
.onTapGesture {
routerPath.navigate(to: .userProfile(accountId: self.statusViewModel.account.id,
accountDisplayName: self.statusViewModel.account.displayNameWithoutEmojis,
accountUserName: self.statusViewModel.account.acct))
}
VStack (alignment: .leading, spacing: 0) {
HStack (alignment: .top) {
@ -71,6 +70,7 @@ struct CommentBody: View {
.padding(.bottom, 8)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.linear(duration: 0.3)) {
if self.statusViewModel.id == self.applicationState.showInteractionStatusId {

View File

@ -34,33 +34,31 @@ struct UserAvatar: View {
}
var body: some View {
Group {
if let accountAvatar {
if let cachedAvatar = CacheImageService.shared.getImage(for: accountAvatar) {
cachedAvatar
.resizable()
.clipShape(applicationState.avatarShape.shape())
.aspectRatio(contentMode: .fit)
.frame(width: size.size.width, height: size.size.height)
} else {
LazyImage(url: accountAvatar) { state in
if let image = state.image {
image
.clipShape(applicationState.avatarShape.shape())
.aspectRatio(contentMode: .fit)
} else if state.isLoading {
placeholderView
} else {
placeholderView
}
}
.priority(.high)
if let accountAvatar {
if let cachedAvatar = CacheImageService.shared.getImage(for: accountAvatar) {
cachedAvatar
.resizable()
.clipShape(applicationState.avatarShape.shape())
.aspectRatio(contentMode: .fit)
.frame(width: size.size.width, height: size.size.height)
}
} else {
placeholderView
.frame(width: size.size.width, height: size.size.height)
LazyImage(url: accountAvatar) { state in
if let image = state.image {
image
.clipShape(applicationState.avatarShape.shape())
.aspectRatio(contentMode: .fit)
} else if state.isLoading {
placeholderView
} else {
placeholderView
}
}
.priority(.high)
.frame(width: size.size.width, height: size.size.height)
}
} else {
placeholderView
.frame(width: size.size.width, height: size.size.height)
}
}