-
-
Notifications
You must be signed in to change notification settings - Fork 30
Smarter emoji completion: recents, synonyms, fuzzy matching, popularity #496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import Foundation | ||
|
|
||
| /// File overview: | ||
| /// The pure value type the emoji ranker reads to personalize results. It is a snapshot: the matcher | ||
| /// and recents helper take it by value so they stay pure and testable, while `EmojiUsageStore` owns | ||
| /// the mutable, persisted state and hands out fresh snapshots. | ||
| /// | ||
| /// Usage is keyed by an emoji's primary alias (e.g. `joy`), not its glyph, so a concept's signal is | ||
| /// stable across skin-tone and gender variants: using 👍🏽 still boosts the 👍 concept, and recents | ||
| /// render in the user's current variant preference at display time. | ||
| struct EmojiUsageSnapshot: Equatable, Sendable { | ||
| /// Primary aliases of recently committed emoji, most recent first, de-duplicated. | ||
| let recentAliases: [String] | ||
| /// Primary alias -> number of times committed. | ||
| let frequency: [String: Int] | ||
|
|
||
| static let empty = EmojiUsageSnapshot(recentAliases: [], frequency: [:]) | ||
|
|
||
| /// Commits before frequency alone marks an alias a favorite. Recency marks it regardless, so a | ||
| /// just-used emoji floats up immediately even on first use. | ||
| static let frequentThreshold = 2 | ||
|
|
||
| /// Whether an alias is a personal favorite. Favorites float to the front of their relevance tier | ||
| /// in the matcher, so your go-to emoji lead among equally-relevant options without ever jumping | ||
| /// ahead of a more relevant match in a stronger tier. | ||
| func isFavorite(_ alias: String) -> Bool { | ||
| let key = alias.lowercased() | ||
| if frequency[key, default: 0] >= Self.frequentThreshold { return true } | ||
| return recentAliases.contains(key) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import Foundation | ||
|
|
||
| /// Narrow persistence surface for `EmojiUsageStore`, so it can be unit-tested against an in-memory | ||
| /// store instead of process-global `UserDefaults` (which is shared across tests and unreliable to | ||
| /// mutate from a sandboxed unit-test host). `UserDefaults` already satisfies every requirement, so | ||
| /// production wiring is unchanged. | ||
| protocol EmojiUsageDefaults: AnyObject { | ||
| func data(forKey defaultName: String) -> Data? | ||
| func set(_ value: Any?, forKey defaultName: String) | ||
| func removeObject(forKey defaultName: String) | ||
| } | ||
|
|
||
| extension UserDefaults: EmojiUsageDefaults {} | ||
|
|
||
| /// File overview: | ||
| /// Persists per-user emoji usage (recents + frequency) so the picker ranks a person's go-to emoji | ||
| /// first and seeds the bare-`:` panel with them. Keyed by primary alias, which is variant-stable | ||
| /// (see `EmojiUsageSnapshot`), so using 👍🏽 still strengthens the 👍 concept. | ||
| /// | ||
| /// `@MainActor` because the only writer is the main-actor `EmojiPickerController` at commit time, and | ||
| /// reads are cheap snapshots taken between keystrokes. State is stored as a single JSON blob so the | ||
| /// read/write is atomic and avoids per-key dictionary bridging quirks. | ||
| /// | ||
| /// The `deinit` is `nonisolated` to dodge a macOS 14 Swift bug: an isolated deinit on a `@MainActor` | ||
| /// class with non-trivial stored properties routes through `swift_task_deinitOnExecutorMainActorBackDeploy`, | ||
| /// which over-releases and aborts the process ("pointer being freed was not allocated") when an | ||
| /// instance is destroyed — it crashed the app-hosted unit tests deterministically. Releasing the | ||
| /// stored UserDefaults reference plus value types is thread-safe and needs no main-actor hop. | ||
| @MainActor | ||
| final class EmojiUsageStore { | ||
| private let defaults: EmojiUsageDefaults | ||
| private var recents: [String] | ||
| private var frequency: [String: Int] | ||
|
|
||
| /// Cap on stored recents: ample for the panel (which shows ~24) while keeping the persisted blob | ||
| /// small. Older aliases fall off the end as new emoji are committed. | ||
| private static let recentsCap = 50 | ||
| private static let storageKey = "cotabbyEmojiUsage" | ||
|
|
||
| private struct Persisted: Codable { | ||
| var recents: [String] | ||
| var frequency: [String: Int] | ||
| } | ||
|
|
||
| init(defaults: EmojiUsageDefaults = UserDefaults.standard) { | ||
| self.defaults = defaults | ||
| if let data = defaults.data(forKey: Self.storageKey), | ||
| let decoded = try? JSONDecoder().decode(Persisted.self, from: data) { | ||
| recents = decoded.recents | ||
| frequency = decoded.frequency | ||
| } else { | ||
| recents = [] | ||
| frequency = [:] | ||
| } | ||
| } | ||
|
|
||
| // See the type doc comment: avoids the macOS 14 isolated-deinit back-deploy crash. | ||
| nonisolated deinit {} | ||
|
|
||
| /// Records one commit of `alias` (an emoji's primary alias): moves it to the front of recents and | ||
| /// increments its frequency, then persists. No-op for blank input. | ||
| func record(alias rawAlias: String) { | ||
| let alias = rawAlias.lowercased().trimmingCharacters(in: .whitespaces) | ||
| guard !alias.isEmpty else { return } | ||
| recents.removeAll { $0 == alias } | ||
| recents.insert(alias, at: 0) | ||
| if recents.count > Self.recentsCap { | ||
| recents.removeLast(recents.count - Self.recentsCap) | ||
| } | ||
| frequency[alias, default: 0] += 1 | ||
| persist() | ||
| } | ||
|
|
||
| /// Immutable snapshot for the pure ranker and recents helper. | ||
| func snapshot() -> EmojiUsageSnapshot { | ||
| EmojiUsageSnapshot(recentAliases: recents, frequency: frequency) | ||
| } | ||
|
|
||
| /// Forgets all recents and frequency. Backs the "Clear Emoji History" settings control. | ||
| func clear() { | ||
| recents = [] | ||
| frequency = [:] | ||
| defaults.removeObject(forKey: Self.storageKey) | ||
| } | ||
|
|
||
| private func persist() { | ||
| guard let data = try? JSONEncoder().encode(Persisted(recents: recents, frequency: frequency)) else { | ||
| return | ||
| } | ||
| defaults.set(data, forKey: Self.storageKey) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.