From 3d3fc3f515ae470f46194d9640a55e316528070d Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 19 Apr 2023 22:20:05 -0400 Subject: [PATCH] Allow switching accounts from share sheet --- .../Controllers/ComposeController.swift | 6 +- .../ComposeUI/Views/CurrentAccountView.swift | 4 + .../TuskerComponents/AvatarImageView.swift | 1 + .../UserAccounts/UserAccountInfo.swift | 2 +- ShareExtension/ShareHostingController.swift | 13 +- .../SwitchAccountContainerView.swift | 140 ++++++++++++++++++ Tusker.xcodeproj/project.pbxproj | 4 + 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 ShareExtension/SwitchAccountContainerView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 365417c6..86c6abd3 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -13,15 +13,17 @@ import TuskerComponents public final class ComposeController: ViewController { public typealias FetchStatus = (String) -> (any StatusProtocol)? public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView + public typealias CurrentAccountContainerView = (AnyView) -> AnyView public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView @Published public private(set) var draft: Draft @Published public var config: ComposeUIConfig - let mastodonController: ComposeMastodonContext + @Published public var mastodonController: ComposeMastodonContext let fetchAvatar: AvatarImageView.FetchAvatar let fetchStatus: FetchStatus let displayNameLabel: DisplayNameLabel + let currentAccountContainerview: CurrentAccountContainerView let replyContentView: ReplyContentView let emojiImageView: EmojiImageView @@ -71,6 +73,7 @@ public final class ComposeController: ViewController { fetchAvatar: @escaping AvatarImageView.FetchAvatar, fetchStatus: @escaping FetchStatus, displayNameLabel: @escaping DisplayNameLabel, + currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 }, replyContentView: @escaping ReplyContentView, emojiImageView: @escaping EmojiImageView ) { @@ -80,6 +83,7 @@ public final class ComposeController: ViewController { self.fetchAvatar = fetchAvatar self.fetchStatus = fetchStatus self.displayNameLabel = displayNameLabel + self.currentAccountContainerview = currentAccountContainerView self.replyContentView = replyContentView self.emojiImageView = emojiImageView diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift index 16ef5aed..064fef95 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift @@ -14,6 +14,10 @@ struct CurrentAccountView: View { @EnvironmentObject private var controller: ComposeController var body: some View { + controller.currentAccountContainerview(AnyView(currentAccount)) + } + + private var currentAccount: some View { HStack(alignment: .top) { AvatarImageView( url: account?.avatar, diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift index 868c4d07..a08d2abd 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift @@ -29,6 +29,7 @@ public struct AvatarImageView: View { .frame(width: size, height: size) .cornerRadius(style.cornerRadiusFraction * size) .task { + image = nil if let url { image = await fetchAvatar(url) } diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift index 2fac2f8a..1565dc90 100644 --- a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift @@ -8,7 +8,7 @@ import Foundation import CryptoKit -public struct UserAccountInfo: Equatable, Hashable { +public struct UserAccountInfo: Equatable, Hashable, Identifiable { public let id: String public let instanceURL: URL public let clientID: String diff --git a/ShareExtension/ShareHostingController.swift b/ShareExtension/ShareHostingController.swift index 11836d2b..df29e7dd 100644 --- a/ShareExtension/ShareHostingController.swift +++ b/ShareExtension/ShareHostingController.swift @@ -24,9 +24,12 @@ class ShareHostingController: UIHostingController { private let controller: ComposeController + private var mastodonContextPublisher: CurrentValueSubject private var cancellables = Set() init(draft: Draft, mastodonContext: ShareMastodonContext) { + let mastodonContextPublisher = CurrentValueSubject(mastodonContext) + self.mastodonContextPublisher = mastodonContextPublisher controller = ComposeController( draft: draft, config: ComposeUIConfig(), @@ -34,6 +37,7 @@ class ShareHostingController: UIHostingController { fetchAvatar: Self.fetchAvatar, fetchStatus: { _ in fatalError("replies aren't allowed in share sheet") }, displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) }, + currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) }, replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") }, emojiImageView: { AnyView(AsyncImage(url: URL($0.url)!) { @@ -49,7 +53,14 @@ class ShareHostingController: UIHostingController { updateConfig() - mastodonContext.$ownAccount + mastodonContextPublisher + .sink { [unowned self] in + self.controller.mastodonController = $0 + self.controller.draft.accountID = $0.accountInfo!.id + } + .store(in: &cancellables) + mastodonContextPublisher + .flatMap { $0.$ownAccount } .sink { [unowned self] in self.controller.currentAccount = $0 } .store(in: &cancellables) } diff --git a/ShareExtension/SwitchAccountContainerView.swift b/ShareExtension/SwitchAccountContainerView.swift new file mode 100644 index 00000000..42cfab67 --- /dev/null +++ b/ShareExtension/SwitchAccountContainerView.swift @@ -0,0 +1,140 @@ +// +// SwitchAccountContainerView.swift +// ShareExtension +// +// Created by Shadowfacts on 4/19/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import SwiftUI +import UserAccounts +import TuskerPreferences +import Pachyderm +import Combine +import ComposeUI + +struct SwitchAccountContainerView: View { + let content: AnyView + let mastodonContextPublisher: CurrentValueSubject + + var accounts: [UserAccountInfo] { + UserAccountsManager.shared.accounts + } + + var body: some View { + if accounts.count > 1 { + Menu { + ForEach(accounts) { account in + Button(action: { selectAccount(account) }) { + AccountButtonLabel(account: account) + } + } + } label: { + HStack(alignment: .center) { + VStack(spacing: 2) { + Image(systemName: "arrowtriangle.up.fill") + .resizable() + .frame(width: 10, height: 5) + Image(systemName: "arrowtriangle.down.fill") + .resizable() + .frame(width: 10, height: 5) + } + .foregroundColor(.secondary) + + content + } + } + } else { + content + } + } + + private func selectAccount(_ account: UserAccountInfo) { + mastodonContextPublisher.send(ShareMastodonContext(accountInfo: account)) + } +} + +private struct AccountButtonLabel: View { + static let urlSession = URLSession(configuration: .ephemeral) + + let account: UserAccountInfo + @State private var avatarImage: Image? + + var body: some View { + label + .task { + await fetchAvatar() + } + } + + @ViewBuilder + private var label: some View { + // subtitles only started being supported on 16.4 + if #available(iOS 16.4, *) { + Label { + Text(account.username) + } icon: { + avatar + } + Text(account.instanceURL.host!) + } else { + Label { + Text("@\(account.username)@\(account.instanceURL.host!)") + } icon: { + avatar + } + } + } + + @ViewBuilder + private var avatar: some View { + if let avatarImage { + avatarImage + } else { + avatarPlaceholder + } + } + + private var avatarPlaceholder: Image { + switch Preferences.shared.avatarStyle { + case .circle: + return Image(systemName: "person.crop.circle") + case .roundRect: + return Image(systemName: "person.crop.square") + } + } + + private func fetchAvatar() async { + let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken, session: Self.urlSession) + let account: Account? = await withCheckedContinuation({ continuation in + client.run(Client.getSelfAccount()) { response in + switch response { + case .success(let account, _): + continuation.resume(returning: account) + case .failure(_): + continuation.resume(returning: nil) + } + } + }) + if let account, + let avatarURL = account.avatar, + let data = try? await Self.urlSession.data(from: avatarURL).0, + let image = UIImage(data: data) { + let size = CGSize(width: 50, height: 50) + let renderer = UIGraphicsImageRenderer(size: size) + let clipped = renderer.image { context in + let bounds = CGRect(origin: .zero, size: size) + let path: UIBezierPath + switch Preferences.shared.avatarStyle { + case .circle: + path = UIBezierPath(ovalIn: bounds) + case .roundRect: + path = UIBezierPath(roundedRect: bounds, cornerRadius: 5) + } + path.addClip() + image.draw(in: bounds) + } + self.avatarImage = Image(uiImage: clipped) + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 53242ada..52ee67ea 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -230,6 +230,7 @@ D6A4532C29EF665D00032932 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4532B29EF665D00032932 /* UserAccounts */; }; D6A4532E29EF7DDD00032932 /* ShareHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4532D29EF7DDD00032932 /* ShareHostingController.swift */; }; D6A4533029EF7DEE00032932 /* ShareMastodonContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4532F29EF7DEE00032932 /* ShareMastodonContext.swift */; }; + D6A4533229F0CFCA00032932 /* SwitchAccountContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4533129F0CFCA00032932 /* SwitchAccountContainerView.swift */; }; D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; }; @@ -622,6 +623,7 @@ D6A4531E29EF64BA00032932 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; D6A4532D29EF7DDD00032932 /* ShareHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareHostingController.swift; sourceTree = ""; }; D6A4532F29EF7DEE00032932 /* ShareMastodonContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareMastodonContext.swift; sourceTree = ""; }; + D6A4533129F0CFCA00032932 /* SwitchAccountContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchAccountContainerView.swift; sourceTree = ""; }; D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = ""; }; D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = ""; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = ""; }; @@ -1308,6 +1310,7 @@ D6A4532F29EF7DEE00032932 /* ShareMastodonContext.swift */, D6A4531529EF64BA00032932 /* ShareViewController.swift */, D6A4532D29EF7DDD00032932 /* ShareHostingController.swift */, + D6A4533129F0CFCA00032932 /* SwitchAccountContainerView.swift */, D6A4531729EF64BA00032932 /* MainInterface.storyboard */, D6A4531A29EF64BA00032932 /* Info.plist */, ); @@ -1925,6 +1928,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D6A4533229F0CFCA00032932 /* SwitchAccountContainerView.swift in Sources */, D6A4532E29EF7DDD00032932 /* ShareHostingController.swift in Sources */, D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */, D6A4533029EF7DEE00032932 /* ShareMastodonContext.swift in Sources */,