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 */
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";

View file

@ -51,7 +51,7 @@
"location" : "https://github.com/Dimillian/TextView",
"state" : {
"branch" : "main",
"revision" : "3555eecb81f918091d4f65c071dd94e64995b41b"
"revision" : "26b2930e82bb379a4abf0fcba408c0a09fbbb407"
}
}
],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 {
do {
// Add space between hashtags and mentions that follow each other

View file

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

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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