feat(emoji): latest-set-wins union for custom emoji across desktop, mobile, and CLI (#989)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-13 13:50:40 -04:00
committed by GitHub
parent 5f8ab33b3b
commit 6e4c8680c8
6 changed files with 138 additions and 53 deletions

View File

@@ -42,19 +42,33 @@ fn emoji_tags_of(event: &serde_json::Value) -> Vec<EmojiEntry> {
out
}
/// Union every member's kind:30030 set, deduped by `(shortcode, url)`.
/// Stable, sorted by shortcode then url, so identical input yields identical output.
/// Union every member's kind:30030 set, collapsed to one entry per shortcode.
/// The most recently published set (`created_at`) wins; equal timestamps
/// tie-break to the lexicographically-smallest URL. Deterministic and
/// fetch-order-independent. Sorted by shortcode.
fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<EmojiEntry> = Vec::new();
let mut by_shortcode: std::collections::HashMap<String, (String, i64)> =
std::collections::HashMap::new();
for event in events {
let created_at = event
.get("created_at")
.and_then(|v| v.as_i64())
.unwrap_or(0);
for entry in emoji_tags_of(event) {
if seen.insert((entry.shortcode.clone(), entry.url.clone())) {
out.push(entry);
match by_shortcode.get(&entry.shortcode) {
Some((url, at)) if *at > created_at || (*at == created_at && *url <= entry.url) => {
}
_ => {
by_shortcode.insert(entry.shortcode, (entry.url, created_at));
}
}
}
}
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
let mut out: Vec<EmojiEntry> = by_shortcode
.into_iter()
.map(|(shortcode, (url, _))| EmojiEntry { shortcode, url })
.collect();
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
out
}
@@ -318,9 +332,10 @@ mod tests {
use super::*;
#[test]
fn union_dedups_by_shortcode_and_url_across_members() {
fn union_latest_set_wins_per_shortcode() {
let events = vec![
serde_json::json!({
"created_at": 100,
"tags": [
["d", "buzz:custom-emoji"],
["emoji", "zort", "https://example.com/zort.png"],
@@ -328,11 +343,10 @@ mod tests {
]
}),
serde_json::json!({
"created_at": 200,
"tags": [
["d", "buzz:custom-emoji"],
// exact duplicate (same shortcode+url) — collapses
["emoji", "narf", "https://example.com/narf.png"],
// same shortcode, different url — both kept (distinct pair)
// newer set claims zort with a different url — newer wins
["emoji", "zort", "https://example.com/zort2.png"]
]
}),
@@ -346,9 +360,34 @@ mod tests {
pairs,
vec![
("narf", "https://example.com/narf.png"),
("zort", "https://example.com/zort.png"),
("zort", "https://example.com/zort2.png"),
]
);
// Order-independence: reversed input yields the identical palette.
let reversed: Vec<_> = events.into_iter().rev().collect();
let emojis_rev = union_custom_emoji(&reversed);
let pairs_rev: Vec<(&str, &str)> = emojis_rev
.iter()
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
.collect();
assert_eq!(pairs, pairs_rev);
}
#[test]
fn union_equal_timestamps_tie_break_to_smallest_url() {
let events = vec![
serde_json::json!({
"created_at": 100,
"tags": [["emoji", "zort", "https://example.com/zort2.png"]]
}),
serde_json::json!({
"created_at": 100,
"tags": [["emoji", "zort", "https://example.com/zort.png"]]
}),
];
let emojis = union_custom_emoji(&events);
assert_eq!(emojis.len(), 1);
assert_eq!(emojis[0].shortcode, "zort");
assert_eq!(emojis[0].url, "https://example.com/zort.png");
}
}

View File

@@ -273,8 +273,8 @@ fn truncate_output(s: String) -> String {
if s.len() <= LIMIT {
return s;
}
let head_end = s.floor_char_boundary(HEAD);
let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL));
let head_end = floor_char_boundary(&s, HEAD);
let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL));
let omitted = tail_start - head_end;
format!(
"{}\n... ({omitted} bytes omitted) ...\n{}",
@@ -283,6 +283,14 @@ fn truncate_output(s: String) -> String {
)
}
fn floor_char_boundary(s: &str, mut index: usize) -> usize {
index = index.min(s.len());
while index > 0 && !s.is_char_boundary(index) {
index -= 1;
}
index
}
#[tauri::command]
pub fn discover_managed_agent_prereqs(
input: DiscoverManagedAgentPrereqsRequest,

View File

@@ -9,11 +9,11 @@ import {
suggestShortcodeFromFilename,
} from "./customEmoji.ts";
function ev(tags) {
function ev(tags, createdAt = 1) {
return {
id: "x",
pubkey: "relay",
created_at: 1,
created_at: createdAt,
kind: 30030,
tags,
content: "",
@@ -135,9 +135,9 @@ test("unionCustomEmoji merges members and sorts by shortcode", () => {
});
test("unionCustomEmoji collapses a shortcode to ONE deterministic winner", () => {
// Two members claim :party_parrot: with different URLs. The palette must
// expose exactly one (lexicographically-smallest URL), since downstream
// identity is shortcode-only and cannot disambiguate two URLs.
// Two members claim :party_parrot: at the same created_at. The palette must
// expose exactly one (tie-break: lexicographically-smallest URL), since
// downstream identity is shortcode-only and cannot disambiguate two URLs.
const out = unionCustomEmoji([
ev([["emoji", "party_parrot", "https://relay/zebra.gif"]]),
ev([["emoji", "party_parrot", "https://relay/alpha.gif"]]),
@@ -147,6 +147,15 @@ test("unionCustomEmoji collapses a shortcode to ONE deterministic winner", () =>
]);
});
test("unionCustomEmoji prefers the most recently published set", () => {
// Newer event wins regardless of URL sort order or input position.
const older = ev([["emoji", "dup", "https://relay/alpha.gif"]], 100);
const newer = ev([["emoji", "dup", "https://relay/zebra.gif"]], 200);
const expected = [{ shortcode: "dup", url: "https://relay/zebra.gif" }];
assert.deepEqual(unionCustomEmoji([older, newer]), expected);
assert.deepEqual(unionCustomEmoji([newer, older]), expected);
});
test("unionCustomEmoji winner is independent of member order", () => {
const a = ev([["emoji", "dup", "https://relay/alpha.gif"]]);
const b = ev([["emoji", "dup", "https://relay/zebra.gif"]]);

View File

@@ -105,25 +105,30 @@ export function customEmojiFromEvent(event: RelayEvent | null): CustomEmoji[] {
/**
* Union every member's kind:30030 set into the workspace palette, collapsed to
* one entry per shortcode. When members disagree on a shortcode's URL, the
* winner is the lexicographically-smallest URL: deterministic and stable across
* reloads, so the same set of events always yields the same palette (no picker
* reshuffle, no ambiguous shortcode→url resolution downstream). Output is
* sorted by shortcode.
* most recently published set wins (`created_at` is signed event data, so this
* is as deterministic and fetch-order-independent as any pure function of the
* events); equal timestamps tie-break to the lexicographically-smallest URL so
* the same set of events always yields the same palette. Output is sorted by
* shortcode.
*/
export function unionCustomEmoji(
events: ReadonlyArray<RelayEvent>,
): CustomEmoji[] {
const urlByShortcode = new Map<string, string>();
const byShortcode = new Map<string, { url: string; createdAt: number }>();
for (const event of events) {
for (const { shortcode, url } of customEmojiFromTags(event.tags)) {
const existing = urlByShortcode.get(shortcode);
if (existing === undefined || url < existing) {
urlByShortcode.set(shortcode, url);
const winner = byShortcode.get(shortcode);
if (
winner === undefined ||
event.created_at > winner.createdAt ||
(event.created_at === winner.createdAt && url < winner.url)
) {
byShortcode.set(shortcode, { url, createdAt: event.created_at });
}
}
}
return [...urlByShortcode]
.map(([shortcode, url]) => ({ shortcode, url }))
return [...byShortcode]
.map(([shortcode, { url }]) => ({ shortcode, url }))
.sort((a, b) => a.shortcode.localeCompare(b.shortcode));
}

View File

@@ -69,21 +69,26 @@ List<CustomEmoji> customEmojiFromTags(List<List<String>> tags) {
/// Union every member's kind:30030 set into the workspace palette, collapsed to
/// one entry per shortcode. When members disagree on a shortcode's URL, the
/// lexicographically-smallest URL wins — deterministic and stable across
/// reloads. Output is sorted by shortcode.
/// most recently published set wins (`created_at` is signed event data, so the
/// result stays deterministic and fetch-order-independent); equal timestamps
/// tie-break to the lexicographically-smallest URL. Output is sorted by
/// shortcode.
List<CustomEmoji> unionCustomEmoji(Iterable<NostrEvent> events) {
final urlByShortcode = <String, String>{};
final byShortcode = <String, ({String url, int createdAt})>{};
for (final event in events) {
for (final e in customEmojiFromTags(event.tags)) {
final existing = urlByShortcode[e.shortcode];
if (existing == null || e.url.compareTo(existing) < 0) {
urlByShortcode[e.shortcode] = e.url;
final winner = byShortcode[e.shortcode];
if (winner == null ||
event.createdAt > winner.createdAt ||
(event.createdAt == winner.createdAt &&
e.url.compareTo(winner.url) < 0)) {
byShortcode[e.shortcode] = (url: e.url, createdAt: event.createdAt);
}
}
}
final result =
urlByShortcode.entries
.map((e) => CustomEmoji(shortcode: e.key, url: e.value))
byShortcode.entries
.map((e) => CustomEmoji(shortcode: e.key, url: e.value.url))
.toList()
..sort((a, b) => a.shortcode.compareTo(b.shortcode));
return result;

View File

@@ -2,11 +2,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
NostrEvent _event(String pubkey, List<List<String>> emojiTags) {
NostrEvent _event(
String pubkey,
List<List<String>> emojiTags, {
int createdAt = 0,
}) {
return NostrEvent(
id: 'id-$pubkey',
pubkey: pubkey,
createdAt: 0,
createdAt: createdAt,
kind: kindEmojiSet,
tags: [
['d', customEmojiSetDTag],
@@ -60,20 +64,35 @@ void main() {
});
group('unionCustomEmoji', () {
test('collapses to one per shortcode, smallest URL wins, sorted', () {
final palette = unionCustomEmoji([
_event('alice', [
['emoji', 'meow', 'https://z/meow.png'],
['emoji', 'wave', 'https://a/wave.png'],
]),
_event('bob', [
['emoji', 'meow', 'https://a/meow.png'], // smaller URL wins
]),
]);
expect(palette, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
]);
test(
'collapses to one per shortcode; same-time tie-breaks to smaller URL',
() {
final palette = unionCustomEmoji([
_event('alice', [
['emoji', 'meow', 'https://z/meow.png'],
['emoji', 'wave', 'https://a/wave.png'],
]),
_event('bob', [
['emoji', 'meow', 'https://a/meow.png'], // tie → smaller URL wins
]),
]);
expect(palette, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
]);
},
);
test('most recently published set wins, regardless of URL order', () {
final older = _event('alice', [
['emoji', 'x', 'https://a/x.png'],
], createdAt: 100);
final newer = _event('bob', [
['emoji', 'x', 'https://z/x.png'],
], createdAt: 200);
const expected = [CustomEmoji(shortcode: 'x', url: 'https://z/x.png')];
expect(unionCustomEmoji([older, newer]), expected);
expect(unionCustomEmoji([newer, older]), expected);
});
test('deterministic regardless of event order', () {