This commit is contained in:
Thomas Ricouard 2023-02-04 17:17:38 +01:00
parent 427452db30
commit 6b285cdbcf
22 changed files with 188 additions and 227 deletions

View file

@ -59,7 +59,7 @@ private struct SafariRouter: ViewModifier {
struct SafariView: UIViewControllerRepresentable {
let url: URL
let inAppBrowserReaderView: Bool
func makeUIViewController(context _: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
let configuration = SFSafariViewController.Configuration()
configuration.entersReaderIfAvailable = inAppBrowserReaderView

View file

@ -1,24 +1,23 @@
import SwiftUI
import Env
import DesignSystem
import Env
import SwiftUI
struct AboutView: View {
@EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme
let versionNumber:String
let versionNumber: String
init() {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
versionNumber = version + " "
}
else {
versionNumber = version + " "
} else {
versionNumber = ""
}
}
var body: some View {
ScrollView{
ScrollView {
VStack(alignment: .leading) {
Divider()
HStack {
@ -58,24 +57,24 @@ struct AboutView: View {
.font(.scaledSubheadline)
.foregroundColor(.gray)
Text("""
[EmojiText](https://github.com/divadretlaw/EmojiText)
[EmojiText](https://github.com/divadretlaw/EmojiText)
[HTML2Markdown](https://gitlab.com/mflint/HTML2Markdown)
[HTML2Markdown](https://gitlab.com/mflint/HTML2Markdown)
[KeychainSwift](https://github.com/evgenyneu/keychain-swift)
[KeychainSwift](https://github.com/evgenyneu/keychain-swift)
[LRUCache](https://github.com/nicklockwood/LRUCache)
[LRUCache](https://github.com/nicklockwood/LRUCache)
[Nuke](https://github.com/kean/Nuke)
[Nuke](https://github.com/kean/Nuke)
[SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
[SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
[TextView](https://github.com/Dimillian/TextView)
[TextView](https://github.com/Dimillian/TextView)
[Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
[Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
[OpenDyslexic](http://opendyslexic.org)
""")
[OpenDyslexic](http://opendyslexic.org)
""")
.padding(.horizontal, 25)
.multilineTextAlignment(.leading)
.font(.scaledSubheadline)
@ -91,12 +90,11 @@ struct AboutView: View {
.environment(\.openURL, OpenURLAction { url in
routerPath.handle(url: url)
})
}
}
struct AboutView_Previews: PreviewProvider {
static var previews: some View {
AboutView()
}
static var previews: some View {
AboutView()
}
}

View file

@ -51,7 +51,7 @@ struct AccountSettingsView: View {
}
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
Button("settings.account.action.delete-cache", role: .destructive) {
@ -62,7 +62,7 @@ struct AccountSettingsView: View {
}
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
Button(role: .destructive) {
if let token = appAccount.oauthToken {

View file

@ -13,7 +13,6 @@ struct ContentSettingsView: View {
var body: some View {
Form {
Section("settings.content.boosts") {
Toggle(isOn: $userPreferences.suppressDupeReblogs) {
Text("settings.content.hide-repeated-boosts")

View file

@ -32,11 +32,11 @@ struct IconSelectorView: View {
static var albertKinngIcons: [Icon] {
[.alt20, .alt21, .alt22, .alt23, .alt24]
}
static var danIcons: [Icon] {
[.alt26, .alt27, .alt28]
}
static var tes6Icons: [Icon] {
[.alt29, .alt30, .alt31, .alt32]
}
@ -76,14 +76,14 @@ struct IconSelectorView: View {
Text("Icons by Albert Kinng")
.font(.scaledHeadline)
}
Section {
makeIconGridView(icons: Icon.danIcons)
} header: {
Text("Icons by Dan van Moll")
.font(.scaledHeadline)
}
Section {
makeIconGridView(icons: Icon.tes6Icons)
} header: {

View file

@ -184,7 +184,6 @@ struct SettingsTabs: View {
Label("settings.app.about", systemImage: "info.circle")
}
} header: {
Text("settings.section.app")
} footer: {

View file

@ -2,29 +2,29 @@ import UIKit
public class HapticManager {
public static let shared: HapticManager = .init()
private let selectionGenerator = UISelectionFeedbackGenerator()
private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let notificationGenerator = UINotificationFeedbackGenerator()
private init() {
selectionGenerator.prepare()
impactGenerator.prepare()
}
public func selectionChanged(){
public func selectionChanged() {
selectionGenerator.selectionChanged()
}
public func impact() {
impactGenerator.impactOccurred()
}
public func impact(intensity: CGFloat) {
impactGenerator.impactOccurred(intensity: intensity)
}
public func notification(type: UINotificationFeedbackGenerator.FeedbackType){
public func notification(type: UINotificationFeedbackGenerator.FeedbackType) {
notificationGenerator.notificationOccurred(type)
}
}

View file

@ -29,9 +29,9 @@ public class UserPreferences: ObservableObject {
@AppStorage("chosen_font") public private(set) var chosenFontData: Data?
@AppStorage("suppress_dupe_reblogs") public var suppressDupeReblogs: Bool = false
@AppStorage("inAppBrowserReaderView") public var inAppBrowserReaderView = false
public var postVisibility: Models.Visibility {
if useInstanceContentSettings {
return serverPreferences?.postVisibility ?? .pub

View file

@ -7,9 +7,9 @@ public struct HTMLString: Codable, Equatable, Hashable {
public var asMarkdown: String = ""
public var asRawText: String = ""
public var statusesURLs = [URL]()
public var asSafeMarkdownAttributedString: AttributedString = AttributedString()
public var asSafeMarkdownAttributedString: AttributedString = .init()
private var regex: NSRegularExpression?
public init(from decoder: Decoder) {
do {
let container = try decoder.singleValueContainer()
@ -17,19 +17,18 @@ public struct HTMLString: Codable, Equatable, Hashable {
} catch {
htmlValue = ""
}
// https://daringfireball.net/projects/markdown/syntax
// Pre-escape \ ` _ * and [ as these are the only
// characters the markdown parser used picks up
// when it renders to attributed text
regex = try? NSRegularExpression(pattern: "([\\_\\*\\`\\[\\\\])", options: .caseInsensitive)
asMarkdown = ""
do {
let document: Document = try SwiftSoup.parse(htmlValue)
handleNode(node: document)
document.outputSettings(OutputSettings().prettyPrint(pretty: false))
try document.select("br").after("\n")
try document.select("p").after("\n\n")
@ -41,15 +40,15 @@ public struct HTMLString: Codable, Equatable, Hashable {
_ = text.removeLast()
}
asRawText = text
if asMarkdown.hasPrefix("\n") {
_ = asMarkdown.removeFirst()
}
} catch {
asRawText = htmlValue
}
do {
let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace)
@ -58,7 +57,7 @@ public struct HTMLString: Codable, Equatable, Hashable {
asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue)
}
}
public init(stringValue: String) {
htmlValue = stringValue
asMarkdown = stringValue
@ -66,22 +65,20 @@ public struct HTMLString: Codable, Equatable, Hashable {
statusesURLs = []
asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(htmlValue)
}
private mutating func handleNode(node: SwiftSoup.Node ) {
private mutating func handleNode(node: SwiftSoup.Node) {
do {
if let className = try? node.attr("class") {
if className == "invisible" {
// don't display
return
}
if className == "ellipsis" {
// descend into this one now and
// append the ellipsis
@ -92,30 +89,26 @@ public struct HTMLString: Codable, Equatable, Hashable {
return
}
}
if node.nodeName() == "p" {
if asMarkdown.count > 0 { // ignore first opening <p>
asMarkdown += "\n\n"
}
}
else if node.nodeName() == "br" {
} else if node.nodeName() == "br" {
if asMarkdown.count > 0 { // ignore first opening <br>
// some code to try and stop double carriage rerturns where they aren't required
// not perfect but effective in almost all cases
if !asMarkdown.hasSuffix("\n") && !asMarkdown.hasSuffix("\u{2028}") {
if let next = node.nextSibling() {
if next.nodeName() == "#text" && (next.description.hasPrefix("\n") || next.description.hasPrefix("\u{2028}")) {
// do nothing
}
else {
} else {
asMarkdown += "\n"
}
}
}
}
}
else if node.nodeName() == "a" {
} else if node.nodeName() == "a" {
let href = try node.attr("href")
if href != "" {
if let url = URL(string: href),
@ -134,27 +127,20 @@ public struct HTMLString: Codable, Equatable, Hashable {
asMarkdown += href
asMarkdown += ")"
return
}
else if node.nodeName() == "#text" {
} else if node.nodeName() == "#text" {
var txt = node.description
if let regex {
// This is the markdown escaper
txt = regex.stringByReplacingMatches(in: txt, options: [], range: NSRange(location: 0, length: txt.count), withTemplate: "\\\\$1")
}
asMarkdown += txt
}
for n in node.getChildNodes() {
handleNode(node: n)
}
}
catch {
}
} catch {}
}
}

View file

@ -28,7 +28,7 @@ public struct Instance: Codable {
public let id: String
public let text: String
}
public struct URLs: Codable {
public let streamingApi: URL?
}

View file

@ -5,8 +5,8 @@ import SwiftUI
public class Client: ObservableObject, Equatable, Identifiable, Hashable {
public static func == (lhs: Client, rhs: Client) -> Bool {
lhs.isAuth == rhs.isAuth &&
lhs.server == rhs.server &&
lhs.oauthToken?.accessToken == rhs.oauthToken?.accessToken
lhs.server == rhs.server &&
lhs.oauthToken?.accessToken == rhs.oauthToken?.accessToken
}
public enum Version: String {
@ -70,7 +70,8 @@ public class Client: ObservableObject, Equatable, Identifiable, Hashable {
private func makeURL(scheme: String = "https",
endpoint: Endpoint,
forceVersion: Version? = nil,
forceServer: String? = nil) -> URL {
forceServer: String? = nil) -> URL
{
var components = URLComponents()
components.scheme = scheme
components.host = forceServer ?? server

View file

@ -119,7 +119,8 @@ class NotificationsViewModel: ObservableObject {
selectedType == nil || selectedType?.rawValue == event.notification.type
{
if event.notification.isConsolidable(selectedType: selectedType),
!consolidatedNotifications.isEmpty {
!consolidatedNotifications.isEmpty
{
// If the notification type can be consolidated, try to consolidate with the latest row
let latestConsolidatedNotification = consolidatedNotifications.removeFirst()
consolidatedNotifications.insert(

View file

@ -6,17 +6,15 @@ struct StatusEditorLanguage: Identifiable, Equatable {
let isoCode: String
let nativeName: String?
let localizedName: String?
static var allAvailableLanguages: [StatusEditorLanguage] = {
Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier.count == 2 }
.map { lang in
let nativeLocale = Locale(languageComponents: Locale.Language.Components(languageCode: lang))
return StatusEditorLanguage(
isoCode: lang.identifier,
nativeName: nativeLocale.localizedString(forLanguageCode: lang.identifier)?.capitalized,
localizedName: Locale.current.localizedString(forLanguageCode: lang.identifier)?.localizedCapitalized
)
}
}()
static var allAvailableLanguages: [StatusEditorLanguage] = Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier.count == 2 }
.map { lang in
let nativeLocale = Locale(languageComponents: Locale.Language.Components(languageCode: lang))
return StatusEditorLanguage(
isoCode: lang.identifier,
nativeName: nativeLocale.localizedString(forLanguageCode: lang.identifier)?.capitalized,
localizedName: Locale.current.localizedString(forLanguageCode: lang.identifier)?.localizedCapitalized
)
}
}

View file

@ -7,7 +7,7 @@ import PhotosUI
import SwiftUI
@MainActor
public class StatusEditorViewModel: ObservableObject {
public class StatusEditorViewModel: ObservableObject {
var mode: Mode
var client: Client?
@ -32,7 +32,7 @@ public class StatusEditorViewModel: ObservableObject {
var statusTextCharacterLength: Int {
urlLengthAdjustments - statusText.string.utf16.count - spoilerTextCount
}
@Published var backupStatusText: NSAttributedString?
@Published var showPoll: Bool = false
@ -129,17 +129,17 @@ public class StatusEditorViewModel: ObservableObject {
}
if !hasExplicitlySelectedLanguage {
// Attempt language resolution using Natural Language
let recognizer = NLLanguageRecognizer()
recognizer.processString(statusText.string)
// Use languageHypotheses to get the probability with it
let hypotheses = recognizer.languageHypotheses(withMaximum: 1)
// Assert that 85% probability is enough :)
// A one word toot that is en/fr compatible is only ~50% confident, for instance
if let (language, probability) = hypotheses.first, probability > 0.85 {
// rawValue return the IETF BCP 47 language tag
selectedLanguage = language.rawValue
}
// Attempt language resolution using Natural Language
let recognizer = NLLanguageRecognizer()
recognizer.processString(statusText.string)
// Use languageHypotheses to get the probability with it
let hypotheses = recognizer.languageHypotheses(withMaximum: 1)
// Assert that 85% probability is enough :)
// A one word toot that is en/fr compatible is only ~50% confident, for instance
if let (language, probability) = hypotheses.first, probability > 0.85 {
// rawValue return the IETF BCP 47 language tag
selectedLanguage = language.rawValue
}
}
let data = StatusData(status: statusText.string,

View file

@ -1,96 +1,84 @@
import Env
import Foundation
import LRUCache
import Models
import SwiftUI
import LRUCache
import Env
public class ReblogCache {
struct CacheEntry : Codable {
var reblogId:String
var postId:String
var seen:Bool
struct CacheEntry: Codable {
var reblogId: String
var postId: String
var seen: Bool
}
static public let shared = ReblogCache()
public static let shared = ReblogCache()
var statusCache = LRUCache<String, CacheEntry>()
private var needsWrite = false
init() {
statusCache.countLimit = 100 // can tune the cache here, 100 is super conservative
statusCache.countLimit = 100 // can tune the cache here, 100 is super conservative
// read any existing cache from disk
if FileManager.default.fileExists(atPath: self.cacheFile.path()) {
if FileManager.default.fileExists(atPath: cacheFile.path()) {
do {
let data = try Data(contentsOf: self.cacheFile)
let data = try Data(contentsOf: cacheFile)
let cacheData = try JSONDecoder().decode([CacheEntry].self, from: data)
for entry in cacheData {
self.statusCache.setValue(entry, forKey: entry.reblogId)
statusCache.setValue(entry, forKey: entry.reblogId)
}
}
catch {
} catch {
print("Error reading cache from disc")
}
print("Starting cache has \(statusCache.count) items")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) { [weak self] in
self?.saveCache()
self?.saveCache()
}
}
private func saveCache() {
if needsWrite {
do {
let data = try JSONEncoder().encode(statusCache.allValues)
try data.write(to: self.cacheFile)
}
catch {
try data.write(to: cacheFile)
} catch {
print("Error writing cache to disc")
}
needsWrite = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) { [weak self] in
self?.saveCache()
self?.saveCache()
}
}
private var cacheFile:URL {
private var cacheFile: URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let documentsDirectory = paths[0]
return URL(fileURLWithPath: documentsDirectory.path()).appendingPathComponent("reblog.json")
}
@MainActor public func removeDuplicateReblogs(_ statuses: inout [Status]) {
if !UserPreferences.shared.suppressDupeReblogs {
return
}
var i = statuses.count
for status in statuses.reversed() {
// go backwards through the status list
// so that we can remove items without
// borking the array
i -= 1
if let reblog = status.reblog {
if let cached = statusCache.value(forKey: reblog.id) {
// this is already cached
if cached.postId != status.id && cached.seen {
// This was posted by someone other than the person we have in the cache
// and we have seen the items at some point, so we might want to suppress it
if status.account.id != CurrentAccount.shared.account?.id {
// just a quick check to makes sure that this wasn't boosted by the current
// user. Hiding that would be confusing
@ -101,26 +89,24 @@ public class ReblogCache {
}
}
}
cache(status, seen:false)
cache(status, seen: false)
}
}
}
public func cache(_ status:Status, seen:Bool) {
public func cache(_ status: Status, seen: Bool) {
var wasSeen = false
var postToCache = status.id
if let reblog = status.reblog {
// only caching boosts at the moment.
if let cached = statusCache.value(forKey: reblog.id) {
// every time we see it, we refresh it in the list
// so poplular things are kept in the cache
wasSeen = cached.seen
if wasSeen {
postToCache = cached.postId
// if we have seen a particular version of the post

View file

@ -1,8 +1,8 @@
import DesignSystem
import Env
import Models
import Shimmer
import SwiftUI
import Env
public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
@EnvironmentObject private var theme: Theme

View file

@ -208,7 +208,7 @@ public struct StatusRowView: View {
threadIcon
}
.accessibilityElement()
.accessibilityLabel(Text("\(status.account.displayName), \(status.createdAt.relativeFormatted)"))
.accessibilityLabel(Text("\(status.account.displayName)"))
}
makeStatusContentView(status: status)
.contentShape(Rectangle())
@ -420,7 +420,7 @@ public struct StatusRowView: View {
.background(Color.black.opacity(0.40))
.transition(.opacity)
}
@ViewBuilder
private var trailinSwipeActions: some View {
Button {
@ -450,7 +450,7 @@ public struct StatusRowView: View {
}
.tint(theme.tintColor)
}
@ViewBuilder
private var leadingSwipeActions: some View {
Button {

View file

@ -3,9 +3,6 @@ import Models
import Network
import SwiftUI
@MainActor
public class StatusRowViewModel: ObservableObject {
let status: Status
@ -29,9 +26,9 @@ public class StatusRowViewModel: ObservableObject {
@Published var translation: String?
@Published var isLoadingTranslation: Bool = false
var seen = false
var filter: Filtered? {
status.reblog?.filtered?.first ?? status.filtered?.first
}
@ -71,10 +68,10 @@ public class StatusRowViewModel: ObservableObject {
isFiltered = filter != nil
}
func markSeen() {
// called in on appear so we can cache that the status has been seen.
if UserPreferences.shared.suppressDupeReblogs && !seen {
if UserPreferences.shared.suppressDupeReblogs && !seen {
ReblogCache.shared.cache(status, seen: true)
seen = true
}

View file

@ -1,14 +1,14 @@
import Env
import Foundation
import Models
import SwiftUI
import Env
@MainActor
class PendingStatusesObserver: ObservableObject {
@Published var pendingStatusesCount: Int = 0
var disableUpdate: Bool = false
var scrollToIndex: ((Int) -> ())?
var scrollToIndex: ((Int) -> Void)?
var pendingStatuses: [String] = [] {
didSet {
@ -28,7 +28,7 @@ class PendingStatusesObserver: ObservableObject {
struct PendingStatusesObserverView: View {
@ObservedObject var observer: PendingStatusesObserver
var body: some View {
if observer.pendingStatusesCount > 0 {
HStack(spacing: 6) {

View file

@ -1,7 +1,7 @@
import Boutique
import Models
import Network
import SwiftUI
import Boutique
public actor TimelineCache {
public static let shared: TimelineCache = .init()
@ -9,7 +9,7 @@ public actor TimelineCache {
private func storageFor(_ client: Client) -> SQLiteStorageEngine {
SQLiteStorageEngine.default(appendingPath: client.id)
}
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
@ -18,27 +18,25 @@ public actor TimelineCache {
public func cachedPostsCount(for client: Client) async -> Int {
await storageFor(client).allKeys().count
}
public func clearCache(for client: Client) async {
public func clearCache(for client: Client) async {
let engine = storageFor(client)
do {
try await engine.removeAllData()
} catch { }
} catch {}
}
func set(statuses: [Status], client: Client) async {
guard !statuses.isEmpty else { return }
let statuses = statuses.prefix(upTo: min(400, statuses.count - 1)).map { $0 }
do {
let engine = storageFor(client)
try await engine.removeAllData()
let itemKeys = statuses.map({ CacheKey($0[keyPath: \.id]) })
let itemKeys = statuses.map { CacheKey($0[keyPath: \.id]) }
let dataAndKeys = try zip(itemKeys, statuses)
.map({ (key: $0, data: try encoder.encode($1)) })
.map { (key: $0, data: try encoder.encode($1)) }
try await engine.write(dataAndKeys)
} catch {
}
} catch {}
}
func getStatuses(for client: Client) async -> [Status]? {
@ -46,18 +44,18 @@ public actor TimelineCache {
do {
return try await engine
.readAllData()
.map({ try decoder.decode(Status.self, from: $0) })
.map { try decoder.decode(Status.self, from: $0) }
.sorted(by: { $0.createdAt > $1.createdAt })
} catch {
return nil
}
}
func setLatestSeenStatuses(ids: [String], for client: Client) {
UserDefaults.standard.set(ids, forKey: client.id)
UserDefaults.standard.set(ids, forKey: "timeline-last-seen-\(client.id)")
}
func getLatestSeenStatus(for client: Client) -> [String]? {
UserDefaults.standard.array(forKey: client.id) as? [String]
UserDefaults.standard.array(forKey: "timeline-last-seen-\(client.id)") as? [String]
}
}

View file

@ -1,11 +1,11 @@
import DesignSystem
import Env
import Introspect
import Models
import Network
import Shimmer
import Status
import SwiftUI
import Introspect
public struct TimelineView: View {
private enum Constants {
@ -55,8 +55,8 @@ public struct TimelineView: View {
.background(theme.primaryBackgroundColor)
.introspect(selector: TargetViewSelector.ancestorOrSiblingContaining,
customize: { (collectionView: UICollectionView) in
self.collectionView = collectionView
})
self.collectionView = collectionView
})
if viewModel.pendingStatusesEnabled {
PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver)
}

View file

@ -6,40 +6,7 @@ import SwiftUI
@MainActor
class TimelineViewModel: ObservableObject {
var client: Client? {
didSet {
if oldValue != client {
statuses = []
}
}
}
// Internal source of truth for a timeline.
private var statuses: [Status] = []
private var visibileStatusesIds = Set<String>()
var scrollToTopVisible: Bool = false {
didSet {
if scrollToTopVisible {
pendingStatusesObserver.pendingStatuses = []
}
}
}
private var canStreamEvents: Bool = true
var isTimelineVisible: Bool = false
let pendingStatusesObserver: PendingStatusesObserver = .init()
private var accountId: String? {
CurrentAccount.shared.account?.id
}
private let cache: TimelineCache = .shared
var scrollToIndexAnimated: Bool = false
@Published var scrollToIndex: Int?
@Published var statusesState: StatusesState = .loading
@Published var timeline: TimelineFilter = .federated {
didSet {
@ -62,6 +29,32 @@ class TimelineViewModel: ObservableObject {
@Published var tag: Tag?
// Internal source of truth for a timeline.
private var statuses: [Status] = []
private let cache: TimelineCache = .shared
private var visibileStatusesIds = Set<String>()
private var canStreamEvents: Bool = true
private var accountId: String? {
CurrentAccount.shared.account?.id
}
var client: Client? {
didSet {
if oldValue != client {
statuses = []
}
}
}
var scrollToTopVisible: Bool = false {
didSet {
if scrollToTopVisible {
pendingStatusesObserver.pendingStatuses = []
}
}
}
var pendingStatusesEnabled: Bool {
timeline == .home
}
@ -69,7 +62,11 @@ class TimelineViewModel: ObservableObject {
var serverName: String {
client?.server ?? "Error"
}
var isTimelineVisible: Bool = false
let pendingStatusesObserver: PendingStatusesObserver = .init()
var scrollToIndexAnimated: Bool = false
init() {
pendingStatusesObserver.scrollToIndex = { [weak self] index in
self?.scrollToIndexAnimated = true
@ -168,12 +165,14 @@ extension TimelineViewModel: StatusesFetcher {
// If we get statuses from the cache for the home timeline, we displays those.
// Else we fetch top most page from the API.
if let cachedStatuses = await getCachedStatuses(),
!cachedStatuses.isEmpty,
timeline == .home {
!cachedStatuses.isEmpty,
timeline == .home
{
statuses = cachedStatuses
if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last,
let index = statuses.firstIndex(where: { $0.id == latestSeenId }),
index > 0 {
index > 0
{
// Restore cache and scroll to latest seen status.
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
scrollToIndexAnimated = false
@ -211,7 +210,7 @@ extension TimelineViewModel: StatusesFetcher {
newStatuses = newStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id })
}
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
// If no new statuses, resume streaming and exit.
@ -219,7 +218,7 @@ extension TimelineViewModel: StatusesFetcher {
canStreamEvents = true
return
}
// If the timeline is not visible, we don't update it as it would mess up the user position.
guard isTimelineVisible else {
canStreamEvents = true
@ -284,7 +283,7 @@ extension TimelineViewModel: StatusesFetcher {
updateMentionsToBeHighlighted(&newStatuses)
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
allStatuses.insert(contentsOf: newStatuses, at: 0)
latestMinId = newStatuses.first?.id ?? ""
}
@ -304,7 +303,6 @@ extension TimelineViewModel: StatusesFetcher {
minId: nil,
offset: statuses.count))
updateMentionsToBeHighlighted(&newStatuses)
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
@ -329,10 +327,10 @@ extension TimelineViewModel: StatusesFetcher {
func statusDidAppear(status: Status) {
pendingStatusesObserver.removeStatus(status: status)
visibileStatusesIds.insert(status.id)
if let client, timeline == .home {
Task {
await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map{ $0 }, for: client)
await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map { $0 }, for: client)
}
}
}