Files
StickerSlack/StickerSlack/Emoji/EmojiHoarder.swift
neon443 ccaaa8e519 animations in emojirow
animated deleting/downloading emojis
stopped it glitching when downloading/deleting an emoji
removed refresh() and uiID cos its not necessary sob
moved downloadImage() to stickerprotocol extension
bundled an image so test emoji loading is faster
2026-03-08 22:38:14 +00:00

319 lines
9.1 KiB
Swift

//
// EmojiHoarder.swift
// StickerSlack
//
// Created by neon443 on 15/10/2025.
//
import Foundation
import SwiftUI
import Combine
import UniformTypeIdentifiers
import Haptics
class EmojiHoarder: ObservableObject {
static let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.neon443.StickerSlack")!.appendingPathComponent("Library", conformingTo: .directory)
nonisolated static let localEmojiDB: URL = EmojiHoarder.container.appendingPathComponent("_localEmojiDB.json", conformingTo: .fileURL)
nonisolated static let localTrie: URL = EmojiHoarder.container.appendingPathComponent("_localTrie.json", conformingTo: .fileURL)
nonisolated static let localTrieDict: URL = EmojiHoarder.container.appendingPathComponent("_localTrieDict.json", conformingTo: .fileURL)
private let endpoint: URL = URL(string: "https://cachet.dunkirk.sh/emojis")!
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
@Published var emojis: [Emoji] = []
@Published var emojiPacks: [EmojiPack] = []
@Published var trie: Trie = Trie()
@Published var downloadedEmojis: Set<String> = []
@Published var downloadedEmojisArr: [String] = []
@Published var searchTerm: String = ""
@Published var letterStats: [EmojiHoarder.LetterStat] = []
@Published var letterStatsSorting: EmojiHoarder.LetterStatSorting = .init(by: .letter, ascending: true)
@Published var showWelcome: Bool = false
init(localOnly: Bool = false, skipIndex: Bool = false) {
self.showWelcome = !UserDefaults.standard.bool(forKey: "showWelcome")
let localDB = loadLocalDB()
withAnimation(.snappy) { self.emojis = localDB }
loadTrie()
if !skipIndex { buildTrie() }
guard !localOnly else { return }
Task {
print("start loading remote db")
await self.loadRemoteDB()
print("end")
if !skipIndex {
await self.buildTrie()
}
}
}
@MainActor
func downloadAllStickers() async {
let start: Date = .now
downloadedEmojisArr = []
let cores = ProcessInfo.processInfo.processorCount-1
var ranges: [Range<Int>] = []
for i in 0..<cores {
let onething = emojis.count/cores
ranges.append(onething*i..<onething + (onething*i))
if i == (0..<cores).last {
let last = ranges.last!
ranges.removeLast()
ranges.append(onething*i..<(onething + (onething*i)+emojis.count-last.upperBound))
}
}
await withTaskGroup { group in
for range in ranges {
group.addTask {
for i in range {
await MainActor.run { self.downloadedEmojisArr.append(self.emojis[i].name) }
guard !self.downloadedEmojis.contains(await self.emojis[i].name) else { continue }
Task { await self.download(emoji: self.emojis[i], skipStoreIndex: true) }
await MainActor.run { self.downloadedEmojis.insert(self.emojis[i].name) }
if i == self.emojis.count {
Task { @MainActor in
self.storeDownloadedIndexes()
}
}
}
}
}
}
print(Date.now.timeIntervalSince(start))
}
@MainActor
func deleteAllStickers() {
for i in emojis.indices {
guard downloadedEmojis.contains(emojis[i].name) else { continue }
delete(emoji: emojis[i], skipStoreIndex: true)
}
downloadedEmojis = []
downloadedEmojisArr = []
storeDownloadedIndexes()
}
private func storeDB() {
try! encoder.encode(emojis).write(to: EmojiHoarder.localEmojiDB)
}
private func storeDB(data: Data) {
try! data.write(to: EmojiHoarder.localEmojiDB)
}
func resetAllIndexes() {
trie.root = TrieNode()
trie.dict = [:]
try? FileManager.default.removeItem(at: EmojiHoarder.localTrieDict)
downloadedEmojis = []
downloadedEmojisArr = []
UserDefaults.standard.removeObject(forKey: "downloadedEmojis")
UserDefaults.standard.removeObject(forKey: "downloadedEmojisArr")
searchTerm = ""
letterStats = []
}
//cl i disabled ts cos its quicker to rebuild it then to load ts
private func saveTrie() {
// return
// guard let data = try? encoder.encode(trie.root) else {
// fatalError("failed to encode trie")
// }
// try! data.write(to: EmojiHoarder.localTrie)
guard let dataDict = try? encoder.encode(trie.dict) else {
fatalError("failed to encode trie dict")
}
try! dataDict.write(to: EmojiHoarder.localTrieDict)
}
private func loadTrie() {
// return
// guard FileManager.default.fileExists(atPath: EmojiHoarder.localTrie.path) else { return }
// guard let data = try? Data(contentsOf: EmojiHoarder.localTrie) else { return }
// guard let decoded = try? decoder.decode(TrieNode.self, from: data) else {
// fatalError("failed to decode trie")
// }
// self.trie.root = decoded
guard FileManager.default.fileExists(atPath: EmojiHoarder.localTrieDict.path) else { return }
guard let dataDict = try? Data(contentsOf: EmojiHoarder.localTrieDict) else { return }
guard let decodedDict = try? decoder.decode([String:Emoji].self, from: dataDict) else {
fatalError("failed to decode dict")
}
self.trie.dict = decodedDict
}
func buildTrie() {
let start = Date().timeIntervalSince1970
for emoji in emojis {
trie.insert(word: emoji.name)
}
buildTrieDict()
saveTrie()
generateLetterStats()
print("done building trie in", Date().timeIntervalSince1970-start)
}
private func buildTrieDict() {
for emoji in emojis {
trie.dict[emoji.name] = emoji
}
buildDownloadedEmojis()
}
private func buildDownloadedEmojis() {
downloadedEmojis = []
downloadedEmojisArr = []
downloadedEmojisArr = (try? decoder.decode([String].self, from: UserDefaults.standard.data(forKey: "downloadedEmojisArr") ?? Data())) ?? []
downloadedEmojis = (try? decoder.decode(Set<String>.self, from: UserDefaults.standard.data(forKey: "downloadedEmojis") ?? Data())) ?? []
if downloadedEmojis.isEmpty || downloadedEmojisArr.isEmpty {
for emoji in emojis {
guard emoji.isLocal else { continue }
downloadedEmojis.insert(emoji.name)
downloadedEmojisArr.append(emoji.name)
}
}
}
func storeDownloadedIndexes() {
UserDefaults.standard.set(try! encoder.encode(downloadedEmojis), forKey: "downloadedEmojis")
UserDefaults.standard.set(try! encoder.encode(downloadedEmojisArr), forKey: "downloadedEmojisArr")
}
nonisolated
private func loadLocalDB() -> [Emoji] {
if let localEmojiDB = try? Data(contentsOf: EmojiHoarder.localEmojiDB) {
let decoded = try! decoder.decode([Emoji].self, from: localEmojiDB)
return decoded
}
return []
}
private func loadRemoteDB() async {
async let fetched = self.fetchRemoteDB()
if let fetched = await fetched {
withAnimation(.snappy) { self.emojis = fetched }
}
}
nonisolated
private func fetchRemoteDB() async -> [Emoji]? {
do {
async let (data, _) = try URLSession.shared.data(from: endpoint)
decoder.dateDecodingStrategy = .iso8601
let decoded: [SlackResponse] = try decoder.decode([SlackResponse].self, from: await data)
try await storeDB(data: await data)
return await SlackResponse.toEmojis(from: decoded)
} catch {
print(error.localizedDescription)
return nil
}
}
private func refreshDB() async {
guard let fetched = await self.fetchRemoteDB() else {
let local = loadLocalDB()
await MainActor.run {
emojis = local
buildTrie()
}
return
}
await MainActor.run {
withAnimation(.snappy) { self.emojis = fetched }
buildTrie()
}
}
nonisolated func download(emoji: Emoji, skipStoreIndex: Bool = false) async {
try? await emoji.downloadImage()
await MainActor.run {
if !skipStoreIndex {
withAnimation(.snappy) {
self.downloadedEmojis.insert(emoji.name)
self.downloadedEmojisArr.append(emoji.name)
}
self.storeDownloadedIndexes()
}
if !skipStoreIndex { Haptic.success.trigger() }
}
}
@MainActor
func delete(emoji: Emoji, skipStoreIndex: Bool = false) {
emoji.deleteImage()
if !skipStoreIndex {
withAnimation(.snappy) {
downloadedEmojis.remove(emoji.name)
downloadedEmojisArr.removeAll(where: { $0 == emoji.name })
}
storeDownloadedIndexes()
}
if !skipStoreIndex { Haptic.heavy.trigger() }
}
func setShowWelcome(to newValue: Bool) {
UserDefaults.standard.set(!newValue, forKey: "showWelcome")
self.showWelcome = newValue
}
func generateLetterStats() {
var result: [EmojiHoarder.LetterStat] = []
for child in trie.root.children {
let count = trie.collectWords(startingWith: child.key, from: child.value).count
let stat = LetterStat(char: child.key, count: count)
result.append(stat)
}
self.letterStats = result
sortLetterStats(by: self.letterStatsSorting)
}
func sortLetterStats(by: EmojiHoarder.LetterStatSorting) {
self.letterStatsSorting = by
let sortByLetter = letterStatsSorting.by == .letter
switch by.ascending {
case true:
letterStats.sort {
if sortByLetter {
$0.char > $1.char
} else {
$0.count > $1.count
}
}
case false:
letterStats.sort {
if sortByLetter {
$0.char > $1.char
} else {
$0.count < $1.count
}
}
}
}
struct LetterStat: Hashable {
var char: String
var count: Int
}
enum SortLetterStatsBy: String, CaseIterable {
case letter = "Letter"
case count = "Count"
}
struct LetterStatSorting: Hashable, Equatable {
var by: EmojiHoarder.SortLetterStatsBy = .count
var ascending: Bool = true
}
}