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