diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index ee51b0c94..a94e02ba9 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -42,19 +42,33 @@ fn emoji_tags_of(event: &serde_json::Value) -> Vec { 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 { - let mut seen = std::collections::HashSet::new(); - let mut out: Vec = Vec::new(); + let mut by_shortcode: std::collections::HashMap = + 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 = 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"); } } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index e63bda767..35462cffc 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -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, diff --git a/desktop/src/shared/api/customEmoji.test.mjs b/desktop/src/shared/api/customEmoji.test.mjs index 9a0c384d1..d499386b5 100644 --- a/desktop/src/shared/api/customEmoji.test.mjs +++ b/desktop/src/shared/api/customEmoji.test.mjs @@ -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"]]); diff --git a/desktop/src/shared/api/customEmoji.ts b/desktop/src/shared/api/customEmoji.ts index 4f97cb2a7..de9ca13f8 100644 --- a/desktop/src/shared/api/customEmoji.ts +++ b/desktop/src/shared/api/customEmoji.ts @@ -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, ): CustomEmoji[] { - const urlByShortcode = new Map(); + const byShortcode = new Map(); 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)); } diff --git a/mobile/lib/features/custom_emoji/custom_emoji.dart b/mobile/lib/features/custom_emoji/custom_emoji.dart index d33a917d7..4793e1776 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji.dart +++ b/mobile/lib/features/custom_emoji/custom_emoji.dart @@ -69,21 +69,26 @@ List customEmojiFromTags(List> 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 unionCustomEmoji(Iterable events) { - final urlByShortcode = {}; + final byShortcode = {}; 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; diff --git a/mobile/test/features/custom_emoji/custom_emoji_test.dart b/mobile/test/features/custom_emoji/custom_emoji_test.dart index 4e9feb89f..5b20f8276 100644 --- a/mobile/test/features/custom_emoji/custom_emoji_test.dart +++ b/mobile/test/features/custom_emoji/custom_emoji_test.dart @@ -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> emojiTags) { +NostrEvent _event( + String pubkey, + List> 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', () {