Add storage usage stats settings view
This commit implements a new Storage settings view that displays storage usage statistics for NostrDB, snapshot database, and Kingfisher image cache. Key features: - Interactive pie chart visualization (iOS 17+) with tap-to-select functionality - Pull-to-refresh gesture to recalculate storage - Categorized list showing each storage type with size and percentage - Total storage sum displayed at bottom - Conditional compilation for iOS 16/17+ compatibility - All calculations run on background thread to avoid blocking main thread - NostrDB storage breakdown Changelog-Added: Storage usage statistics view in Settings Changelog-Changed: Moved clear cache button to storage settings Closes: https://github.com/damus-io/damus/issues/3649 Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// StorageStatsManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2026-02-20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
|
||||
/// Storage statistics for various Damus data stores
|
||||
struct StorageStats: Hashable {
|
||||
/// Detailed breakdown of NostrDB storage by kind, indices, and other
|
||||
let nostrdbDetails: NdbStats?
|
||||
|
||||
/// Size of the main NostrDB database file in bytes (total)
|
||||
let nostrdbSize: UInt64
|
||||
|
||||
/// Size of the snapshot NostrDB database file in bytes
|
||||
let snapshotSize: UInt64
|
||||
|
||||
/// Size of the Kingfisher image cache in bytes
|
||||
let imageCacheSize: UInt64
|
||||
|
||||
/// Total storage used across all data stores
|
||||
var totalSize: UInt64 {
|
||||
return nostrdbSize + snapshotSize + imageCacheSize
|
||||
}
|
||||
|
||||
/// Calculate the percentage of total storage used by a specific size
|
||||
/// - Parameter size: The size to calculate percentage for
|
||||
/// - Returns: Percentage value between 0.0 and 100.0
|
||||
func percentage(for size: UInt64) -> Double {
|
||||
guard totalSize > 0 else { return 0.0 }
|
||||
return Double(size) / Double(totalSize) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for calculating storage statistics across Damus data stores
|
||||
struct StorageStatsManager {
|
||||
static let shared = StorageStatsManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Calculate storage statistics for all Damus data stores
|
||||
///
|
||||
/// This method runs all file operations on a background thread to avoid blocking
|
||||
/// the main thread. It calculates:
|
||||
/// - NostrDB database file size
|
||||
/// - Detailed NostrDB breakdown (if ndb instance provided)
|
||||
/// - Snapshot database file size
|
||||
/// - Kingfisher image cache size
|
||||
///
|
||||
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
|
||||
/// - Returns: StorageStats containing all calculated sizes
|
||||
/// - Throws: Error if critical file operations fail
|
||||
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
|
||||
// Run all file operations on background thread
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let nostrdbSize = self.getNostrDBSize()
|
||||
let snapshotSize = self.getSnapshotDBSize()
|
||||
|
||||
// Get detailed NostrDB stats if ndb instance provided
|
||||
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
|
||||
|
||||
// Kingfisher cache size requires async callback
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
let imageCacheSize: UInt64
|
||||
switch result {
|
||||
case .success(let size):
|
||||
imageCacheSize = UInt64(size)
|
||||
case .failure(let error):
|
||||
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
|
||||
imageCacheSize = 0
|
||||
}
|
||||
|
||||
let stats = StorageStats(
|
||||
nostrdbDetails: nostrdbDetails,
|
||||
nostrdbSize: nostrdbSize,
|
||||
snapshotSize: snapshotSize,
|
||||
imageCacheSize: imageCacheSize
|
||||
)
|
||||
|
||||
continuation.resume(returning: stats)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the size of the main NostrDB database file
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getNostrDBSize() -> UInt64 {
|
||||
guard let dbPath = Ndb.db_path else {
|
||||
Log.error("Failed to get NostrDB path", for: .storage)
|
||||
return 0
|
||||
}
|
||||
|
||||
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
|
||||
return getFileSize(at: dataFilePath, description: "NostrDB")
|
||||
}
|
||||
|
||||
/// Get the size of the snapshot NostrDB database file
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getSnapshotDBSize() -> UInt64 {
|
||||
guard let snapshotPath = Ndb.snapshot_db_path else {
|
||||
Log.error("Failed to get snapshot DB path", for: .storage)
|
||||
return 0
|
||||
}
|
||||
|
||||
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
|
||||
return getFileSize(at: dataFilePath, description: "Snapshot DB")
|
||||
}
|
||||
|
||||
/// Get the size of a file at the specified path
|
||||
/// - Parameters:
|
||||
/// - path: Full path to the file
|
||||
/// - description: Human-readable description for logging
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getFileSize(at path: String, description: String) -> UInt64 {
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
|
||||
return 0
|
||||
}
|
||||
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: path)
|
||||
guard let fileSize = attributes[.size] as? UInt64 else {
|
||||
Log.error("Failed to get size attribute for %@", for: .storage, description)
|
||||
return 0
|
||||
}
|
||||
return fileSize
|
||||
} catch {
|
||||
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string
|
||||
/// - Parameter bytes: Number of bytes
|
||||
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
|
||||
static func formatBytes(_ bytes: UInt64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useAll]
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.isAdaptive = true
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// StorageStatsViewHelper.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2026-02-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Shared helper functions for storage statistics views
|
||||
/// Consolidates common logic between StorageSettingsView and NostrDBDetailView
|
||||
enum StorageStatsViewHelper {
|
||||
|
||||
// MARK: - Category Ranges
|
||||
|
||||
/// Computes cumulative ranges for angle selection in pie charts (iOS 17+)
|
||||
/// - Parameter categories: Array of storage categories
|
||||
/// - Returns: Array of tuples containing category ID and cumulative range
|
||||
static func computeCategoryRanges(for categories: [StorageCategory]) -> [(category: String, range: Range<Double>)] {
|
||||
var total: UInt64 = 0
|
||||
return categories.map { category in
|
||||
let newTotal = total + category.size
|
||||
let result = (category: category.id, range: Double(total)..<Double(newTotal))
|
||||
total = newTotal
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage Stats Loading
|
||||
|
||||
/// Load storage statistics asynchronously
|
||||
/// - Parameter ndb: The NostrDB instance
|
||||
/// - Returns: Calculated storage statistics
|
||||
/// - Throws: Error if storage calculation fails
|
||||
@concurrent
|
||||
static func loadStorageStatsAsync(ndb: Ndb) async throws -> StorageStats {
|
||||
return try await StorageStatsManager.shared.calculateStorageStats(ndb: ndb)
|
||||
}
|
||||
|
||||
// MARK: - Export Preparation
|
||||
|
||||
/// Prepare export text for storage statistics on background thread
|
||||
/// - Parameters:
|
||||
/// - stats: The storage statistics to export
|
||||
/// - formatter: Closure that formats the stats into text
|
||||
/// - Returns: Formatted text ready for export
|
||||
@concurrent
|
||||
static func prepareExportText(
|
||||
stats: StorageStats,
|
||||
formatter: @escaping @concurrent (StorageStats) async -> String
|
||||
) async -> String {
|
||||
return await formatter(stats)
|
||||
}
|
||||
|
||||
// MARK: - Text Formatting
|
||||
|
||||
/// Format storage statistics as exportable text
|
||||
/// - Parameter stats: The storage statistics to format
|
||||
/// - Returns: Formatted text representation of storage stats
|
||||
@concurrent
|
||||
static func formatStorageStatsAsText(_ stats: StorageStats) async -> String {
|
||||
// Build categories list
|
||||
let categories = [
|
||||
StorageCategory(
|
||||
id: "nostrdb",
|
||||
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
|
||||
icon: "internaldrive.fill",
|
||||
color: .blue,
|
||||
size: stats.nostrdbSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "snapshot",
|
||||
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
|
||||
icon: "doc.on.doc.fill",
|
||||
color: .purple,
|
||||
size: stats.snapshotSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "cache",
|
||||
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
|
||||
icon: "photo.fill",
|
||||
color: .orange,
|
||||
size: stats.imageCacheSize
|
||||
)
|
||||
]
|
||||
|
||||
var text = "Damus Storage Statistics\n"
|
||||
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
|
||||
text += String(repeating: "=", count: 50) + "\n\n"
|
||||
|
||||
// Top-level Categories
|
||||
text += "Storage Breakdown:\n"
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
|
||||
for category in categories {
|
||||
let percentage = stats.percentage(for: category.size)
|
||||
let titlePadded = category.title.padding(toLength: 25, withPad: " ", startingAt: 0)
|
||||
let sizePadded = StorageStatsManager.formatBytes(category.size).padding(toLength: 10, withPad: " ", startingAt: 0)
|
||||
text += "\(titlePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
|
||||
}
|
||||
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
let totalTitlePadded = "Total Storage".padding(toLength: 25, withPad: " ", startingAt: 0)
|
||||
let totalSizePadded = StorageStatsManager.formatBytes(stats.totalSize).padding(toLength: 10, withPad: " ", startingAt: 0)
|
||||
text += "\(totalTitlePadded) \(totalSizePadded)\n\n"
|
||||
|
||||
// Add NostrDB detailed breakdown if available
|
||||
if let details = stats.nostrdbDetails {
|
||||
text += await formatNostrDBDetails(details: details)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/// Format NostrDB statistics as exportable text
|
||||
/// - Parameter stats: The storage statistics containing NostrDB details
|
||||
/// - Returns: Formatted text representation of NostrDB stats breakdown
|
||||
@concurrent
|
||||
static func formatNostrDBStatsAsText(_ stats: StorageStats) async -> String {
|
||||
guard let details = stats.nostrdbDetails else {
|
||||
return "NostrDB details not available"
|
||||
}
|
||||
|
||||
var text = "Damus NostrDB Detailed Statistics\n"
|
||||
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
|
||||
text += String(repeating: "=", count: 50) + "\n\n"
|
||||
|
||||
text += await formatNostrDBDetails(details: details)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Format NostrDB details section
|
||||
/// - Parameter details: The NostrDB statistics details
|
||||
/// - Returns: Formatted text representation of NostrDB details
|
||||
@concurrent
|
||||
private static func formatNostrDBDetails(details: NdbStats) async -> String {
|
||||
var text = String(repeating: "=", count: 50) + "\n\n"
|
||||
text += "NostrDB Detailed Breakdown:\n"
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
|
||||
// Per-database breakdown (sorted by size, already done in getStats)
|
||||
if !details.databaseStats.isEmpty {
|
||||
text += "\nDatabases:\n"
|
||||
|
||||
for dbStat in details.databaseStats {
|
||||
let percentage = details.totalSize > 0 ? Double(dbStat.totalSize) / Double(details.totalSize) * 100.0 : 0.0
|
||||
let dbNamePadded = dbStat.database.displayName.padding(toLength: 30, withPad: " ", startingAt: 0)
|
||||
let sizePadded = StorageStatsManager.formatBytes(dbStat.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
|
||||
text += "\(dbNamePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
|
||||
|
||||
// Only show keys/values breakdown if both exist
|
||||
if dbStat.keySize > 0 && dbStat.valueSize > 0 {
|
||||
text += " Keys: \(StorageStatsManager.formatBytes(dbStat.keySize)), Values: \(StorageStatsManager.formatBytes(dbStat.valueSize))\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text += "\n" + String(repeating: "-", count: 50) + "\n"
|
||||
let nostrdbTitlePadded = "NostrDB Total".padding(toLength: 30, withPad: " ", startingAt: 0)
|
||||
let nostrdbSizePadded = StorageStatsManager.formatBytes(details.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
|
||||
text += "\(nostrdbTitlePadded) \(nostrdbSizePadded)\n"
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,6 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
|
||||
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
|
||||
|
||||
/// A simple type to keep track of the cache clearing state
|
||||
fileprivate enum CacheClearingState {
|
||||
case not_cleared
|
||||
case clearing
|
||||
case cleared
|
||||
}
|
||||
|
||||
struct ResizedEventPreview: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@@ -59,8 +49,6 @@ struct AppearanceSettingsView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
|
||||
@State var showing_cache_clear_alert: Bool = false
|
||||
|
||||
@State var showing_enable_animation_alert: Bool = false
|
||||
@State var enable_animation_toggle_is_user_initiated: Bool = true
|
||||
@@ -142,8 +130,6 @@ struct AppearanceSettingsView: View {
|
||||
.tag(uploader.model.tag)
|
||||
}
|
||||
}
|
||||
|
||||
self.ClearCacheButton
|
||||
}
|
||||
|
||||
// MARK: - GIFs
|
||||
@@ -192,30 +178,6 @@ struct AppearanceSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func clear_cache_button_action() {
|
||||
cache_clearing_state = .clearing
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
|
||||
group.leave()
|
||||
})
|
||||
|
||||
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
|
||||
group.enter()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
cache_clearing_state = .cleared
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
|
||||
cache_clearing_state = .not_cleared
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var EnableAnimationsToggle: some View {
|
||||
Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation)
|
||||
.toggleStyle(.switch)
|
||||
@@ -231,7 +193,9 @@ struct AppearanceSettingsView: View {
|
||||
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
|
||||
message: Text("Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?", comment: "Message explaining consequences of changing the 'enable animation' setting"),
|
||||
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
|
||||
self.clear_cache_button_action()
|
||||
Task.detached(priority: .utility, operation: {
|
||||
await DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {})
|
||||
})
|
||||
},
|
||||
secondaryButton: .cancel() {
|
||||
// Toggle back if user cancels action
|
||||
@@ -241,33 +205,6 @@ struct AppearanceSettingsView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var ClearCacheButton: some View {
|
||||
Button(action: { self.showing_cache_clear_alert = true }, label: {
|
||||
HStack(spacing: 6) {
|
||||
switch cache_clearing_state {
|
||||
case .not_cleared:
|
||||
Text("Clear Cache", comment: "Button to clear image cache.")
|
||||
case .clearing:
|
||||
ProgressView()
|
||||
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
|
||||
case .cleared:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
|
||||
}
|
||||
}
|
||||
})
|
||||
.disabled(self.cache_clearing_state != .not_cleared)
|
||||
.alert(isPresented: $showing_cache_clear_alert) {
|
||||
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
|
||||
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
|
||||
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
|
||||
self.clear_cache_button_action()
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ struct ConfigView: View {
|
||||
private let translationTitle = NSLocalizedString("Translation", comment: "Section header for text and appearance settings")
|
||||
private let reactionsTitle = NSLocalizedString("Reactions", comment: "Section header for reactions settings")
|
||||
private let developerTitle = NSLocalizedString("Developer", comment: "Section header for developer settings")
|
||||
private let storageTitle = NSLocalizedString("Storage", comment: "Section header for storage usage statistics")
|
||||
private let firstAidTitle = NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings")
|
||||
private let signOutTitle = NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account.")
|
||||
private let deleteAccountTitle = NSLocalizedString("Delete Account", comment: "Button to delete the user's account.")
|
||||
@@ -104,6 +105,12 @@ struct ConfigView: View {
|
||||
IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack)
|
||||
}
|
||||
}
|
||||
// Storage
|
||||
if showSettingsButton(title: storageTitle){
|
||||
NavigationLink(value: Route.StorageSettings(settings: settings)){
|
||||
IconLabel(storageTitle, img_name: "disk", color: .gray)
|
||||
}
|
||||
}
|
||||
//First Aid
|
||||
if showSettingsButton(title: firstAidTitle){
|
||||
NavigationLink(value: Route.FirstAidSettings(settings: settings)){
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// NostrDBDetailView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2026-02-23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// Detail view displaying NostrDB storage breakdown by kind, indices, and other categories
|
||||
struct NostrDBDetailView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
let initialStats: StorageStats
|
||||
|
||||
@State private var stats: StorageStats
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var error: String?
|
||||
@State private var selectedAngle: Double?
|
||||
@State private var showShareSheet: Bool = false
|
||||
@State private var exportText: String?
|
||||
@State private var isPreparingExport: Bool = false
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(damus_state: DamusState, settings: UserSettingsStore, stats: StorageStats) {
|
||||
self.damus_state = damus_state
|
||||
self.settings = settings
|
||||
self.initialStats = stats
|
||||
self._stats = State(initialValue: stats)
|
||||
}
|
||||
|
||||
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
|
||||
private var categoryRanges: [(category: String, range: Range<Double>)] {
|
||||
guard stats.nostrdbDetails != nil else { return [] }
|
||||
return StorageStatsViewHelper.computeCategoryRanges(for: detailedCategories)
|
||||
}
|
||||
|
||||
/// Selected storage category based on pie chart interaction (iOS 17+)
|
||||
private var selectedCategory: StorageCategory? {
|
||||
guard let selectedAngle = selectedAngle else { return nil }
|
||||
|
||||
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
|
||||
return detailedCategories[selectedIndex]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Detailed categories showing per-database breakdown
|
||||
private var detailedCategories: [StorageCategory] {
|
||||
guard let details = stats.nostrdbDetails else { return [] }
|
||||
|
||||
var result: [StorageCategory] = []
|
||||
|
||||
// Per-database categories (sorted by size descending in getStats)
|
||||
for dbStat in details.databaseStats {
|
||||
result.append(StorageCategory(
|
||||
id: dbStat.database.id,
|
||||
title: dbStat.database.displayName,
|
||||
icon: dbStat.database.icon,
|
||||
color: dbStat.database.color,
|
||||
size: dbStat.totalSize
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Chart Section (iOS 17+ only)
|
||||
if stats.nostrdbDetails != nil {
|
||||
if #available(iOS 17.0, *) {
|
||||
Section {
|
||||
StoragePieChart(
|
||||
categories: detailedCategories,
|
||||
selectedAngle: $selectedAngle,
|
||||
selectedCategory: selectedCategory,
|
||||
totalSize: stats.nostrdbDetails?.totalSize ?? stats.nostrdbSize
|
||||
)
|
||||
.frame(height: 300)
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
// Detailed Categories List
|
||||
Section {
|
||||
ForEach(detailedCategories) { category in
|
||||
if #available(iOS 17.0, *) {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: percentageOfNostrDB(for: category.size),
|
||||
isSelected: selectedCategory?.id == category.id
|
||||
)
|
||||
} else {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: percentageOfNostrDB(for: category.size),
|
||||
isSelected: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NostrDB Total
|
||||
Section {
|
||||
HStack {
|
||||
Text("NostrDB Total", comment: "Label for total NostrDB storage")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(StorageStatsManager.formatBytes(stats.nostrdbSize))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if isLoading {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
if let error = error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
.navigationTitle(NSLocalizedString("NostrDB Details", comment: "Navigation title for NostrDB detail view"))
|
||||
.toolbar {
|
||||
if stats.nostrdbDetails != nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { Task { await prepareExport() } }) {
|
||||
if isPreparingExport {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.disabled(isPreparingExport)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let exportText = exportText {
|
||||
TextShareSheet(activityItems: [exportText])
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadStorageStatsAsync()
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare export text on background thread before showing share sheet
|
||||
@concurrent
|
||||
private func prepareExport() async {
|
||||
// Atomically check/export all needed @State on MainActor
|
||||
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
|
||||
let hasDetails = stats.nostrdbDetails != nil
|
||||
let notAlreadyPreparing = !isPreparingExport
|
||||
if hasDetails && notAlreadyPreparing {
|
||||
isPreparingExport = true
|
||||
return (true, stats)
|
||||
} else {
|
||||
return (false, nil)
|
||||
}
|
||||
}
|
||||
guard shouldProceed, let statsSnapshot else { return }
|
||||
|
||||
// Format text off-main
|
||||
let text = await StorageStatsViewHelper.formatNostrDBStatsAsText(statsSnapshot)
|
||||
|
||||
// Update UI on main thread
|
||||
await MainActor.run {
|
||||
self.exportText = text
|
||||
self.isPreparingExport = false
|
||||
self.showShareSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate percentage of NostrDB size
|
||||
private func percentageOfNostrDB(for size: UInt64) -> Double {
|
||||
guard stats.nostrdbSize > 0 else { return 0.0 }
|
||||
return Double(size) / Double(stats.nostrdbSize) * 100.0
|
||||
}
|
||||
|
||||
/// Load storage statistics asynchronously (for refreshable)
|
||||
private func loadStorageStatsAsync() async {
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
error = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
|
||||
await MainActor.run {
|
||||
self.stats = calculatedStats
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview("NostrDB Detail") {
|
||||
NavigationStack {
|
||||
NostrDBDetailView(
|
||||
damus_state: test_damus_state,
|
||||
settings: test_damus_state.settings,
|
||||
stats: StorageStats(
|
||||
nostrdbDetails: NdbStats(
|
||||
databaseStats: [
|
||||
NdbDatabaseStats(database: .other, keySize: 0, valueSize: 2000000000),
|
||||
NdbDatabaseStats(database: .note, keySize: 50000, valueSize: 200000),
|
||||
NdbDatabaseStats(database: .noteBlocks, keySize: 100000, valueSize: 50000),
|
||||
NdbDatabaseStats(database: .profile, keySize: 25000, valueSize: 100000),
|
||||
NdbDatabaseStats(database: .noteId, keySize: 75000, valueSize: 75000)
|
||||
]
|
||||
),
|
||||
nostrdbSize: 2500000000,
|
||||
snapshotSize: 100000,
|
||||
imageCacheSize: 5000000
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//
|
||||
// StorageSettingsView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2026-02-20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
|
||||
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
|
||||
|
||||
/// A simple type to keep track of the cache clearing state
|
||||
fileprivate enum CacheClearingState {
|
||||
case not_cleared
|
||||
case clearing
|
||||
case cleared
|
||||
}
|
||||
|
||||
/// Storage category for display in list and chart
|
||||
struct StorageCategory: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
let size: UInt64
|
||||
|
||||
var range: Range<Double> {
|
||||
return 0..<Double(size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings view displaying storage usage statistics for Damus data stores
|
||||
struct StorageSettingsView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var stats: StorageStats?
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var error: String?
|
||||
@State private var selectedAngle: Double?
|
||||
@State private var showShareSheet: Bool = false
|
||||
@State private var exportText: String?
|
||||
@State private var isPreparingExport: Bool = false
|
||||
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
|
||||
@State var showing_cache_clear_alert: Bool = false
|
||||
|
||||
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
|
||||
private var categoryRanges: [(category: String, range: Range<Double>)] {
|
||||
guard let stats = stats else { return [] }
|
||||
return StorageStatsViewHelper.computeCategoryRanges(for: categories)
|
||||
}
|
||||
|
||||
/// Selected storage category based on pie chart interaction (iOS 17+)
|
||||
private var selectedCategory: StorageCategory? {
|
||||
guard let selectedAngle = selectedAngle else { return nil }
|
||||
|
||||
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
|
||||
return categories[selectedIndex]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// All storage categories for display (top-level view)
|
||||
private var categories: [StorageCategory] {
|
||||
guard let stats = stats else { return [] }
|
||||
|
||||
return [
|
||||
StorageCategory(
|
||||
id: "nostrdb",
|
||||
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
|
||||
icon: "internaldrive.fill",
|
||||
color: .blue,
|
||||
size: stats.nostrdbSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "snapshot",
|
||||
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
|
||||
icon: "doc.on.doc.fill",
|
||||
color: .purple,
|
||||
size: stats.snapshotSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "cache",
|
||||
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
|
||||
icon: "photo.fill",
|
||||
color: .orange,
|
||||
size: stats.imageCacheSize
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Chart Section (iOS 17+ only)
|
||||
if let stats = stats {
|
||||
if #available(iOS 17.0, *) {
|
||||
Section {
|
||||
StoragePieChart(
|
||||
categories: categories,
|
||||
selectedAngle: $selectedAngle,
|
||||
selectedCategory: selectedCategory,
|
||||
totalSize: stats.totalSize
|
||||
)
|
||||
.frame(height: 300)
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
// Categories List
|
||||
Section {
|
||||
ForEach(categories) { category in
|
||||
if category.id == "nostrdb", stats.nostrdbDetails != nil {
|
||||
// NostrDB is drillable when we have detailed stats
|
||||
NavigationLink(value: Route.NostrDBStorageDetail(stats: stats)) {
|
||||
if #available(iOS 17.0, *) {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: selectedCategory?.id == category.id
|
||||
)
|
||||
} else {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Other categories are not drillable
|
||||
if #available(iOS 17.0, *) {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: selectedCategory?.id == category.id
|
||||
)
|
||||
} else {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Total at bottom
|
||||
Section {
|
||||
HStack {
|
||||
Text("Total Storage", comment: "Label for total storage used")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(StorageStatsManager.formatBytes(stats.totalSize))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Cache Section
|
||||
Section {
|
||||
self.ClearCacheButton
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if isLoading {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
if let error = error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
.navigationTitle(NSLocalizedString("Storage", comment: "Navigation title for storage settings"))
|
||||
.toolbar {
|
||||
if stats != nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { Task { await prepareExport() } }) {
|
||||
if isPreparingExport {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.disabled(isPreparingExport)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let exportText = exportText {
|
||||
TextShareSheet(activityItems: [exportText])
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadStorageStatsAsync()
|
||||
}
|
||||
.onAppear {
|
||||
if stats == nil {
|
||||
loadStorageStats()
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare export text on background thread before showing share sheet
|
||||
@concurrent
|
||||
private func prepareExport() async {
|
||||
// Capture all relevant @State in one MainActor.run
|
||||
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
|
||||
let hasStats = stats != nil
|
||||
let notAlreadyPreparing = !isPreparingExport
|
||||
if hasStats && notAlreadyPreparing {
|
||||
isPreparingExport = true
|
||||
return (true, stats)
|
||||
} else {
|
||||
return (false, nil)
|
||||
}
|
||||
}
|
||||
guard shouldProceed, let statsSnapshot else { return }
|
||||
|
||||
// Format text on background thread using shared helper
|
||||
let text = await StorageStatsViewHelper.formatStorageStatsAsText(statsSnapshot)
|
||||
|
||||
// Update UI on main thread
|
||||
await MainActor.run {
|
||||
self.exportText = text
|
||||
self.isPreparingExport = false
|
||||
self.showShareSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Load storage statistics on a background thread (for onAppear)
|
||||
private func loadStorageStats() {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
await loadStorageStatsAsync()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load storage statistics asynchronously (for refreshable)
|
||||
@concurrent
|
||||
private func loadStorageStatsAsync() async {
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
error = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
|
||||
await MainActor.run {
|
||||
self.stats = calculatedStats
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache button action with loading state management
|
||||
func clear_cache_button_action() {
|
||||
cache_clearing_state = .clearing
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
|
||||
group.leave()
|
||||
})
|
||||
|
||||
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
|
||||
group.enter()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
cache_clearing_state = .cleared
|
||||
|
||||
// Refresh storage stats after clearing cache
|
||||
loadStorageStats()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
|
||||
cache_clearing_state = .not_cleared
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache button view with confirmation dialog
|
||||
var ClearCacheButton: some View {
|
||||
Button(action: { self.showing_cache_clear_alert = true }, label: {
|
||||
HStack(spacing: 6) {
|
||||
switch cache_clearing_state {
|
||||
case .not_cleared:
|
||||
Text("Clear Cache", comment: "Button to clear image cache.")
|
||||
case .clearing:
|
||||
ProgressView()
|
||||
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
|
||||
case .cleared:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
|
||||
}
|
||||
}
|
||||
})
|
||||
.disabled(self.cache_clearing_state != .not_cleared)
|
||||
.alert(isPresented: $showing_cache_clear_alert) {
|
||||
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
|
||||
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
|
||||
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
|
||||
self.clear_cache_button_action()
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pie chart displaying storage usage distribution (iOS 17+)
|
||||
@available(iOS 17.0, *)
|
||||
struct StoragePieChart: View {
|
||||
let categories: [StorageCategory]
|
||||
@Binding var selectedAngle: Double?
|
||||
let selectedCategory: StorageCategory?
|
||||
let totalSize: UInt64
|
||||
|
||||
var body: some View {
|
||||
Chart(categories) { category in
|
||||
SectorMark(
|
||||
angle: .value("Size", category.size),
|
||||
innerRadius: .ratio(0.618),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.cornerRadius(4)
|
||||
.foregroundStyle(category.color)
|
||||
.opacity(selectedCategory == nil || selectedCategory?.id == category.id ? 1.0 : 0.5)
|
||||
}
|
||||
.chartAngleSelection(value: $selectedAngle)
|
||||
.chartBackground { chartProxy in
|
||||
GeometryReader { geometry in
|
||||
if let anchor = chartProxy.plotFrame {
|
||||
let frame = geometry[anchor]
|
||||
centerLabel
|
||||
.position(x: frame.midX, y: frame.midY)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
}
|
||||
|
||||
/// Center label showing selected category or total
|
||||
private var centerLabel: some View {
|
||||
VStack(spacing: 4) {
|
||||
if let selected = selectedCategory {
|
||||
Image(systemName: selected.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(selected.color)
|
||||
Text(selected.title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(StorageStatsManager.formatBytes(selected.size))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total", comment: "Label for total storage in pie chart center")
|
||||
.font(.headline)
|
||||
Text(StorageStatsManager.formatBytes(totalSize))
|
||||
.font(.title2)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row displaying a storage category with icon, name, size, and percentage
|
||||
struct StorageCategoryRow: View {
|
||||
let category: StorageCategory
|
||||
let percentage: Double
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
.frame(width: 24)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(category.title)
|
||||
.font(.body)
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(StorageStatsManager.formatBytes(category.size))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.opacity(isSelected ? 1.0 : 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-based ShareSheet wrapper for SwiftUI
|
||||
struct TextShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(
|
||||
activityItems: activityItems,
|
||||
applicationActivities: nil
|
||||
)
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
|
||||
// No updates needed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview("Storage Settings") {
|
||||
NavigationStack {
|
||||
StorageSettingsView(
|
||||
damus_state: test_damus_state,
|
||||
settings: test_damus_state.settings
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ enum Route: Hashable {
|
||||
case SearchSettings(settings: UserSettingsStore)
|
||||
case DeveloperSettings(settings: UserSettingsStore)
|
||||
case FirstAidSettings(settings: UserSettingsStore)
|
||||
case StorageSettings(settings: UserSettingsStore)
|
||||
case NostrDBStorageDetail(stats: StorageStats)
|
||||
case Thread(thread: ThreadModel)
|
||||
case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference)
|
||||
case Reposts(reposts: EventsModel)
|
||||
@@ -100,6 +102,10 @@ enum Route: Hashable {
|
||||
DeveloperSettingsView(settings: settings, damus_state: damusState)
|
||||
case .FirstAidSettings(settings: let settings):
|
||||
FirstAidSettingsView(damus_state: damusState, settings: settings)
|
||||
case .StorageSettings(settings: let settings):
|
||||
StorageSettingsView(damus_state: damusState, settings: settings)
|
||||
case .NostrDBStorageDetail(stats: let stats):
|
||||
NostrDBDetailView(damus_state: damusState, settings: damusState.settings, stats: stats)
|
||||
case .Thread(let thread):
|
||||
ChatroomThreadView(damus: damusState, thread: thread)
|
||||
//ThreadView(state: damusState, thread: thread)
|
||||
@@ -204,6 +210,11 @@ enum Route: Hashable {
|
||||
hasher.combine("developerSettings")
|
||||
case .FirstAidSettings:
|
||||
hasher.combine("firstAidSettings")
|
||||
case .StorageSettings:
|
||||
hasher.combine("storageSettings")
|
||||
case .NostrDBStorageDetail(let stats):
|
||||
hasher.combine("nostrDBStorageDetail")
|
||||
hasher.combine(stats)
|
||||
case .Thread(let threadModel):
|
||||
hasher.combine("thread")
|
||||
hasher.combine(threadModel.original_event.id)
|
||||
|
||||
Reference in New Issue
Block a user