Add connections dashboard
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
|
||||
public struct Connection: Codable {
|
||||
public let id: String
|
||||
public let inbound: String
|
||||
public let inboundType: String
|
||||
public let ipVersion: Int32
|
||||
public let network: String
|
||||
public let source: String
|
||||
public let destination: String
|
||||
public let domain: String
|
||||
public let displayDestination: String
|
||||
public let protocolName: String
|
||||
public let user: String
|
||||
public let fromOutbound: String
|
||||
public let createdAt: Date
|
||||
public let closedAt: Date?
|
||||
public var upload: Int64
|
||||
public var download: Int64
|
||||
public var uploadTotal: Int64
|
||||
public var downloadTotal: Int64
|
||||
public let rule: String
|
||||
public let outbound: String
|
||||
public let outboundType: String
|
||||
public let chain: [String]
|
||||
|
||||
var hashValue: Int {
|
||||
var value = id.hashValue
|
||||
(value, _) = value.addingReportingOverflow(upload.hashValue)
|
||||
(value, _) = value.addingReportingOverflow(download.hashValue)
|
||||
(value, _) = value.addingReportingOverflow(uploadTotal.hashValue)
|
||||
(value, _) = value.addingReportingOverflow(downloadTotal.hashValue)
|
||||
return value
|
||||
}
|
||||
|
||||
func performSearch(_ content: String) -> Bool {
|
||||
for item in content.components(separatedBy: " ") {
|
||||
let itemSep = item.components(separatedBy: ":")
|
||||
if itemSep.count == 2 {
|
||||
if !performSearchType(type: itemSep[0], value: itemSep[1]) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !performSearchPlain(item) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func performSearchPlain(_ content: String) -> Bool {
|
||||
destination.contains(content) ||
|
||||
domain.contains(content)
|
||||
}
|
||||
|
||||
private func performSearchType(type: String, value: String) -> Bool {
|
||||
switch type {
|
||||
// TODO: impl more
|
||||
case "network":
|
||||
return network == value
|
||||
case "inbound":
|
||||
return inbound.contains(value)
|
||||
case "inbound.type":
|
||||
return inboundType == value
|
||||
case "source":
|
||||
return source.contains(value)
|
||||
case "destination":
|
||||
return destination.contains(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import SwiftUI
|
||||
|
||||
public struct ConnectionDetailsView: View {
|
||||
private let connection: Connection
|
||||
public init(_ connection: Connection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
if connection.closedAt != nil {
|
||||
FormTextItem("State", "Closed")
|
||||
FormTextItem("Created At", connection.createdAt.myFormat)
|
||||
} else {
|
||||
FormTextItem("State", "Active")
|
||||
FormTextItem("Created At", connection.createdAt.myFormat)
|
||||
}
|
||||
if let closedAt = connection.closedAt {
|
||||
FormTextItem("Closed At", closedAt.myFormat)
|
||||
}
|
||||
FormTextItem("Upload", LibboxFormatBytes(connection.uploadTotal))
|
||||
FormTextItem("Download", LibboxFormatBytes(connection.downloadTotal))
|
||||
Section("Metadata") {
|
||||
FormTextItem("Inbound", connection.inbound)
|
||||
FormTextItem("Inbound Type", connection.inboundType)
|
||||
FormTextItem("IP Version", "\(connection.ipVersion)")
|
||||
FormTextItem("Network", connection.network.uppercased())
|
||||
FormTextItem("Source", connection.source)
|
||||
FormTextItem("Destination", connection.destination)
|
||||
if !connection.domain.isEmpty {
|
||||
FormTextItem("Domain", connection.domain)
|
||||
}
|
||||
if !connection.protocolName.isEmpty {
|
||||
FormTextItem("Protocol", connection.protocolName)
|
||||
}
|
||||
if !connection.user.isEmpty {
|
||||
FormTextItem("User", connection.user)
|
||||
}
|
||||
if !connection.fromOutbound.isEmpty {
|
||||
FormTextItem("From Outbound", connection.fromOutbound)
|
||||
}
|
||||
if !connection.rule.isEmpty {
|
||||
FormTextItem("Match Rule", connection.rule)
|
||||
}
|
||||
FormTextItem("Outbound", connection.outbound)
|
||||
FormTextItem("Outbound Type", connection.outboundType)
|
||||
if connection.chain.count > 1 {
|
||||
FormTextItem("Chain", connection.chain.reversed().joined(separator: "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public enum ConnectionListPage: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
case active
|
||||
case closed
|
||||
}
|
||||
|
||||
public extension ConnectionListPage {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .active:
|
||||
return NSLocalizedString("Active", comment: "")
|
||||
case .closed:
|
||||
return NSLocalizedString("Closed", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
switch self {
|
||||
case .active:
|
||||
return Label(title, systemImage: "play.fill")
|
||||
case .closed:
|
||||
return Label(title, systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ConnectionListView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isLoading = true
|
||||
@StateObject private var commandClient = CommandClient(.connections)
|
||||
@State private var connections: [Connection] = []
|
||||
@State private var selection: ConnectionListPage = .active
|
||||
@State private var searchText = ""
|
||||
@State private var alert: Alert?
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
Text("Loading...")
|
||||
} else {
|
||||
if connections.isEmpty {
|
||||
Text("Empty connections")
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading) {
|
||||
ForEach(connections.filter { it in
|
||||
searchText == "" || it.performSearch(searchText)
|
||||
}, id: \.hashValue) { it in
|
||||
ConnectionView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Menu {
|
||||
Picker("State", selection: $commandClient.connectionStateFilter) {
|
||||
ForEach(ConnectionStateFilter.allCases) { state in
|
||||
Text(state.name)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Sort By", selection: $commandClient.connectionSort) {
|
||||
ForEach(ConnectionSort.allCases, id: \.self) { sortBy in
|
||||
Text(sortBy.name)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Close All Connections", role: .destructive) {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.closeConnections()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Filter", systemImage: "line.3.horizontal.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
.alertBinding($alert)
|
||||
.onAppear {
|
||||
connect()
|
||||
}
|
||||
.onDisappear {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
.onChangeCompat(of: scenePhase) { newValue in
|
||||
if newValue == .active {
|
||||
commandClient.connect()
|
||||
} else {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: commandClient.connectionStateFilter) { it in
|
||||
commandClient.filterConnectionsNow()
|
||||
Task {
|
||||
await SharedPreferences.connectionStateFilter.set(it.rawValue)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: commandClient.connectionSort) { it in
|
||||
commandClient.filterConnectionsNow()
|
||||
Task {
|
||||
await SharedPreferences.connectionSort.set(it.rawValue)
|
||||
}
|
||||
}
|
||||
.onReceive(commandClient.$connections, perform: { connections in
|
||||
if let connections {
|
||||
self.connections = convertConnections(connections)
|
||||
isLoading = false
|
||||
}
|
||||
})
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
#if os(iOS)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
#endif
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
return Color(uiColor: .secondarySystemGroupedBackground)
|
||||
#elseif os(macOS)
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
#elseif os(tvOS)
|
||||
return Color(uiColor: .black)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
if ApplicationLibrary.inPreview {
|
||||
isLoading = false
|
||||
} else {
|
||||
commandClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] {
|
||||
var connections = [Connection]()
|
||||
for goConnection in goConnections {
|
||||
if goConnection.outboundType == "dns" {
|
||||
continue
|
||||
}
|
||||
var closedAt: Date?
|
||||
if goConnection.closedAt > 0 {
|
||||
closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000)
|
||||
}
|
||||
connections.append(Connection(
|
||||
id: goConnection.id_,
|
||||
inbound: goConnection.inbound,
|
||||
inboundType: goConnection.inboundType,
|
||||
ipVersion: goConnection.ipVersion,
|
||||
network: goConnection.network,
|
||||
source: goConnection.source,
|
||||
destination: goConnection.destination,
|
||||
domain: goConnection.domain,
|
||||
displayDestination: goConnection.displayDestination(),
|
||||
protocolName: goConnection.protocol,
|
||||
user: goConnection.user,
|
||||
fromOutbound: goConnection.fromOutbound,
|
||||
createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000),
|
||||
closedAt: closedAt,
|
||||
upload: goConnection.uplink,
|
||||
download: goConnection.downlink,
|
||||
uploadTotal: goConnection.uplinkTotal,
|
||||
downloadTotal: goConnection.downlinkTotal,
|
||||
rule: goConnection.rule,
|
||||
outbound: goConnection.outbound,
|
||||
outboundType: goConnection.outboundType,
|
||||
chain: goConnection.chain()!.toArray()
|
||||
))
|
||||
}
|
||||
return connections
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Libbox
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ConnectionView: View {
|
||||
private let connection: Connection
|
||||
public init(_ connection: Connection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
private func format(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
public func formatInterval(_ createdAt: Date, _ closedAt: Date) -> String {
|
||||
LibboxFormatDuration(Int64((closedAt.timeIntervalSince1970 - createdAt.timeIntervalSince1970) * 1000))
|
||||
}
|
||||
|
||||
@State private var alert: Alert?
|
||||
|
||||
public var body: some View {
|
||||
FormNavigationLink {
|
||||
ConnectionDetailsView(connection)
|
||||
} label: {
|
||||
VStack {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
Text("\(connection.network.uppercased()) \(connection.displayDestination)")
|
||||
Spacer()
|
||||
if connection.closedAt == nil {
|
||||
Text("Active").foregroundStyle(.green)
|
||||
} else {
|
||||
Text("Closed").foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.font(.caption2.monospaced().bold())
|
||||
.padding([.bottom], 4)
|
||||
HStack {
|
||||
if let closedAt = connection.closedAt {
|
||||
VStack(alignment: .leading) {
|
||||
Text("↑ \(LibboxFormatBytes(connection.uploadTotal))")
|
||||
Text("↓ \(LibboxFormatBytes(connection.downloadTotal))")
|
||||
}
|
||||
.font(.caption2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(format(connection.createdAt))
|
||||
Text(formatInterval(connection.createdAt, closedAt))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
Text(connection.inboundType + "/" + connection.inbound)
|
||||
Text(connection.chain.reversed().joined(separator: "/"))
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
Text("↑ \(LibboxFormatBytes(connection.upload))/s")
|
||||
Text("↓ \(LibboxFormatBytes(connection.download))/s")
|
||||
}
|
||||
.font(.caption2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(LibboxFormatBytes(connection.uploadTotal))
|
||||
Text(LibboxFormatBytes(connection.downloadTotal))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
Text(connection.inboundType + "/" + connection.inbound)
|
||||
Text(connection.chain.reversed().joined(separator: "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
}
|
||||
.foregroundColor(.textColor)
|
||||
#if !os(tvOS)
|
||||
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
|
||||
.background(backgroundColor)
|
||||
.cornerRadius(10)
|
||||
#endif
|
||||
}
|
||||
.background(.clear)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.alertBinding($alert)
|
||||
.contextMenu {
|
||||
if connection.closedAt == nil {
|
||||
Button("Close", role: .destructive) {
|
||||
Task {
|
||||
await closeConnection()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
return Color(uiColor: .secondarySystemGroupedBackground)
|
||||
#elseif os(macOS)
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
#elseif os(tvOS)
|
||||
return Color.black
|
||||
#endif
|
||||
}
|
||||
|
||||
private nonisolated func closeConnection() async {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.closeConnection(connection.id)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,23 +50,24 @@ public struct ActiveDashboardView: View {
|
||||
VStack {
|
||||
#if os(iOS) || os(tvOS)
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
|
||||
Picker("Page", selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.label
|
||||
}
|
||||
viewBuilder {
|
||||
#if os(iOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
content1
|
||||
} else {
|
||||
content0
|
||||
}
|
||||
#else
|
||||
content0
|
||||
#endif
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
#if os(iOS)
|
||||
.padding([.leading, .trailing])
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
TabView(selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
.tag(page)
|
||||
}
|
||||
.onAppear {
|
||||
UIScrollView.appearance().isScrollEnabled = false
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
} else {
|
||||
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
}
|
||||
@@ -90,6 +91,45 @@ public struct ActiveDashboardView: View {
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content0: some View {
|
||||
Picker("Page", selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.label
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
#if os(iOS)
|
||||
.padding([.leading, .trailing])
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
TabView(selection: $selection) {
|
||||
ForEach(DashboardPage.enabledCases) { page in
|
||||
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content1: some View {
|
||||
TabView(selection: $selection) {
|
||||
ForEach(DashboardPage.enabledCases) { page in
|
||||
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarTitleMenu {
|
||||
Picker("Page", selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
|
||||
@@ -9,6 +9,22 @@ public enum DashboardPage: Int, CaseIterable, Identifiable {
|
||||
|
||||
case overview
|
||||
case groups
|
||||
case connections
|
||||
}
|
||||
|
||||
public extension DashboardPage {
|
||||
#if !os(tvOS)
|
||||
static var enabledCases: [DashboardPage] = [
|
||||
.overview,
|
||||
.groups,
|
||||
.connections,
|
||||
]
|
||||
#else
|
||||
static var enabledCases: [DashboardPage] = [
|
||||
.overview,
|
||||
.groups,
|
||||
]
|
||||
#endif
|
||||
}
|
||||
|
||||
public extension DashboardPage {
|
||||
@@ -18,15 +34,19 @@ public extension DashboardPage {
|
||||
return NSLocalizedString("Overview", comment: "")
|
||||
case .groups:
|
||||
return NSLocalizedString("Groups", comment: "")
|
||||
case .connections:
|
||||
return NSLocalizedString("Connections", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
switch self {
|
||||
case .overview:
|
||||
return Label("Overview", systemImage: "text.and.command.macwindow")
|
||||
return Label(title, systemImage: "text.and.command.macwindow")
|
||||
case .groups:
|
||||
return Label("Groups", systemImage: "rectangle.3.group.fill")
|
||||
return Label(title, systemImage: "rectangle.3.group.fill")
|
||||
case .connections:
|
||||
return Label(title, systemImage: "list.bullet.rectangle.portrait.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +58,8 @@ public extension DashboardPage {
|
||||
OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled)
|
||||
case .groups:
|
||||
GroupListView()
|
||||
case .connections:
|
||||
ConnectionListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ public struct LogView: View {
|
||||
} else {
|
||||
ScrollViewReader { reader in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in
|
||||
Text(it.element)
|
||||
.font(logFont)
|
||||
|
||||
@@ -10,6 +10,7 @@ public enum NavigationPage: Int, CaseIterable, Identifiable {
|
||||
case dashboard
|
||||
#if os(macOS)
|
||||
case groups
|
||||
case connections
|
||||
#endif
|
||||
case logs
|
||||
case profiles
|
||||
@@ -35,6 +36,8 @@ public extension NavigationPage {
|
||||
#if os(macOS)
|
||||
case .groups:
|
||||
return NSLocalizedString("Groups", comment: "")
|
||||
case .connections:
|
||||
return NSLocalizedString("Connections", comment: "")
|
||||
#endif
|
||||
case .logs:
|
||||
return NSLocalizedString("Logs", comment: "")
|
||||
@@ -52,6 +55,8 @@ public extension NavigationPage {
|
||||
#if os(macOS)
|
||||
case .groups:
|
||||
return "rectangle.3.group.fill"
|
||||
case .connections:
|
||||
return "list.bullet.rectangle.portrait.fill"
|
||||
#endif
|
||||
case .logs:
|
||||
return "doc.text.fill"
|
||||
@@ -71,6 +76,8 @@ public extension NavigationPage {
|
||||
#if os(macOS)
|
||||
case .groups:
|
||||
GroupListView()
|
||||
case .connections:
|
||||
ConnectionListView()
|
||||
#endif
|
||||
case .logs:
|
||||
LogView()
|
||||
@@ -89,7 +96,7 @@ public extension NavigationPage {
|
||||
#if os(macOS)
|
||||
func visible(_ profile: ExtensionProfile?) -> Bool {
|
||||
switch self {
|
||||
case .groups:
|
||||
case .groups, .connections:
|
||||
return profile?.status.isConnectedStrict == true
|
||||
default:
|
||||
return true
|
||||
|
||||
@@ -51,7 +51,7 @@ public struct EditProfileView: View {
|
||||
}
|
||||
if profile.type == .remote {
|
||||
Section("Status") {
|
||||
FormTextItem("Last Updated", profile.lastUpdatedString)
|
||||
FormTextItem("Last Updated", profile.lastUpdated!.myFormat)
|
||||
}
|
||||
}
|
||||
Section("Action") {
|
||||
|
||||
@@ -334,7 +334,7 @@ public struct ProfileView: View {
|
||||
Text(profile.name)
|
||||
if profile.type == .remote {
|
||||
Spacer(minLength: 4)
|
||||
Text("Last Updated: \(profile.origin.lastUpdatedString)").font(.caption)
|
||||
Text("Last Updated: \(profile.origin.lastUpdated!.myFormat)").font(.caption)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
|
||||
Reference in New Issue
Block a user