Swiftformat

This commit is contained in:
Thomas Ricouard 2023-01-17 11:36:01 +01:00
parent 96344e2815
commit 7f6419ebae
161 changed files with 1777 additions and 1746 deletions

1
.swiftformat Normal file
View File

@ -0,0 +1 @@
--indent 2

View File

@ -644,7 +644,7 @@
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 730;
DEVELOPMENT_TEAM = Z6P74P6T99;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesShareExtension/Info.plist;
@ -674,7 +674,7 @@
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 730;
DEVELOPMENT_TEAM = Z6P74P6T99;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesShareExtension/Info.plist;

View File

@ -1,16 +1,16 @@
import Account
import AppAccount
import DesignSystem
import Env
import Lists
import Status
import SwiftUI
import Timeline
import Account
import Env
import Status
import DesignSystem
import Lists
import AppAccount
@MainActor
extension View {
func withAppRouteur() -> some View {
self.navigationDestination(for: RouteurDestinations.self) { destination in
navigationDestination(for: RouteurDestinations.self) { destination in
switch destination {
case let .accountDetail(id):
AccountDetailView(accountId: id)
@ -35,9 +35,9 @@ extension View {
}
}
}
func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
self.sheet(item: sheetDestinations) { destination in
sheet(item: sheetDestinations) { destination in
switch destination {
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
@ -69,10 +69,9 @@ extension View {
}
}
}
func withEnvironments() -> some View {
self
.environmentObject(CurrentAccount.shared)
environmentObject(CurrentAccount.shared)
.environmentObject(UserPreferences.shared)
.environmentObject(CurrentInstance.shared)
.environmentObject(Theme.shared)

View File

@ -1,18 +1,18 @@
import SwiftUI
import AVFoundation
import Timeline
import Network
import KeychainSwift
import Env
import DesignSystem
import RevenueCat
import AppAccount
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import Network
import RevenueCat
import SwiftUI
import Timeline
@main
struct IceCubesApp: App {
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance.shared
@ -21,38 +21,38 @@ struct IceCubesApp: App {
@StateObject private var watcher = StreamWatcher()
@StateObject private var quickLook = QuickLook()
@StateObject private var theme = Theme.shared
@State private var selectedTab: Tab = .timeline
@State private var selectSidebarItem: Tab? = .timeline
@State private var popToRootTab: Tab = .other
@State private var sideBarLoadedTabs: [Tab] = []
private var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
}
var body: some Scene {
WindowGroup {
appView
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(currentInstance)
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(PushNotificationsService.shared)
.sheet(item: $quickLook.url, content: { url in
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
.edgesIgnoringSafeArea(.bottom)
})
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(currentInstance)
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(PushNotificationsService.shared)
.sheet(item: $quickLook.url, content: { url in
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
.edgesIgnoringSafeArea(.bottom)
})
}
.onChange(of: scenePhase) { scenePhase in
handleScenePhase(scenePhase: scenePhase)
@ -64,7 +64,7 @@ struct IceCubesApp: App {
}
}
}
@ViewBuilder
private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
@ -73,14 +73,14 @@ struct IceCubesApp: App {
tabBarView
}
}
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab {
return watcher.unreadNotificationsCount + userPreferences.pushNotificationsCount
}
return 0
}
private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab,
@ -107,7 +107,7 @@ struct IceCubesApp: App {
}
}
}
private var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
@ -132,14 +132,14 @@ struct IceCubesApp: App {
}
}
}
private func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
watcher.setClient(client: client)
}
private func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
@ -156,33 +156,34 @@ struct IceCubesApp: App {
break
}
}
private func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
}
private func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
}

View File

@ -1,6 +1,6 @@
import UIKit
import SwiftUI
import QuickLook
import SwiftUI
import UIKit
extension URL: Identifiable {
public var id: String {
@ -11,7 +11,7 @@ extension URL: Identifiable {
struct QuickLookPreview: UIViewControllerRepresentable {
let selectedURL: URL
let urls: [URL]
func makeUIViewController(context: Context) -> UINavigationController {
let controller = AppQLPreviewCpntroller()
controller.dataSource = context.coordinator
@ -19,30 +19,31 @@ struct QuickLookPreview: UIViewControllerRepresentable {
let nav = UINavigationController(rootViewController: controller)
return nav
}
func updateUIViewController(
_ uiViewController: UINavigationController, context: Context) {}
_: UINavigationController, context _: Context
) {}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
let parent: QuickLookPreview
init(parent: QuickLookPreview) {
self.parent = parent
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
func numberOfPreviewItems(in _: QLPreviewController) -> Int {
return parent.urls.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
func previewController(_: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return parent.urls[index] as QLPreviewItem
}
func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {
func previewController(_: QLPreviewController, editingModeFor _: QLPreviewItem) -> QLPreviewItemEditingMode {
.createCopy
}
}
@ -52,22 +53,22 @@ class AppQLPreviewCpntroller: QLPreviewController {
private var closeButton: UIBarButtonItem {
.init(title: "Done", style: .plain, target: self, action: #selector(onCloseButton))
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = closeButton
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationItem.rightBarButtonItem = closeButton
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
navigationItem.rightBarButtonItem = closeButton
}
@objc private func onCloseButton() {
dismiss(animated: true)
}

View File

@ -1,85 +1,85 @@
import SwiftUI
import SafariServices
import Env
import DesignSystem
import Env
import SafariServices
import SwiftUI
extension View {
func withSafariRouteur() -> some View {
modifier(SafariRouteur())
}
func withSafariRouteur() -> some View {
modifier(SafariRouteur())
}
}
private struct SafariRouteur: ViewModifier {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var routeurPath: RouterPath
@State private var safari: SFSafariViewController?
func body(content: Content) -> some View {
content
.environment(\.openURL, OpenURLAction { url in
routeurPath.handle(url: url)
})
.onAppear {
routeurPath.urlHandler = { url in
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }
// SFSafariViewController only supports initial URLs with http:// or https:// schemes.
guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
return .systemAction
}
let safari = SFSafariViewController(url: url)
safari.preferredBarTintColor = UIColor(theme.primaryBackgroundColor)
safari.preferredControlTintColor = UIColor(theme.tintColor)
self.safari = safari
return .handled
}
}
.background {
SafariPresenter(safari: safari)
}
}
struct SafariPresenter: UIViewRepresentable {
var safari: SFSafariViewController?
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let safari = safari, let viewController = uiView.findTopViewController() else { return }
viewController.present(safari, animated: true)
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var routeurPath: RouterPath
@State private var safari: SFSafariViewController?
func body(content: Content) -> some View {
content
.environment(\.openURL, OpenURLAction { url in
routeurPath.handle(url: url)
})
.onAppear {
routeurPath.urlHandler = { url in
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }
// SFSafariViewController only supports initial URLs with http:// or https:// schemes.
guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
return .systemAction
}
let safari = SFSafariViewController(url: url)
safari.preferredBarTintColor = UIColor(theme.primaryBackgroundColor)
safari.preferredControlTintColor = UIColor(theme.tintColor)
self.safari = safari
return .handled
}
}
.background {
SafariPresenter(safari: safari)
}
}
struct SafariPresenter: UIViewRepresentable {
var safari: SFSafariViewController?
func makeUIView(context _: Context) -> UIView {
let view = UIView(frame: .zero)
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ uiView: UIView, context _: Context) {
guard let safari = safari, let viewController = uiView.findTopViewController() else { return }
viewController.present(safari, animated: true)
}
}
}
private extension UIView {
func findTopViewController() -> UIViewController? {
if let nextResponder = self.next as? UIViewController {
return nextResponder.topViewController()
} else if let nextResponder = self.next as? UIView {
return nextResponder.findTopViewController()
} else {
return nil
}
func findTopViewController() -> UIViewController? {
if let nextResponder = next as? UIViewController {
return nextResponder.topViewController()
} else if let nextResponder = next as? UIView {
return nextResponder.findTopViewController()
} else {
return nil
}
}
}
private extension UIViewController {
func topViewController() -> UIViewController? {
if let nvc = self as? UINavigationController {
return nvc.visibleViewController?.topViewController()
} else if let tbc = self as? UITabBarController, let selected = tbc.selectedViewController {
return selected.topViewController()
} else if let presented = self.presentedViewController {
return presented.topViewController()
}
return self
func topViewController() -> UIViewController? {
if let nvc = self as? UINavigationController {
return nvc.visibleViewController?.topViewController()
} else if let tbc = self as? UITabBarController, let selected = tbc.selectedViewController {
return selected.topViewController()
} else if let presented = presentedViewController {
return presented.topViewController()
}
return self
}
}

View File

@ -1,18 +1,18 @@
import SwiftUI
import Env
import Account
import DesignSystem
import AppAccount
import DesignSystem
import Env
import SwiftUI
struct SideBarView<Content: View>: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var theme: Theme
@Binding var selectedTab: Tab
@Binding var popToRootTab: Tab
var tabs: [Tab]
@ViewBuilder var content: () -> Content
var body: some View {
HStack(spacing: 0) {
VStack(alignment: .center) {

View File

@ -1,11 +1,10 @@
import SwiftUI
import Env
import Models
import Shimmer
import Explore
import Env
import Network
import AppAccount
import Env
import Explore
import Models
import Network
import Shimmer
import SwiftUI
struct ExploreTab: View {
@EnvironmentObject private var preferences: UserPreferences
@ -13,7 +12,7 @@ struct ExploreTab: View {
@EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routeurPath.path) {
ExploreView()

View File

@ -1,12 +1,11 @@
import SwiftUI
import Env
import Network
import Account
import Models
import Shimmer
import AppAccount
import Conversations
import Env
import AppAccount
import Models
import Network
import Shimmer
import SwiftUI
struct MessagesTab: View {
@EnvironmentObject private var watcher: StreamWatcher
@ -14,7 +13,7 @@ struct MessagesTab: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routeurPath.path) {
ConversationsListView()

View File

@ -1,9 +1,9 @@
import SwiftUI
import Timeline
import AppAccount
import Env
import Network
import Notifications
import AppAccount
import SwiftUI
import Timeline
struct NotificationsTab: View {
@EnvironmentObject private var client: Client
@ -12,7 +12,7 @@ struct NotificationsTab: View {
@EnvironmentObject private var userPreferences: UserPreferences
@StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routeurPath.path) {
NotificationsListView()

View File

@ -1,23 +1,23 @@
import SwiftUI
import Network
import Models
import Env
import DesignSystem
import NukeUI
import Shimmer
import AppAccount
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import Shimmer
import SwiftUI
struct AddAccountView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var theme: Theme
@State private var instanceName: String = ""
@State private var instance: Instance?
@State private var isSigninIn = false
@ -26,9 +26,9 @@ struct AddAccountView: View {
@State private var instanceFetchError: String?
private let instanceNamePublisher = PassthroughSubject<String, Never>()
@FocusState private var isInstanceURLFieldFocused: Bool
var body: some View {
NavigationStack {
Form {
@ -102,7 +102,7 @@ struct AddAccountView: View {
})
}
}
private var signInSection: some View {
Section {
Button {
@ -127,13 +127,13 @@ struct AddAccountView: View {
}
.listRowBackground(theme.tintColor)
}
private var instancesListView: some View {
Section("Suggestions") {
if instances.isEmpty {
placeholderRow
} else {
ForEach(instanceName.isEmpty ? instances : instances.filter{ $0.name.contains(instanceName.lowercased()) }) { instance in
ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in
Button {
self.instanceName = instance.name
} label: {
@ -154,7 +154,7 @@ struct AddAccountView: View {
}
}
}
private var placeholderRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Loading...")
@ -171,7 +171,7 @@ struct AddAccountView: View {
.shimmering()
.listRowBackground(theme.primaryBackgroundColor)
}
private func signIn() async {
do {
signInClient = .init(server: instanceName)
@ -184,7 +184,7 @@ struct AddAccountView: View {
isSigninIn = false
}
}
private func continueSignIn(url: URL) async {
guard let client = signInClient else {
isSigninIn = false

View File

@ -1,13 +1,13 @@
import SwiftUI
import Models
import DesignSystem
import Models
import Status
import SwiftUI
struct DisplaySettingsView: View {
@EnvironmentObject private var theme: Theme
@State private var isThemeSelectorPresented = false
var body: some View {
Form {
Section("Theme") {
@ -17,7 +17,7 @@ struct DisplaySettingsView: View {
ColorPicker("Secondary Background color", selection: $theme.secondaryBackgroundColor)
}
.listRowBackground(theme.primaryBackgroundColor)
Section("Display") {
Picker("Avatar position", selection: $theme.avatarPosition) {
ForEach(Theme.AvatarPosition.allCases, id: \.rawValue) { position in
@ -34,7 +34,7 @@ struct DisplaySettingsView: View {
Text(buttonStyle.description).tag(buttonStyle)
}
}
Picker("Status media style", selection: $theme.statusDisplayStyle) {
ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in
Text(buttonStyle.description).tag(buttonStyle)
@ -42,7 +42,7 @@ struct DisplaySettingsView: View {
}
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
Button {
theme.selectedSet = .iceCubeDark
@ -59,7 +59,7 @@ struct DisplaySettingsView: View {
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
}
private var themeSelectorButton: some View {
NavigationLink(destination: ThemePreviewView()) {
HStack {

View File

@ -1,12 +1,12 @@
import SwiftUI
import DesignSystem
import SwiftUI
struct IconSelectorView: View {
enum Icon: Int, CaseIterable, Identifiable {
var id: String {
"\(rawValue)"
}
init(string: String) {
if string == Icon.primary.appIconName {
self = .primary
@ -14,12 +14,12 @@ struct IconSelectorView: View {
self = .init(rawValue: Int(String(string.last!))!)!
}
}
case primary = 0
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8
case alt9, alt10, alt11, alt12, alt13, alt14
case alt15, alt16, alt17, alt18, alt19
var appIconName: String {
switch self {
case .primary:
@ -28,17 +28,17 @@ struct IconSelectorView: View {
return "AppIconAlternate\(rawValue)"
}
}
var iconName: String {
"icon\(rawValue)"
}
}
@EnvironmentObject private var theme: Theme
@State private var currentIcon = UIApplication.shared.alternateIconName ?? Icon.primary.appIconName
private let columns = [GridItem(.adaptive(minimum: 125, maximum: 1024))]
var body: some View {
ScrollView {
VStack(alignment: .leading) {

View File

@ -1,13 +1,13 @@
import SwiftUI
import Models
import DesignSystem
import Models
import NukeUI
import SwiftUI
struct InstanceInfoView: View {
@EnvironmentObject private var theme: Theme
let instance: Instance
var body: some View {
Form {
InstanceInfoSection(instance: instance)
@ -20,9 +20,9 @@ struct InstanceInfoView: View {
public struct InstanceInfoSection: View {
@EnvironmentObject private var theme: Theme
let instance: Instance
public var body: some View {
Section("Instance info") {
LabeledContent("Name", value: instance.title)
@ -34,7 +34,7 @@ public struct InstanceInfoSection: View {
LabeledContent("Domains", value: "\(instance.stats.domainCount)")
}
.listRowBackground(theme.primaryBackgroundColor)
Section("Instance rules") {
ForEach(instance.rules) { rule in
Text(rule.text)

View File

@ -1,19 +1,19 @@
import SwiftUI
import Models
import DesignSystem
import NukeUI
import Network
import UserNotifications
import Env
import AppAccount
import DesignSystem
import Env
import Models
import Network
import NukeUI
import SwiftUI
import UserNotifications
struct PushNotificationsView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotificationsService
@State private var subscriptions: [PushSubscription] = []
var body: some View {
Form {
Section {
@ -24,7 +24,7 @@ struct PushNotificationsView: View {
Text("Receive push notifications on new activities")
}
.listRowBackground(theme.primaryBackgroundColor)
if pushNotifications.isPushEnabled {
Section {
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) {
@ -87,7 +87,7 @@ struct PushNotificationsView: View {
updateSubscriptions()
}
}
private func updateSubscriptions() {
Task {
await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts)

View File

@ -1,11 +1,11 @@
import Account
import AppAccount
import DesignSystem
import Env
import Models
import Network
import SwiftUI
import Timeline
import Env
import Network
import Account
import Models
import DesignSystem
import AppAccount
struct SettingsTabs: View {
@EnvironmentObject private var pushNotifications: PushNotificationsService
@ -14,13 +14,13 @@ struct SettingsTabs: View {
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var theme: Theme
@StateObject private var routeurPath = RouterPath()
@State private var addAccountSheetPresented = false
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routeurPath.path) {
Form {
@ -52,7 +52,7 @@ struct SettingsTabs: View {
}
}
}
private var accountsSection: some View {
Section("Accounts") {
ForEach(appAccountsManager.availableAccounts) { account in
@ -73,7 +73,7 @@ struct SettingsTabs: View {
}
.listRowBackground(theme.primaryBackgroundColor)
}
@ViewBuilder
private var generalSection: some View {
Section("General") {
@ -106,7 +106,7 @@ struct SettingsTabs: View {
}
.listRowBackground(theme.primaryBackgroundColor)
}
private var appSection: some View {
Section("App") {
NavigationLink(destination: IconSelectorView()) {
@ -121,19 +121,19 @@ struct SettingsTabs: View {
}
}
}
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp")!) {
Label("Source (GitHub link)", systemImage: "link")
}
.tint(theme.labelColor)
NavigationLink(destination: SupportAppView()) {
Label("Support the app", systemImage: "wand.and.stars")
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
private var addAccountButton: some View {
Button {
addAccountSheetPresented.toggle()
@ -144,7 +144,7 @@ struct SettingsTabs: View {
AddAccountView()
}
}
private var remoteLocalTimelinesView: some View {
Form {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in

View File

@ -1,21 +1,21 @@
import SwiftUI
import Env
import DesignSystem
import Env
import RevenueCat
import Shimmer
import SwiftUI
struct SupportAppView: View {
enum Tips: String, CaseIterable {
case one, two, three
init(productId: String) {
self = .init(rawValue: String(productId.split(separator: ".")[2]))!
}
var productId: String {
"icecubes.tipjar.\(rawValue)"
}
var title: String {
switch self {
case .one:
@ -26,7 +26,7 @@ struct SupportAppView: View {
return "🤯 Generous Tip"
}
}
var subtitle: String {
switch self {
case .one:
@ -38,15 +38,15 @@ struct SupportAppView: View {
}
}
}
@EnvironmentObject private var theme: Theme
@State private var loadingProducts: Bool = false
@State private var products: [StoreProduct] = []
@State private var isProcessingPurchase: Bool = false
@State private var purchaseSuccessDisplayed: Bool = false
@State private var purchaseErrorDisplayed: Bool = false
var body: some View {
Form {
Section {
@ -65,7 +65,7 @@ struct SupportAppView: View {
}
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
if loadingProducts {
HStack {
@ -133,7 +133,7 @@ struct SupportAppView: View {
})
.onAppear {
loadingProducts = true
Purchases.shared.getProducts(Tips.allCases.map{ $0.productId }) { products in
Purchases.shared.getProducts(Tips.allCases.map { $0.productId }) { products in
self.products = products.sorted(by: { $0.price < $1.price })
withAnimation {
loadingProducts = false

View File

@ -1,22 +1,22 @@
import Foundation
import Status
import Account
import Explore
import Foundation
import Status
import SwiftUI
enum Tab: Int, Identifiable, Hashable {
case timeline, notifications, explore, messages, settings, other
case trending, federated, local
case profile
var id: Int {
rawValue
}
static func loggedOutTab() -> [Tab] {
[.timeline, .settings]
}
static func loggedInTabs() -> [Tab] {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
return [.timeline, .trending, .federated, .local, .notifications, .explore, .messages, .settings]
@ -24,7 +24,7 @@ enum Tab: Int, Identifiable, Hashable {
return [.timeline, .notifications, .explore, .messages, .settings]
}
}
@ViewBuilder
func makeContentView(popToRootTab: Binding<Tab>) -> some View {
switch self {
@ -48,7 +48,7 @@ enum Tab: Int, Identifiable, Hashable {
EmptyView()
}
}
@ViewBuilder
var label: some View {
switch self {
@ -72,7 +72,7 @@ enum Tab: Int, Identifiable, Hashable {
EmptyView()
}
}
var iconName: String {
switch self {
case .timeline:
@ -96,4 +96,3 @@ enum Tab: Int, Identifiable, Hashable {
}
}
}

View File

@ -1,26 +1,26 @@
import SwiftUI
import Network
import Models
import Env
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import Shimmer
import Combine
import SwiftUI
struct AddRemoteTimelineView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@State private var instanceName: String = ""
@State private var instance: Instance?
@State private var instances: [InstanceSocial] = []
private let instanceNamePublisher = PassthroughSubject<String, Never>()
@FocusState private var isInstanceURLFieldFocused: Bool
var body: some View {
NavigationStack {
Form {
@ -44,7 +44,7 @@ struct AddRemoteTimelineView: View {
Text("Add")
}
.listRowBackground(theme.primaryBackgroundColor)
instancesListView
}
.formStyle(.grouped)
@ -76,14 +76,14 @@ struct AddRemoteTimelineView: View {
}
}
}
private var instancesListView: some View {
Section("Suggestions") {
if instances.isEmpty {
ProgressView()
.listRowBackground(theme.primaryBackgroundColor)
} else {
ForEach(instanceName.isEmpty ? instances : instances.filter{ $0.name.contains(instanceName.lowercased()) }) { instance in
ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in
Button {
self.instanceName = instance.name
} label: {

View File

@ -1,11 +1,11 @@
import SwiftUI
import Timeline
import Env
import Network
import AppAccount
import Combine
import DesignSystem
import Env
import Models
import AppAccount
import Network
import SwiftUI
import Timeline
struct TimelineTab: View {
@EnvironmentObject private var theme: Theme
@ -14,19 +14,19 @@ struct TimelineTab: View {
@EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab
@State private var didAppear: Bool = false
@State private var timeline: TimelineFilter
@State private var scrollToTopSignal: Int = 0
private let canFilterTimeline: Bool
init(popToRootTab: Binding<Tab>, timeline: TimelineFilter? = nil) {
canFilterTimeline = timeline == nil
self.timeline = timeline ?? .home
_popToRootTab = popToRootTab
}
var body: some View {
NavigationStack(path: $routeurPath.path) {
TimelineView(timeline: $timeline, scrollToTopSignal: $scrollToTopSignal)
@ -71,8 +71,7 @@ struct TimelineTab: View {
.withSafariRouteur()
.environmentObject(routeurPath)
}
@ViewBuilder
private var timelineFilterButton: some View {
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
@ -93,7 +92,7 @@ struct TimelineTab: View {
}
}
}
if !currentAccount.tags.isEmpty {
Menu("Followed Tags") {
ForEach(currentAccount.tags) { tag in
@ -105,7 +104,7 @@ struct TimelineTab: View {
}
}
}
Menu("Local Timelines") {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
Button {
@ -121,7 +120,7 @@ struct TimelineTab: View {
}
}
}
private var addAccountButton: some View {
Button {
routeurPath.presentedSheet = .addAccount
@ -129,7 +128,7 @@ struct TimelineTab: View {
Image(systemName: "person.badge.plus")
}
}
@ToolbarContentBuilder
private var toolbarView: some ToolbarContent {
if canFilterTimeline {

View File

@ -1,71 +1,75 @@
import UserNotifications
import KeychainSwift
import Env
import CryptoKit
import Env
import KeychainSwift
import Models
import UIKit
import UserNotifications
@MainActor
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent {
let privateKey = PushNotificationsService.shared.notificationsPrivateKeyAsKey
let auth = PushNotificationsService.shared.notificationsAuthKeyAsKey
guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String,
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else {
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64())
else {
contentHandler(bestAttemptContent)
return
}
guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String,
let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()),
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) else {
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
else {
contentHandler(bestAttemptContent)
return
}
guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String,
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64()) else {
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64())
else {
contentHandler(bestAttemptContent)
return
}
guard let plaintextData = NotificationService.decrypt(payload: payload,
salt: salt,
auth: auth,
privateKey: privateKey,
publicKey: publicKey),
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else {
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData)
else {
contentHandler(bestAttemptContent)
return
}
bestAttemptContent.title = notification.title
bestAttemptContent.subtitle = ""
bestAttemptContent.body = notification.body.escape()
bestAttemptContent.userInfo["plaintext"] = plaintextData
bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "glass.wav"))
bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "glass.wav"))
let preferences = UserPreferences.shared
preferences.pushNotificationsCount += 1
bestAttemptContent.badge = .init(integerLiteral: preferences.pushNotificationsCount)
if let urlString = notification.icon,
let url = URL(string: urlString) {
let url = URL(string: urlString)
{
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments")
try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
let filename = url.lastPathComponent
let fileURL = temporaryDirectoryURL.appendingPathComponent(filename)
Task {
if let (data, _) = try? await URLSession.shared.data(for: .init(url: url)) {
if let image = UIImage(data: data) {

View File

@ -1,26 +1,26 @@
import Foundation
import CryptoKit
import Foundation
extension NotificationService {
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
return nil
}
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceData = nonce.withUnsafeBytes(Array.init)
guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else {
return nil
}
var _plaintext: Data?
do {
_plaintext = try AES.GCM.open(sealedBox, using: key)
@ -30,20 +30,20 @@ extension NotificationService {
guard let plaintext = _plaintext else {
return nil
}
let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1])
guard plaintext.count >= 2 + paddingLength else {
print("1")
fatalError()
}
let unpadded = plaintext.suffix(from: paddingLength + 2)
return Data(unpadded)
}
static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
private static func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
var info = Data()
info.append("Content-Encoding: ".data(using: .utf8)!)
info.append(type.data(using: .utf8)!)
info.append(0)
@ -55,32 +55,29 @@ extension NotificationService {
info.append(0)
info.append(65)
info.append(serverPublicKey)
return info
}
}
extension String {
func escape() -> String {
return self
.replacingOccurrences(of: "&amp;", with: "&")
return replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&#39;", with: "")
}
func URLSafeBase64ToBase64() -> String {
var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let countMod4 = count % 4
if countMod4 != 0 {
base64.append(String(repeating: "=", count: 4 - countMod4))
}
return base64
}
}

View File

@ -8,14 +8,14 @@
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>4</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionMainStoryboard</key>

View File

@ -1,18 +1,18 @@
import Account
import AppAccount
import DesignSystem
import Env
import Network
import Status
import SwiftUI
import UIKit
import Status
import DesignSystem
import Account
import Network
import Env
import AppAccount
class ShareViewController: UIViewController {
@IBOutlet var container: UIView!
override func viewDidLoad() {
super.viewDidLoad()
let appAccountsManager = AppAccountsManager.shared
let client = appAccountsManager.currentClient
let account = CurrentAccount.shared
@ -22,7 +22,7 @@ class ShareViewController: UIViewController {
let colorScheme = traitCollection.userInterfaceStyle
let theme = Theme.shared
theme.setColor(withName: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
if let item = extensionContext?.inputItems.first as? NSExtensionItem {
if let attachments = item.attachments {
let view = StatusEditorView(mode: .shareExtension(items: attachments))
@ -35,24 +35,24 @@ class ShareViewController: UIViewController {
.tint(theme.tintColor)
.preferredColorScheme(colorScheme == .light ? .light : .dark)
let childView = UIHostingController(rootView: view)
self.addChild(childView)
childView.view.frame = self.container.bounds
self.container.addSubview(childView.view)
addChild(childView)
childView.view.frame = container.bounds
container.addSubview(childView.view)
childView.didMove(toParent: self)
}
}
NotificationCenter.default.addObserver(forName: NotificationsName.shareSheetClose,
object: nil,
queue: nil) { _ in
self.close()
self.close()
}
}
func close() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}

View File

@ -11,7 +11,8 @@ let package = Package(
products: [
.library(
name: "Account",
targets: ["Account"]),
targets: ["Account"]
),
],
dependencies: [
.package(name: "Network", path: "../Network"),
@ -25,9 +26,11 @@ let package = Package(
.product(name: "Network", package: "Network"),
.product(name: "Models", package: "Models"),
.product(name: "Status", package: "Status"),
]),
]
),
.testTarget(
name: "AccountTests",
dependencies: ["Account"]),
dependencies: ["Account"]
),
]
)

View File

@ -1,23 +1,23 @@
import SwiftUI
import Models
import DesignSystem
import Env
import Shimmer
import NukeUI
import EmojiText
import Env
import Models
import NukeUI
import Shimmer
import SwiftUI
struct AccountDetailHeaderView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var quickLook: QuickLook
@EnvironmentObject private var routeurPath: RouterPath
@Environment(\.redactionReasons) private var reasons
@ObservedObject var viewModel: AccountDetailViewModel
let account: Account
let scrollViewProxy: ScrollViewProxy?
@Binding var scrollOffset: CGFloat
private var bannerHeight: CGFloat {
200 + (scrollOffset > 0 ? scrollOffset * 2 : 0)
}
@ -28,9 +28,9 @@ struct AccountDetailHeaderView: View {
accountInfoView
}
}
private var headerImageView: some View {
GeometryReader { proxy in
GeometryReader { _ in
ZStack(alignment: .bottomTrailing) {
if reasons.contains(.placeholder) {
Rectangle()
@ -53,7 +53,7 @@ struct AccountDetailHeaderView: View {
}
.frame(height: bannerHeight)
}
if viewModel.relationship?.followedBy == true {
Text("Follows You")
.font(.footnote)
@ -75,15 +75,15 @@ struct AccountDetailHeaderView: View {
}
}
}
private var accountAvatarView: some View {
HStack {
AvatarView(url: account.avatar, size: .account)
.onTapGesture {
Task {
await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar)
.onTapGesture {
Task {
await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar)
}
}
}
Spacer()
Group {
Button {
@ -102,17 +102,17 @@ struct AccountDetailHeaderView: View {
}.offset(y: 20)
}
}
private var accountInfoView: some View {
Group {
accountAvatarView
HStack {
VStack(alignment: .leading, spacing: 0) {
EmojiTextApp(account.safeDisplayName.asMarkdown, emojis: account.emojis)
.font(.headline)
.font(.headline)
Text("@\(account.acct)")
.font(.callout)
.foregroundColor(.gray)
.font(.callout)
.foregroundColor(.gray)
}
Spacer()
if let relationship = viewModel.relationship, !viewModel.isCurrentUser {
@ -133,7 +133,7 @@ struct AccountDetailHeaderView: View {
.padding(.horizontal, .layoutPadding)
.offset(y: -40)
}
private func makeCustomInfoLabel(title: String, count: Int) -> some View {
VStack {
Text("\(count)")

View File

@ -1,13 +1,13 @@
import SwiftUI
import DesignSystem
import EmojiText
import Env
import Models
import Network
import Status
import Shimmer
import DesignSystem
import Env
import EmojiText
import Status
import SwiftUI
public struct AccountDetailView: View {
public struct AccountDetailView: View {
@Environment(\.redactionReasons) private var reasons
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var currentAccount: CurrentAccount
@ -15,7 +15,7 @@ public struct AccountDetailView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@EnvironmentObject private var routeurPath: RouterPath
@StateObject private var viewModel: AccountDetailViewModel
@State private var scrollOffset: CGFloat = 0
@State private var isFieldsSheetDisplayed: Bool = false
@ -23,17 +23,17 @@ public struct AccountDetailView: View {
@State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = ""
@State private var isEditingAccount: Bool = false
/// When coming from a URL like a mention tap in a status.
public init(accountId: String) {
_viewModel = StateObject(wrappedValue: .init(accountId: accountId))
}
/// When the account is already fetched by the parent caller.
public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account))
}
public var body: some View {
ScrollViewReader { proxy in
ScrollViewOffsetReader { offset in
@ -58,7 +58,7 @@ public struct AccountDetailView: View {
.offset(y: -20)
}
.id("status")
switch viewModel.tabState {
case .statuses:
if viewModel.selectedTab == .statuses {
@ -94,9 +94,10 @@ public struct AccountDetailView: View {
await viewModel.fetchStatuses()
}
}
.onChange(of: watcher.latestEvent?.id) { id in
.onChange(of: watcher.latestEvent?.id) { _ in
if let latestEvent = watcher.latestEvent,
viewModel.accountId == currentAccount.account?.id {
viewModel.accountId == currentAccount.account?.id
{
viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount)
}
}
@ -117,7 +118,7 @@ public struct AccountDetailView: View {
toolbarContent
}
}
@ViewBuilder
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
switch viewModel.accountState {
@ -137,7 +138,7 @@ public struct AccountDetailView: View {
Text("Error: \(error.localizedDescription)")
}
}
@ViewBuilder
private var featuredTagsView: some View {
if !viewModel.featuredTags.isEmpty || !viewModel.fields.isEmpty {
@ -178,7 +179,7 @@ public struct AccountDetailView: View {
}
}
}
@ViewBuilder
private var familliarFollowers: some View {
if !viewModel.familliarFollowers.isEmpty {
@ -203,7 +204,7 @@ public struct AccountDetailView: View {
.padding(.bottom, 12)
}
}
private var fieldSheetView: some View {
NavigationStack {
List {
@ -244,7 +245,7 @@ public struct AccountDetailView: View {
}
}
}
private var tagsListView: some View {
Group {
ForEach(currentAccount.tags) { tag in
@ -260,7 +261,7 @@ public struct AccountDetailView: View {
await currentAccount.fetchFollowedTags()
}
}
private var listsListView: some View {
Group {
ForEach(currentAccount.lists) { list in
@ -309,7 +310,7 @@ public struct AccountDetailView: View {
Text("Enter the name for your list")
}
}
@ViewBuilder
private var pinnedPostsView: some View {
if !viewModel.pinned.isEmpty {
@ -327,7 +328,7 @@ public struct AccountDetailView: View {
}
}
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) {
@ -341,7 +342,7 @@ public struct AccountDetailView: View {
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
if let account = viewModel.account {
@ -360,7 +361,7 @@ public struct AccountDetailView: View {
}
Divider()
}
if viewModel.relationship?.following == true {
Button {
routeurPath.presentedSheet = .listAddAccount(account: account)
@ -368,13 +369,13 @@ public struct AccountDetailView: View {
Label("Add/Remove from lists", systemImage: "list.bullet")
}
}
if let url = account.url {
ShareLink(item: url)
}
Divider()
if isCurrentUser {
Button {
isEditingAccount = true
@ -396,4 +397,3 @@ struct AccountDetailView_Previews: PreviewProvider {
AccountDetailView(account: .placeholder())
}
}

View File

@ -1,30 +1,30 @@
import SwiftUI
import Network
import Models
import Status
import Env
import Models
import Network
import Status
import SwiftUI
@MainActor
class AccountDetailViewModel: ObservableObject, StatusesFetcher {
let accountId: String
var client: Client?
var isCurrentUser: Bool = false
enum AccountState {
case loading, data(account: Account), error(error: Error)
}
enum Tab: Int {
case statuses, favourites, bookmarks, followedTags, postsAndReplies, media, lists
static var currentAccountTabs: [Tab] {
[.statuses, .favourites, .bookmarks, .followedTags, .lists]
}
static var accountTabs: [Tab] {
[.statuses, .postsAndReplies, .media]
}
var iconName: String {
switch self {
case .statuses: return "bubble.right"
@ -37,13 +37,13 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
}
}
}
enum TabState {
case followedTags
case statuses(statusesState: StatusesState)
case lists
}
@Published var accountState: AccountState = .loading
@Published var tabState: TabState = .statuses(statusesState: .loading) {
didSet {
@ -57,8 +57,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
}
}
}
@Published var statusesState: StatusesState = .loading
@Published var relationship: Relationshionship?
@Published var pinned: [Status] = []
@Published var favourites: [Status] = []
@ -81,45 +82,45 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
}
}
}
private(set) var account: Account?
private var tabTask: Task<Void, Never>?
private(set) var statuses: [Status] = []
/// When coming from a URL like a mention tap in a status.
init(accountId: String) {
self.accountId = accountId
self.isCurrentUser = false
isCurrentUser = false
}
/// When the account is already fetched by the parent caller.
init(account: Account) {
self.accountId = account.id
accountId = account.id
self.account = account
self.accountState = .data(account: account)
accountState = .data(account: account)
}
struct AccountData {
let account: Account
let featuredTags: [FeaturedTag]
let relationships: [Relationshionship]
let familliarFollowers: [FamilliarAccounts]
}
func fetchAccount() async {
guard let client else { return }
do {
let data = try await fetchAccountData(accountId: accountId, client: client)
accountState = .data(account: data.account)
account = data.account
fields = data.account.fields
featuredTags = data.featuredTags
featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt }
relationship = data.relationships.first
familliarFollowers = data.familliarFollowers.first?.accounts ?? []
} catch {
if let account {
accountState = .data(account: account)
@ -128,7 +129,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
}
}
}
func fetchAccountData(accountId: String, client: Client) async throws -> AccountData {
async let account: Account = client.get(endpoint: Accounts.accounts(id: accountId))
async let featuredTags: [FeaturedTag] = client.get(endpoint: Accounts.featuredTags(id: accountId))
@ -145,26 +146,26 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
relationships: [],
familliarFollowers: [])
}
func fetchStatuses() async {
guard let client else { return }
do {
tabState = .statuses(statusesState: .loading)
statuses =
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: nil,
tag: nil,
onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: nil))
if selectedTab == .statuses {
pinned =
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: nil,
tag: nil,
onlyMedia: nil,
excludeReplies: nil,
pinned: true))
onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: nil))
if selectedTab == .statuses {
pinned =
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: nil,
tag: nil,
onlyMedia: nil,
excludeReplies: nil,
pinned: true))
}
if isCurrentUser {
(favourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nil))
@ -175,7 +176,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .statuses(statusesState: .error(error: error))
}
}
func fetchNextPage() async {
guard let client else { return }
do {
@ -184,12 +185,12 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
guard let lastId = statuses.last?.id else { return }
tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .loadingNextPage))
let newStatuses: [Status] =
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: lastId,
tag: nil,
onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: nil))
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: lastId,
tag: nil,
onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: nil))
statuses.append(contentsOf: newStatuses)
tabState = .statuses(statusesState: .display(statuses: statuses,
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage))
@ -212,7 +213,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .statuses(statusesState: .error(error: error))
}
}
private func reloadTabState() {
switch selectedTab {
case .statuses, .postsAndReplies, .media:
@ -229,7 +230,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .lists
}
}
func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) {
if let event = event as? StreamEventUpdate {
if event.status.account.id == currentAccount.account?.id {

View File

@ -1,17 +1,17 @@
import SwiftUI
import DesignSystem
import EmojiText
import Env
import Models
import Network
import DesignSystem
import Env
import EmojiText
import SwiftUI
@MainActor
public class AccountsListRowViewModel: ObservableObject {
var client: Client?
@Published var account: Account
@Published var relationShip: Relationshionship
public init(account: Account, relationShip: Relationshionship) {
self.account = account
self.relationShip = relationShip
@ -22,13 +22,13 @@ public struct AccountsListRow: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject private var client: Client
@StateObject var viewModel: AccountsListRowViewModel
public init(viewModel: AccountsListRowViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
public var body: some View {
HStack(alignment: .top) {
AvatarView(url: viewModel.account.avatar, size: .status)

View File

@ -1,25 +1,25 @@
import SwiftUI
import Network
import Models
import Env
import Shimmer
import DesignSystem
import Env
import Models
import Network
import Shimmer
import SwiftUI
public struct AccountsListView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@StateObject private var viewModel: AccountsListViewModel
@State private var didAppear: Bool = false
public init(mode: AccountsListMode) {
_viewModel = StateObject(wrappedValue: .init(mode: mode))
}
public var body: some View {
List {
switch viewModel.state {
case .loading:
ForEach(Account.placeholders()) { account in
ForEach(Account.placeholders()) { _ in
AccountsListRow(viewModel: .init(account: .placeholder(), relationShip: .placeholder()))
.redacted(reason: .placeholder)
.shimmering()
@ -30,10 +30,10 @@ public struct AccountsListView: View {
if let relationship = relationships.first(where: { $0.id == account.id }) {
AccountsListRow(viewModel: .init(account: account,
relationShip: relationship))
.listRowBackground(theme.primaryBackgroundColor)
.listRowBackground(theme.primaryBackgroundColor)
}
}
switch nextPageState {
case .hasNextPage:
loadingRow
@ -43,14 +43,14 @@ public struct AccountsListView: View {
await viewModel.fetchNextPage()
}
}
case .loadingNextPage:
loadingRow
.listRowBackground(theme.primaryBackgroundColor)
case .none:
EmptyView()
}
case let .error(error):
Text(error.localizedDescription)
.listRowBackground(theme.primaryBackgroundColor)
@ -63,12 +63,12 @@ public struct AccountsListView: View {
.navigationBarTitleDisplayMode(.inline)
.task {
viewModel.client = client
guard !didAppear else { return}
guard !didAppear else { return }
didAppear = true
await viewModel.fetch()
}
}
private var loadingRow: some View {
HStack {
Spacer()

View File

@ -1,11 +1,11 @@
import SwiftUI
import Models
import Network
import SwiftUI
public enum AccountsListMode {
case following(accountId: String), followers(accountId: String)
case favouritedBy(statusId: String), rebloggedBy(statusId: String)
var title: String {
switch self {
case .following:
@ -23,31 +23,32 @@ public enum AccountsListMode {
@MainActor
class AccountsListViewModel: ObservableObject {
var client: Client?
let mode: AccountsListMode
public enum State {
public enum PagingState {
case hasNextPage, loadingNextPage, none
}
case loading
case display(accounts: [Account],
relationships: [Relationshionship],
nextPageState: PagingState)
case error(error: Error)
}
private var accounts: [Account] = []
private var relationships: [Relationshionship] = []
@Published var state = State.loading
private var nextPageId: String?
init(mode: AccountsListMode) {
self.mode = mode
}
func fetch() async {
guard let client else { return }
do {
@ -69,13 +70,13 @@ class AccountsListViewModel: ObservableObject {
}
nextPageId = link?.maxId
relationships = try await client.get(endpoint:
Accounts.relationships(ids: accounts.map{ $0.id }))
Accounts.relationships(ids: accounts.map { $0.id }))
state = .display(accounts: accounts,
relationships: relationships,
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
} catch { }
} catch {}
}
func fetchNextPage() async {
guard let client, let nextPageId else { return }
do {
@ -93,13 +94,13 @@ class AccountsListViewModel: ObservableObject {
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.rebloggedBy(id: statusId,
maxId: nextPageId))
case let .favouritedBy(statusId):
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favouritedBy(id: statusId,
maxId: nextPageId))
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favouritedBy(id: statusId,
maxId: nextPageId))
}
accounts.append(contentsOf: newAccounts)
let newRelationships: [Relationshionship] =
try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map{ $0.id }))
try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map { $0.id }))
relationships.append(contentsOf: newRelationships)
self.nextPageId = link?.maxId
state = .display(accounts: accounts,

View File

@ -1,15 +1,15 @@
import SwiftUI
import DesignSystem
import Models
import Network
import DesignSystem
import SwiftUI
struct EditAccountView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme
@StateObject private var viewModel = EditAccountViewModel()
public var body: some View {
NavigationStack {
Form {
@ -31,15 +31,15 @@ struct EditAccountView: View {
.alert("Error while saving your profile",
isPresented: $viewModel.saveError,
actions: {
Button("Ok", action: { })
}, message: { Text("Error while saving your profile, please try again.") })
Button("Ok", action: {})
}, message: { Text("Error while saving your profile, please try again.") })
.task {
viewModel.client = client
await viewModel.fetchAccount()
}
}
}
private var loadingSection: some View {
Section {
HStack {
@ -50,7 +50,7 @@ struct EditAccountView: View {
}
.listRowBackground(theme.primaryBackgroundColor)
}
@ViewBuilder
private var aboutSections: some View {
Section("Display Name") {
@ -63,7 +63,7 @@ struct EditAccountView: View {
}
.listRowBackground(theme.primaryBackgroundColor)
}
private var postSettingsSection: some View {
Section("Post settings") {
Picker(selection: $viewModel.postPrivacy) {
@ -80,7 +80,7 @@ struct EditAccountView: View {
}
.listRowBackground(theme.primaryBackgroundColor)
}
private var accountSection: some View {
Section("Account settings") {
Toggle(isOn: $viewModel.isLocked) {
@ -95,7 +95,7 @@ struct EditAccountView: View {
}
.listRowBackground(theme.primaryBackgroundColor)
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
@ -103,7 +103,7 @@ struct EditAccountView: View {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {

View File

@ -1,11 +1,11 @@
import SwiftUI
import Models
import Network
import SwiftUI
@MainActor
class EditAccountViewModel: ObservableObject {
public var client: Client?
@Published var displayName: String = ""
@Published var note: String = ""
@Published var postPrivacy = Models.Visibility.pub
@ -13,13 +13,13 @@ class EditAccountViewModel: ObservableObject {
@Published var isBot: Bool = false
@Published var isLocked: Bool = false
@Published var isDiscoverable: Bool = false
@Published var isLoading: Bool = true
@Published var isSaving: Bool = false
@Published var saveError: Bool = false
init() { }
init() {}
func fetchAccount() async {
guard let client else { return }
do {
@ -34,20 +34,20 @@ class EditAccountViewModel: ObservableObject {
withAnimation {
isLoading = false
}
} catch { }
} catch {}
}
func save() async {
isSaving = true
do {
let response =
try await client?.patch(endpoint: Accounts.updateCredentials(displayName: displayName,
note: note,
privacy: postPrivacy,
isSensitive: isSensitive,
isBot: isBot,
isLocked: isLocked,
isDiscoverable: isDiscoverable))
try await client?.patch(endpoint: Accounts.updateCredentials(displayName: displayName,
note: note,
privacy: postPrivacy,
isSensitive: isSensitive,
isBot: isBot,
isLocked: isLocked,
isDiscoverable: isDiscoverable))
if response?.statusCode != 200 {
saveError = true
}
@ -57,5 +57,4 @@ class EditAccountViewModel: ObservableObject {
saveError = true
}
}
}

View File

@ -1,23 +1,23 @@
import Foundation
import SwiftUI
import Models
import Network
import SwiftUI
@MainActor
public class FollowButtonViewModel: ObservableObject {
var client: Client?
public let accountId: String
public let shouldDisplayNotify: Bool
@Published private(set) public var relationship: Relationshionship
@Published private(set) public var isUpdating: Bool = false
@Published public private(set) var relationship: Relationshionship
@Published public private(set) var isUpdating: Bool = false
public init(accountId: String, relationship: Relationshionship, shouldDisplayNotify: Bool) {
self.accountId = accountId
self.relationship = relationship
self.shouldDisplayNotify = shouldDisplayNotify
}
func follow() async {
guard let client else { return }
isUpdating = true
@ -28,7 +28,7 @@ public class FollowButtonViewModel: ObservableObject {
}
isUpdating = false
}
func unfollow() async {
guard let client else { return }
isUpdating = true
@ -39,7 +39,7 @@ public class FollowButtonViewModel: ObservableObject {
}
isUpdating = false
}
func toggleNotify() async {
guard let client else { return }
do {
@ -53,11 +53,11 @@ public class FollowButtonViewModel: ObservableObject {
public struct FollowButton: View {
@EnvironmentObject private var client: Client
@StateObject private var viewModel: FollowButtonViewModel
public init(viewModel: FollowButtonViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
public var body: some View {
HStack {
Button {

View File

@ -1,11 +1,11 @@
import XCTest
@testable import Account
import XCTest
final class AccountTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(Account().text, "Hello, World!")
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(Account().text, "Hello, World!")
}
}

View File

@ -11,7 +11,8 @@ let package = Package(
products: [
.library(
name: "AppAccount",
targets: ["AppAccount"]),
targets: ["AppAccount"]
),
],
dependencies: [
.package(name: "Network", path: "../Network"),
@ -27,6 +28,7 @@ let package = Package(
.product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"),
])
]
),
]
)

View File

@ -1,17 +1,17 @@
import SwiftUI
import Network
import CryptoKit
import KeychainSwift
import Models
import CryptoKit
import Network
import SwiftUI
public struct AppAccount: Codable, Identifiable {
public let server: String
public let oauthToken: OauthToken?
public var id: String {
key
}
private static var keychain: KeychainSwift {
let keychain = KeychainSwift()
#if !DEBUG
@ -19,7 +19,7 @@ public struct AppAccount: Codable, Identifiable {
#endif
return keychain
}
public var key: String {
if let oauthToken {
return "\(server):\(oauthToken.createdAt)"
@ -27,22 +27,22 @@ public struct AppAccount: Codable, Identifiable {
return "\(server):anonymous:\(Date().timeIntervalSince1970)"
}
}
public init(server: String, oauthToken: OauthToken? = nil) {
self.server = server
self.oauthToken = oauthToken
}
public func save() throws {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
Self.keychain.set(data, forKey: key)
}
public func delete() {
Self.keychain.delete(key)
}
public static func retrieveAll() -> [AppAccount] {
migrateLegacyAccounts()
let keychain = Self.keychain
@ -58,7 +58,7 @@ public struct AppAccount: Codable, Identifiable {
}
return accounts
}
public static func migrateLegacyAccounts() {
let keychain = KeychainSwift()
let decoder = JSONDecoder()
@ -71,7 +71,7 @@ public struct AppAccount: Codable, Identifiable {
}
}
}
public static func deleteAll() {
let keychain = Self.keychain
let keys = keychain.allKeys

View File

@ -1,17 +1,17 @@
import SwiftUI
import DesignSystem
import Env
import EmojiText
import Env
import SwiftUI
public struct AppAccountView: View {
@EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject var appAccounts: AppAccountsManager
@StateObject var viewModel: AppAccountViewModel
public init(viewModel: AppAccountViewModel) {
_viewModel = .init(wrappedValue: viewModel)
}
public var body: some View {
HStack {
if let account = viewModel.account {
@ -43,7 +43,8 @@ public struct AppAccountView: View {
}
.onTapGesture {
if appAccounts.currentAccount.id == viewModel.appAccount.id,
let account = viewModel.account {
let account = viewModel.account
{
routeurPath.navigate(to: .accountDetailWithAccount(account: account))
} else {
appAccounts.currentAccount = viewModel.appAccount

View File

@ -1,23 +1,23 @@
import SwiftUI
import Models
import Network
import SwiftUI
@MainActor
public class AppAccountViewModel: ObservableObject {
let appAccount: AppAccount
let client: Client
@Published var account: Account?
var acct: String {
"@\(account?.acct ?? "...")@\(appAccount.server)"
}
public init(appAccount: AppAccount) {
self.appAccount = appAccount
self.client = .init(server: appAccount.server, oauthToken: appAccount.oauthToken)
client = .init(server: appAccount.server, oauthToken: appAccount.oauthToken)
}
func fetchAccount() async {
do {
account = try await client.get(endpoint: Accounts.verifyCredentials)

View File

@ -1,13 +1,13 @@
import SwiftUI
import Network
import Env
import Models
import Network
import SwiftUI
@MainActor
public class AppAccountsManager: ObservableObject {
@AppStorage("latestCurrentAccountKey", store: UserPreferences.sharedDefault)
static public var latestCurrentAccountKey: String = ""
public static var latestCurrentAccountKey: String = ""
@Published public var currentAccount: AppAccount {
didSet {
Self.latestCurrentAccountKey = currentAccount.id
@ -15,16 +15,17 @@ public class AppAccountsManager: ObservableObject {
oauthToken: currentAccount.oauthToken)
}
}
@Published public var availableAccounts: [AppAccount]
@Published public var currentClient: Client
public var pushAccounts: [PushNotificationsService.PushAccounts] {
availableAccounts.filter{ $0.oauthToken != nil}
.map{ .init(server: $0.server, token: $0.oauthToken!) }
availableAccounts.filter { $0.oauthToken != nil }
.map { .init(server: $0.server, token: $0.oauthToken!) }
}
public static var shared = AppAccountsManager()
internal init() {
var defaultAccount = AppAccount(server: AppInfo.defaultServer, oauthToken: nil)
let keychainAccounts = AppAccount.retrieveAll()
@ -37,15 +38,15 @@ public class AppAccountsManager: ObservableObject {
currentAccount = defaultAccount
currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken)
}
public func add(account: AppAccount) {
do {
try account.save()
availableAccounts.append(account)
currentAccount = account
} catch { }
} catch {}
}
public func delete(account: AppAccount) {
availableAccounts.removeAll(where: { $0.id == account.id })
account.delete()

View File

@ -1,26 +1,27 @@
import SwiftUI
import Env
import DesignSystem
import Env
import SwiftUI
public struct AppAccountsSelectorView: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var appAccounts: AppAccountsManager
@ObservedObject var routeurPath: RouterPath
@State private var accountsViewModel: [AppAccountViewModel] = []
private let accountCreationEnabled: Bool
private let avatarSize: AvatarView.Size
public init(routeurPath: RouterPath,
accountCreationEnabled: Bool = true,
avatarSize: AvatarView.Size = .badge) {
avatarSize: AvatarView.Size = .badge)
{
self.routeurPath = routeurPath
self.accountCreationEnabled = accountCreationEnabled
self.avatarSize = avatarSize
}
public var body: some View {
Group {
if UIDevice.current.userInterfaceIdiom == .pad {
@ -43,7 +44,7 @@ public struct AppAccountsSelectorView: View {
refreshAccounts()
}
}
@ViewBuilder
private var labelView: some View {
if let avatar = currentAccount.account?.avatar {
@ -52,14 +53,15 @@ public struct AppAccountsSelectorView: View {
EmptyView()
}
}
@ViewBuilder
private var menuView: some View {
ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in
Section(viewModel.acct) {
Button {
if let account = currentAccount.account,
viewModel.account?.id == account.id {
viewModel.account?.id == account.id
{
routeurPath.navigate(to: .accountDetailWithAccount(account: account))
} else {
appAccounts.currentAccount = viewModel.appAccount
@ -83,7 +85,7 @@ public struct AppAccountsSelectorView: View {
}
}
}
private func refreshAccounts() {
if accountsViewModel.isEmpty || appAccounts.availableAccounts.count != accountsViewModel.count {
accountsViewModel = []
@ -98,5 +100,4 @@ public struct AppAccountsSelectorView: View {
}
}
}
}

View File

@ -11,7 +11,8 @@ let package = Package(
products: [
.library(
name: "Conversations",
targets: ["Conversations"]),
targets: ["Conversations"]
),
],
dependencies: [
.package(name: "Models", path: "../Models"),
@ -27,7 +28,7 @@ let package = Package(
.product(name: "Network", package: "Network"),
.product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"),
]),
]
),
]
)

View File

@ -1,25 +1,25 @@
import SwiftUI
import Models
import Accounts
import DesignSystem
import Env
import Models
import Network
import SwiftUI
struct ConversationsListRow: View {
@EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme
let conversation: Conversation
@ObservedObject var viewModel: ConversationsListViewModel
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 8) {
AvatarView(url: conversation.accounts.first!.avatar)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(conversation.accounts.map{ $0.safeDisplayName }.joined(separator: ", "))
Text(conversation.accounts.map { $0.safeDisplayName }.joined(separator: ", "))
.font(.headline)
.foregroundColor(theme.labelColor)
.multilineTextAlignment(.leading)
@ -52,7 +52,7 @@ struct ConversationsListRow: View {
contextMenu
}
}
private var actionsView: some View {
HStack(spacing: 12) {
Button {
@ -71,7 +71,7 @@ struct ConversationsListRow: View {
.padding(.leading, 48)
.foregroundColor(.gray)
}
@ViewBuilder
private var contextMenu: some View {
Button {
@ -81,7 +81,7 @@ struct ConversationsListRow: View {
} label: {
Label("Mark as read", systemImage: "eye")
}
Button(role: .destructive) {
Task {
await viewModel.delete(conversation: conversation)

View File

@ -1,27 +1,27 @@
import SwiftUI
import Network
import Models
import DesignSystem
import Shimmer
import Env
import Models
import Network
import Shimmer
import SwiftUI
public struct ConversationsListView: View {
@EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme
@StateObject private var viewModel = ConversationsListViewModel()
public init() { }
public init() {}
private var conversations: [Conversation] {
if viewModel.isLoadingFirstPage {
return Conversation.placeholders()
}
return viewModel.conversations
}
public var body: some View {
ScrollView {
LazyVStack {
@ -61,7 +61,7 @@ public struct ConversationsListView: View {
.toolbar {
StatusEditorToolbarItem(visibility: .direct)
}
.onChange(of: watcher.latestEvent?.id) { id in
.onChange(of: watcher.latestEvent?.id) { _ in
if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent)
}

View File

@ -5,13 +5,13 @@ import SwiftUI
@MainActor
class ConversationsListViewModel: ObservableObject {
var client: Client?
@Published var isLoadingFirstPage: Bool = true
@Published var conversations: [Conversation] = []
@Published var isError: Bool = false
public init() { }
public init() {}
func fetchConversations() async {
guard let client else { return }
if conversations.isEmpty {
@ -25,18 +25,18 @@ class ConversationsListViewModel: ObservableObject {
isLoadingFirstPage = false
}
}
func markAsRead(conversation: Conversation) async {
guard let client else { return }
_ = try? await client.post(endpoint: Conversations.read(id: conversation.id))
}
func delete(conversation: Conversation) async {
guard let client else { return }
_ = try? await client.delete(endpoint: Conversations.delete(id: conversation.id))
await fetchConversations()
}
func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventConversation {
if let index = conversations.firstIndex(where: { $0.id == event.conversation.id }) {

View File

@ -11,14 +11,15 @@ let package = Package(
products: [
.library(
name: "DesignSystem",
targets: ["DesignSystem"]),
targets: ["DesignSystem"]
),
],
dependencies: [
.package(name: "Models", path: "../Models"),
.package(name: "Env", path: "../Env"),
.package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0"),
.package(url: "https://github.com/kean/Nuke", from: "11.5.0"),
.package(url: "https://github.com/divadretlaw/EmojiText", from: "1.1.0")
.package(url: "https://github.com/divadretlaw/EmojiText", from: "1.1.0"),
],
targets: [
.target(
@ -29,8 +30,8 @@ let package = Package(
.product(name: "Shimmer", package: "SwiftUI-Shimmer"),
.product(name: "NukeUI", package: "Nuke"),
.product(name: "Nuke", package: "Nuke"),
.product(name: "EmojiText", package: "EmojiText")
]),
.product(name: "EmojiText", package: "EmojiText"),
]
),
]
)

View File

@ -1,22 +1,22 @@
import Foundation
import SwiftUI
import NukeUI
import Models
import NukeUI
import SwiftUI
extension Account {
public extension Account {
private struct Part: Identifiable {
let id = UUID().uuidString
let value: Substring
}
public var safeDisplayName: String {
var safeDisplayName: String {
if displayName.isEmpty {
return username
}
return displayName
}
public var displayNameWithoutEmojis: String {
var displayNameWithoutEmojis: String {
var name = safeDisplayName
for emoji in emojis {
name = name.replacingOccurrences(of: ":\(emoji.shortcode):", with: "")

View File

@ -24,68 +24,65 @@ public enum ColorSetName: String {
public struct IceCubeDark: ColorSet {
public var name: ColorSetName = .iceCubeDark
public var scheme: ColorScheme = .dark
public var tintColor: Color = Color(red: 187/255, green: 59/255, blue: 226/255)
public var primaryBackgroundColor: Color = Color(red: 16/255, green: 21/255, blue: 35/255)
public var secondaryBackgroundColor: Color = Color(red: 30/255, green: 35/255, blue: 62/255)
public var tintColor: Color = .init(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
public var primaryBackgroundColor: Color = .init(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
public var secondaryBackgroundColor: Color = .init(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
public var labelColor: Color = .white
public init() {}
}
public struct IceCubeLight: ColorSet {
public var name: ColorSetName = .iceCubeLight
public var scheme: ColorScheme = .light
public var tintColor: Color = Color(red: 187/255, green: 59/255, blue: 226/255)
public var tintColor: Color = .init(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
public var primaryBackgroundColor: Color = .white
public var secondaryBackgroundColor: Color = Color(hex:0xF0F1F2)
public var secondaryBackgroundColor: Color = .init(hex: 0xF0F1F2)
public var labelColor: Color = .black
public init() {}
}
public struct DesertDark: ColorSet {
public var name: ColorSetName = .desertDark
public var scheme: ColorScheme = .dark
public var tintColor: Color = Color(hex: 0xdf915e)
public var primaryBackgroundColor: Color = Color(hex: 0x433744)
public var secondaryBackgroundColor: Color = Color(hex:0x654868)
public var tintColor: Color = .init(hex: 0xDF915E)
public var primaryBackgroundColor: Color = .init(hex: 0x433744)
public var secondaryBackgroundColor: Color = .init(hex: 0x654868)
public var labelColor: Color = .white
public init() {}
}
public struct DesertLight: ColorSet {
public var name: ColorSetName = .desertLight
public var scheme: ColorScheme = .light
public var tintColor: Color = Color(hex: 0xdf915e)
public var primaryBackgroundColor: Color = Color(hex: 0xfcf2eb)
public var secondaryBackgroundColor: Color = Color(hex:0xeeede7)
public var tintColor: Color = .init(hex: 0xDF915E)
public var primaryBackgroundColor: Color = .init(hex: 0xFCF2EB)
public var secondaryBackgroundColor: Color = .init(hex: 0xEEEDE7)
public var labelColor: Color = .black
public init() {}
}
public struct NemesisDark: ColorSet {
public var name: ColorSetName = .nemesisDark
public var scheme: ColorScheme = .dark
public var tintColor: Color = Color(hex: 0x17a2f2)
public var primaryBackgroundColor: Color = Color(hex: 0x000000)
public var secondaryBackgroundColor: Color = Color(hex:0x151e2b)
public var tintColor: Color = .init(hex: 0x17A2F2)
public var primaryBackgroundColor: Color = .init(hex: 0x000000)
public var secondaryBackgroundColor: Color = .init(hex: 0x151E2B)
public var labelColor: Color = .white
public init() {}
}
public struct NemesisLight: ColorSet {
public var name: ColorSetName = .nemesisLight
public var scheme: ColorScheme = .light
public var tintColor: Color = Color(hex: 0x17a2f2)
public var primaryBackgroundColor: Color = Color(hex: 0xffffff)
public var secondaryBackgroundColor: Color = Color(hex:0xe8ecef)
public var tintColor: Color = .init(hex: 0x17A2F2)
public var primaryBackgroundColor: Color = .init(hex: 0xFFFFFF)
public var secondaryBackgroundColor: Color = .init(hex: 0xE8ECEF)
public var labelColor: Color = .black
public init() {}
}

View File

@ -1,8 +1,8 @@
import Foundation
extension CGFloat {
public static let layoutPadding: CGFloat = 20
public static let dividerPadding: CGFloat = 2
public static let statusColumnsSpacing: CGFloat = 8
public static let maxColumnWidth: CGFloat = 650
public extension CGFloat {
static let layoutPadding: CGFloat = 20
static let dividerPadding: CGFloat = 2
static let statusColumnsSpacing: CGFloat = 8
static let maxColumnWidth: CGFloat = 650
}

View File

@ -1,28 +1,28 @@
import SwiftUI
extension Color {
public static var brand: Color {
Color(red: 187/255, green: 59/255, blue: 226/255)
public extension Color {
static var brand: Color {
Color(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
}
public static var primaryBackground: Color {
Color(red: 16/255, green: 21/255, blue: 35/255)
static var primaryBackground: Color {
Color(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
}
public static var secondaryBackground: Color {
Color(red: 30/255, green: 35/255, blue: 62/255)
static var secondaryBackground: Color {
Color(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
}
public static var label: Color {
static var label: Color {
Color("label", bundle: .module)
}
}
extension Color: RawRepresentable {
public init?(rawValue: Int) {
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF
let blue = Double(rawValue & 0x0000FF) / 0xFF
let blue = Double(rawValue & 0x0000FF) / 0xFF
self = Color(red: red, green: green, blue: blue)
}
@ -42,11 +42,10 @@ extension Color: RawRepresentable {
}
extension Color {
init(hex: Int, opacity: Double = 1.0) {
let red = Double((hex & 0xff0000) >> 16) / 255.0
let green = Double((hex & 0xff00) >> 8) / 255.0
let blue = Double((hex & 0xff) >> 0) / 255.0
self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity)
}
init(hex: Int, opacity: Double = 1.0) {
let red = Double((hex & 0xFF0000) >> 16) / 255.0
let green = Double((hex & 0xFF00) >> 8) / 255.0
let blue = Double((hex & 0xFF) >> 0) / 255.0
self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity)
}
}

View File

@ -7,10 +7,10 @@ public class Theme: ObservableObject {
case avatarPosition, avatarShape, statusActionsDisplay, statusDisplayStyle
case selectedSet, selectedScheme
}
public enum AvatarPosition: String, CaseIterable {
case leading, top
public var description: LocalizedStringKey {
switch self {
case .leading:
@ -33,7 +33,7 @@ public class Theme: ObservableObject {
}
}
}
public enum StatusActionsDisplay: String, CaseIterable {
case full, discret, none
@ -48,7 +48,7 @@ public class Theme: ObservableObject {
}
}
}
public enum StatusDisplayStyle: String, CaseIterable {
case large, compact
@ -61,7 +61,7 @@ public class Theme: ObservableObject {
}
}
}
@AppStorage("is_previously_set") private var isSet: Bool = false
@AppStorage(ThemeKey.selectedScheme.rawValue) public var selectedScheme: ColorScheme = .dark
@AppStorage(ThemeKey.tint.rawValue) public var tintColor: Color = .black
@ -79,19 +79,19 @@ public class Theme: ObservableObject {
@Published public var selectedSet: ColorSetName = .iceCubeDark
private var cancellables = Set<AnyCancellable>()
public static let shared = Theme()
private init() {
selectedSet = storedSet
// If theme is never set before set the default store. This should only execute once after install.
if !isSet {
setColor(withName: .iceCubeDark)
isSet = true
}
avatarPosition = AvatarPosition(rawValue: rawAvatarPosition) ?? .top
avatarShape = AvatarShape(rawValue: rawAvatarShape) ?? .rounded
@ -110,7 +110,7 @@ public class Theme: ObservableObject {
self?.rawAvatarShape = shape
}
.store(in: &cancellables)
// Workaround, since @AppStorage can't be directly observed
$selectedSet
.dropFirst()
@ -119,7 +119,7 @@ public class Theme: ObservableObject {
}
.store(in: &cancellables)
}
public static var allColorSet: [ColorSet] {
[
IceCubeDark(),
@ -127,17 +127,17 @@ public class Theme: ObservableObject {
DesertDark(),
DesertLight(),
NemesisDark(),
NemesisLight()
NemesisLight(),
]
}
public func setColor(withName name: ColorSetName) {
let colorSet = Theme.allColorSet.filter { $0.name == name }.first ?? IceCubeDark()
self.selectedScheme = colorSet.scheme
self.tintColor = colorSet.tintColor
self.primaryBackgroundColor = colorSet.primaryBackgroundColor
self.secondaryBackgroundColor = colorSet.secondaryBackgroundColor
self.labelColor = colorSet.labelColor
self.storedSet = name
selectedScheme = colorSet.scheme
tintColor = colorSet.tintColor
primaryBackgroundColor = colorSet.primaryBackgroundColor
secondaryBackgroundColor = colorSet.secondaryBackgroundColor
labelColor = colorSet.labelColor
storedSet = name
}
}

View File

@ -1,68 +1,68 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
import UIKit
#endif
public extension View {
func applyTheme(_ theme: Theme) -> some View {
modifier(ThemeApplier(theme: theme))
}
func applyTheme(_ theme: Theme) -> some View {
modifier(ThemeApplier(theme: theme))
}
}
struct ThemeApplier: ViewModifier {
@ObservedObject var theme: Theme
func body(content: Content) -> some View {
content
.tint(theme.tintColor)
.preferredColorScheme(theme.selectedScheme == ColorScheme.dark ? .dark : .light)
#if canImport(UIKit)
.onAppear {
setWindowTint(theme.tintColor)
setWindowUserInterfaceStyle(theme.selectedScheme)
setBarsColor(theme.primaryBackgroundColor)
}
.onChange(of: theme.tintColor) { newValue in
setWindowTint(newValue)
}
.onChange(of: theme.selectedScheme) { newValue in
setWindowUserInterfaceStyle(newValue)
}
.onChange(of: theme.primaryBackgroundColor) { newValue in
setBarsColor(newValue)
}
#endif
}
@ObservedObject var theme: Theme
func body(content: Content) -> some View {
content
.tint(theme.tintColor)
.preferredColorScheme(theme.selectedScheme == ColorScheme.dark ? .dark : .light)
#if canImport(UIKit)
private func setWindowUserInterfaceStyle(_ colorScheme: ColorScheme) {
allWindows()
.forEach {
switch colorScheme {
case .dark:
$0.overrideUserInterfaceStyle = .dark
case .light:
$0.overrideUserInterfaceStyle = .light
}
}
}
private func setWindowTint(_ color: Color) {
allWindows()
.forEach {
$0.tintColor = UIColor(color)
}
}
private func setBarsColor(_ color: Color) {
UINavigationBar.appearance().isTranslucent = true
UINavigationBar.appearance().barTintColor = UIColor(color)
}
private func allWindows() -> [UIWindow] {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
}
.onAppear {
setWindowTint(theme.tintColor)
setWindowUserInterfaceStyle(theme.selectedScheme)
setBarsColor(theme.primaryBackgroundColor)
}
.onChange(of: theme.tintColor) { newValue in
setWindowTint(newValue)
}
.onChange(of: theme.selectedScheme) { newValue in
setWindowUserInterfaceStyle(newValue)
}
.onChange(of: theme.primaryBackgroundColor) { newValue in
setBarsColor(newValue)
}
#endif
}
#if canImport(UIKit)
private func setWindowUserInterfaceStyle(_ colorScheme: ColorScheme) {
allWindows()
.forEach {
switch colorScheme {
case .dark:
$0.overrideUserInterfaceStyle = .dark
case .light:
$0.overrideUserInterfaceStyle = .light
}
}
}
private func setWindowTint(_ color: Color) {
allWindows()
.forEach {
$0.tintColor = UIColor(color)
}
}
private func setBarsColor(_ color: Color) {
UINavigationBar.appearance().isTranslucent = true
UINavigationBar.appearance().barTintColor = UIColor(color)
}
private func allWindows() -> [UIWindow] {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
}
#endif
}

View File

@ -1,6 +1,6 @@
import SwiftUI
import Shimmer
import NukeUI
import Shimmer
import SwiftUI
public struct AvatarView: View {
@Environment(\.redactionReasons) private var reasons
@ -8,7 +8,7 @@ public struct AvatarView: View {
public enum Size {
case account, status, embed, badge, boost
public var size: CGSize {
switch self {
case .account:
@ -23,7 +23,7 @@ public struct AvatarView: View {
return .init(width: 12, height: 12)
}
}
var cornerRadius: CGFloat {
switch self {
case .badge, .boost:
@ -33,36 +33,36 @@ public struct AvatarView: View {
}
}
}
public let url: URL
public let size: Size
public init(url: URL, size: Size = .status) {
self.url = url
self.size = size
}
public var body: some View {
Group {
if reasons == .placeholder {
RoundedRectangle(cornerRadius: size.cornerRadius)
.fill(.gray)
.frame(width: size.size.width, height: size.size.height)
} else {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizingMode(.aspectFit)
} else if state.isLoading {
placeholderView
.shimmering()
} else {
placeholderView
}
} else {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizingMode(.aspectFit)
} else if state.isLoading {
placeholderView
.shimmering()
} else {
placeholderView
}
.processors([.resize(size: size.size), .roundedCorners(radius: size.cornerRadius)])
.frame(width: size.size.width, height: size.size.height)
}
.processors([.resize(size: size.size), .roundedCorners(radius: size.cornerRadius)])
.frame(width: size.size.width, height: size.size.height)
}
}
.clipShape(clipShape)
.overlay(
@ -78,7 +78,7 @@ public struct AvatarView: View {
return AnyShape(RoundedRectangle(cornerRadius: size.cornerRadius))
}
}
@ViewBuilder
private var placeholderView: some View {
if size == .badge {

View File

@ -1,20 +1,20 @@
import Foundation
import EmojiText
import Models
import Foundation
import HTML2Markdown
import Models
import SwiftUI
public struct EmojiTextApp: View {
private let markdown: String
private let emojis: [any CustomEmoji]
private let append: (() -> Text)?
public init(_ markdown: HTMLString, emojis: [Emoji], append: (() -> Text)? = nil) {
self.markdown = markdown
self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) }
self.append = append
}
public var body: some View {
if let append {
EmojiText(markdown: markdown, emojis: emojis)

View File

@ -4,13 +4,13 @@ public struct EmptyView: View {
public let iconName: String
public let title: String
public let message: String
public init(iconName: String, title: String, message: String) {
self.iconName = iconName
self.title = title
self.message = message
}
public var body: some View {
VStack {
Image(systemName: iconName)

View File

@ -4,15 +4,15 @@ public struct ErrorView: View {
public let title: String
public let message: String
public let buttonTitle: String
public let onButtonPress: (() -> Void)
public let onButtonPress: () -> Void
public init(title: String, message: String, buttonTitle: String, onButtonPress: @escaping (() -> Void)) {
self.title = title
self.message = message
self.buttonTitle = buttonTitle
self.onButtonPress = onButtonPress
}
public var body: some View {
VStack {
Image(systemName: "exclamationmark.triangle.fill")

View File

@ -15,7 +15,7 @@ public struct ScrollViewOffsetReader<Content: View>: View {
self.onOffsetChange = onOffsetChange
self.content = content
}
public var body: some View {
ScrollView {
offsetReader
@ -40,5 +40,5 @@ public struct ScrollViewOffsetReader<Content: View>: View {
private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
static func reduce(value _: inout CGFloat, nextValue _: () -> CGFloat) {}
}

View File

@ -1,10 +1,10 @@
import SwiftUI
import Env
import Models
import SwiftUI
@MainActor
extension View {
public func statusEditorToolbarItem(routeurPath: RouterPath, visibility: Models.Visibility) -> some ToolbarContent {
public extension View {
func statusEditorToolbarItem(routeurPath: RouterPath, visibility: Models.Visibility) -> some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
routeurPath.presentedSheet = .newStatusEditor(visibility: visibility)
@ -18,11 +18,11 @@ extension View {
public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath
let visibility: Models.Visibility
public init(visibility: Models.Visibility) {
self.visibility = visibility
}
public var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {

View File

@ -1,16 +1,16 @@
import Env
import Models
import SwiftUI
import Env
public struct TagRowView: View {
@EnvironmentObject private var routeurPath: RouterPath
let tag: Tag
public init(tag: Tag) {
self.tag = tag
}
public var body: some View {
HStack {
VStack(alignment: .leading) {

View File

@ -1,24 +1,24 @@
import SwiftUI
import Combine
import SwiftUI
public struct ThemePreviewView: View {
private let gutterSpace: Double = 8
@EnvironmentObject private var theme: Theme
@Environment(\.dismiss) var dismiss
public init() {}
public var body: some View {
ScrollView {
HStack (spacing: gutterSpace) {
HStack(spacing: gutterSpace) {
ThemeBoxView(color: IceCubeDark())
ThemeBoxView(color: IceCubeLight())
}
HStack (spacing: gutterSpace) {
HStack(spacing: gutterSpace) {
ThemeBoxView(color: DesertDark())
ThemeBoxView(color: DesertLight())
}
HStack (spacing: gutterSpace) {
HStack(spacing: gutterSpace) {
ThemeBoxView(color: NemesisDark())
ThemeBoxView(color: NemesisLight())
}
@ -31,13 +31,12 @@ public struct ThemePreviewView: View {
}
struct ThemeBoxView: View {
@EnvironmentObject var theme: Theme
private let gutterSpace = 8.0
@State private var isSelected = false
var color: ColorSet
var body: some View {
ZStack(alignment: .topTrailing) {
Rectangle()
@ -45,19 +44,19 @@ struct ThemeBoxView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.cornerRadius(4)
.shadow(radius: 2, x: 2, y: 4)
VStack (spacing: gutterSpace) {
VStack(spacing: gutterSpace) {
Text(color.name.rawValue)
.foregroundColor(color.tintColor)
.font(.system(size: 20))
.fontWeight(.bold)
Text("Toots preview")
.foregroundColor(color.labelColor)
.frame(maxWidth: .infinity)
.padding()
.background(color.primaryBackgroundColor)
Text("#icecube, #techhub")
.foregroundColor(color.tintColor)
if isSelected {
@ -95,4 +94,3 @@ struct ThemeBoxView: View {
}
}
}

View File

@ -11,11 +11,12 @@ let package = Package(
products: [
.library(
name: "Env",
targets: ["Env"]),
targets: ["Env"]
),
],
dependencies: [
.package(name: "Models", path: "../Models"),
.package(name: "Network", path: "../Network")
.package(name: "Network", path: "../Network"),
],
targets: [
.target(
@ -23,6 +24,7 @@ let package = Package(
dependencies: [
.product(name: "Models", package: "Models"),
.product(name: "Network", package: "Network"),
]),
]
),
]
)

View File

@ -7,21 +7,21 @@ public class CurrentAccount: ObservableObject {
@Published public private(set) var account: Account?
@Published public private(set) var lists: [List] = []
@Published public private(set) var tags: [Tag] = []
private var client: Client?
static public let shared = CurrentAccount()
private init() { }
public static let shared = CurrentAccount()
private init() {}
public func setClient(client: Client) {
self.client = client
Task(priority: .userInitiated) {
await fetchUserData()
}
}
private func fetchUserData() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.fetchConnections() }
@ -30,15 +30,15 @@ public class CurrentAccount: ObservableObject {
group.addTask { await self.fetchFollowedTags() }
}
}
public func fetchConnections() async {
guard let client = client else { return }
do {
let connections: [String] = try await client.get(endpoint: Instances.peers)
client.addConnections(connections)
} catch { }
} catch {}
}
public func fetchCurrentAccount() async {
guard let client = client, client.isAuth else {
account = nil
@ -46,7 +46,7 @@ public class CurrentAccount: ObservableObject {
}
account = try? await client.get(endpoint: Accounts.verifyCredentials)
}
public func fetchLists() async {
guard let client, client.isAuth else { return }
do {
@ -55,7 +55,7 @@ public class CurrentAccount: ObservableObject {
lists = []
}
}
public func fetchFollowedTags() async {
guard let client, client.isAuth else { return }
do {
@ -64,15 +64,15 @@ public class CurrentAccount: ObservableObject {
tags = []
}
}
public func createList(title: String) async {
guard let client else { return }
do {
let list: Models.List = try await client.post(endpoint: Lists.createList(title: title))
lists.append(list)
} catch { }
} catch {}
}
public func deleteList(list: Models.List) async {
guard let client else { return }
lists.removeAll(where: { $0.id == list.id })
@ -81,7 +81,7 @@ public class CurrentAccount: ObservableObject {
lists.append(list)
}
}
public func followTag(id: String) async -> Tag? {
guard let client else { return nil }
do {
@ -92,12 +92,12 @@ public class CurrentAccount: ObservableObject {
return nil
}
}
public func unfollowTag(id: String) async -> Tag? {
guard let client else { return nil }
do {
let tag: Tag = try await client.post(endpoint: Tags.unfollow(id: id))
tags.removeAll{ $0.id == tag.id }
tags.removeAll { $0.id == tag.id }
return tag
} catch {
return nil

View File

@ -5,20 +5,20 @@ import Network
@MainActor
public class CurrentInstance: ObservableObject {
@Published public private(set) var instance: Instance?
private var client: Client?
static public let shared = CurrentInstance()
private init() { }
public static let shared = CurrentInstance()
private init() {}
public func setClient(client: Client) {
self.client = client
Task {
await fetchCurrentInstance()
}
}
public func fetchCurrentInstance() async {
guard let client = client else { return }
Task {

View File

@ -9,7 +9,7 @@ extension Array: RawRepresentable where Element: Codable {
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)

View File

@ -1,6 +1,6 @@
import Foundation
public enum PreferredBrowser: Int, CaseIterable {
case inAppSafari
case safari
case inAppSafari
case safari
}

View File

@ -1,10 +1,10 @@
import Foundation
import UserNotifications
import SwiftUI
import KeychainSwift
import CryptoKit
import Foundation
import KeychainSwift
import Models
import Network
import SwiftUI
import UserNotifications
@MainActor
public class PushNotificationsService: ObservableObject {
@ -13,21 +13,21 @@ public class PushNotificationsService: ObservableObject {
static let keychainAuthKey = "notifications_auth_key"
static let keychainPrivateKey = "notifications_private_key"
}
public struct PushAccounts {
public let server: String
public let token: OauthToken
public init(server: String, token: OauthToken) {
self.server = server
self.token = token
}
}
public static let shared = PushNotificationsService()
@Published public var pushToken: Data?
@AppStorage("user_push_is_on") public var isUserPushEnabled: Bool = true
@Published public var isPushEnabled: Bool = false {
didSet {
@ -36,15 +36,16 @@ public class PushNotificationsService: ObservableObject {
}
}
}
@Published public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true
private var subscriptions: [PushSubscription] = []
private var keychain: KeychainSwift {
let keychain = KeychainSwift()
#if !DEBUG
@ -52,15 +53,15 @@ public class PushNotificationsService: ObservableObject {
#endif
return keychain
}
public func requestPushNotifications() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (_, _) in
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
public func fetchSubscriptions(accounts: [PushAccounts]) async {
subscriptions = []
for account in accounts {
@ -68,11 +69,11 @@ public class PushNotificationsService: ObservableObject {
do {
let sub: PushSubscription = try await client.get(endpoint: Push.subscription)
subscriptions.append(sub)
} catch { }
} catch {}
}
refreshSubscriptionsUI()
}
public func updateSubscriptions(accounts: [PushAccounts]) async {
subscriptions = []
let key = notificationsPrivateKeyAsKey.publicKey.x963Representation
@ -80,15 +81,15 @@ public class PushNotificationsService: ObservableObject {
guard let pushToken = pushToken, isUserPushEnabled else { return }
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
var listenerURL = Constants.endpoint
listenerURL += "/push/"
listenerURL += pushToken.hexString
listenerURL += "/\(account.server)"
#if DEBUG
listenerURL += "?sandbox=true"
#endif
let sub: PushSubscription =
do {
var listenerURL = Constants.endpoint
listenerURL += "/push/"
listenerURL += pushToken.hexString
listenerURL += "/\(account.server)"
#if DEBUG
listenerURL += "?sandbox=true"
#endif
let sub: PushSubscription =
try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
p256dh: key,
auth: authKey,
@ -98,23 +99,23 @@ public class PushNotificationsService: ObservableObject {
follow: isFollowNotificationEnabled,
favourite: isFavoriteNotificationEnabled,
poll: isPollNotificationEnabled))
subscriptions.append(sub)
} catch { }
}
subscriptions.append(sub)
} catch {}
}
refreshSubscriptionsUI()
}
public func deleteSubscriptions(accounts: [PushAccounts]) async {
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
_ = try await client.delete(endpoint: Push.subscription)
} catch { }
} catch {}
}
await fetchSubscriptions(accounts: accounts)
refreshSubscriptionsUI()
}
private func refreshSubscriptionsUI() {
if let sub = subscriptions.first {
isPushEnabled = true
@ -128,12 +129,13 @@ public class PushNotificationsService: ObservableObject {
isPushEnabled = false
}
}
// MARK: - Key management
public var notificationsPrivateKeyAsKey: P256.KeyAgreement.PrivateKey {
if let key = keychain.get(Constants.keychainPrivateKey),
let data = Data(base64Encoded: key) {
let data = Data(base64Encoded: key)
{
do {
return try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
} catch {
@ -151,10 +153,11 @@ public class PushNotificationsService: ObservableObject {
return key
}
}
public var notificationsAuthKeyAsKey: Data {
if let key = keychain.get(Constants.keychainAuthKey),
let data = Data(base64Encoded: key) {
let data = Data(base64Encoded: key)
{
return data
} else {
let key = Self.makeRandomeNotificationsAuthKey()
@ -164,8 +167,8 @@ public class PushNotificationsService: ObservableObject {
return key
}
}
static private func makeRandomeNotificationsAuthKey() -> Data {
private static func makeRandomeNotificationsAuthKey() -> Data {
let byteCount = 16
var bytes = Data(count: byteCount)
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
@ -178,4 +181,3 @@ extension Data {
return map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
}
}

View File

@ -7,11 +7,9 @@ public class QuickLook: ObservableObject {
@Published public private(set) var urls: [URL] = []
@Published public private(set) var isPreparing: Bool = false
@Published public private(set) var latestError: Error?
public init() {
}
public init() {}
public func prepareFor(urls: [URL], selectedURL: URL) async {
withAnimation {
isPreparing = true
@ -43,7 +41,7 @@ public class QuickLook: ObservableObject {
latestError = error
}
}
private func localPathFor(url: URL) async throws -> URL {
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
let path = tempDir.appendingPathComponent(url.lastPathComponent)

View File

@ -1,7 +1,7 @@
import Foundation
import SwiftUI
import Models
import Network
import SwiftUI
public enum RouteurDestinations: Hashable {
case accountDetail(id: String)
@ -26,7 +26,7 @@ public enum SheetDestinations: Identifiable {
case listAddAccount(account: Account)
case addAccount
case addRemoteLocalTimeline
public var id: String {
switch self {
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, .mentionStatusEditor:
@ -47,19 +47,20 @@ public enum SheetDestinations: Identifiable {
public class RouterPath: ObservableObject {
public var client: Client?
public var urlHandler: ((URL) -> OpenURLAction.Result)?
@Published public var path: [RouteurDestinations] = []
@Published public var presentedSheet: SheetDestinations?
public init() {}
public func navigate(to: RouteurDestinations) {
path.append(to)
}
public func handleStatus(status: AnyStatus, url: URL) -> OpenURLAction.Result {
if url.pathComponents.contains(where: { $0 == "tags" }),
let tag = url.pathComponents.last {
let tag = url.pathComponents.last
{
navigate(to: .hashTag(tag: tag, account: nil))
return .handled
} else if let mention = status.mentions.first(where: { $0.url == url }) {
@ -68,7 +69,8 @@ public class RouterPath: ObservableObject {
} else if let client = client,
client.isAuth,
client.hasConnection(with: url),
let id = Int(url.lastPathComponent) {
let id = Int(url.lastPathComponent)
{
if url.absoluteString.contains(client.server) {
navigate(to: .statusDetail(id: String(id)))
} else {
@ -78,10 +80,11 @@ public class RouterPath: ObservableObject {
}
return urlHandler?(url) ?? .systemAction
}
public func handle(url: URL) -> OpenURLAction.Result {
if url.pathComponents.contains(where: { $0 == "tags" }),
let tag = url.pathComponents.last {
let tag = url.pathComponents.last
{
navigate(to: .hashTag(tag: tag, account: nil))
return .handled
} else if url.lastPathComponent.first == "@", let host = url.host {
@ -93,7 +96,7 @@ public class RouterPath: ObservableObject {
}
return urlHandler?(url) ?? .systemAction
}
public func navigateToAccountFrom(acct: String, url: URL) async {
guard let client else { return }
Task {
@ -109,7 +112,7 @@ public class RouterPath: ObservableObject {
}
}
}
public func navigateToAccountFrom(url: URL) async {
guard let client else { return }
Task {

View File

@ -3,28 +3,28 @@ import Models
import Network
@MainActor
public class StreamWatcher: ObservableObject {
public class StreamWatcher: ObservableObject {
private var client: Client?
private var task: URLSessionWebSocketTask?
private var watchedStreams: [Stream] = []
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
public enum Stream: String {
case publicTimeline = "public"
case user
case direct
}
@Published public var events: [any StreamEvent] = []
@Published public var unreadNotificationsCount: Int = 0
@Published public var latestEvent: (any StreamEvent)?
public init() {
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
public func setClient(client: Client) {
if self.client != nil {
stopWatching()
@ -32,13 +32,13 @@ public class StreamWatcher: ObservableObject {
self.client = client
connect()
}
private func connect() {
task = client?.makeWebSocketTask(endpoint: Streaming.streaming)
task?.resume()
receiveMessage()
}
public func watch(streams: [Stream]) {
if client?.isAuth == false {
return
@ -51,17 +51,17 @@ public class StreamWatcher: ObservableObject {
sendMessage(message: StreamMessage(type: "subscribe", stream: stream.rawValue))
}
}
public func stopWatching() {
task?.cancel()
task = nil
}
private func sendMessage(message: StreamMessage) {
task?.send(.data(try! encoder.encode(message)),
completionHandler: { _ in })
}
private func receiveMessage() {
task?.receive(completionHandler: { result in
switch result {
@ -86,13 +86,13 @@ public class StreamWatcher: ObservableObject {
} catch {
print("Error decoding streaming event: \(error.localizedDescription)")
}
default:
break
}
self.receiveMessage()
self.receiveMessage()
case .failure:
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { [weak self] in
guard let self = self else { return }
@ -103,7 +103,7 @@ public class StreamWatcher: ObservableObject {
}
})
}
private func rawEventToEvent(rawEvent: RawStreamEvent) -> (any StreamEvent)? {
guard let payloadData = rawEvent.payload.data(using: .utf8) else {
return nil

View File

@ -1,19 +1,19 @@
import SwiftUI
import Foundation
import Models
import Network
import SwiftUI
@MainActor
public class UserPreferences: ObservableObject {
public static let sharedDefault = UserDefaults.init(suiteName: "group.icecubesapps")
public static let sharedDefault = UserDefaults(suiteName: "group.icecubesapps")
public static let shared = UserPreferences()
private var client: Client?
@AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = []
@AppStorage("preferred_browser") public var preferredBrowser: PreferredBrowser = .inAppSafari
@AppStorage("draft_posts") public var draftsPosts: [String] = []
public var pushNotificationsCount: Int {
get {
Self.sharedDefault?.integer(forKey: "push_notifications_count") ?? 0
@ -22,18 +22,18 @@ public class UserPreferences: ObservableObject {
Self.sharedDefault?.set(newValue, forKey: "push_notifications_count")
}
}
@Published public var serverPreferences: ServerPreferences?
private init() { }
private init() {}
public func setClient(client: Client) {
self.client = client
Task {
await refreshServerPreferences()
}
}
public func refreshServerPreferences() async {
guard let client, client.isAuth else { return }
serverPreferences = try? await client.get(endpoint: Accounts.preferences)

View File

@ -11,7 +11,8 @@ let package = Package(
products: [
.library(
name: "Explore",
targets: ["Explore"]),
targets: ["Explore"]
),
],
dependencies: [
.package(name: "Account", path: "../Account"),
@ -30,8 +31,8 @@ let package = Package(
.product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"),
.product(name: "Status", package: "Status"),
.product(name: "DesignSystem", package: "DesignSystem")
])
.product(name: "DesignSystem", package: "DesignSystem"),
]
),
]
)

View File

@ -1,21 +1,21 @@
import SwiftUI
import Env
import Network
import DesignSystem
import Models
import Status
import Shimmer
import Account
import DesignSystem
import Env
import Models
import Network
import Shimmer
import Status
import SwiftUI
public struct ExploreView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@EnvironmentObject private var routeurPath: RouterPath
@StateObject private var viewModel = ExploreViewModel()
public init() { }
public init() {}
public var body: some View {
List {
if !viewModel.searchQuery.isEmpty {
@ -30,7 +30,7 @@ public struct ExploreView: View {
EmptyView(iconName: "magnifyingglass",
title: "Search your instance",
message: "From this screen you can search anything on \(client.server)")
.listRowBackground(theme.secondaryBackgroundColor)
.listRowBackground(theme.secondaryBackgroundColor)
} else {
if !viewModel.trendingTags.isEmpty {
trendingTagsSection
@ -64,10 +64,10 @@ public struct ExploreView: View {
suggestedTokens: $viewModel.suggestedToken,
prompt: Text("Search users, posts and tags"),
token: { token in
Text(token.rawValue)
})
Text(token.rawValue)
})
}
private var loadingView: some View {
ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: .init(status: status, isCompact: false))
@ -77,7 +77,7 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor)
}
}
@ViewBuilder
private func makeSearchResultsView(results: SearchResults) -> some View {
if !results.accounts.isEmpty {
@ -109,16 +109,16 @@ public struct ExploreView: View {
}
}
}
private var suggestedAccountsSection: some View {
Section("Suggested Users") {
ForEach(viewModel.suggestedAccounts
.prefix(upTo: viewModel.suggestedAccounts.count > 3 ? 3 : viewModel.suggestedAccounts.count)) { account in
if let relationship = viewModel.suggestedAccountsRelationShips.first(where: { $0.id == account.id }) {
AccountsListRow(viewModel: .init(account: account, relationShip: relationship))
.listRowBackground(theme.primaryBackgroundColor)
if let relationship = viewModel.suggestedAccountsRelationShips.first(where: { $0.id == account.id }) {
AccountsListRow(viewModel: .init(account: account, relationShip: relationship))
.listRowBackground(theme.primaryBackgroundColor)
}
}
}
NavigationLink {
List {
ForEach(viewModel.suggestedAccounts) { account in
@ -140,15 +140,15 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor)
}
}
private var trendingTagsSection: some View {
Section("Trending Tags") {
ForEach(viewModel.trendingTags
.prefix(upTo: viewModel.trendingTags.count > 5 ? 5 : viewModel.trendingTags.count)) { tag in
TagRowView(tag: tag)
TagRowView(tag: tag)
.listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 4)
}
.padding(.vertical, 4)
}
NavigationLink {
List {
ForEach(viewModel.trendingTags) { tag in
@ -169,16 +169,16 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor)
}
}
private var trendingPostsSection: some View {
Section("Trending Posts") {
ForEach(viewModel.trendingStatuses
.prefix(upTo: viewModel.trendingStatuses.count > 3 ? 3 : viewModel.trendingStatuses.count)) { status in
StatusRowView(viewModel: .init(status: status, isCompact: false))
StatusRowView(viewModel: .init(status: status, isCompact: false))
.listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 8)
}
.padding(.vertical, 8)
}
NavigationLink {
List {
ForEach(viewModel.trendingStatuses) { status in
@ -199,15 +199,15 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor)
}
}
private var trendingLinksSection: some View {
Section("Trending Links") {
ForEach(viewModel.trendingLinks
.prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in
StatusCardView(card: card)
StatusCardView(card: card)
.listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 8)
}
.padding(.vertical, 8)
}
NavigationLink {
List {
ForEach(viewModel.trendingLinks) { card in
@ -228,5 +228,4 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor)
}
}
}

View File

@ -1,7 +1,7 @@
import SwiftUI
import Combine
import Models
import Network
import Combine
import SwiftUI
@MainActor
class ExploreViewModel: ObservableObject {
@ -17,16 +17,16 @@ class ExploreViewModel: ObservableObject {
}
}
}
enum Token: String, Identifiable {
case user = "@user"
case statuses = "@posts"
case tag = "#hashtag"
var id: String {
rawValue
}
var apiType: String {
switch self {
case .user:
@ -42,7 +42,7 @@ class ExploreViewModel: ObservableObject {
var allSectionsEmpty: Bool {
trendingLinks.isEmpty && trendingTags.isEmpty && trendingStatuses.isEmpty && suggestedAccounts.isEmpty
}
@Published var tokens: [Token] = []
@Published var suggestedToken: [Token] = []
@Published var searchQuery = ""
@ -53,7 +53,7 @@ class ExploreViewModel: ObservableObject {
@Published var trendingTags: [Tag] = []
@Published var trendingStatuses: [Status] = []
@Published var trendingLinks: [Card] = []
private var searchTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
@ -61,7 +61,7 @@ class ExploreViewModel: ObservableObject {
$searchQuery
.removeDuplicates()
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink(receiveValue: { [weak self] newValue in
.sink(receiveValue: { [weak self] _ in
guard let self else { return }
if self.searchQuery.starts(with: "@") {
@ -76,17 +76,17 @@ class ExploreViewModel: ObservableObject {
})
.store(in: &cancellables)
}
func fetchTrending() async {
guard let client else { return }
do {
let data = try await fetchTrendingsData(client: client)
self.suggestedAccounts = data.suggestedAccounts
self.trendingTags = data.trendingTags
self.trendingStatuses = data.trendingStatuses
self.trendingLinks = data.trendingLinks
self.suggestedAccountsRelationShips = try await client.get(endpoint: Accounts.relationships(ids: self.suggestedAccounts.map{ $0.id }))
suggestedAccounts = data.suggestedAccounts
trendingTags = data.trendingTags
trendingStatuses = data.trendingStatuses
trendingLinks = data.trendingLinks
suggestedAccountsRelationShips = try await client.get(endpoint: Accounts.relationships(ids: suggestedAccounts.map { $0.id }))
withAnimation {
isLoaded = true
}
@ -94,14 +94,14 @@ class ExploreViewModel: ObservableObject {
isLoaded = true
}
}
private struct TrendingData {
let suggestedAccounts: [Account]
let trendingTags: [Tag]
let trendingStatuses: [Status]
let trendingLinks: [Card]
}
private func fetchTrendingsData(client: Client) async throws -> TrendingData {
async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions)
async let trendingTags: [Tag] = client.get(endpoint: Trends.tags)
@ -112,7 +112,7 @@ class ExploreViewModel: ObservableObject {
trendingStatuses: trendingStatuses,
trendingLinks: trendingLinks)
}
func search() {
guard !searchQuery.isEmpty else { return }
searchTask?.cancel()
@ -127,10 +127,10 @@ class ExploreViewModel: ObservableObject {
following: nil),
forceVersion: .v2)
let relationships: [Relationshionship] =
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map{ $0.id }))
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map { $0.id }))
results.relationships = relationships
self.results[searchQuery] = results
} catch { }
} catch {}
}
}
}

View File

@ -11,7 +11,8 @@ let package = Package(
products: [
.library(
name: "Lists",
targets: ["Lists"]),
targets: ["Lists"]
),
],
dependencies: [
.package(name: "Network", path: "../Network"),
@ -26,8 +27,8 @@ let package = Package(
.product(name: "Network", package: "Network"),
.product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem")
]),
.product(name: "DesignSystem", package: "DesignSystem"),
]
),
]
)

View File

@ -1,8 +1,8 @@
import SwiftUI
import Network
import DesignSystem
import Env
import Models
import Network
import SwiftUI
public struct ListAddAccountView: View {
@Environment(\.dismiss) private var dismiss
@ -10,15 +10,14 @@ public struct ListAddAccountView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var viewModel: ListAddAccountViewModel
@State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = ""
public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account))
}
public var body: some View {
NavigationStack {
List {

View File

@ -1,20 +1,20 @@
import SwiftUI
import Models
import Network
import SwiftUI
@MainActor
class ListAddAccountViewModel: ObservableObject {
let account: Account
@Published var inLists: [Models.List] = []
@Published var isLoadingInfo: Bool = true
var client: Client?
init(account: Account) {
self.account = account
}
func fetchInfo() async {
guard let client else { return }
isLoadingInfo = true
@ -27,7 +27,7 @@ class ListAddAccountViewModel: ObservableObject {
}
}
}
func addToList(list: Models.List) async {
guard let client else { return }
let response = try? await client.post(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
@ -35,7 +35,7 @@ class ListAddAccountViewModel: ObservableObject {
inLists.append(list)
}
}
func removeFromList(list: Models.List) async {
guard let client else { return }
let response = try? await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))

View File

@ -1,20 +1,20 @@
import SwiftUI
import Models
import DesignSystem
import Network
import EmojiText
import Models
import Network
import SwiftUI
public struct ListEditView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@StateObject private var viewModel: ListEditViewModel
public init(list: Models.List) {
_viewModel = StateObject(wrappedValue: .init(list: list))
}
public var body: some View {
NavigationStack {
List {

View File

@ -1,20 +1,20 @@
import SwiftUI
import Models
import Network
import SwiftUI
@MainActor
public class ListEditViewModel: ObservableObject {
let list: Models.List
var client: Client?
@Published var isLoadingAccounts: Bool = true
@Published var accounts: [Account] = []
init(list: Models.List) {
self.list = list
}
func fetchAccounts() async {
guard let client else { return }
isLoadingAccounts = true
@ -25,14 +25,14 @@ public class ListEditViewModel: ObservableObject {
isLoadingAccounts = false
}
}
func delete(account: Account) async {
guard let client else { return }
do {
let response = try await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
let response = try await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
if response?.statusCode == 200 {
accounts.removeAll(where: { $0.id == account.id })
}
} catch { }
} catch {}
}
}

View File

@ -11,7 +11,8 @@ let package = Package(
products: [
.library(
name: "Models",
targets: ["Models"]),
targets: ["Models"]
),
],
dependencies: [
.package(url: "https://gitlab.com/mflint/HTML2Markdown", exact: "1.0.0"),
@ -21,9 +22,11 @@ let package = Package(
.target(
name: "Models",
dependencies: ["HTML2Markdown",
"SwiftSoup"]),
"SwiftSoup"]
),
.testTarget(
name: "ModelsTests",
dependencies: ["Models"]),
dependencies: ["Models"]
),
]
)

View File

@ -1,21 +1,20 @@
import Foundation
public struct Account: Codable, Identifiable, Equatable, Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public struct Field: Codable, Equatable, Identifiable {
public var id: String {
value + name
}
public let name: String
public let value: HTMLString
public let verifiedAt: String?
}
public struct Source: Codable, Equatable {
public let privacy: Visibility
public let sensitive: Bool
@ -23,7 +22,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
public let note: String
public let fields: [Field]
}
public let id: String
public let username: String
public let displayName: String
@ -43,7 +42,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
public let source: Source?
public let bot: Bool
public let discoverable: Bool?
public static func placeholder() -> Account {
.init(id: UUID().uuidString,
username: "Username",
@ -65,7 +64,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
bot: false,
discoverable: true)
}
public static func placeholders() -> [Account] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]

View File

@ -5,8 +5,8 @@ import SwiftUI
public typealias HTMLString = String
extension HTMLString {
public var asMarkdown: String {
public extension HTMLString {
var asMarkdown: String {
do {
let dom = try HTMLParser().parse(html: self)
return dom.toMarkdown()
@ -16,8 +16,8 @@ extension HTMLString {
return self
}
}
public var asRawText: String {
var asRawText: String {
do {
let document: Document = try SwiftSoup.parse(self)
return try document.text()
@ -25,8 +25,8 @@ extension HTMLString {
return self
}
}
public func findStatusesURLs() -> [URL]? {
func findStatusesURLs() -> [URL]? {
do {
let document: Document = try SwiftSoup.parse(self)
let links: Elements = try document.select("a")
@ -34,7 +34,8 @@ extension HTMLString {
for link in links {
let href = try link.attr("href")
if let url = URL(string: href),
let _ = Int(url.lastPathComponent) {
let _ = Int(url.lastPathComponent)
{
URLs.append(url)
}
}
@ -43,8 +44,8 @@ extension HTMLString {
return nil
}
}
public var asSafeAttributedString: AttributedString {
var asSafeAttributedString: AttributedString {
do {
let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace)
@ -54,4 +55,3 @@ extension HTMLString {
}
}
}

View File

@ -10,23 +10,23 @@ extension ServerDate {
dateFormatter.timeZone = .init(abbreviation: "UTC")
return dateFormatter
}
private static var createdAtRelativeFormatter: RelativeDateTimeFormatter {
let dateFormatter = RelativeDateTimeFormatter()
dateFormatter.unitsStyle = .abbreviated
return dateFormatter
}
private static var createdAtShortDateFormatted: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
return dateFormatter
}
public var asDate: Date {
Self.createdAtDateFormatter.date(from: self)!
}
public var formatted: String {
let calendar = Calendar(identifier: .gregorian)
if calendar.numberOfDaysBetween(asDate, and: Date()) > 1 {
@ -37,13 +37,12 @@ extension ServerDate {
}
}
extension Calendar {
func numberOfDaysBetween(_ from: Date, and to: Date) -> Int {
let fromDate = startOfDay(for: from)
let toDate = startOfDay(for: to)
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)
return numberOfDays.day!
}
}

View File

@ -1,6 +1,6 @@
import Foundation
public struct AppInfo {
public enum AppInfo {
public static let clientName = "IceCubesApp"
public static let scheme = "icecubesapp://"
public static let scopes = "read write follow push"

View File

@ -4,7 +4,7 @@ public struct Card: Codable, Identifiable {
public var id: String {
url.absoluteString
}
public let url: URL
public let title: String?
public let description: String?

View File

@ -5,11 +5,11 @@ public struct Conversation: Identifiable, Decodable {
public let unread: Bool
public let lastStatus: Status
public let accounts: [Account]
public static func placeholder() -> Conversation {
.init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()])
}
public static func placeholders() -> [Conversation] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]

View File

@ -1,15 +1,14 @@
import Foundation
public struct Emoji: Codable, Hashable, Identifiable {
public func hash(into hasher: inout Hasher) {
hasher.combine(shortcode)
}
public var id: String {
shortcode
}
public let shortcode: String
public let url: URL
public let staticUrl: URL

View File

@ -9,12 +9,12 @@ public struct Filter: Codable, Identifiable {
public enum Action: String, Codable {
case warn, hide
}
public enum Context: String, Codable {
case home, notifications, account, thread
case pub = "public"
}
public let id: String
public let title: String
public let context: [String]

View File

@ -6,29 +6,29 @@ public struct Instance: Codable {
public let statusCount: Int
public let domainCount: Int
}
public struct Configuration: Codable {
public struct Statuses: Codable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
}
public struct Polls: Codable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
public let minExpiration: Int
public let maxExpiration: Int
}
public let statuses: Statuses
public let polls: Polls
}
public struct Rule: Codable, Identifiable {
public let id: String
public let text: String
}
public let title: String
public let shortDescription: String
public let email: String

View File

@ -4,6 +4,7 @@ public struct InstanceSocial: Decodable, Identifiable {
public struct Info: Decodable {
public let shortDescription: String
}
public let id: String
public let name: String
public let dead: Bool

View File

@ -1,17 +1,16 @@
import Foundation
public struct MastodonPushNotification: Codable {
public let accessToken: String
public let notificationID: Int
public let notificationType: String
public let preferredLocale: String?
public let icon: String?
public let title: String
public let body: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case notificationID = "notification_id"

View File

@ -1,31 +1,31 @@
import Foundation
public struct MediaAttachement: Codable, Identifiable, Hashable {
public struct MetaContainer: Codable, Equatable {
public struct Meta: Codable, Equatable {
public let width: Int?
public let height: Int?
}
public let original: Meta?
}
public enum SupportedType: String {
case image, gifv, video, audio
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public let id: String
public let type: String
public var supportedType: SupportedType? {
SupportedType(rawValue: type)
}
public let url: URL?
public let previewUrl: URL?
public let description: String?
public let meta: MetaContainer?
}

View File

@ -4,17 +4,17 @@ public struct Notification: Codable, Identifiable {
public enum NotificationType: String, CaseIterable {
case follow, follow_request, mention, reblog, status, favourite, poll, update
}
public let id: String
public let type: String
public let createdAt: ServerDate
public let account: Account
public let status: Status?
public var supportedType: NotificationType? {
.init(rawValue: type)
}
public static func placeholder() -> Notification {
.init(id: UUID().uuidString,
type: NotificationType.favourite.rawValue,
@ -22,9 +22,8 @@ public struct Notification: Codable, Identifiable {
account: .placeholder(),
status: .placeholder())
}
public static func placeholders() -> [Notification] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
}
}

View File

@ -5,12 +5,12 @@ public struct Poll: Codable {
enum CodingKeys: String, CodingKey {
case title, votesCount
}
public var id = UUID().uuidString
public let title: String
public let votesCount: Int
}
public let id: String
public let expiresAt: ServerDate
public let expired: Bool

View File

@ -9,7 +9,7 @@ public struct PushSubscription: Identifiable, Decodable {
public let poll: Bool
public let status: Bool
}
public let id: Int
public let endpoint: URL
public let serverKey: String

View File

@ -14,8 +14,8 @@ public struct Relationshionship: Codable {
public let endorsed: Bool
public let note: String
public let notifying: Bool
static public func placeholder() -> Relationshionship {
public static func placeholder() -> Relationshionship {
.init(id: UUID().uuidString,
following: false,
showingReblogs: false,

View File

@ -4,7 +4,7 @@ public struct SearchResults: Decodable {
enum CodingKeys: String, CodingKey {
case accounts, statuses, hashtags
}
public let accounts: [Account]
public var relationships: [Relationshionship] = []
public let statuses: [Status]

View File

@ -6,13 +6,13 @@ public struct ServerPreferences: Decodable {
public let postLanguage: String?
public let autoExpandmedia: AutoExpandMedia?
public let autoExpandSpoilers: Bool?
public enum AutoExpandMedia: String, Decodable {
case showAll = "show_all"
case hideAll = "hide_all"
case hideSensitive = "default"
}
enum CodingKeys: String, CodingKey {
case postVisibility = "posting:default:visibility"
case postIsSensitive = "posting:default:sensitive"

View File

@ -4,12 +4,13 @@ public struct Application: Codable, Identifiable {
public var id: String {
name
}
public let name: String
public let website: URL?
}
extension Application {
public init(from decoder: Decoder) throws {
public extension Application {
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
@ -53,12 +54,11 @@ public protocol AnyStatus {
var language: String? { get }
}
public struct Status: AnyStatus, Codable, Identifiable {
public var viewId: String {
id + createdAt + (editedAt ?? "")
}
public let id: String
public let content: HTMLString
public let account: Account
@ -85,7 +85,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
public let filtered: [Filtered]?
public let sensitive: Bool
public let language: String?
public static func placeholder() -> Status {
.init(id: UUID().uuidString,
content: "This is a #toot\nWith some @content\nAnd some more content for your #eyes @only",
@ -114,7 +114,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
sensitive: false,
language: nil)
}
public static func placeholders() -> [Status] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
}
@ -124,7 +124,7 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
public var viewId: String {
id + createdAt + (editedAt ?? "")
}
public let id: String
public let content: String
public let account: Account

View File

@ -6,7 +6,7 @@ public struct RawStreamEvent: Decodable {
public let payload: String
}
public protocol StreamEvent: Identifiable{
public protocol StreamEvent: Identifiable {
var date: Date { get }
var id: String { get }
}

View File

@ -3,10 +3,9 @@ import Foundation
public struct StreamMessage: Encodable {
public let type: String
public let stream: String
public init(type: String, stream: String) {
self.type = type
self.stream = stream
}
}

Some files were not shown because too many files have changed in this diff Show More