Merge branch 'main' into update-nuke

This commit is contained in:
Thomas Ricouard 2022-12-27 14:23:41 +01:00 committed by GitHub
commit f14640a2ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 704 additions and 239 deletions

View file

@ -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";

View file

@ -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"
} }
} }
], ],

View file

@ -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))
} }
} }
} }

View file

@ -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

View file

@ -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
}
} }
} }

View file

@ -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
}
} }
} }

View file

@ -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)

View file

@ -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)")
} }
} }

View 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()
}
}
}

View file

@ -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
} }

View file

@ -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))
} }
} }

View file

@ -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)")

View file

@ -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 {

View file

@ -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

View file

@ -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)
} }
}
}
}
} }

View file

@ -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)
} }

View file

@ -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
} }

View file

@ -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 {

View file

@ -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 { }
}
}
} }

View file

@ -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

View file

@ -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?
} }

View 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]
}

View file

@ -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
} }

View file

@ -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)

View 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
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -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)"
}
}
}
}

View 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)
}
}
}
}

View 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"
}
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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)
} }

View file

@ -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
} }