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

@ -1,24 +1,23 @@
import SwiftUI
import Env
import DesignSystem import DesignSystem
import Env
import SwiftUI
struct AboutView: View { struct AboutView: View {
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let versionNumber:String let versionNumber: String
init() { init() {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
versionNumber = version + " " versionNumber = version + " "
} } else {
else {
versionNumber = "" versionNumber = ""
} }
} }
var body: some View { var body: some View {
ScrollView{ ScrollView {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Divider() Divider()
HStack { HStack {
@ -58,24 +57,24 @@ struct AboutView: View {
.font(.scaledSubheadline) .font(.scaledSubheadline)
.foregroundColor(.gray) .foregroundColor(.gray)
Text(""" 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) .padding(.horizontal, 25)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.font(.scaledSubheadline) .font(.scaledSubheadline)
@ -91,7 +90,6 @@ struct AboutView: View {
.environment(\.openURL, OpenURLAction { url in .environment(\.openURL, OpenURLAction { url in
routerPath.handle(url: url) routerPath.handle(url: url)
}) })
} }
} }

View file

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

View file

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

View file

@ -12,7 +12,7 @@ public class HapticManager {
impactGenerator.prepare() impactGenerator.prepare()
} }
public func selectionChanged(){ public func selectionChanged() {
selectionGenerator.selectionChanged() selectionGenerator.selectionChanged()
} }
@ -24,7 +24,7 @@ public class HapticManager {
impactGenerator.impactOccurred(intensity: intensity) impactGenerator.impactOccurred(intensity: intensity)
} }
public func notification(type: UINotificationFeedbackGenerator.FeedbackType){ public func notification(type: UINotificationFeedbackGenerator.FeedbackType) {
notificationGenerator.notificationOccurred(type) notificationGenerator.notificationOccurred(type)
} }
} }

View file

@ -7,7 +7,7 @@ public struct HTMLString: Codable, Equatable, Hashable {
public var asMarkdown: String = "" public var asMarkdown: String = ""
public var asRawText: String = "" public var asRawText: String = ""
public var statusesURLs = [URL]() public var statusesURLs = [URL]()
public var asSafeMarkdownAttributedString: AttributedString = AttributedString() public var asSafeMarkdownAttributedString: AttributedString = .init()
private var regex: NSRegularExpression? private var regex: NSRegularExpression?
public init(from decoder: Decoder) { public init(from decoder: Decoder) {
@ -26,7 +26,6 @@ public struct HTMLString: Codable, Equatable, Hashable {
asMarkdown = "" asMarkdown = ""
do { do {
let document: Document = try SwiftSoup.parse(htmlValue) let document: Document = try SwiftSoup.parse(htmlValue)
handleNode(node: document) handleNode(node: document)
@ -72,9 +71,7 @@ public struct HTMLString: Codable, Equatable, Hashable {
try container.encode(htmlValue) try container.encode(htmlValue)
} }
private mutating func handleNode(node: SwiftSoup.Node ) { private mutating func handleNode(node: SwiftSoup.Node) {
do { do {
if let className = try? node.attr("class") { if let className = try? node.attr("class") {
if className == "invisible" { if className == "invisible" {
@ -97,25 +94,21 @@ public struct HTMLString: Codable, Equatable, Hashable {
if asMarkdown.count > 0 { // ignore first opening <p> if asMarkdown.count > 0 { // ignore first opening <p>
asMarkdown += "\n\n" asMarkdown += "\n\n"
} }
} } else if node.nodeName() == "br" {
else if node.nodeName() == "br" {
if asMarkdown.count > 0 { // ignore first opening <br> if asMarkdown.count > 0 { // ignore first opening <br>
// some code to try and stop double carriage rerturns where they aren't required // some code to try and stop double carriage rerturns where they aren't required
// not perfect but effective in almost all cases // not perfect but effective in almost all cases
if !asMarkdown.hasSuffix("\n") && !asMarkdown.hasSuffix("\u{2028}") { if !asMarkdown.hasSuffix("\n") && !asMarkdown.hasSuffix("\u{2028}") {
if let next = node.nextSibling() { if let next = node.nextSibling() {
if next.nodeName() == "#text" && (next.description.hasPrefix("\n") || next.description.hasPrefix("\u{2028}")) { if next.nodeName() == "#text" && (next.description.hasPrefix("\n") || next.description.hasPrefix("\u{2028}")) {
// do nothing // do nothing
} } else {
else {
asMarkdown += "\n" asMarkdown += "\n"
} }
} }
} }
} }
} } else if node.nodeName() == "a" {
else if node.nodeName() == "a" {
let href = try node.attr("href") let href = try node.attr("href")
if href != "" { if href != "" {
if let url = URL(string: href), if let url = URL(string: href),
@ -134,9 +127,7 @@ public struct HTMLString: Codable, Equatable, Hashable {
asMarkdown += href asMarkdown += href
asMarkdown += ")" asMarkdown += ")"
return return
} } else if node.nodeName() == "#text" {
else if node.nodeName() == "#text" {
var txt = node.description var txt = node.description
if let regex { if let regex {
@ -150,11 +141,6 @@ public struct HTMLString: Codable, Equatable, Hashable {
for n in node.getChildNodes() { for n in node.getChildNodes() {
handleNode(node: n) handleNode(node: n)
} }
} catch {}
}
catch {
}
} }
} }

View file

@ -70,7 +70,8 @@ public class Client: ObservableObject, Equatable, Identifiable, Hashable {
private func makeURL(scheme: String = "https", private func makeURL(scheme: String = "https",
endpoint: Endpoint, endpoint: Endpoint,
forceVersion: Version? = nil, forceVersion: Version? = nil,
forceServer: String? = nil) -> URL { forceServer: String? = nil) -> URL
{
var components = URLComponents() var components = URLComponents()
components.scheme = scheme components.scheme = scheme
components.host = forceServer ?? server components.host = forceServer ?? server

View file

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

View file

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

View file

@ -1,36 +1,32 @@
import Env
import Foundation import Foundation
import LRUCache
import Models import Models
import SwiftUI import SwiftUI
import LRUCache
import Env
public class ReblogCache { public class ReblogCache {
struct CacheEntry: Codable {
struct CacheEntry : Codable { var reblogId: String
var reblogId:String var postId: String
var postId:String var seen: Bool
var seen:Bool
} }
static public let shared = ReblogCache() public static let shared = ReblogCache()
var statusCache = LRUCache<String, CacheEntry>() var statusCache = LRUCache<String, CacheEntry>()
private var needsWrite = false private var needsWrite = false
init() { 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 // read any existing cache from disk
if FileManager.default.fileExists(atPath: self.cacheFile.path()) { if FileManager.default.fileExists(atPath: cacheFile.path()) {
do { do {
let data = try Data(contentsOf: self.cacheFile) let data = try Data(contentsOf: cacheFile)
let cacheData = try JSONDecoder().decode([CacheEntry].self, from: data) let cacheData = try JSONDecoder().decode([CacheEntry].self, from: data)
for entry in cacheData { 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("Error reading cache from disc")
} }
print("Starting cache has \(statusCache.count) items") print("Starting cache has \(statusCache.count) items")
@ -38,18 +34,14 @@ public class ReblogCache {
DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) { [weak self] in
self?.saveCache() self?.saveCache()
} }
} }
private func saveCache() { private func saveCache() {
if needsWrite { if needsWrite {
do { do {
let data = try JSONEncoder().encode(statusCache.allValues) let data = try JSONEncoder().encode(statusCache.allValues)
try data.write(to: self.cacheFile) try data.write(to: cacheFile)
} } catch {
catch {
print("Error writing cache to disc") print("Error writing cache to disc")
} }
needsWrite = false needsWrite = false
@ -60,8 +52,7 @@ public class ReblogCache {
} }
} }
private var cacheFile: URL {
private var cacheFile:URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let documentsDirectory = paths[0] let documentsDirectory = paths[0]
@ -69,7 +60,6 @@ public class ReblogCache {
} }
@MainActor public func removeDuplicateReblogs(_ statuses: inout [Status]) { @MainActor public func removeDuplicateReblogs(_ statuses: inout [Status]) {
if !UserPreferences.shared.suppressDupeReblogs { if !UserPreferences.shared.suppressDupeReblogs {
return return
} }
@ -83,9 +73,7 @@ public class ReblogCache {
i -= 1 i -= 1
if let reblog = status.reblog { if let reblog = status.reblog {
if let cached = statusCache.value(forKey: reblog.id) { if let cached = statusCache.value(forKey: reblog.id) {
// this is already cached // this is already cached
if cached.postId != status.id && cached.seen { if cached.postId != status.id && cached.seen {
// This was posted by someone other than the person we have in the cache // This was posted by someone other than the person we have in the cache
@ -101,20 +89,18 @@ 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 wasSeen = false
var postToCache = status.id var postToCache = status.id
if let reblog = status.reblog { if let reblog = status.reblog {
// only caching boosts at the moment. // only caching boosts at the moment.
if let cached = statusCache.value(forKey: reblog.id) { if let cached = statusCache.value(forKey: reblog.id) {
// every time we see it, we refresh it in the list // every time we see it, we refresh it in the list
// so poplular things are kept in the cache // so poplular things are kept in the cache

View file

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

View file

@ -208,7 +208,7 @@ public struct StatusRowView: View {
threadIcon threadIcon
} }
.accessibilityElement() .accessibilityElement()
.accessibilityLabel(Text("\(status.account.displayName), \(status.createdAt.relativeFormatted)")) .accessibilityLabel(Text("\(status.account.displayName)"))
} }
makeStatusContentView(status: status) makeStatusContentView(status: status)
.contentShape(Rectangle()) .contentShape(Rectangle())

View file

@ -3,9 +3,6 @@ import Models
import Network import Network
import SwiftUI import SwiftUI
@MainActor @MainActor
public class StatusRowViewModel: ObservableObject { public class StatusRowViewModel: ObservableObject {
let status: Status let status: Status

View file

@ -1,14 +1,14 @@
import Env
import Foundation import Foundation
import Models import Models
import SwiftUI import SwiftUI
import Env
@MainActor @MainActor
class PendingStatusesObserver: ObservableObject { class PendingStatusesObserver: ObservableObject {
@Published var pendingStatusesCount: Int = 0 @Published var pendingStatusesCount: Int = 0
var disableUpdate: Bool = false var disableUpdate: Bool = false
var scrollToIndex: ((Int) -> ())? var scrollToIndex: ((Int) -> Void)?
var pendingStatuses: [String] = [] { var pendingStatuses: [String] = [] {
didSet { didSet {

View file

@ -1,7 +1,7 @@
import Boutique
import Models import Models
import Network import Network
import SwiftUI import SwiftUI
import Boutique
public actor TimelineCache { public actor TimelineCache {
public static let shared: TimelineCache = .init() public static let shared: TimelineCache = .init()
@ -23,7 +23,7 @@ public actor TimelineCache {
let engine = storageFor(client) let engine = storageFor(client)
do { do {
try await engine.removeAllData() try await engine.removeAllData()
} catch { } } catch {}
} }
func set(statuses: [Status], client: Client) async { func set(statuses: [Status], client: Client) async {
@ -32,13 +32,11 @@ public actor TimelineCache {
do { do {
let engine = storageFor(client) let engine = storageFor(client)
try await engine.removeAllData() 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) 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) try await engine.write(dataAndKeys)
} catch { } catch {}
}
} }
func getStatuses(for client: Client) async -> [Status]? { func getStatuses(for client: Client) async -> [Status]? {
@ -46,7 +44,7 @@ public actor TimelineCache {
do { do {
return try await engine return try await engine
.readAllData() .readAllData()
.map({ try decoder.decode(Status.self, from: $0) }) .map { try decoder.decode(Status.self, from: $0) }
.sorted(by: { $0.createdAt > $1.createdAt }) .sorted(by: { $0.createdAt > $1.createdAt })
} catch { } catch {
return nil return nil
@ -54,10 +52,10 @@ public actor TimelineCache {
} }
func setLatestSeenStatuses(ids: [String], for client: Client) { 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]? { 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 DesignSystem
import Env import Env
import Introspect
import Models import Models
import Network import Network
import Shimmer import Shimmer
import Status import Status
import SwiftUI import SwiftUI
import Introspect
public struct TimelineView: View { public struct TimelineView: View {
private enum Constants { private enum Constants {

View file

@ -6,40 +6,7 @@ import SwiftUI
@MainActor @MainActor
class TimelineViewModel: ObservableObject { 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 scrollToIndex: Int?
@Published var statusesState: StatusesState = .loading @Published var statusesState: StatusesState = .loading
@Published var timeline: TimelineFilter = .federated { @Published var timeline: TimelineFilter = .federated {
didSet { didSet {
@ -62,6 +29,32 @@ class TimelineViewModel: ObservableObject {
@Published var tag: Tag? @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 { var pendingStatusesEnabled: Bool {
timeline == .home timeline == .home
} }
@ -70,6 +63,10 @@ class TimelineViewModel: ObservableObject {
client?.server ?? "Error" client?.server ?? "Error"
} }
var isTimelineVisible: Bool = false
let pendingStatusesObserver: PendingStatusesObserver = .init()
var scrollToIndexAnimated: Bool = false
init() { init() {
pendingStatusesObserver.scrollToIndex = { [weak self] index in pendingStatusesObserver.scrollToIndex = { [weak self] index in
self?.scrollToIndexAnimated = true self?.scrollToIndexAnimated = true
@ -169,11 +166,13 @@ extension TimelineViewModel: StatusesFetcher {
// Else we fetch top most page from the API. // Else we fetch top most page from the API.
if let cachedStatuses = await getCachedStatuses(), if let cachedStatuses = await getCachedStatuses(),
!cachedStatuses.isEmpty, !cachedStatuses.isEmpty,
timeline == .home { timeline == .home
{
statuses = cachedStatuses statuses = cachedStatuses
if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last, if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last,
let index = statuses.firstIndex(where: { $0.id == latestSeenId }), let index = statuses.firstIndex(where: { $0.id == latestSeenId }),
index > 0 { index > 0
{
// Restore cache and scroll to latest seen status. // Restore cache and scroll to latest seen status.
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
scrollToIndexAnimated = false scrollToIndexAnimated = false
@ -304,7 +303,6 @@ extension TimelineViewModel: StatusesFetcher {
minId: nil, minId: nil,
offset: statuses.count)) offset: statuses.count))
updateMentionsToBeHighlighted(&newStatuses) updateMentionsToBeHighlighted(&newStatuses)
ReblogCache.shared.removeDuplicateReblogs(&newStatuses) ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
@ -332,7 +330,7 @@ extension TimelineViewModel: StatusesFetcher {
if let client, timeline == .home { if let client, timeline == .home {
Task { Task {
await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map{ $0 }, for: client) await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map { $0 }, for: client)
} }
} }
} }