parent
9353bbb56c
commit
ff11835333
|
@ -13,8 +13,9 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
public let instanceURL: URL
|
||||
public let clientID: String
|
||||
public let clientSecret: String
|
||||
public private(set) var username: String!
|
||||
public let accessToken: String
|
||||
public let username: String!
|
||||
public internal(set) var accessToken: String
|
||||
public internal(set) var scopes: [String]?
|
||||
|
||||
// Sort of hack to be able to access these from the share extension.
|
||||
public internal(set) var serverDefaultLanguage: String?
|
||||
|
@ -40,16 +41,19 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.username = nil
|
||||
self.accessToken = accessToken
|
||||
self.scopes = nil
|
||||
}
|
||||
|
||||
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
|
||||
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String, scopes: [String]) {
|
||||
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
|
||||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.username = username
|
||||
self.accessToken = accessToken
|
||||
self.scopes = scopes
|
||||
}
|
||||
|
||||
init?(userDefaultsDict dict: [String: Any]) {
|
||||
|
@ -67,6 +71,7 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
self.clientSecret = secret
|
||||
self.username = dict["username"] as? String
|
||||
self.accessToken = accessToken
|
||||
self.scopes = dict["scopes"] as? [String]
|
||||
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
|
||||
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
|
||||
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
|
||||
|
@ -83,6 +88,9 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
if let username {
|
||||
dict["username"] = username
|
||||
}
|
||||
if let scopes {
|
||||
dict["scopes"] = scopes
|
||||
}
|
||||
if let serverDefaultLanguage {
|
||||
dict["serverDefaultLanguage"] = serverDefaultLanguage
|
||||
}
|
||||
|
@ -100,12 +108,4 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
// slashes are not allowed in the persistent store coordinator name
|
||||
id.replacingOccurrences(of: "/", with: "_")
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,9 @@ public class UserAccountsManager: ObservableObject {
|
|||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
username: "admin",
|
||||
accessToken: "access_token")
|
||||
accessToken: "access_token",
|
||||
scopes: []
|
||||
)
|
||||
]
|
||||
}
|
||||
} else {
|
||||
|
@ -38,7 +40,7 @@ public class UserAccountsManager: ObservableObject {
|
|||
private let accountsKey = "accounts"
|
||||
public private(set) var accounts: [UserAccountInfo] {
|
||||
get {
|
||||
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
|
||||
if let array = defaults.array(forKey: accountsKey) as? [[String: Any]] {
|
||||
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
|
||||
} else {
|
||||
return []
|
||||
|
@ -101,12 +103,12 @@ public class UserAccountsManager: ObservableObject {
|
|||
return !accounts.isEmpty
|
||||
}
|
||||
|
||||
public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
|
||||
public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String, scopes: [String]) -> UserAccountInfo {
|
||||
var accounts = self.accounts
|
||||
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
|
||||
accounts.remove(at: index)
|
||||
}
|
||||
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
|
||||
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken, scopes: scopes)
|
||||
accounts.append(info)
|
||||
self.accounts = accounts
|
||||
return info
|
||||
|
@ -146,6 +148,16 @@ public class UserAccountsManager: ObservableObject {
|
|||
accounts[index] = account
|
||||
}
|
||||
|
||||
public func updateAccessToken(_ account: UserAccountInfo, token: String, scopes: [String]) {
|
||||
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
|
||||
return
|
||||
}
|
||||
var account = account
|
||||
account.accessToken = token
|
||||
account.scopes = scopes
|
||||
accounts[index] = account
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension Notification.Name {
|
||||
|
|
|
@ -94,6 +94,7 @@
|
|||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; };
|
||||
D630C3C82BC43AFD00208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3C72BC43AFD00208903 /* PushNotifications */; };
|
||||
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; };
|
||||
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; };
|
||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
|
||||
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
|
||||
|
@ -499,6 +500,7 @@
|
|||
D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMetaIndicatorsView.swift; sourceTree = "<group>"; };
|
||||
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; };
|
||||
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Push.swift"; sourceTree = "<group>"; };
|
||||
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAuthorizationTokenService.swift; sourceTree = "<group>"; };
|
||||
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; };
|
||||
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; };
|
||||
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1668,6 +1670,7 @@
|
|||
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
|
||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2064,6 +2067,7 @@
|
|||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
|
||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */,
|
||||
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
||||
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
|
||||
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
|
||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
||||
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// GetAuthorizationTokenService.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/9/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
class GetAuthorizationTokenService {
|
||||
let instanceURL: URL
|
||||
let clientID: String
|
||||
let presentationContextProvider: ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
init(instanceURL: URL, clientID: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding) {
|
||||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.presentationContextProvider = presentationContextProvider
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws -> String {
|
||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||
components.path = "/oauth/authorize"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
|
||||
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
|
||||
]
|
||||
let authorizeURL = components.url!
|
||||
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
let authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
|
||||
if let error = error {
|
||||
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
|
||||
continuation.resume(throwing: Error.cancelled)
|
||||
} else {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
} else if let url = url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let item = components.queryItems?.first(where: { $0.name == "code" }),
|
||||
let code = item.value {
|
||||
continuation.resume(returning: code)
|
||||
} else {
|
||||
continuation.resume(throwing: Error.noAuthorizationCode)
|
||||
}
|
||||
})
|
||||
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
|
||||
authenticationSession.prefersEphemeralWebBrowserSession = true
|
||||
authenticationSession.presentationContextProvider = presentationContextProvider
|
||||
authenticationSession.start()
|
||||
})
|
||||
}
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case cancelled
|
||||
case noAuthorizationCode
|
||||
}
|
||||
}
|
|
@ -24,22 +24,22 @@ final class MastodonController: ObservableObject, Sendable {
|
|||
static let oauthScopes = [Scope.read, .write, .follow, .push]
|
||||
|
||||
@MainActor
|
||||
static private(set) var all = [UserAccountInfo: MastodonController]()
|
||||
static private(set) var all = [String: MastodonController]()
|
||||
|
||||
@MainActor
|
||||
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
|
||||
if let controller = all[account] {
|
||||
if let controller = all[account.id] {
|
||||
return controller
|
||||
} else {
|
||||
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
|
||||
all[account] = controller
|
||||
all[account.id] = controller
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func removeForAccount(_ account: UserAccountInfo) {
|
||||
all.removeValue(forKey: account)
|
||||
all.removeValue(forKey: account.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
@ -158,7 +158,14 @@ class OnboardingViewController: UINavigationController {
|
|||
throw Error.gettingOwnAccount(error)
|
||||
}
|
||||
|
||||
let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
|
||||
let accountInfo = UserAccountsManager.shared.addAccount(
|
||||
instanceURL: instanceURL,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
username: ownAccount.username,
|
||||
accessToken: accessToken,
|
||||
scopes: MastodonController.oauthScopes.map(\.rawValue)
|
||||
)
|
||||
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
|
||||
}
|
||||
|
||||
|
@ -177,38 +184,19 @@ class OnboardingViewController: UINavigationController {
|
|||
|
||||
@MainActor
|
||||
private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String {
|
||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||
components.path = "/oauth/authorize"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
|
||||
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
|
||||
]
|
||||
let authorizeURL = components.url!
|
||||
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
|
||||
if let error = error {
|
||||
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
|
||||
continuation.resume(throwing: Error.cancelled)
|
||||
} else {
|
||||
continuation.resume(throwing: Error.authenticationSessionError(error))
|
||||
}
|
||||
} else if let url = url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let item = components.queryItems?.first(where: { $0.name == "code" }),
|
||||
let code = item.value {
|
||||
continuation.resume(returning: code)
|
||||
} else {
|
||||
continuation.resume(throwing: Error.noAuthorizationCode)
|
||||
}
|
||||
})
|
||||
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
|
||||
self.authenticationSession!.prefersEphemeralWebBrowserSession = true
|
||||
self.authenticationSession!.presentationContextProvider = self
|
||||
self.authenticationSession!.start()
|
||||
})
|
||||
do {
|
||||
let service = GetAuthorizationTokenService(instanceURL: instanceURL, clientID: clientID, presentationContextProvider: self)
|
||||
return try await service.run()
|
||||
} catch let error as GetAuthorizationTokenService.Error {
|
||||
switch error {
|
||||
case .cancelled:
|
||||
throw Error.cancelled
|
||||
case .noAuthorizationCode:
|
||||
throw Error.noAuthorizationCode
|
||||
}
|
||||
} catch {
|
||||
throw Error.authenticationSessionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ struct PushInstanceSettingsView: View {
|
|||
@State private var mode: AsyncToggle.Mode
|
||||
@State private var error: Error?
|
||||
@State private var subscription: PushNotifications.PushSubscription?
|
||||
@State private var showReLoginRequiredAlert = false
|
||||
|
||||
@MainActor
|
||||
init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) {
|
||||
|
@ -43,12 +44,20 @@ struct PushInstanceSettingsView: View {
|
|||
} message: { data in
|
||||
Text(data.localizedDescription)
|
||||
}
|
||||
.alert("Re-Login Required", isPresented: $showReLoginRequiredAlert) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Login") {
|
||||
NotificationCenter.default.post(name: .reLogInRequired, object: account)
|
||||
}
|
||||
} message: {
|
||||
Text("You must grant permission on \(account.instanceURL.host!) to turn on push notifications.")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNotificationsEnabled(enabled: Bool) async -> Bool {
|
||||
if enabled {
|
||||
do {
|
||||
try await enableNotifications()
|
||||
return try await enableNotifications()
|
||||
} catch {
|
||||
PushManager.logger.error("Error creating instance subscription: \(String(describing: error))")
|
||||
self.error = .enabling(error)
|
||||
|
@ -66,13 +75,19 @@ struct PushInstanceSettingsView: View {
|
|||
return true
|
||||
}
|
||||
|
||||
private func enableNotifications() async throws {
|
||||
private func enableNotifications() async throws -> Bool {
|
||||
guard account.scopes?.contains(Scope.push.rawValue) == true else {
|
||||
showReLoginRequiredAlert = true
|
||||
return false
|
||||
}
|
||||
|
||||
let subscription = try await PushManager.shared.createSubscription(account: account)
|
||||
let mastodonController = await MastodonController.getForAccount(account)
|
||||
do {
|
||||
let result = try await mastodonController.createPushSubscription(subscription: subscription)
|
||||
PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
|
||||
self.subscription = subscription
|
||||
return true
|
||||
} catch {
|
||||
// if creation failed, remove the subscription locally as well
|
||||
await PushManager.shared.removeSubscription(account: account)
|
||||
|
|
|
@ -10,6 +10,8 @@ import UIKit
|
|||
import SwiftUI
|
||||
import UserAccounts
|
||||
import SafariServices
|
||||
import AuthenticationServices
|
||||
import Pachyderm
|
||||
|
||||
class PreferencesNavigationController: UINavigationController {
|
||||
|
||||
|
@ -37,6 +39,7 @@ class PreferencesNavigationController: UINavigationController {
|
|||
NotificationCenter.default.addObserver(self, selector: #selector(activateAccount(_:)), name: .activateAccount, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userLoggedOut), name: .userLoggedOut, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(showMastodonSettings), name: .showMastodonSettings, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reLogInToAccount), name: .reLogInRequired, object: nil)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
|
@ -63,7 +66,7 @@ class PreferencesNavigationController: UINavigationController {
|
|||
dismiss(animated: true) // dismisses instance selector
|
||||
}
|
||||
|
||||
@objc func activateAccount(_ notification: Notification) {
|
||||
@objc func activateAccount(_ notification: Foundation.Notification) {
|
||||
// TODO: this is a temporary measure
|
||||
// when switching accounts shortly after adding a new one, there is an old instance of PreferncesNavigationController still around
|
||||
// which tries to handle the notification but is unable to because it no longer is in a window (and therefore doesn't have a scene delegate)
|
||||
|
@ -106,6 +109,27 @@ class PreferencesNavigationController: UINavigationController {
|
|||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
@objc private func reLogInToAccount(_ notification: Foundation.Notification) {
|
||||
guard let account = notification.object as? UserAccountInfo else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
let service = GetAuthorizationTokenService(instanceURL: account.instanceURL, clientID: account.clientID, presentationContextProvider: self)
|
||||
let code = try await service.run()
|
||||
let mastodonController = MastodonController.getForAccount(account)
|
||||
let token = try await mastodonController.authorize(authorizationCode: code)
|
||||
UserAccountsManager.shared.updateAccessToken(account, token: token, scopes: MastodonController.oauthScopes.map(\.rawValue))
|
||||
// try to revoke the old token
|
||||
try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken, clientID: account.clientID, clientSecret: account.clientSecret).revokeAccessToken()
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Error Updating Permissions", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PreferencesNavigationController: OnboardingViewControllerDelegate {
|
||||
|
@ -124,3 +148,14 @@ extension PreferencesNavigationController: OnboardingViewControllerDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PreferencesNavigationController: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
view.window!
|
||||
}
|
||||
}
|
||||
|
||||
extension Foundation.Notification.Name {
|
||||
static let showMastodonSettings = Notification.Name("Tusker.showMastodonSettings")
|
||||
static let reLogInRequired = Notification.Name("Tusker.reLogInRequired")
|
||||
}
|
||||
|
|
|
@ -143,10 +143,6 @@ struct PreferencesView: View {
|
|||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let showMastodonSettings = Notification.Name("Tusker.showMastodonSettings")
|
||||
}
|
||||
|
||||
//#if DEBUG
|
||||
//struct PreferencesView_Previews : PreviewProvider {
|
||||
// static var previews: some View {
|
||||
|
|
Loading…
Reference in New Issue