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