mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-05 16:59:30 +00:00
Cleanup
This commit is contained in:
parent
427452db30
commit
6b285cdbcf
22 changed files with 188 additions and 227 deletions
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -184,7 +184,6 @@ struct SettingsTabs: View {
|
|||
Label("settings.app.about", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
|
||||
} header: {
|
||||
Text("settings.section.app")
|
||||
} footer: {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ public struct Instance: Codable {
|
|||
public let id: String
|
||||
public let text: String
|
||||
}
|
||||
|
||||
|
||||
public struct URLs: Codable {
|
||||
public let streamingApi: URL?
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue