mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]]);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user