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 out
} }
/// Union every member's kind:30030 set, deduped by `(shortcode, url)`. /// Union every member's kind:30030 set, collapsed to one entry per shortcode.
/// Stable, sorted by shortcode then url, so identical input yields identical output. /// 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> { fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
let mut seen = std::collections::HashSet::new(); let mut by_shortcode: std::collections::HashMap<String, (String, i64)> =
let mut out: Vec<EmojiEntry> = Vec::new(); std::collections::HashMap::new();
for event in events { 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) { for entry in emoji_tags_of(event) {
if seen.insert((entry.shortcode.clone(), entry.url.clone())) { match by_shortcode.get(&entry.shortcode) {
out.push(entry); 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 out
} }
@@ -318,9 +332,10 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn union_dedups_by_shortcode_and_url_across_members() { fn union_latest_set_wins_per_shortcode() {
let events = vec![ let events = vec![
serde_json::json!({ serde_json::json!({
"created_at": 100,
"tags": [ "tags": [
["d", "buzz:custom-emoji"], ["d", "buzz:custom-emoji"],
["emoji", "zort", "https://example.com/zort.png"], ["emoji", "zort", "https://example.com/zort.png"],
@@ -328,11 +343,10 @@ mod tests {
] ]
}), }),
serde_json::json!({ serde_json::json!({
"created_at": 200,
"tags": [ "tags": [
["d", "buzz:custom-emoji"], ["d", "buzz:custom-emoji"],
// exact duplicate (same shortcode+url) — collapses // newer set claims zort with a different url — newer wins
["emoji", "narf", "https://example.com/narf.png"],
// same shortcode, different url — both kept (distinct pair)
["emoji", "zort", "https://example.com/zort2.png"] ["emoji", "zort", "https://example.com/zort2.png"]
] ]
}), }),
@@ -346,9 +360,34 @@ mod tests {
pairs, pairs,
vec![ vec![
("narf", "https://example.com/narf.png"), ("narf", "https://example.com/narf.png"),
("zort", "https://example.com/zort.png"),
("zort", "https://example.com/zort2.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 { if s.len() <= LIMIT {
return s; return s;
} }
let head_end = s.floor_char_boundary(HEAD); let head_end = floor_char_boundary(&s, HEAD);
let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL)); let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL));
let omitted = tail_start - head_end; let omitted = tail_start - head_end;
format!( format!(
"{}\n... ({omitted} bytes omitted) ...\n{}", "{}\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] #[tauri::command]
pub fn discover_managed_agent_prereqs( pub fn discover_managed_agent_prereqs(
input: DiscoverManagedAgentPrereqsRequest, input: DiscoverManagedAgentPrereqsRequest,

View File

@@ -9,11 +9,11 @@ import {
suggestShortcodeFromFilename, suggestShortcodeFromFilename,
} from "./customEmoji.ts"; } from "./customEmoji.ts";
function ev(tags) { function ev(tags, createdAt = 1) {
return { return {
id: "x", id: "x",
pubkey: "relay", pubkey: "relay",
created_at: 1, created_at: createdAt,
kind: 30030, kind: 30030,
tags, tags,
content: "", content: "",
@@ -135,9 +135,9 @@ test("unionCustomEmoji merges members and sorts by shortcode", () => {
}); });
test("unionCustomEmoji collapses a shortcode to ONE deterministic winner", () => { test("unionCustomEmoji collapses a shortcode to ONE deterministic winner", () => {
// Two members claim :party_parrot: with different URLs. The palette must // Two members claim :party_parrot: at the same created_at. The palette must
// expose exactly one (lexicographically-smallest URL), since downstream // expose exactly one (tie-break: lexicographically-smallest URL), since
// identity is shortcode-only and cannot disambiguate two URLs. // downstream identity is shortcode-only and cannot disambiguate two URLs.
const out = unionCustomEmoji([ const out = unionCustomEmoji([
ev([["emoji", "party_parrot", "https://relay/zebra.gif"]]), ev([["emoji", "party_parrot", "https://relay/zebra.gif"]]),
ev([["emoji", "party_parrot", "https://relay/alpha.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", () => { test("unionCustomEmoji winner is independent of member order", () => {
const a = ev([["emoji", "dup", "https://relay/alpha.gif"]]); const a = ev([["emoji", "dup", "https://relay/alpha.gif"]]);
const b = ev([["emoji", "dup", "https://relay/zebra.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 * 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 * one entry per shortcode. When members disagree on a shortcode's URL, the
* winner is the lexicographically-smallest URL: deterministic and stable across * most recently published set wins (`created_at` is signed event data, so this
* reloads, so the same set of events always yields the same palette (no picker * is as deterministic and fetch-order-independent as any pure function of the
* reshuffle, no ambiguous shortcode→url resolution downstream). Output is * events); equal timestamps tie-break to the lexicographically-smallest URL so
* sorted by shortcode. * the same set of events always yields the same palette. Output is sorted by
* shortcode.
*/ */
export function unionCustomEmoji( export function unionCustomEmoji(
events: ReadonlyArray<RelayEvent>, events: ReadonlyArray<RelayEvent>,
): CustomEmoji[] { ): CustomEmoji[] {
const urlByShortcode = new Map<string, string>(); const byShortcode = new Map<string, { url: string; createdAt: number }>();
for (const event of events) { for (const event of events) {
for (const { shortcode, url } of customEmojiFromTags(event.tags)) { for (const { shortcode, url } of customEmojiFromTags(event.tags)) {
const existing = urlByShortcode.get(shortcode); const winner = byShortcode.get(shortcode);
if (existing === undefined || url < existing) { if (
urlByShortcode.set(shortcode, url); 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] return [...byShortcode]
.map(([shortcode, url]) => ({ shortcode, url })) .map(([shortcode, { url }]) => ({ shortcode, url }))
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)); .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 /// 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 /// one entry per shortcode. When members disagree on a shortcode's URL, the
/// lexicographically-smallest URL wins — deterministic and stable across /// most recently published set wins (`created_at` is signed event data, so the
/// reloads. Output is sorted by shortcode. /// 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) { List<CustomEmoji> unionCustomEmoji(Iterable<NostrEvent> events) {
final urlByShortcode = <String, String>{}; final byShortcode = <String, ({String url, int createdAt})>{};
for (final event in events) { for (final event in events) {
for (final e in customEmojiFromTags(event.tags)) { for (final e in customEmojiFromTags(event.tags)) {
final existing = urlByShortcode[e.shortcode]; final winner = byShortcode[e.shortcode];
if (existing == null || e.url.compareTo(existing) < 0) { if (winner == null ||
urlByShortcode[e.shortcode] = e.url; 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 = final result =
urlByShortcode.entries byShortcode.entries
.map((e) => CustomEmoji(shortcode: e.key, url: e.value)) .map((e) => CustomEmoji(shortcode: e.key, url: e.value.url))
.toList() .toList()
..sort((a, b) => a.shortcode.compareTo(b.shortcode)); ..sort((a, b) => a.shortcode.compareTo(b.shortcode));
return result; 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/features/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/relay/nostr_models.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( return NostrEvent(
id: 'id-$pubkey', id: 'id-$pubkey',
pubkey: pubkey, pubkey: pubkey,
createdAt: 0, createdAt: createdAt,
kind: kindEmojiSet, kind: kindEmojiSet,
tags: [ tags: [
['d', customEmojiSetDTag], ['d', customEmojiSetDTag],
@@ -60,20 +64,35 @@ void main() {
}); });
group('unionCustomEmoji', () { group('unionCustomEmoji', () {
test('collapses to one per shortcode, smallest URL wins, sorted', () { test(
final palette = unionCustomEmoji([ 'collapses to one per shortcode; same-time tie-breaks to smaller URL',
_event('alice', [ () {
['emoji', 'meow', 'https://z/meow.png'], final palette = unionCustomEmoji([
['emoji', 'wave', 'https://a/wave.png'], _event('alice', [
]), ['emoji', 'meow', 'https://z/meow.png'],
_event('bob', [ ['emoji', 'wave', 'https://a/wave.png'],
['emoji', 'meow', 'https://a/meow.png'], // smaller URL wins ]),
]), _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'), 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', () { test('deterministic regardless of event order', () {