mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-12-22 23:26:37 +00:00
Merge branch 'main' into update-nuke
This commit is contained in:
commit
f14640a2ee
36 changed files with 704 additions and 239 deletions
|
@ -9,6 +9,7 @@
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
9F24EEB829360C330042359D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F24EEB729360C330042359D /* Preview Assets.xcassets */; };
|
9F24EEB829360C330042359D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F24EEB729360C330042359D /* Preview Assets.xcassets */; };
|
||||||
9F295540292B6C3400E0E81B /* Timeline in Frameworks */ = {isa = PBXBuildFile; productRef = 9F29553F292B6C3400E0E81B /* Timeline */; };
|
9F295540292B6C3400E0E81B /* Timeline in Frameworks */ = {isa = PBXBuildFile; productRef = 9F29553F292B6C3400E0E81B /* Timeline */; };
|
||||||
|
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; };
|
||||||
9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; };
|
9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; };
|
||||||
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; };
|
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; };
|
||||||
9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; };
|
9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; };
|
||||||
|
@ -34,6 +35,7 @@
|
||||||
9F24EEB729360C330042359D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
9F24EEB729360C330042359D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||||
9F29553D292B67B600E0E81B /* Network */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Network; path = Packages/Network; sourceTree = "<group>"; };
|
9F29553D292B67B600E0E81B /* Network */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Network; path = Packages/Network; sourceTree = "<group>"; };
|
||||||
9F29553E292B6AF600E0E81B /* Timeline */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Timeline; path = Packages/Timeline; sourceTree = "<group>"; };
|
9F29553E292B6AF600E0E81B /* Timeline */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Timeline; path = Packages/Timeline; sourceTree = "<group>"; };
|
||||||
|
9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = "<group>"; };
|
||||||
9F35DB42294F9A2900B3281A /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Status; path = Packages/Status; sourceTree = "<group>"; };
|
9F35DB42294F9A2900B3281A /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Status; path = Packages/Status; sourceTree = "<group>"; };
|
||||||
9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = "<group>"; };
|
9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = "<group>"; };
|
||||||
9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = "<group>"; };
|
9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = "<group>"; };
|
||||||
|
@ -104,6 +106,7 @@
|
||||||
9F35DB4629506F6600B3281A /* NotificationTab.swift */,
|
9F35DB4629506F6600B3281A /* NotificationTab.swift */,
|
||||||
9F35DB4B2952005C00B3281A /* AccountTab.swift */,
|
9F35DB4B2952005C00B3281A /* AccountTab.swift */,
|
||||||
9F55C68C2955968700F94077 /* ExploreTab.swift */,
|
9F55C68C2955968700F94077 /* ExploreTab.swift */,
|
||||||
|
9F2B92F5295AE04800DE16D0 /* Tabs.swift */,
|
||||||
);
|
);
|
||||||
path = Tabs;
|
path = Tabs;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -259,6 +262,7 @@
|
||||||
9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */,
|
9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */,
|
||||||
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */,
|
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */,
|
||||||
9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */,
|
9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */,
|
||||||
|
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */,
|
||||||
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */,
|
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */,
|
||||||
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */,
|
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */,
|
||||||
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
|
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
|
||||||
|
@ -393,7 +397,7 @@
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 500;
|
CURRENT_PROJECT_VERSION = 550;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\"";
|
DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\"";
|
||||||
DEVELOPMENT_TEAM = Z6P74P6T99;
|
DEVELOPMENT_TEAM = Z6P74P6T99;
|
||||||
|
@ -411,8 +415,8 @@
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
|
@ -439,7 +443,7 @@
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 500;
|
CURRENT_PROJECT_VERSION = 550;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\"";
|
DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\"";
|
||||||
DEVELOPMENT_TEAM = Z6P74P6T99;
|
DEVELOPMENT_TEAM = Z6P74P6T99;
|
||||||
|
@ -457,8 +461,8 @@
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
"location" : "https://github.com/Dimillian/TextView",
|
"location" : "https://github.com/Dimillian/TextView",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "3555eecb81f918091d4f65c071dd94e64995b41b"
|
"revision" : "26b2930e82bb379a4abf0fcba408c0a09fbbb407"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -38,6 +38,8 @@ extension View {
|
||||||
StatusEditorView(mode: .new)
|
StatusEditorView(mode: .new)
|
||||||
case let .editStatusEditor(status):
|
case let .editStatusEditor(status):
|
||||||
StatusEditorView(mode: .edit(status: status))
|
StatusEditorView(mode: .edit(status: status))
|
||||||
|
case let .quoteStatusEditor(status):
|
||||||
|
StatusEditorView(mode: .quote(status: status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,10 +7,6 @@ import DesignSystem
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct IceCubesApp: App {
|
struct IceCubesApp: App {
|
||||||
enum Tab: Int {
|
|
||||||
case timeline, notifications, explore, account, settings, other
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let defaultServer = "mastodon.social"
|
public static let defaultServer = "mastodon.social"
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@ -37,34 +33,14 @@ struct IceCubesApp: App {
|
||||||
}
|
}
|
||||||
selectedTab = newTab
|
selectedTab = newTab
|
||||||
})) {
|
})) {
|
||||||
TimelineTab(popToRootTab: $popToRootTab)
|
ForEach(appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()) { tab in
|
||||||
.tabItem {
|
tab.makeContentView(popToRootTab: $popToRootTab)
|
||||||
Label("Timeline", systemImage: "rectangle.on.rectangle")
|
|
||||||
}
|
|
||||||
.tag(Tab.timeline)
|
|
||||||
if appAccountsManager.currentClient.isAuth {
|
|
||||||
NotificationsTab(popToRootTab: $popToRootTab)
|
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Notifications", systemImage: "bell")
|
tab.label
|
||||||
|
.badge(tab == .notifications ? watcher.unreadNotificationsCount : 0)
|
||||||
}
|
}
|
||||||
.badge(watcher.unreadNotificationsCount)
|
.tag(tab)
|
||||||
.tag(Tab.notifications)
|
|
||||||
ExploreTab(popToRootTab: $popToRootTab)
|
|
||||||
.tabItem {
|
|
||||||
Label("Explore", systemImage: "magnifyingglass")
|
|
||||||
}
|
|
||||||
.tag(Tab.explore)
|
|
||||||
AccountTab(popToRootTab: $popToRootTab)
|
|
||||||
.tabItem {
|
|
||||||
Label("Profile", systemImage: "person.circle")
|
|
||||||
}
|
|
||||||
.tag(Tab.account)
|
|
||||||
}
|
}
|
||||||
SettingsTabs()
|
|
||||||
.tabItem {
|
|
||||||
Label("Settings", systemImage: "gear")
|
|
||||||
}
|
|
||||||
.tag(Tab.settings)
|
|
||||||
}
|
}
|
||||||
.tint(theme.tintColor)
|
.tint(theme.tintColor)
|
||||||
.onChange(of: appAccountsManager.currentClient) { newClient in
|
.onChange(of: appAccountsManager.currentClient) { newClient in
|
||||||
|
|
|
@ -6,14 +6,15 @@ import Models
|
||||||
import Shimmer
|
import Shimmer
|
||||||
|
|
||||||
struct AccountTab: View {
|
struct AccountTab: View {
|
||||||
|
@EnvironmentObject private var client: Client
|
||||||
@EnvironmentObject private var currentAccount: CurrentAccount
|
@EnvironmentObject private var currentAccount: CurrentAccount
|
||||||
@StateObject private var routeurPath = RouterPath()
|
@StateObject private var routeurPath = RouterPath()
|
||||||
@Binding var popToRootTab: IceCubesApp.Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routeurPath.path) {
|
NavigationStack(path: $routeurPath.path) {
|
||||||
if let account = currentAccount.account {
|
if let account = currentAccount.account {
|
||||||
AccountDetailView(account: account, isCurrentUser: true)
|
AccountDetailView(account: account)
|
||||||
.withAppRouteur()
|
.withAppRouteur()
|
||||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||||
} else {
|
} else {
|
||||||
|
@ -28,5 +29,8 @@ struct AccountTab: View {
|
||||||
routeurPath.path = []
|
routeurPath.path = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
routeurPath.client = client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,13 @@ import Env
|
||||||
import Models
|
import Models
|
||||||
import Shimmer
|
import Shimmer
|
||||||
import Explore
|
import Explore
|
||||||
|
import Env
|
||||||
|
import Network
|
||||||
|
|
||||||
struct ExploreTab: View {
|
struct ExploreTab: View {
|
||||||
|
@EnvironmentObject private var client: Client
|
||||||
@StateObject private var routeurPath = RouterPath()
|
@StateObject private var routeurPath = RouterPath()
|
||||||
@Binding var popToRootTab: IceCubesApp.Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routeurPath.path) {
|
NavigationStack(path: $routeurPath.path) {
|
||||||
|
@ -20,5 +23,8 @@ struct ExploreTab: View {
|
||||||
routeurPath.path = []
|
routeurPath.path = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
routeurPath.client = client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,10 @@ import Network
|
||||||
import Notifications
|
import Notifications
|
||||||
|
|
||||||
struct NotificationsTab: View {
|
struct NotificationsTab: View {
|
||||||
|
@EnvironmentObject private var client: Client
|
||||||
@EnvironmentObject private var watcher: StreamWatcher
|
@EnvironmentObject private var watcher: StreamWatcher
|
||||||
@StateObject private var routeurPath = RouterPath()
|
@StateObject private var routeurPath = RouterPath()
|
||||||
@Binding var popToRootTab: IceCubesApp.Tab
|
@Binding var popToRootTab: Tab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $routeurPath.path) {
|
NavigationStack(path: $routeurPath.path) {
|
||||||
|
@ -16,6 +17,7 @@ struct NotificationsTab: View {
|
||||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
routeurPath.client = client
|
||||||
watcher.unreadNotificationsCount = 0
|
watcher.unreadNotificationsCount = 0
|
||||||
}
|
}
|
||||||
.environmentObject(routeurPath)
|
.environmentObject(routeurPath)
|
||||||
|
|
|
@ -84,7 +84,7 @@ struct SettingsTabs: View {
|
||||||
LabeledContent("Email", value: instanceData.email)
|
LabeledContent("Email", value: instanceData.email)
|
||||||
LabeledContent("Version", value: instanceData.version)
|
LabeledContent("Version", value: instanceData.version)
|
||||||
LabeledContent("Users", value: "\(instanceData.stats.userCount)")
|
LabeledContent("Users", value: "\(instanceData.stats.userCount)")
|
||||||
LabeledContent("Status", value: "\(instanceData.stats.statusCount)")
|
LabeledContent("Posts", value: "\(instanceData.stats.statusCount)")
|
||||||
LabeledContent("Domains", value: "\(instanceData.stats.domainCount)")
|
LabeledContent("Domains", value: "\(instanceData.stats.domainCount)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
58
IceCubesApp/App/Tabs/Tabs.swift
Normal file
58
IceCubesApp/App/Tabs/Tabs.swift
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import Foundation
|
||||||
|
import Status
|
||||||
|
import Account
|
||||||
|
import Explore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum Tab: Int, Identifiable {
|
||||||
|
case timeline, notifications, explore, account, settings, other
|
||||||
|
|
||||||
|
var id: Int {
|
||||||
|
rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loggedOutTab() -> [Tab] {
|
||||||
|
[.timeline, .settings]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loggedInTabs() -> [Tab] {
|
||||||
|
[.timeline, .notifications, .explore, .account, .settings]
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func makeContentView(popToRootTab: Binding<Tab>) -> some View {
|
||||||
|
switch self {
|
||||||
|
case .timeline:
|
||||||
|
TimelineTab(popToRootTab: popToRootTab)
|
||||||
|
case .notifications:
|
||||||
|
NotificationsTab(popToRootTab: popToRootTab)
|
||||||
|
case .explore:
|
||||||
|
ExploreTab(popToRootTab: popToRootTab)
|
||||||
|
case .account:
|
||||||
|
AccountTab(popToRootTab: popToRootTab)
|
||||||
|
case .settings:
|
||||||
|
SettingsTabs()
|
||||||
|
case .other:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var label: some View {
|
||||||
|
switch self {
|
||||||
|
case .timeline:
|
||||||
|
Label("Timeline", systemImage: "rectangle.on.rectangle")
|
||||||
|
case .notifications:
|
||||||
|
Label("Notifications", systemImage: "bell")
|
||||||
|
case .explore:
|
||||||
|
Label("Explore", systemImage: "magnifyingglass")
|
||||||
|
case .account:
|
||||||
|
Label("Profile", systemImage: "person.circle")
|
||||||
|
case .settings:
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
case .other:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import Combine
|
||||||
struct TimelineTab: View {
|
struct TimelineTab: View {
|
||||||
@EnvironmentObject private var client: Client
|
@EnvironmentObject private var client: Client
|
||||||
@StateObject private var routeurPath = RouterPath()
|
@StateObject private var routeurPath = RouterPath()
|
||||||
@Binding var popToRootTab: IceCubesApp.Tab
|
@Binding var popToRootTab: Tab
|
||||||
@State private var timeline: TimelineFilter = .home
|
@State private var timeline: TimelineFilter = .home
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -31,6 +31,7 @@ struct TimelineTab: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
routeurPath.client = client
|
||||||
if !client.isAuth {
|
if !client.isAuth {
|
||||||
timeline = .pub
|
timeline = .pub
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ struct AccountDetailHeaderView: View {
|
||||||
let isCurrentUser: Bool
|
let isCurrentUser: Bool
|
||||||
let account: Account
|
let account: Account
|
||||||
let relationship: Relationshionship?
|
let relationship: Relationshionship?
|
||||||
|
let scrollViewProxy: ScrollViewProxy?
|
||||||
|
|
||||||
@Binding var scrollOffset: CGFloat
|
@Binding var scrollOffset: CGFloat
|
||||||
|
|
||||||
|
@ -82,7 +83,13 @@ struct AccountDetailHeaderView: View {
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Group {
|
Group {
|
||||||
makeCustomInfoLabel(title: "Posts", count: account.statusesCount)
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
scrollViewProxy?.scrollTo("status", anchor: .top)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
makeCustomInfoLabel(title: "Posts", count: account.statusesCount)
|
||||||
|
}
|
||||||
NavigationLink(value: RouteurDestinations.following(id: account.id)) {
|
NavigationLink(value: RouteurDestinations.following(id: account.id)) {
|
||||||
makeCustomInfoLabel(title: "Following", count: account.followingCount)
|
makeCustomInfoLabel(title: "Following", count: account.followingCount)
|
||||||
}
|
}
|
||||||
|
@ -138,6 +145,7 @@ struct AccountDetailHeaderView_Previews: PreviewProvider {
|
||||||
AccountDetailHeaderView(isCurrentUser: false,
|
AccountDetailHeaderView(isCurrentUser: false,
|
||||||
account: .placeholder(),
|
account: .placeholder(),
|
||||||
relationship: .placeholder(),
|
relationship: .placeholder(),
|
||||||
|
scrollViewProxy: nil,
|
||||||
scrollOffset: .constant(0))
|
scrollOffset: .constant(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,56 +17,59 @@ public struct AccountDetailView: View {
|
||||||
@StateObject private var viewModel: AccountDetailViewModel
|
@StateObject private var viewModel: AccountDetailViewModel
|
||||||
@State private var scrollOffset: CGFloat = 0
|
@State private var scrollOffset: CGFloat = 0
|
||||||
@State private var isFieldsSheetDisplayed: Bool = false
|
@State private var isFieldsSheetDisplayed: Bool = false
|
||||||
|
@State private var isCurrentUser: Bool = false
|
||||||
private let isCurrentUser: Bool
|
|
||||||
|
|
||||||
/// When coming from a URL like a mention tap in a status.
|
/// When coming from a URL like a mention tap in a status.
|
||||||
public init(accountId: String) {
|
public init(accountId: String) {
|
||||||
_viewModel = StateObject(wrappedValue: .init(accountId: accountId))
|
_viewModel = StateObject(wrappedValue: .init(accountId: accountId))
|
||||||
isCurrentUser = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the account is already fetched by the parent caller.
|
/// When the account is already fetched by the parent caller.
|
||||||
public init(account: Account, isCurrentUser: Bool = false) {
|
public init(account: Account) {
|
||||||
_viewModel = StateObject(wrappedValue: .init(account: account,
|
_viewModel = StateObject(wrappedValue: .init(account: account))
|
||||||
isCurrentUser: isCurrentUser))
|
|
||||||
self.isCurrentUser = isCurrentUser
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ScrollViewOffsetReader { offset in
|
ScrollViewReader { proxy in
|
||||||
self.scrollOffset = offset
|
ScrollViewOffsetReader { offset in
|
||||||
} content: {
|
self.scrollOffset = offset
|
||||||
LazyVStack(alignment: .leading) {
|
} content: {
|
||||||
headerView
|
LazyVStack(alignment: .leading) {
|
||||||
familliarFollowers
|
makeHeaderView(proxy: proxy)
|
||||||
.offset(y: -36)
|
familliarFollowers
|
||||||
featuredTagsView
|
.offset(y: -36)
|
||||||
.offset(y: -36)
|
featuredTagsView
|
||||||
if isCurrentUser {
|
.offset(y: -36)
|
||||||
Picker("", selection: $viewModel.selectedTab) {
|
Group {
|
||||||
ForEach(AccountDetailViewModel.Tab.allCases, id: \.self) { tab in
|
if isCurrentUser {
|
||||||
Text(tab.title).tag(tab)
|
Picker("", selection: $viewModel.selectedTab) {
|
||||||
|
ForEach(AccountDetailViewModel.Tab.allCases, id: \.self) { tab in
|
||||||
|
Text(tab.title).tag(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||||
|
.offset(y: -20)
|
||||||
|
} else {
|
||||||
|
Divider()
|
||||||
|
.offset(y: -20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.id("status")
|
||||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
|
||||||
.offset(y: -20)
|
switch viewModel.tabState {
|
||||||
} else {
|
case .statuses:
|
||||||
Divider()
|
StatusesListView(fetcher: viewModel)
|
||||||
.offset(y: -20)
|
case let .followedTags(tags):
|
||||||
}
|
makeTagsListView(tags: tags)
|
||||||
|
}
|
||||||
switch viewModel.tabState {
|
|
||||||
case .statuses:
|
|
||||||
StatusesListView(fetcher: viewModel)
|
|
||||||
case let .followedTags(tags):
|
|
||||||
makeTagsListView(tags: tags)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
guard reasons != .placeholder else { return }
|
guard reasons != .placeholder else { return }
|
||||||
|
isCurrentUser = currentAccount.account?.id == viewModel.accountId
|
||||||
|
viewModel.isCurrentUser = isCurrentUser
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
await viewModel.fetchAccount()
|
await viewModel.fetchAccount()
|
||||||
if viewModel.statuses.isEmpty {
|
if viewModel.statuses.isEmpty {
|
||||||
|
@ -90,18 +93,20 @@ public struct AccountDetailView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var headerView: some View {
|
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
|
||||||
switch viewModel.accountState {
|
switch viewModel.accountState {
|
||||||
case .loading:
|
case .loading:
|
||||||
AccountDetailHeaderView(isCurrentUser: isCurrentUser,
|
AccountDetailHeaderView(isCurrentUser: isCurrentUser,
|
||||||
account: .placeholder(),
|
account: .placeholder(),
|
||||||
relationship: .placeholder(),
|
relationship: .placeholder(),
|
||||||
|
scrollViewProxy: proxy,
|
||||||
scrollOffset: $scrollOffset)
|
scrollOffset: $scrollOffset)
|
||||||
.redacted(reason: .placeholder)
|
.redacted(reason: .placeholder)
|
||||||
case let .data(account):
|
case let .data(account):
|
||||||
AccountDetailHeaderView(isCurrentUser: isCurrentUser,
|
AccountDetailHeaderView(isCurrentUser: isCurrentUser,
|
||||||
account: account,
|
account: account,
|
||||||
relationship: viewModel.relationship,
|
relationship: viewModel.relationship,
|
||||||
|
scrollViewProxy: proxy,
|
||||||
scrollOffset: $scrollOffset)
|
scrollOffset: $scrollOffset)
|
||||||
case let .error(error):
|
case let .error(error):
|
||||||
Text("Error: \(error.localizedDescription)")
|
Text("Error: \(error.localizedDescription)")
|
||||||
|
|
|
@ -8,6 +8,7 @@ import Env
|
||||||
class AccountDetailViewModel: ObservableObject, StatusesFetcher {
|
class AccountDetailViewModel: ObservableObject, StatusesFetcher {
|
||||||
let accountId: String
|
let accountId: String
|
||||||
var client: Client?
|
var client: Client?
|
||||||
|
var isCurrentUser: Bool = false
|
||||||
|
|
||||||
enum AccountState {
|
enum AccountState {
|
||||||
case loading, data(account: Account), error(error: Error)
|
case loading, data(account: Account), error(error: Error)
|
||||||
|
@ -62,7 +63,6 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
|
||||||
private var account: Account?
|
private var account: Account?
|
||||||
|
|
||||||
private(set) var statuses: [Status] = []
|
private(set) var statuses: [Status] = []
|
||||||
private let isCurrentUser: Bool
|
|
||||||
|
|
||||||
/// When coming from a URL like a mention tap in a status.
|
/// When coming from a URL like a mention tap in a status.
|
||||||
init(accountId: String) {
|
init(accountId: String) {
|
||||||
|
@ -71,10 +71,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the account is already fetched by the parent caller.
|
/// When the account is already fetched by the parent caller.
|
||||||
init(account: Account, isCurrentUser: Bool) {
|
init(account: Account) {
|
||||||
self.accountId = account.id
|
self.accountId = account.id
|
||||||
self.accountState = .data(account: account)
|
self.accountState = .data(account: account)
|
||||||
self.isCurrentUser = isCurrentUser
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAccount() async {
|
func fetchAccount() async {
|
||||||
|
|
|
@ -18,6 +18,7 @@ public class AccountsListRowViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AccountsListRow: View {
|
public struct AccountsListRow: View {
|
||||||
|
@EnvironmentObject private var currentAccount: CurrentAccount
|
||||||
@EnvironmentObject private var routeurPath: RouterPath
|
@EnvironmentObject private var routeurPath: RouterPath
|
||||||
@EnvironmentObject private var client: Client
|
@EnvironmentObject private var client: Client
|
||||||
|
|
||||||
|
@ -45,8 +46,10 @@ public struct AccountsListRow: View {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
FollowButton(viewModel: .init(accountId: viewModel.account.id,
|
if currentAccount.account?.id != viewModel.account.id {
|
||||||
relationship: viewModel.relationShip))
|
FollowButton(viewModel: .init(accountId: viewModel.account.id,
|
||||||
|
relationship: viewModel.relationShip))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
|
|
|
@ -5,27 +5,35 @@ import Models
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
extension Account {
|
extension Account {
|
||||||
|
private struct Part: Identifiable {
|
||||||
|
let id = UUID().uuidString
|
||||||
|
let value: Substring
|
||||||
|
}
|
||||||
|
|
||||||
public var displayNameWithEmojis: some View {
|
public var displayNameWithEmojis: some View {
|
||||||
let splittedDisplayName = displayName.split(separator: ":")
|
let splittedDisplayName = displayName.split(separator: ":").map{ Part(value: $0) }
|
||||||
return HStack(spacing: 0) {
|
return HStack(spacing: 0) {
|
||||||
ForEach(splittedDisplayName, id: \.self) { part in
|
if displayName.isEmpty {
|
||||||
if let emoji = emojis.first(where: { $0.shortcode == part }) {
|
Text(" ")
|
||||||
LazyImage(url: emoji.url) { state in
|
}
|
||||||
if let image = state.image {
|
ForEach(splittedDisplayName, id: \.id) { part in
|
||||||
image
|
if let emoji = emojis.first(where: { $0.shortcode == part.value }) {
|
||||||
.resizingMode(.aspectFit)
|
LazyImage(url: emoji.url) { state in
|
||||||
} else if state.isLoading {
|
if let image = state.image {
|
||||||
ProgressView()
|
image
|
||||||
} else {
|
.resizingMode(.aspectFit)
|
||||||
ProgressView()
|
} else if state.isLoading {
|
||||||
}
|
ProgressView()
|
||||||
}
|
} else {
|
||||||
.processors([.resize(size: .init(width: 20, height: 20))])
|
ProgressView()
|
||||||
.frame(width: 20, height: 20)
|
}
|
||||||
} else {
|
}
|
||||||
Text(part)
|
.processors([.resize(size: .init(width: 20, height: 20))])
|
||||||
}
|
.frame(width: 20, height: 20)
|
||||||
}
|
} else {
|
||||||
}
|
Text(part.value)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import NukeUI
|
||||||
|
|
||||||
public struct AvatarView: View {
|
public struct AvatarView: View {
|
||||||
public enum Size {
|
public enum Size {
|
||||||
case account, status, badge
|
case account, status, embed, badge
|
||||||
|
|
||||||
var size: CGSize {
|
var size: CGSize {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -12,6 +12,8 @@ public struct AvatarView: View {
|
||||||
return .init(width: 80, height: 80)
|
return .init(width: 80, height: 80)
|
||||||
case .status:
|
case .status:
|
||||||
return .init(width: 40, height: 40)
|
return .init(width: 40, height: 40)
|
||||||
|
case .embed:
|
||||||
|
return .init(width: 34, height: 34)
|
||||||
case .badge:
|
case .badge:
|
||||||
return .init(width: 28, height: 28)
|
return .init(width: 28, height: 28)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Models
|
import Models
|
||||||
|
import Network
|
||||||
|
|
||||||
public enum RouteurDestinations: Hashable {
|
public enum RouteurDestinations: Hashable {
|
||||||
case accountDetail(id: String)
|
case accountDetail(id: String)
|
||||||
|
@ -17,16 +18,19 @@ public enum SheetDestinations: Identifiable {
|
||||||
case newStatusEditor
|
case newStatusEditor
|
||||||
case editStatusEditor(status: Status)
|
case editStatusEditor(status: Status)
|
||||||
case replyToStatusEditor(status: Status)
|
case replyToStatusEditor(status: Status)
|
||||||
|
case quoteStatusEditor(status: Status)
|
||||||
|
|
||||||
public var id: String {
|
public var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor:
|
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor:
|
||||||
return "statusEditor"
|
return "statusEditor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RouterPath: ObservableObject {
|
public class RouterPath: ObservableObject {
|
||||||
|
public var client: Client?
|
||||||
|
|
||||||
@Published public var path: [RouteurDestinations] = []
|
@Published public var path: [RouteurDestinations] = []
|
||||||
@Published public var presentedSheet: SheetDestinations?
|
@Published public var presentedSheet: SheetDestinations?
|
||||||
|
|
||||||
|
@ -44,6 +48,10 @@ public class RouterPath: ObservableObject {
|
||||||
} else if let mention = status.mentions.first(where: { $0.url == url }) {
|
} else if let mention = status.mentions.first(where: { $0.url == url }) {
|
||||||
navigate(to: .accountDetail(id: mention.id))
|
navigate(to: .accountDetail(id: mention.id))
|
||||||
return .handled
|
return .handled
|
||||||
|
} else if let client = client,
|
||||||
|
let id = status.content.findStatusesIds(instance: client.server)?.first(where: { String($0) == url.lastPathComponent}) {
|
||||||
|
navigate(to: .statusDetail(id: String(id)))
|
||||||
|
return .handled
|
||||||
}
|
}
|
||||||
return .systemAction
|
return .systemAction
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,19 +13,19 @@ public struct ExploreView: View {
|
||||||
@EnvironmentObject private var routeurPath: RouterPath
|
@EnvironmentObject private var routeurPath: RouterPath
|
||||||
|
|
||||||
@StateObject private var viewModel = ExploreViewModel()
|
@StateObject private var viewModel = ExploreViewModel()
|
||||||
@State private var searchQuery: String = ""
|
|
||||||
|
|
||||||
public init() { }
|
public init() { }
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
List {
|
List {
|
||||||
if !viewModel.isLoaded {
|
if !viewModel.searchQuery.isEmpty {
|
||||||
ForEach(Status.placeholders()) { status in
|
if let results = viewModel.results[viewModel.searchQuery] {
|
||||||
StatusRowView(viewModel: .init(status: status, isEmbed: false))
|
makeSearchResultsView(results: results)
|
||||||
.padding(.vertical, 8)
|
} else {
|
||||||
.redacted(reason: .placeholder)
|
loadingView
|
||||||
.shimmering()
|
|
||||||
}
|
}
|
||||||
|
} else if !viewModel.isLoaded {
|
||||||
|
loadingView
|
||||||
} else {
|
} else {
|
||||||
trendingTagsSection
|
trendingTagsSection
|
||||||
suggestedAccountsSection
|
suggestedAccountsSection
|
||||||
|
@ -45,7 +45,51 @@ public struct ExploreView: View {
|
||||||
}
|
}
|
||||||
.listStyle(.grouped)
|
.listStyle(.grouped)
|
||||||
.navigationTitle("Explore")
|
.navigationTitle("Explore")
|
||||||
.searchable(text: $searchQuery)
|
.searchable(text: $viewModel.searchQuery,
|
||||||
|
tokens: $viewModel.tokens,
|
||||||
|
suggestedTokens: $viewModel.suggestedToken,
|
||||||
|
prompt: Text("Search users, posts and tags"),
|
||||||
|
token: { token in
|
||||||
|
Text(token.rawValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
ForEach(Status.placeholders()) { status in
|
||||||
|
StatusRowView(viewModel: .init(status: status, isEmbed: false))
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
.shimmering()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeSearchResultsView(results: SearchResults) -> some View {
|
||||||
|
if !results.accounts.isEmpty {
|
||||||
|
Section("Users") {
|
||||||
|
ForEach(results.accounts) { account in
|
||||||
|
if let relationship = results.relationships.first(where: { $0.id == account.id }) {
|
||||||
|
AccountsListRow(viewModel: .init(account: account, relationShip: relationship))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !results.hashtags.isEmpty {
|
||||||
|
Section("Tags") {
|
||||||
|
ForEach(results.hashtags) { tag in
|
||||||
|
TagRowView(tag: tag)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !results.statuses.isEmpty {
|
||||||
|
Section("Posts") {
|
||||||
|
ForEach(results.statuses) { status in
|
||||||
|
StatusRowView(viewModel: .init(status: status))
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var suggestedAccountsSection: some View {
|
private var suggestedAccountsSection: some View {
|
||||||
|
|
|
@ -6,6 +6,44 @@ import Network
|
||||||
class ExploreViewModel: ObservableObject {
|
class ExploreViewModel: ObservableObject {
|
||||||
var client: Client?
|
var client: Client?
|
||||||
|
|
||||||
|
enum Token: String, Identifiable {
|
||||||
|
case user = "@user"
|
||||||
|
case statuses = "@posts"
|
||||||
|
case tag = "#hasgtag"
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiType: String {
|
||||||
|
switch self {
|
||||||
|
case .user:
|
||||||
|
return "accounts"
|
||||||
|
case .tag:
|
||||||
|
return "hashtags"
|
||||||
|
case .statuses:
|
||||||
|
return "statuses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var tokens: [Token] = []
|
||||||
|
@Published var suggestedToken: [Token] = []
|
||||||
|
@Published var searchQuery = "" {
|
||||||
|
didSet {
|
||||||
|
if searchQuery.starts(with: "@") {
|
||||||
|
suggestedToken = [.user, .statuses]
|
||||||
|
} else if searchQuery.starts(with: "#") {
|
||||||
|
suggestedToken = [.tag]
|
||||||
|
} else if !tokens.isEmpty {
|
||||||
|
suggestedToken = []
|
||||||
|
search()
|
||||||
|
} else {
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var results: [String: SearchResults] = [:]
|
||||||
@Published var isLoaded = false
|
@Published var isLoaded = false
|
||||||
@Published var suggestedAccounts: [Account] = []
|
@Published var suggestedAccounts: [Account] = []
|
||||||
@Published var suggestedAccountsRelationShips: [Relationshionship] = []
|
@Published var suggestedAccountsRelationShips: [Relationshionship] = []
|
||||||
|
@ -13,10 +51,13 @@ class ExploreViewModel: ObservableObject {
|
||||||
@Published var trendingStatuses: [Status] = []
|
@Published var trendingStatuses: [Status] = []
|
||||||
@Published var trendingLinks: [Card] = []
|
@Published var trendingLinks: [Card] = []
|
||||||
|
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
func fetchTrending() async {
|
func fetchTrending() async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
do {
|
do {
|
||||||
isLoaded = false
|
isLoaded = false
|
||||||
|
|
||||||
async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions)
|
async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions)
|
||||||
async let trendingTags: [Tag] = client.get(endpoint: Trends.tags)
|
async let trendingTags: [Tag] = client.get(endpoint: Trends.tags)
|
||||||
async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses)
|
async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses)
|
||||||
|
@ -32,4 +73,24 @@ class ExploreViewModel: ObservableObject {
|
||||||
isLoaded = true
|
isLoaded = true
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func search() {
|
||||||
|
guard !searchQuery.isEmpty else { return }
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchTask = nil
|
||||||
|
searchTask = Task {
|
||||||
|
guard let client else { return }
|
||||||
|
do {
|
||||||
|
let apiType = tokens.first?.apiType
|
||||||
|
var results: SearchResults = try await client.get(endpoint: Search.search(query: searchQuery,
|
||||||
|
type: apiType,
|
||||||
|
offset: nil),
|
||||||
|
forceVersion: .v2)
|
||||||
|
let relationships: [Relationshionship] =
|
||||||
|
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map{ $0.id }))
|
||||||
|
results.relationships = relationships
|
||||||
|
self.results[searchQuery] = results
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,25 @@ extension HTMLString {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func findStatusesIds(instance: String) -> [Int]? {
|
||||||
|
do {
|
||||||
|
let document: Document = try SwiftSoup.parse(self)
|
||||||
|
let links: Elements = try document.select("a")
|
||||||
|
var ids: [Int] = []
|
||||||
|
for link in links {
|
||||||
|
let href = try link.attr("href")
|
||||||
|
if href.contains(instance),
|
||||||
|
let url = URL(string: href),
|
||||||
|
let statusId = Int(url.lastPathComponent) {
|
||||||
|
ids.append(statusId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var asSafeAttributedString: AttributedString {
|
public var asSafeAttributedString: AttributedString {
|
||||||
do {
|
do {
|
||||||
// Add space between hashtags and mentions that follow each other
|
// Add space between hashtags and mentions that follow each other
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct MediaAttachement: Codable, Identifiable, Hashable {
|
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 {
|
public enum SupportedType: String {
|
||||||
case image, gifv
|
case image, gifv
|
||||||
}
|
}
|
||||||
|
@ -17,5 +26,6 @@ public struct MediaAttachement: Codable, Identifiable, Hashable {
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let previewUrl: URL?
|
public let previewUrl: URL?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
|
public let meta: MetaContainer?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
12
Packages/Models/Sources/Models/SearchResults.swift
Normal file
12
Packages/Models/Sources/Models/SearchResults.swift
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct SearchResults: Decodable {
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accounts, statuses, hashtags
|
||||||
|
}
|
||||||
|
|
||||||
|
public let accounts: [Account]
|
||||||
|
public var relationships: [Relationshionship] = []
|
||||||
|
public let statuses: [Status]
|
||||||
|
public let hashtags: [Tag]
|
||||||
|
}
|
|
@ -5,6 +5,14 @@ public struct Application: Codable, Identifiable {
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
public let name: String
|
public let name: String
|
||||||
|
public let website: URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Visibility: String, Codable {
|
||||||
|
case pub = "public"
|
||||||
|
case unlisted
|
||||||
|
case priv = "private"
|
||||||
|
case direct
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol AnyStatus {
|
public protocol AnyStatus {
|
||||||
|
@ -27,6 +35,7 @@ public protocol AnyStatus {
|
||||||
var url: URL? { get }
|
var url: URL? { get }
|
||||||
var application: Application? { get }
|
var application: Application? { get }
|
||||||
var inReplyToAccountId: String? { get }
|
var inReplyToAccountId: String? { get }
|
||||||
|
var visibility: Visibility { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -54,6 +63,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
||||||
public let url: URL?
|
public let url: URL?
|
||||||
public let application: Application?
|
public let application: Application?
|
||||||
public let inReplyToAccountId: String?
|
public let inReplyToAccountId: String?
|
||||||
|
public let visibility: Visibility
|
||||||
|
|
||||||
public static func placeholder() -> Status {
|
public static func placeholder() -> Status {
|
||||||
.init(id: UUID().uuidString,
|
.init(id: UUID().uuidString,
|
||||||
|
@ -74,7 +84,8 @@ public struct Status: AnyStatus, Codable, Identifiable {
|
||||||
emojis: [],
|
emojis: [],
|
||||||
url: nil,
|
url: nil,
|
||||||
application: nil,
|
application: nil,
|
||||||
inReplyToAccountId: nil)
|
inReplyToAccountId: nil,
|
||||||
|
visibility: .pub)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func placeholders() -> [Status] {
|
public static func placeholders() -> [Status] {
|
||||||
|
@ -105,4 +116,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
||||||
public let url: URL?
|
public let url: URL?
|
||||||
public var application: Application?
|
public var application: Application?
|
||||||
public let inReplyToAccountId: String?
|
public let inReplyToAccountId: String?
|
||||||
|
public let visibility: Visibility
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ public class Client: ObservableObject, Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Version: String {
|
public enum Version: String {
|
||||||
case v1
|
case v1, v2
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum OauthError: Error {
|
public enum OauthError: Error {
|
||||||
|
@ -40,14 +40,14 @@ public class Client: ObservableObject, Equatable {
|
||||||
self.oauthToken = oauthToken
|
self.oauthToken = oauthToken
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeURL(scheme: String = "https", endpoint: Endpoint) -> URL {
|
private func makeURL(scheme: String = "https", endpoint: Endpoint, forceVersion: Version? = nil) -> URL {
|
||||||
var components = URLComponents()
|
var components = URLComponents()
|
||||||
components.scheme = scheme
|
components.scheme = scheme
|
||||||
components.host = server
|
components.host = server
|
||||||
if type(of: endpoint) == Oauth.self {
|
if type(of: endpoint) == Oauth.self {
|
||||||
components.path += "/\(endpoint.path())"
|
components.path += "/\(endpoint.path())"
|
||||||
} else {
|
} else {
|
||||||
components.path += "/api/\(version.rawValue)/\(endpoint.path())"
|
components.path += "/api/\(forceVersion?.rawValue ?? version.rawValue)/\(endpoint.path())"
|
||||||
}
|
}
|
||||||
components.queryItems = endpoint.queryItems()
|
components.queryItems = endpoint.queryItems()
|
||||||
return components.url!
|
return components.url!
|
||||||
|
@ -67,8 +67,8 @@ public class Client: ObservableObject, Equatable {
|
||||||
return makeURLRequest(url: url, httpMethod: "GET")
|
return makeURLRequest(url: url, httpMethod: "GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func get<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
|
public func get<Entity: Decodable>(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity {
|
||||||
try await makeEntityRequest(endpoint: endpoint, method: "GET")
|
try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) {
|
public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) {
|
||||||
|
@ -97,8 +97,10 @@ public class Client: ObservableObject, Equatable {
|
||||||
return httpResponse as? HTTPURLResponse
|
return httpResponse as? HTTPURLResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeEntityRequest<Entity: Decodable>(endpoint: Endpoint, method: String) async throws -> Entity {
|
private func makeEntityRequest<Entity: Decodable>(endpoint: Endpoint,
|
||||||
let url = makeURL(endpoint: endpoint)
|
method: String,
|
||||||
|
forceVersion: Version? = nil) async throws -> Entity {
|
||||||
|
let url = makeURL(endpoint: endpoint, forceVersion: forceVersion)
|
||||||
let request = makeURLRequest(url: url, httpMethod: method)
|
let request = makeURLRequest(url: url, httpMethod: method)
|
||||||
let (data, httpResponse) = try await urlSession.data(for: request)
|
let (data, httpResponse) = try await urlSession.data(for: request)
|
||||||
logResponseOnError(httpResponse: httpResponse, data: data)
|
logResponseOnError(httpResponse: httpResponse, data: data)
|
||||||
|
|
26
Packages/Network/Sources/Network/Endpoint/Search.swift
Normal file
26
Packages/Network/Sources/Network/Endpoint/Search.swift
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum Search: Endpoint {
|
||||||
|
case search(query: String, type: String?, offset: Int?)
|
||||||
|
|
||||||
|
public func path() -> String {
|
||||||
|
switch self {
|
||||||
|
case .search:
|
||||||
|
return "search"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func queryItems() -> [URLQueryItem]? {
|
||||||
|
switch self {
|
||||||
|
case let .search(query, type, offset):
|
||||||
|
var params: [URLQueryItem] = [.init(name: "q", value: query)]
|
||||||
|
if let type {
|
||||||
|
params.append(.init(name: "type", value: type))
|
||||||
|
}
|
||||||
|
if let offset {
|
||||||
|
params.append(.init(name: "offset", value: String(offset)))
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,12 +21,17 @@ public struct StatusEditorView: View {
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
VStack(spacing: 12) {
|
ScrollView {
|
||||||
accountHeaderView
|
VStack(spacing: 12) {
|
||||||
TextView($viewModel.statusText)
|
accountHeaderView
|
||||||
.placeholder("What's on your mind")
|
TextView($viewModel.statusText)
|
||||||
mediasView
|
.placeholder("What's on your mind")
|
||||||
Spacer()
|
if let status = viewModel.embededStatus {
|
||||||
|
StatusEmbededView(status: status)
|
||||||
|
}
|
||||||
|
mediasView
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
accessoryView
|
accessoryView
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
@ -34,6 +39,9 @@ public struct StatusEditorView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
viewModel.prepareStatusText()
|
viewModel.prepareStatusText()
|
||||||
|
if !client.isAuth {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||||
.navigationTitle(viewModel.mode.title)
|
.navigationTitle(viewModel.mode.title)
|
||||||
|
|
|
@ -6,38 +6,20 @@ import PhotosUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class StatusEditorViewModel: ObservableObject {
|
public class StatusEditorViewModel: ObservableObject {
|
||||||
public enum Mode {
|
struct ImageContainer: Identifiable {
|
||||||
case replyTo(status: Status)
|
let id = UUID().uuidString
|
||||||
case new
|
let image: UIImage
|
||||||
case edit(status: Status)
|
|
||||||
|
|
||||||
var replyToStatus: Status? {
|
|
||||||
switch self {
|
|
||||||
case let .replyTo(status):
|
|
||||||
return status
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .new:
|
|
||||||
return "New Post"
|
|
||||||
case .edit:
|
|
||||||
return "Edit your post"
|
|
||||||
case let .replyTo(status):
|
|
||||||
return "Reply to \(status.account.displayName)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mode: Mode
|
var mode: Mode
|
||||||
|
let generator = UINotificationFeedbackGenerator()
|
||||||
|
|
||||||
@Published var statusText = NSAttributedString(string: "") {
|
var client: Client?
|
||||||
|
|
||||||
|
@Published var statusText = NSMutableAttributedString(string: "") {
|
||||||
didSet {
|
didSet {
|
||||||
guard !internalUpdate else { return }
|
|
||||||
highlightMeta()
|
highlightMeta()
|
||||||
|
checkEmbed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,16 +31,8 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
@Published var mediasImages: [ImageContainer] = []
|
@Published var mediasImages: [ImageContainer] = []
|
||||||
|
|
||||||
struct ImageContainer: Identifiable {
|
@Published var embededStatus: Status?
|
||||||
let id = UUID().uuidString
|
|
||||||
let image: UIImage
|
|
||||||
}
|
|
||||||
|
|
||||||
var client: Client?
|
|
||||||
private var internalUpdate: Bool = false
|
|
||||||
|
|
||||||
let generator = UINotificationFeedbackGenerator()
|
|
||||||
|
|
||||||
init(mode: Mode) {
|
init(mode: Mode) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
}
|
}
|
||||||
|
@ -69,7 +43,7 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
isPosting = true
|
isPosting = true
|
||||||
let postStatus: Status?
|
let postStatus: Status?
|
||||||
switch mode {
|
switch mode {
|
||||||
case .new, .replyTo:
|
case .new, .replyTo, .quote:
|
||||||
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
|
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
|
||||||
inReplyTo: mode.replyToStatus?.id,
|
inReplyTo: mode.replyToStatus?.id,
|
||||||
mediaIds: nil,
|
mediaIds: nil,
|
||||||
|
@ -93,45 +67,65 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
func prepareStatusText() {
|
func prepareStatusText() {
|
||||||
switch mode {
|
switch mode {
|
||||||
case let .replyTo(status):
|
case let .replyTo(status):
|
||||||
statusText = .init(string: "@\(status.account.acct) ")
|
statusText = .init(string: "@\(status.reblog?.account.acct ?? status.account.acct) ")
|
||||||
case let .edit(status):
|
case let .edit(status):
|
||||||
statusText = .init(string: status.content.asRawText)
|
statusText = .init(status.content.asSafeAttributedString)
|
||||||
|
case let .quote(status):
|
||||||
|
self.embededStatus = status
|
||||||
|
if let url = status.reblog?.url ?? status.url {
|
||||||
|
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func highlightMeta() {
|
private func highlightMeta() {
|
||||||
let mutableString = NSMutableAttributedString(string: statusText.string)
|
statusText.addAttributes([.foregroundColor: UIColor(Color.label)],
|
||||||
mutableString.addAttributes([.foregroundColor: UIColor(Color.label)],
|
range: NSMakeRange(0, statusText.string.utf16.count))
|
||||||
range: NSMakeRange(0, mutableString.string.utf16.count))
|
|
||||||
let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})"
|
let hashtagPattern = "(#+[a-zA-Z0-9(_)]{1,})"
|
||||||
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
|
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
|
||||||
var ranges: [NSRange] = [NSRange]()
|
let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)"
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||||
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
||||||
|
let urlRegex = try NSRegularExpression(pattern: urlPattern, options: [])
|
||||||
|
|
||||||
ranges = hashtagRegex.matches(in: mutableString.string,
|
var ranges = hashtagRegex.matches(in: statusText.string,
|
||||||
options: [],
|
options: [],
|
||||||
range: NSMakeRange(0, mutableString.string.utf16.count)).map { $0.range }
|
range: NSMakeRange(0, statusText.string.utf16.count)).map { $0.range }
|
||||||
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
|
ranges.append(contentsOf: mentionRegex.matches(in: statusText.string,
|
||||||
options: [],
|
options: [],
|
||||||
range: NSMakeRange(0, mutableString.string.utf16.count)).map {$0.range})
|
range: NSMakeRange(0, statusText.string.utf16.count)).map {$0.range})
|
||||||
|
|
||||||
|
let urlRanges = urlRegex.matches(in: statusText.string,
|
||||||
|
options: [],
|
||||||
|
range: NSMakeRange(0, statusText.string.utf16.count)).map { $0.range }
|
||||||
|
|
||||||
for range in ranges {
|
for range in ranges {
|
||||||
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
statusText.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
||||||
range: NSRange(location: range.location, length: range.length))
|
range: NSRange(location: range.location, length: range.length))
|
||||||
}
|
}
|
||||||
internalUpdate = true
|
|
||||||
statusText = mutableString
|
for range in urlRanges {
|
||||||
internalUpdate = false
|
statusText.addAttributes([.foregroundColor: UIColor(Color.brand),
|
||||||
|
.underlineStyle: NSUnderlineStyle.single,
|
||||||
|
.underlineColor: UIColor(Color.brand)],
|
||||||
|
range: NSRange(location: range.location, length: range.length))
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func checkEmbed() {
|
||||||
|
if let embededStatus, !statusText.string.contains(embededStatus.reblog?.id ?? embededStatus.id) {
|
||||||
|
self.embededStatus = nil
|
||||||
|
self.mode = .new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func inflateSelectedMedias() {
|
func inflateSelectedMedias() {
|
||||||
for media in selectedMedias {
|
for media in selectedMedias {
|
||||||
media.loadTransferable(type: Data.self) { [weak self] result in
|
media.loadTransferable(type: Data.self) { [weak self] result in
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import Models
|
||||||
|
|
||||||
|
extension StatusEditorViewModel {
|
||||||
|
public enum Mode {
|
||||||
|
case replyTo(status: Status)
|
||||||
|
case new
|
||||||
|
case edit(status: Status)
|
||||||
|
case quote(status: Status)
|
||||||
|
|
||||||
|
var replyToStatus: Status? {
|
||||||
|
switch self {
|
||||||
|
case let .replyTo(status):
|
||||||
|
return status
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .new:
|
||||||
|
return "New Post"
|
||||||
|
case .edit:
|
||||||
|
return "Edit your post"
|
||||||
|
case let .replyTo(status):
|
||||||
|
return "Reply to \(status.reblog?.account.displayName ?? status.account.displayName)"
|
||||||
|
case let .quote(status):
|
||||||
|
return "Quote of \(status.reblog?.account.displayName ?? status.account.displayName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
48
Packages/Status/Sources/Status/Embed/StatusEmbededView.swift
Normal file
48
Packages/Status/Sources/Status/Embed/StatusEmbededView.swift
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Models
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct StatusEmbededView: View {
|
||||||
|
public let status: Status
|
||||||
|
|
||||||
|
public init(status: Status) {
|
||||||
|
self.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
makeAccountView(account: status.reblog?.account ?? status.account)
|
||||||
|
StatusRowView(viewModel: .init(status: status, isEmbed: true))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.10))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAccountView(account: Account) -> some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
AvatarView(url: account.avatar, size: .embed)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
status.account.displayNameWithEmojis
|
||||||
|
.font(.footnote)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Group {
|
||||||
|
Text("@\(account.acct)") +
|
||||||
|
Text(" ⸱ ") +
|
||||||
|
Text(status.reblog?.createdAt.formatted ?? status.createdAt.formatted)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
Packages/Status/Sources/Status/Ext/Visibility.swift
Normal file
16
Packages/Status/Sources/Status/Ext/Visibility.swift
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import Models
|
||||||
|
|
||||||
|
extension Visibility {
|
||||||
|
public var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .pub:
|
||||||
|
return "globe.americas"
|
||||||
|
case .unlisted:
|
||||||
|
return "lock.open"
|
||||||
|
case .priv:
|
||||||
|
return "lock"
|
||||||
|
case .direct:
|
||||||
|
return "at.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import Network
|
||||||
import DesignSystem
|
import DesignSystem
|
||||||
|
|
||||||
struct StatusActionsView: View {
|
struct StatusActionsView: View {
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@EnvironmentObject private var routeurPath: RouterPath
|
@EnvironmentObject private var routeurPath: RouterPath
|
||||||
@ObservedObject var viewModel: StatusRowViewModel
|
@ObservedObject var viewModel: StatusRowViewModel
|
||||||
|
|
||||||
|
@ -91,10 +92,19 @@ struct StatusActionsView: View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(viewModel.status.createdAt.asDate, style: .date)
|
Text(viewModel.status.createdAt.asDate, style: .date)
|
||||||
Text(viewModel.status.createdAt.asDate, style: .time)
|
Text(viewModel.status.createdAt.asDate, style: .time)
|
||||||
|
Text("·")
|
||||||
|
Image(systemName: viewModel.status.visibility.iconName)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(viewModel.status.application?.name ?? "")
|
Text(viewModel.status.application?.name ?? "")
|
||||||
|
.underline()
|
||||||
|
.onTapGesture {
|
||||||
|
if let url = viewModel.status.application?.website {
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
if viewModel.favouritesCount > 0 {
|
if viewModel.favouritesCount > 0 {
|
||||||
Divider()
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Models
|
||||||
import Env
|
import Env
|
||||||
import Shimmer
|
import Shimmer
|
||||||
import NukeUI
|
import NukeUI
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
public struct StatusMediaPreviewView: View {
|
public struct StatusMediaPreviewView: View {
|
||||||
@EnvironmentObject private var quickLook: QuickLook
|
@EnvironmentObject private var quickLook: QuickLook
|
||||||
|
@ -10,6 +11,7 @@ public struct StatusMediaPreviewView: View {
|
||||||
public let attachements: [MediaAttachement]
|
public let attachements: [MediaAttachement]
|
||||||
|
|
||||||
@State private var isQuickLookLoading: Bool = false
|
@State private var isQuickLookLoading: Bool = false
|
||||||
|
@State private var width: CGFloat = 0
|
||||||
|
|
||||||
private var imageMaxHeight: CGFloat {
|
private var imageMaxHeight: CGFloat {
|
||||||
if attachements.count == 1 {
|
if attachements.count == 1 {
|
||||||
|
@ -18,6 +20,20 @@ public struct StatusMediaPreviewView: View {
|
||||||
return attachements.count > 2 ? 100 : 200
|
return attachements.count > 2 ? 100 : 200
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func size(for media: MediaAttachement) -> CGSize? {
|
||||||
|
if let width = media.meta?.original.width,
|
||||||
|
let height = media.meta?.original.height {
|
||||||
|
return .init(width: CGFloat(width), height: CGFloat(height))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func imageSize(from: CGSize, newWidth: CGFloat) -> CGSize {
|
||||||
|
let ratio = newWidth / from.width
|
||||||
|
let newHeight = from.height * ratio
|
||||||
|
return .init(width: newWidth, height: newHeight)
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if attachements.count == 1, let attachement = attachements.first {
|
if attachements.count == 1, let attachement = attachements.first {
|
||||||
|
@ -60,21 +76,38 @@ public struct StatusMediaPreviewView: View {
|
||||||
private func makeFeaturedImagePreview(attachement: MediaAttachement) -> some View {
|
private func makeFeaturedImagePreview(attachement: MediaAttachement) -> some View {
|
||||||
switch attachement.supportedType {
|
switch attachement.supportedType {
|
||||||
case .image:
|
case .image:
|
||||||
AsyncImage(
|
if let size = size(for: attachement) {
|
||||||
url: attachement.url,
|
let newSize = imageSize(from: size,
|
||||||
content: { image in
|
newWidth: UIScreen.main.bounds.width - (DS.Constants.layoutPadding * 2))
|
||||||
image
|
LazyImage(url: attachement.url) { state in
|
||||||
.resizable()
|
if let image = state.image {
|
||||||
.aspectRatio(contentMode: .fill)
|
image
|
||||||
.cornerRadius(4)
|
.resizingMode(.aspectFill)
|
||||||
},
|
.cornerRadius(4)
|
||||||
placeholder: {
|
.frame(width: newSize.width, height: newSize.height)
|
||||||
RoundedRectangle(cornerRadius: 4)
|
} else {
|
||||||
.fill(Color.gray)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.frame(height: imageMaxHeight)
|
.fill(Color.gray)
|
||||||
.shimmering()
|
.frame(width: newSize.width, height: newSize.height)
|
||||||
|
.shimmering()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
url: attachement.url,
|
||||||
|
content: { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.cornerRadius(4)
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.gray)
|
||||||
|
.frame(height: imageMaxHeight)
|
||||||
|
.shimmering()
|
||||||
|
})
|
||||||
|
}
|
||||||
case .gifv:
|
case .gifv:
|
||||||
VideoPlayerView(viewModel: .init(url: attachement.url))
|
VideoPlayerView(viewModel: .init(url: attachement.url))
|
||||||
.frame(height: imageMaxHeight)
|
.frame(height: imageMaxHeight)
|
||||||
|
|
|
@ -35,6 +35,14 @@ public struct StatusRowView: View {
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.client = client
|
viewModel.client = client
|
||||||
|
if !viewModel.isEmbed {
|
||||||
|
Task {
|
||||||
|
await viewModel.loadEmbededStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
contextMenu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,54 +89,65 @@ public struct StatusRowView: View {
|
||||||
Button {
|
Button {
|
||||||
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
||||||
} label: {
|
} label: {
|
||||||
makeAccountView(status: status)
|
accountView(status: status)
|
||||||
}.buttonStyle(.plain)
|
}.buttonStyle(.plain)
|
||||||
Spacer()
|
Spacer()
|
||||||
menuButton
|
menuButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
makeStatusContentView(status: status)
|
||||||
Group {
|
|
||||||
Text(status.content.asSafeAttributedString)
|
|
||||||
.font(.body)
|
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
routeurPath.handleStatus(status: status, url: url)
|
|
||||||
})
|
|
||||||
|
|
||||||
if !status.mediaAttachments.isEmpty {
|
|
||||||
if viewModel.isEmbed {
|
|
||||||
Image(systemName: "paperclip")
|
|
||||||
} else {
|
|
||||||
StatusMediaPreviewView(attachements: status.mediaAttachments)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let card = status.card, !viewModel.isEmbed {
|
|
||||||
StatusCardView(card: card)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func makeStatusContentView(status: AnyStatus) -> some View {
|
||||||
private func makeAccountView(status: AnyStatus) -> some View {
|
Group {
|
||||||
AvatarView(url: status.account.avatar, size: .status)
|
Text(status.content.asSafeAttributedString)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
.font(.body)
|
||||||
status.account.displayNameWithEmojis
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
.font(.subheadline)
|
routeurPath.handleStatus(status: status, url: url)
|
||||||
.fontWeight(.semibold)
|
})
|
||||||
Group {
|
|
||||||
Text("@\(status.account.acct)") +
|
if !viewModel.isEmbed, let embed = viewModel.embededStatus {
|
||||||
Text(" ⸱ ") +
|
StatusEmbededView(status: embed)
|
||||||
Text(status.createdAt.formatted)
|
}
|
||||||
|
|
||||||
|
if !status.mediaAttachments.isEmpty {
|
||||||
|
if viewModel.isEmbed {
|
||||||
|
Image(systemName: "paperclip")
|
||||||
|
} else {
|
||||||
|
StatusMediaPreviewView(attachements: status.mediaAttachments)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let card = status.card, !viewModel.isEmbed {
|
||||||
|
StatusCardView(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func accountView(status: AnyStatus) -> some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
AvatarView(url: status.account.avatar, size: .status)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
status.account.displayNameWithEmojis
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Group {
|
||||||
|
Text("@\(status.account.acct)") +
|
||||||
|
Text(" ⸱ ") +
|
||||||
|
Text(status.createdAt.formatted) +
|
||||||
|
Text(" ⸱ ") +
|
||||||
|
Text(Image(systemName: viewModel.status.visibility.iconName))
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,6 +173,21 @@ public struct StatusRowView: View {
|
||||||
} } label: {
|
} } label: {
|
||||||
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
|
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
|
||||||
}
|
}
|
||||||
|
Button { Task {
|
||||||
|
if viewModel.isReblogged {
|
||||||
|
await viewModel.unReblog()
|
||||||
|
} else {
|
||||||
|
await viewModel.reblog()
|
||||||
|
}
|
||||||
|
} } label: {
|
||||||
|
Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
|
||||||
|
} label: {
|
||||||
|
Label("Quote this post", systemImage: "quote.bubble")
|
||||||
|
}
|
||||||
|
|
||||||
if let url = viewModel.status.reblog?.url ?? viewModel.status.url {
|
if let url = viewModel.status.reblog?.url ?? viewModel.status.url {
|
||||||
Button { UIApplication.shared.open(url) } label: {
|
Button { UIApplication.shared.open(url) } label: {
|
||||||
Label("View in Browser", systemImage: "safari")
|
Label("View in Browser", systemImage: "safari")
|
||||||
|
|
|
@ -13,6 +13,7 @@ public class StatusRowViewModel: ObservableObject {
|
||||||
@Published var isReblogged: Bool
|
@Published var isReblogged: Bool
|
||||||
@Published var reblogsCount: Int
|
@Published var reblogsCount: Int
|
||||||
@Published var repliesCount: Int
|
@Published var repliesCount: Int
|
||||||
|
@Published var embededStatus: Status?
|
||||||
|
|
||||||
var client: Client?
|
var client: Client?
|
||||||
|
|
||||||
|
@ -34,6 +35,16 @@ public class StatusRowViewModel: ObservableObject {
|
||||||
self.repliesCount = status.reblog?.repliesCount ?? status.repliesCount
|
self.repliesCount = status.reblog?.repliesCount ?? status.repliesCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadEmbededStatus() async {
|
||||||
|
guard let client,
|
||||||
|
let ids = status.content.findStatusesIds(instance: client.server),
|
||||||
|
!ids.isEmpty,
|
||||||
|
let id = ids.first else { return }
|
||||||
|
do {
|
||||||
|
self.embededStatus = try await client.get(endpoint: Statuses.status(id: String(id)))
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
func favourite() async {
|
func favourite() async {
|
||||||
guard let client, client.isAuth else { return }
|
guard let client, client.isAuth else { return }
|
||||||
isFavourited = true
|
isFavourited = true
|
||||||
|
|
|
@ -7,6 +7,10 @@ import DesignSystem
|
||||||
import Env
|
import Env
|
||||||
|
|
||||||
public struct TimelineView: View {
|
public struct TimelineView: View {
|
||||||
|
private enum Constants {
|
||||||
|
static let scrollToTop = "top"
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@EnvironmentObject private var account: CurrentAccount
|
@EnvironmentObject private var account: CurrentAccount
|
||||||
@EnvironmentObject private var watcher: StreamWatcher
|
@EnvironmentObject private var watcher: StreamWatcher
|
||||||
|
@ -25,7 +29,7 @@ public struct TimelineView: View {
|
||||||
LazyVStack {
|
LazyVStack {
|
||||||
tagHeaderView
|
tagHeaderView
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
.id("top")
|
.id(Constants.scrollToTop)
|
||||||
StatusesListView(fetcher: viewModel)
|
StatusesListView(fetcher: viewModel)
|
||||||
}
|
}
|
||||||
.padding(.top, DS.Constants.layoutPadding)
|
.padding(.top, DS.Constants.layoutPadding)
|
||||||
|
@ -70,8 +74,10 @@ public struct TimelineView: View {
|
||||||
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
|
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
|
||||||
if !viewModel.pendingStatuses.isEmpty {
|
if !viewModel.pendingStatuses.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
proxy.scrollTo("top")
|
proxy.scrollTo(Constants.scrollToTop)
|
||||||
viewModel.displayPendingStatuses()
|
withAnimation {
|
||||||
|
viewModel.displayPendingStatuses()
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(viewModel.pendingStatusesButtonTitle)
|
Text(viewModel.pendingStatusesButtonTitle)
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,7 +128,8 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
||||||
statuses.insert(event.status, at: 0)
|
statuses.insert(event.status, at: 0)
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||||
} else if pendingStatusesEnabled,
|
} else if pendingStatusesEnabled,
|
||||||
!statuses.contains(where: { $0.id == event.status.id }) {
|
!statuses.contains(where: { $0.id == event.status.id }),
|
||||||
|
!pendingStatuses.contains(where: { $0.id == event.status.id }){
|
||||||
pendingStatuses.insert(event.status, at: 0)
|
pendingStatuses.insert(event.status, at: 0)
|
||||||
pendingStatusesState = .stream
|
pendingStatusesState = .stream
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue