mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
Merge remote-tracking branch 'origin/main' into hayt/usage-first-turn-baseline
* origin/main: Render mobile agent mention chips (#3702) fix(catalog): update Amp description (#3758) fix(acp): preserve truncated thread context (#3340) Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
commit
3695674c26
@@ -19,6 +19,7 @@
|
||||
//!
|
||||
//! `AcpClient` is NOT Clone — ownership moves out on claim and back on return.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -800,6 +801,9 @@ pub enum IdleSwitchResult {
|
||||
/// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s.
|
||||
const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000);
|
||||
|
||||
/// Short, single-attempt timeout for best-effort exact truncated-thread counts.
|
||||
const CONTEXT_COUNT_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Delay between the first failed context fetch and the single retry.
|
||||
const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -2605,7 +2609,14 @@ async fn fetch_conversation_context(
|
||||
let last_event = batch.events.last()?;
|
||||
let tags = crate::queue::parse_thread_tags(&last_event.event);
|
||||
if let Some(root_id) = tags.root_event_id {
|
||||
return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await;
|
||||
return fetch_thread_context(
|
||||
batch.channel_id,
|
||||
&root_id,
|
||||
limit,
|
||||
ctx.agent_keys.public_key(),
|
||||
&ctx.rest_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// DM non-reply: fetch recent conversation history.
|
||||
@@ -2767,12 +2778,48 @@ async fn fetch_prompt_profile_lookup(
|
||||
}
|
||||
|
||||
/// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag.
|
||||
///
|
||||
/// The reply query intentionally requests one more reply than the configured
|
||||
/// display window. That sentinel event lets the prompt say `N of M, truncated`
|
||||
/// when the relay has more thread history, instead of reporting the capped page
|
||||
/// as the total. When the window is full, a best-effort `/count` attempts to
|
||||
/// improve that lower-bound total; because it is a separate racy request, the
|
||||
/// result is clamped to the sentinel-proven minimum. The query also asks for the
|
||||
/// agent's newest reply separately so the next prompt can include the agent's
|
||||
/// own prior turn even in busy threads where the recent-message window would
|
||||
/// otherwise push it out.
|
||||
async fn fetch_thread_context(
|
||||
channel_id: Uuid,
|
||||
root_event_id: &str,
|
||||
limit: u32,
|
||||
agent_pubkey: nostr::PublicKey,
|
||||
rest: &RestClient,
|
||||
) -> Option<ConversationContext> {
|
||||
fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_event_id,
|
||||
limit,
|
||||
agent_pubkey,
|
||||
|filters| async move { rest.query(&filters).await },
|
||||
|filters| async move { rest.count(&filters).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_thread_context_with<Query, QueryFut, Count, CountFut>(
|
||||
channel_id: Uuid,
|
||||
root_event_id: &str,
|
||||
limit: u32,
|
||||
agent_pubkey: nostr::PublicKey,
|
||||
query: Query,
|
||||
count: Count,
|
||||
) -> Option<ConversationContext>
|
||||
where
|
||||
Query: Fn(Vec<nostr::Filter>) -> QueryFut,
|
||||
QueryFut: std::future::Future<Output = Result<serde_json::Value, crate::relay::RelayError>>,
|
||||
Count: Fn(Vec<nostr::Filter>) -> CountFut,
|
||||
CountFut: std::future::Future<Output = Result<serde_json::Value, crate::relay::RelayError>>,
|
||||
{
|
||||
use nostr::{Alphabet, SingleLetterTag};
|
||||
|
||||
// Defense-in-depth: validate hex event ID.
|
||||
@@ -2791,7 +2838,8 @@ async fn fetch_thread_context(
|
||||
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
|
||||
let ch_str = channel_id.to_string();
|
||||
|
||||
// Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
|
||||
// Three filters: (1) root event by ID, (2) recent replies with #e=root +
|
||||
// #h=channel plus a sentinel, and (3) the agent's newest reply for pinning.
|
||||
let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?);
|
||||
let replies_filter = nostr::Filter::new()
|
||||
.kinds([
|
||||
@@ -2800,16 +2848,23 @@ async fn fetch_thread_context(
|
||||
])
|
||||
.custom_tags(e_tag, [root_event_id])
|
||||
.custom_tags(h_tag, [ch_str.as_str()])
|
||||
.limit(limit as usize);
|
||||
.limit(limit.saturating_add(1) as usize);
|
||||
let agent_reply_filter = replies_filter.clone().author(agent_pubkey).limit(1);
|
||||
|
||||
fetch_with_retry(|| async {
|
||||
let context = fetch_with_retry(|| async {
|
||||
match timeout(
|
||||
CONTEXT_FETCH_TIMEOUT,
|
||||
rest.query(&[root_filter.clone(), replies_filter.clone()]),
|
||||
query(vec![
|
||||
root_filter.clone(),
|
||||
replies_filter.clone(),
|
||||
agent_reply_filter.clone(),
|
||||
]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id),
|
||||
Ok(Ok(json)) => {
|
||||
parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
channel_id = %channel_id,
|
||||
@@ -2828,7 +2883,75 @@ async fn fetch_thread_context(
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.await;
|
||||
|
||||
let mut parsed = context?;
|
||||
|
||||
if matches!(
|
||||
parsed.context,
|
||||
ConversationContext::Thread {
|
||||
truncated: true,
|
||||
..
|
||||
}
|
||||
) {
|
||||
let replies_count_filter = replies_filter.clone().limit(0);
|
||||
if let Some(total) = fetch_thread_total(
|
||||
channel_id,
|
||||
&replies_count_filter,
|
||||
parsed.root_present,
|
||||
&count,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let ConversationContext::Thread {
|
||||
total: context_total,
|
||||
..
|
||||
} = &mut parsed.context
|
||||
{
|
||||
let sentinel_minimum = *context_total;
|
||||
// `/count` is a separate best-effort request after the message
|
||||
// query. If replies are deleted between the two, the exact count
|
||||
// can fall below the already-proven sentinel minimum; never
|
||||
// render impossible labels like `13 of 12 messages, truncated`.
|
||||
*context_total = total.max(sentinel_minimum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(parsed.context)
|
||||
}
|
||||
|
||||
/// Best-effort exact thread size for truncated context labels.
|
||||
async fn fetch_thread_total<Count, CountFut>(
|
||||
channel_id: Uuid,
|
||||
replies_filter: &nostr::Filter,
|
||||
root_present: bool,
|
||||
count: &Count,
|
||||
) -> Option<usize>
|
||||
where
|
||||
Count: Fn(Vec<nostr::Filter>) -> CountFut,
|
||||
CountFut: std::future::Future<Output = Result<serde_json::Value, crate::relay::RelayError>>,
|
||||
{
|
||||
let replies_count =
|
||||
match timeout(CONTEXT_COUNT_TIMEOUT, count(vec![replies_filter.clone()])).await {
|
||||
Ok(Ok(json)) => json.get("count").and_then(|v| v.as_u64())?,
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
channel_id = %channel_id,
|
||||
"thread context count failed; using sentinel minimum: {e}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
channel_id = %channel_id,
|
||||
"thread context count timed out; using sentinel minimum"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(replies_count as usize + usize::from(root_present))
|
||||
}
|
||||
|
||||
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
|
||||
@@ -2981,48 +3104,110 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option<ContextMessage> {
|
||||
|
||||
/// Parse a Nostr query response (array of events) into thread context.
|
||||
///
|
||||
/// Separates the root event (matching `root_event_id`) from replies, sorts
|
||||
/// chronologically by `created_at`.
|
||||
/// Separates the root event (matching `root_event_id`) from replies, keeps the
|
||||
/// newest `limit` replies returned by the sentinel query, then sorts the
|
||||
/// displayed window chronologically for the prompt. If the agent's newest reply
|
||||
/// is outside that window, keep it instead of the oldest displayed reply so the
|
||||
/// next prompt always includes the agent's most recent prior turn.
|
||||
#[cfg(test)]
|
||||
fn parse_nostr_thread_response(
|
||||
json: serde_json::Value,
|
||||
root_event_id: &str,
|
||||
limit: u32,
|
||||
agent_pubkey: &nostr::PublicKey,
|
||||
) -> Option<ConversationContext> {
|
||||
parse_nostr_thread_response_with_meta(json, root_event_id, limit, agent_pubkey)
|
||||
.map(|parsed| parsed.context)
|
||||
}
|
||||
|
||||
struct ParsedThreadContext {
|
||||
context: ConversationContext,
|
||||
root_present: bool,
|
||||
}
|
||||
|
||||
fn parse_nostr_thread_response_with_meta(
|
||||
json: serde_json::Value,
|
||||
root_event_id: &str,
|
||||
limit: u32,
|
||||
agent_pubkey: &nostr::PublicKey,
|
||||
) -> Option<ParsedThreadContext> {
|
||||
let events = json.as_array()?;
|
||||
let agent_pubkey_hex = agent_pubkey.to_hex();
|
||||
let mut root_msg = None;
|
||||
let mut reply_msgs = Vec::new();
|
||||
let mut seen_reply_ids = HashSet::new();
|
||||
|
||||
for ev in events {
|
||||
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Some(msg) = json_to_context_message(ev) {
|
||||
if ev_id == root_event_id {
|
||||
root_msg = Some(msg);
|
||||
} else {
|
||||
} else if seen_reply_ids.insert(ev_id.to_string()) {
|
||||
let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex);
|
||||
reply_msgs.push((
|
||||
ev_id.to_string(),
|
||||
ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
is_agent,
|
||||
msg,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort replies chronologically.
|
||||
reply_msgs.sort_by_key(|(ts, _)| *ts);
|
||||
let root_present = root_msg.is_some();
|
||||
let fetched_total = reply_msgs.len() + usize::from(root_present);
|
||||
let newest_agent_reply = reply_msgs
|
||||
.iter()
|
||||
.filter(|(_, _, is_agent, _)| *is_agent)
|
||||
.max_by_key(|(_, ts, _, _)| *ts)
|
||||
.cloned();
|
||||
|
||||
let truncated = reply_msgs.len() > limit as usize;
|
||||
if truncated {
|
||||
// The relay returns limited REQ results newest-first. Sort explicitly so
|
||||
// the sentinel we drop is the oldest reply in the fetched window, not an
|
||||
// arbitrary last element if the HTTP bridge ever changes iteration order.
|
||||
reply_msgs.sort_by_key(|(_, ts, _, _)| Reverse(*ts));
|
||||
reply_msgs.truncate(limit as usize);
|
||||
}
|
||||
|
||||
if let Some(agent_reply) = newest_agent_reply {
|
||||
let agent_reply_already_displayed =
|
||||
reply_msgs.iter().any(|(id, _, _, _)| *id == agent_reply.0);
|
||||
if !agent_reply_already_displayed {
|
||||
reply_msgs.sort_by_key(|(_, ts, _, _)| *ts);
|
||||
if let Some(oldest) = reply_msgs.first_mut() {
|
||||
*oldest = agent_reply;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort displayed replies chronologically.
|
||||
reply_msgs.sort_by_key(|(_, ts, _, _)| *ts);
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(root) = root_msg {
|
||||
messages.push(root);
|
||||
}
|
||||
messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg));
|
||||
messages.extend(reply_msgs.into_iter().map(|(_, _, _, msg)| msg));
|
||||
|
||||
let total = messages.len();
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated: false, // query returns all within limit
|
||||
let total = if truncated {
|
||||
fetched_total // all distinct fetched replies plus the root are proven visible history
|
||||
} else {
|
||||
messages.len()
|
||||
};
|
||||
|
||||
Some(ParsedThreadContext {
|
||||
context: ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
},
|
||||
root_present,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4209,6 +4394,572 @@ mod tests {
|
||||
assert!(parse_dm_response(json, 12).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nostr_thread_response_marks_query_window_truncated() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let json = json!([
|
||||
{
|
||||
"id": root_id,
|
||||
"pubkey": "rootpub",
|
||||
"content": "root",
|
||||
"created_at": 1000
|
||||
},
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pubkey": agent_hex,
|
||||
"content": "newest agent reply",
|
||||
"created_at": 4000
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pubkey": "humanpub",
|
||||
"content": "middle reply",
|
||||
"created_at": 3000
|
||||
},
|
||||
{
|
||||
"id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"pubkey": "oldpub",
|
||||
"content": "sentinel omitted reply",
|
||||
"created_at": 2000
|
||||
}
|
||||
]);
|
||||
|
||||
let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
|
||||
.expect("should parse");
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert_eq!(messages.len(), 3); // root + 2 displayed replies
|
||||
assert_eq!(total, 4); // root + displayed replies + sentinel
|
||||
assert!(truncated);
|
||||
assert_eq!(messages[0].content, "root");
|
||||
assert_eq!(messages[1].content, "middle reply");
|
||||
assert_eq!(messages[2].content, "newest agent reply");
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|msg| msg.content != "sentinel omitted reply"));
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nostr_thread_response_not_truncated_below_limit() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let json = json!([
|
||||
{
|
||||
"id": root_id,
|
||||
"pubkey": "rootpub",
|
||||
"content": "root",
|
||||
"created_at": 1000
|
||||
},
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pubkey": "replypub",
|
||||
"content": "reply",
|
||||
"created_at": 2000
|
||||
}
|
||||
]);
|
||||
|
||||
let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
|
||||
.expect("should parse");
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(total, 2);
|
||||
assert!(!truncated);
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nostr_thread_response_keeps_agent_reply_outside_recent_window() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let json = json!([
|
||||
{
|
||||
"id": root_id,
|
||||
"pubkey": "rootpub",
|
||||
"content": "root",
|
||||
"created_at": 1000
|
||||
},
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"pubkey": "humanpub",
|
||||
"content": "newer human reply",
|
||||
"created_at": 5000
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pubkey": "humanpub",
|
||||
"content": "middle human reply",
|
||||
"created_at": 4000
|
||||
},
|
||||
{
|
||||
"id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"pubkey": "humanpub",
|
||||
"content": "oldest displayed reply without agent pin",
|
||||
"created_at": 3000
|
||||
},
|
||||
{
|
||||
"id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"pubkey": agent_hex,
|
||||
"content": "agent reply outside recent window",
|
||||
"created_at": 2000
|
||||
}
|
||||
]);
|
||||
|
||||
let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
|
||||
.expect("should parse");
|
||||
match ctx {
|
||||
ConversationContext::Thread { messages, .. } => {
|
||||
assert_eq!(messages.len(), 3); // root + 2 displayed replies
|
||||
assert_eq!(messages[0].content, "root");
|
||||
assert!(messages
|
||||
.iter()
|
||||
.any(|msg| msg.content == "agent reply outside recent window"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.any(|msg| msg.content == "newer human reply"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|msg| msg.content != "middle human reply"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|msg| msg.content != "oldest displayed reply without agent pin"));
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_thread_context_uses_exact_count_when_above_sentinel_minimum() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let channel_id = Uuid::new_v4();
|
||||
let agent_pubkey = agent.public_key();
|
||||
let json = json!([
|
||||
thread_event(root_id, "rootpub", "root", 1000),
|
||||
thread_event(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"humanpub",
|
||||
"newest reply",
|
||||
4000
|
||||
),
|
||||
thread_event(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"humanpub",
|
||||
"middle reply",
|
||||
3000
|
||||
),
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"humanpub",
|
||||
"sentinel reply",
|
||||
2000
|
||||
)
|
||||
]);
|
||||
|
||||
let ctx = fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_id,
|
||||
2,
|
||||
agent_pubkey,
|
||||
move |filters| {
|
||||
assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3);
|
||||
std::future::ready(Ok(json.clone()))
|
||||
},
|
||||
move |filters| {
|
||||
assert_thread_count_filter(&filters, channel_id, root_id);
|
||||
std::future::ready(Ok(json!({ "count": 6 })))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("thread context");
|
||||
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(total, 7); // 6 replies + root
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_thread_context_does_not_add_missing_root_to_exact_count() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let channel_id = Uuid::new_v4();
|
||||
let json = json!([
|
||||
thread_event(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"humanpub",
|
||||
"newest reply",
|
||||
4000
|
||||
),
|
||||
thread_event(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"humanpub",
|
||||
"middle reply",
|
||||
3000
|
||||
),
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"humanpub",
|
||||
"sentinel reply",
|
||||
2000
|
||||
)
|
||||
]);
|
||||
|
||||
let ctx = fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_id,
|
||||
2,
|
||||
agent.public_key(),
|
||||
move |_filters| std::future::ready(Ok(json.clone())),
|
||||
|_filters| std::future::ready(Ok(json!({ "count": 6 }))),
|
||||
)
|
||||
.await
|
||||
.expect("thread context");
|
||||
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(total, 6);
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_thread_context_clamps_count_below_sentinel_minimum() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let channel_id = Uuid::new_v4();
|
||||
let json = json!([
|
||||
thread_event(root_id, "rootpub", "root", 1000),
|
||||
thread_event(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"humanpub",
|
||||
"newest reply",
|
||||
4000
|
||||
),
|
||||
thread_event(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"humanpub",
|
||||
"middle reply",
|
||||
3000
|
||||
),
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"humanpub",
|
||||
"sentinel reply",
|
||||
2000
|
||||
)
|
||||
]);
|
||||
|
||||
let ctx = fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_id,
|
||||
2,
|
||||
agent.public_key(),
|
||||
move |_filters| std::future::ready(Ok(json.clone())),
|
||||
|_filters| std::future::ready(Ok(json!({ "count": 1 }))),
|
||||
)
|
||||
.await
|
||||
.expect("thread context");
|
||||
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(total, 4); // root + displayed replies + sentinel minimum
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_thread_context_preserves_sentinel_minimum_when_count_fails() {
|
||||
let agent = Keys::generate();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let channel_id = Uuid::new_v4();
|
||||
let json = json!([
|
||||
thread_event(root_id, "rootpub", "root", 1000),
|
||||
thread_event(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"humanpub",
|
||||
"newest reply",
|
||||
4000
|
||||
),
|
||||
thread_event(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"humanpub",
|
||||
"middle reply",
|
||||
3000
|
||||
),
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"humanpub",
|
||||
"sentinel reply",
|
||||
2000
|
||||
)
|
||||
]);
|
||||
|
||||
let ctx = fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_id,
|
||||
2,
|
||||
agent.public_key(),
|
||||
move |_filters| std::future::ready(Ok(json.clone())),
|
||||
|_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))),
|
||||
)
|
||||
.await
|
||||
.expect("thread context");
|
||||
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(total, 4); // count failure leaves parser's sentinel minimum intact
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_thread_context_deduplicates_and_pins_agent_reply() {
|
||||
let agent = Keys::generate();
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let channel_id = Uuid::new_v4();
|
||||
let json = json!([
|
||||
thread_event(root_id, "rootpub", "root", 1000),
|
||||
thread_event(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"humanpub",
|
||||
"newer human reply",
|
||||
5000
|
||||
),
|
||||
thread_event(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"humanpub",
|
||||
"middle human reply",
|
||||
4000
|
||||
),
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
&agent_hex,
|
||||
"agent reply outside recent window",
|
||||
2000
|
||||
),
|
||||
// Same event as the separately fetched author-filtered result; the
|
||||
// parser should deduplicate it before pinning.
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
&agent_hex,
|
||||
"agent reply outside recent window",
|
||||
2000
|
||||
)
|
||||
]);
|
||||
|
||||
let ctx = fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_id,
|
||||
2,
|
||||
agent.public_key(),
|
||||
move |_filters| std::future::ready(Ok(json.clone())),
|
||||
|_filters| std::future::ready(Ok(json!({ "count": 3 }))),
|
||||
)
|
||||
.await
|
||||
.expect("thread context");
|
||||
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(total, 4);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|msg| msg.content == "agent reply outside recent window")
|
||||
.count(),
|
||||
1,
|
||||
"separate agent-reply query must not duplicate the same event"
|
||||
);
|
||||
assert!(messages
|
||||
.iter()
|
||||
.any(|msg| msg.content == "newer human reply"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|msg| msg.content != "middle human reply"));
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_thread_context_uses_distinct_fetched_replies_as_minimum() {
|
||||
let agent = Keys::generate();
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
let channel_id = Uuid::new_v4();
|
||||
let json = json!([
|
||||
thread_event(root_id, "rootpub", "root", 1000),
|
||||
thread_event(
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"humanpub",
|
||||
"newest human reply",
|
||||
5000
|
||||
),
|
||||
thread_event(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"humanpub",
|
||||
"middle human reply",
|
||||
4000
|
||||
),
|
||||
thread_event(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"humanpub",
|
||||
"sentinel human reply",
|
||||
3000
|
||||
),
|
||||
thread_event(
|
||||
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
&agent_hex,
|
||||
"older distinct agent reply",
|
||||
2000
|
||||
)
|
||||
]);
|
||||
|
||||
let ctx = fetch_thread_context_with(
|
||||
channel_id,
|
||||
root_id,
|
||||
2,
|
||||
agent.public_key(),
|
||||
move |_filters| std::future::ready(Ok(json.clone())),
|
||||
|_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))),
|
||||
)
|
||||
.await
|
||||
.expect("thread context");
|
||||
|
||||
match ctx {
|
||||
ConversationContext::Thread {
|
||||
messages,
|
||||
total,
|
||||
truncated,
|
||||
} => {
|
||||
assert!(truncated);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(
|
||||
total, 5,
|
||||
"root plus all four distinct fetched replies prove the lower bound"
|
||||
);
|
||||
assert!(messages
|
||||
.iter()
|
||||
.any(|msg| msg.content == "older distinct agent reply"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.any(|msg| msg.content == "newest human reply"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|msg| msg.content != "middle human reply"));
|
||||
assert!(messages
|
||||
.iter()
|
||||
.all(|msg| msg.content != "sentinel human reply"));
|
||||
}
|
||||
_ => panic!("expected Thread context"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_thread_query_filters(
|
||||
filters: &[nostr::Filter],
|
||||
channel_id: Uuid,
|
||||
root_id: &str,
|
||||
agent_pubkey: nostr::PublicKey,
|
||||
reply_limit: u64,
|
||||
) {
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
3,
|
||||
"root, recent replies, and agent reply filters"
|
||||
);
|
||||
|
||||
let root = serde_json::to_value(&filters[0]).expect("serialize root filter");
|
||||
assert_eq!(root.get("ids"), Some(&json!([root_id])));
|
||||
assert!(root.get("limit").is_none());
|
||||
|
||||
let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter");
|
||||
assert_eq!(replies.get("kinds"), Some(&json!([9, 40002])));
|
||||
assert_eq!(replies.get("#e"), Some(&json!([root_id])));
|
||||
assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()])));
|
||||
assert_eq!(replies.get("limit"), Some(&json!(reply_limit)));
|
||||
assert!(replies.get("authors").is_none());
|
||||
|
||||
let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter");
|
||||
assert_eq!(agent.get("kinds"), Some(&json!([9, 40002])));
|
||||
assert_eq!(agent.get("#e"), Some(&json!([root_id])));
|
||||
assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()])));
|
||||
assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()])));
|
||||
assert_eq!(agent.get("limit"), Some(&json!(1)));
|
||||
}
|
||||
|
||||
fn assert_thread_count_filter(filters: &[nostr::Filter], channel_id: Uuid, root_id: &str) {
|
||||
assert_eq!(filters.len(), 1, "count should query only matching replies");
|
||||
|
||||
let count = serde_json::to_value(&filters[0]).expect("serialize count filter");
|
||||
assert_eq!(count.get("kinds"), Some(&json!([9, 40002])));
|
||||
assert_eq!(count.get("#e"), Some(&json!([root_id])));
|
||||
assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()])));
|
||||
assert_eq!(count.get("limit"), Some(&json!(0)));
|
||||
assert!(count.get("ids").is_none());
|
||||
assert!(count.get("authors").is_none());
|
||||
}
|
||||
|
||||
fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"pubkey": pubkey,
|
||||
"content": content,
|
||||
"created_at": created_at
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_context_message_integer_timestamp() {
|
||||
let obj = json!({
|
||||
|
||||
@@ -405,6 +405,19 @@ impl RestClient {
|
||||
.map_err(|e| RelayError::Http(e.to_string()))
|
||||
}
|
||||
|
||||
/// Count events via the HTTP bridge: `POST /count` with NIP-98 auth.
|
||||
///
|
||||
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
|
||||
/// Returns the bridge response as a `serde_json::Value` (usually `{ "count": n }`).
|
||||
pub async fn count(&self, filters: &[nostr::Filter]) -> Result<Value, RelayError> {
|
||||
let body_bytes = serde_json::to_vec(filters)
|
||||
.map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?;
|
||||
let resp = self.bridge_post("/count", &body_bytes).await?;
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))
|
||||
}
|
||||
|
||||
/// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth.
|
||||
///
|
||||
/// The event must already be signed. Returns the relay response JSON.
|
||||
|
||||
@@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record<string, string> = {
|
||||
// https://moonshotai.github.io/kimi-cli/en/
|
||||
kimi: "A terminal coding agent for software development and command-line tasks.",
|
||||
// Sources: https://ampcode.com, https://ampcode.com/manual
|
||||
amp: "A coding agent from Sourcegraph.",
|
||||
amp: "A coding agent for your terminal and editor.",
|
||||
// Sources: https://github.com/NousResearch/hermes-agent,
|
||||
// https://hermes-agent.nousresearch.com/docs/
|
||||
hermes: "A general-purpose AI agent from Nous Research.",
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/mentions/mention_tags.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
@@ -87,8 +89,16 @@ class ActivityPage extends HookConsumerWidget {
|
||||
];
|
||||
|
||||
// Preload sender profiles for visible rows.
|
||||
final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList();
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
final preloadPubkeys = {
|
||||
for (final item in visibleItems) item.item.pubkey.toLowerCase(),
|
||||
for (final item in visibleItems)
|
||||
...mentionedPubkeysFromTags(item.item.tags),
|
||||
}.toList()..sort();
|
||||
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
|
||||
return null;
|
||||
}, [preloadPubkeysKey]);
|
||||
|
||||
final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length;
|
||||
|
||||
|
||||
@@ -61,6 +61,28 @@ class _InboxRow extends ConsumerWidget {
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final profile = userCache[item.item.pubkey.toLowerCase()];
|
||||
final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey);
|
||||
final profileMentionNames = {
|
||||
for (final pubkey in mentionedPubkeysFromTags(item.item.tags))
|
||||
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
|
||||
pubkey: userCache[pubkey]!.displayName!.trim(),
|
||||
};
|
||||
final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags);
|
||||
final knownAgentPubkeys = channel == null
|
||||
? ref.watch(knownAgentPubkeysProvider)
|
||||
: ref.watch(agentMentionPubkeysProvider(channel!.id));
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: knownAgentPubkeys,
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
final isDm = channel?.isDm ?? false;
|
||||
final channelName = channel != null && !isDm
|
||||
@@ -172,6 +194,8 @@ class _InboxRow extends ConsumerWidget {
|
||||
// Message preview.
|
||||
MessageContent(
|
||||
content: item.item.displayContent,
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: item.item.tags,
|
||||
maxLines: 2,
|
||||
baseStyle: activityPreviewTextStyle.copyWith(
|
||||
|
||||
@@ -8,21 +8,20 @@ import '../channel_typing_provider.dart';
|
||||
///
|
||||
/// Used by both the members button badge and the members sheet to avoid
|
||||
/// duplicating the bot-typing cross-reference logic.
|
||||
final workingBotPubkeysProvider = Provider.family<Set<String>, String>((
|
||||
ref,
|
||||
channelId,
|
||||
) {
|
||||
final typingEntries = ref.watch(channelTypingProvider(channelId));
|
||||
final membersAsync = ref.watch(channelMembersProvider(channelId));
|
||||
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
|
||||
final workingBotPubkeysProvider = Provider.autoDispose
|
||||
.family<Set<String>, String>((ref, channelId) {
|
||||
final typingEntries = ref.watch(channelTypingProvider(channelId));
|
||||
final membersAsync = ref.watch(channelMembersProvider(channelId));
|
||||
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
|
||||
|
||||
final botPubkeys = <String>{
|
||||
for (final m in allMembers)
|
||||
if (m.isBot) m.pubkey.toLowerCase(),
|
||||
};
|
||||
final botPubkeys = <String>{
|
||||
for (final m in allMembers)
|
||||
if (m.isBot) m.pubkey.toLowerCase(),
|
||||
};
|
||||
|
||||
return <String>{
|
||||
for (final e in typingEntries)
|
||||
if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(),
|
||||
};
|
||||
});
|
||||
return <String>{
|
||||
for (final e in typingEntries)
|
||||
if (botPubkeys.contains(e.pubkey.toLowerCase()))
|
||||
e.pubkey.toLowerCase(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
@@ -39,7 +40,6 @@ import 'manage_channel_sheet.dart';
|
||||
import 'members_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_content.dart';
|
||||
import 'mentions/mention_candidates_provider.dart';
|
||||
import 'read_state/deferred_read_state_update.dart';
|
||||
import 'read_state/read_state_provider.dart';
|
||||
import 'read_state/read_state_time.dart';
|
||||
|
||||
@@ -36,8 +36,14 @@ class _MessageBubble extends ConsumerWidget {
|
||||
|
||||
// Build mention names map from event p-tags.
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final knownAgentPubkeys = ref.watch(
|
||||
mentionAgentPubkeysProvider(currentChannelId),
|
||||
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(
|
||||
agentMentionPubkeysProvider(currentChannelId),
|
||||
),
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = <String, String>{};
|
||||
final agentMentionPubkeys = <String>{};
|
||||
@@ -51,6 +57,12 @@ class _MessageBubble extends ConsumerWidget {
|
||||
agentMentionPubkeys.add(normalizedPubkey);
|
||||
}
|
||||
}
|
||||
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: message.mentionPubkeys,
|
||||
profileMentionNames: mentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
@@ -166,7 +178,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: mentionNames,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import 'channel.dart';
|
||||
@@ -392,8 +393,9 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
|
||||
});
|
||||
|
||||
/// Channel members from kind:39002 NIP-29 members event.
|
||||
final channelMembersProvider =
|
||||
FutureProvider.family<List<ChannelMember>, String>((ref, channelId) async {
|
||||
final channelMembersProvider = FutureProvider.autoDispose
|
||||
.family<List<ChannelMember>, String>((ref, channelId) async {
|
||||
ref.watch(channelMembershipUpdateProvider(channelId));
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.channelMembers(channelId),
|
||||
@@ -557,6 +559,7 @@ class ChannelActions {
|
||||
);
|
||||
}
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> joinChannel(String channelId) async {
|
||||
@@ -626,6 +629,7 @@ class ChannelActions {
|
||||
await _ref.read(channelsProvider.notifier).refresh();
|
||||
_ref.invalidate(channelDetailsProvider(channelId));
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
_ref.invalidate(channelCanvasProvider(channelId));
|
||||
}
|
||||
|
||||
@@ -659,6 +663,7 @@ class ChannelActions {
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> removeMember({
|
||||
@@ -674,6 +679,7 @@ class ChannelActions {
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> addReaction(String eventId, String emoji) async {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'pending_local_messages_provider.dart';
|
||||
import 'channel_window.dart';
|
||||
import 'thread_replies_provider.dart';
|
||||
@@ -222,11 +221,6 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
_lastKnownMessages = merged;
|
||||
state = AsyncData(merged);
|
||||
}
|
||||
|
||||
if (event.kind == EventKind.systemMessage &&
|
||||
_isMembershipEvent(event.content)) {
|
||||
ref.invalidate(channelMembersProvider(channelId));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleWindowLiveEvent(NostrEvent event) {
|
||||
@@ -288,12 +282,6 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
.confirm(eventIds);
|
||||
}
|
||||
|
||||
static bool _isMembershipEvent(String content) {
|
||||
return content.contains('member_joined') ||
|
||||
content.contains('member_left') ||
|
||||
content.contains('member_removed');
|
||||
}
|
||||
|
||||
/// Adds a just-signed outgoing message before the relay acknowledges it.
|
||||
/// The live relay echo is deduplicated by event id.
|
||||
void addLocalMessage(NostrEvent event) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
@@ -36,6 +37,7 @@ import 'mentions/mention_ranking.dart';
|
||||
import 'photo_library.dart';
|
||||
|
||||
part 'compose_bar/helpers.dart';
|
||||
part 'compose_bar/agent_mention_labels.dart';
|
||||
part 'compose_bar/markdown_editing_controller.dart';
|
||||
part 'compose_bar/suggestions.dart';
|
||||
part 'compose_bar/formatting_toolbar.dart';
|
||||
@@ -231,6 +233,16 @@ class ComposeBar extends HookConsumerWidget {
|
||||
// owners so @mention suggestions show names ("managed by …" included).
|
||||
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
|
||||
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
|
||||
final agentMentionLabels = _agentMentionLabels(
|
||||
candidates: mentionMap.value.values,
|
||||
);
|
||||
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
|
||||
'\u0000',
|
||||
);
|
||||
useEffect(() {
|
||||
controller.setAgentMentionNames(agentMentionLabels);
|
||||
return null;
|
||||
}, [controller, agentMentionLabelsKey]);
|
||||
useEffect(
|
||||
() {
|
||||
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
part of '../compose_bar.dart';
|
||||
|
||||
Set<String> _agentMentionLabels({
|
||||
required Iterable<MentionCandidate> candidates,
|
||||
}) {
|
||||
return <String>{
|
||||
for (final candidate in candidates)
|
||||
if (candidate.isAgent) candidate.label,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class _MarkdownRule {
|
||||
}
|
||||
|
||||
class _MarkdownEditingController extends TextEditingController {
|
||||
final Set<String> _agentMentionNames = <String>{};
|
||||
|
||||
static final _rules = [
|
||||
_MarkdownRule(
|
||||
r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```',
|
||||
@@ -31,6 +33,21 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
_MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic),
|
||||
];
|
||||
|
||||
/// Updates the known agent labels which should render as agent mention
|
||||
/// chips. The editor still stores the literal `@Name` text, matching the
|
||||
/// markdown sent to the relay.
|
||||
void setAgentMentionNames(Iterable<String> names) {
|
||||
final next = {
|
||||
for (final name in names)
|
||||
if (name.trim().isNotEmpty) name.trim().toLowerCase(),
|
||||
};
|
||||
if (setEquals(_agentMentionNames, next)) return;
|
||||
_agentMentionNames
|
||||
..clear()
|
||||
..addAll(next);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
@@ -81,6 +98,7 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
if (nextRule == null || nextMatch == null) {
|
||||
spans.addAll(
|
||||
_buildTextSpans(
|
||||
context,
|
||||
source.substring(offset),
|
||||
inheritedStyle,
|
||||
sourceOffset + offset,
|
||||
@@ -93,6 +111,7 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
if (nextMatch.start > 0) {
|
||||
spans.addAll(
|
||||
_buildTextSpans(
|
||||
context,
|
||||
tail.substring(0, nextMatch.start),
|
||||
inheritedStyle,
|
||||
sourceOffset + offset,
|
||||
@@ -129,10 +148,12 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
} else {
|
||||
spans.addAll(
|
||||
_buildTextSpans(
|
||||
context,
|
||||
content,
|
||||
contentStyle,
|
||||
matchOffset + contentStart,
|
||||
composingRange,
|
||||
renderAgentMentions: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -150,11 +171,18 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildTextSpans(
|
||||
BuildContext context,
|
||||
String source,
|
||||
TextStyle style,
|
||||
int sourceOffset,
|
||||
TextRange composingRange,
|
||||
) {
|
||||
TextRange composingRange, {
|
||||
bool renderAgentMentions = true,
|
||||
}) {
|
||||
List<InlineSpan> buildTextSegment(String text, TextStyle segmentStyle) =>
|
||||
renderAgentMentions
|
||||
? _buildAgentMentionSpans(context, text, segmentStyle)
|
||||
: [TextSpan(text: text, style: segmentStyle)];
|
||||
|
||||
if (source.isEmpty) return const [];
|
||||
final localStart = (composingRange.start - sourceOffset)
|
||||
.clamp(0, source.length)
|
||||
@@ -165,7 +193,7 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
if (!composingRange.isValid ||
|
||||
composingRange.isCollapsed ||
|
||||
localStart >= localEnd) {
|
||||
return [TextSpan(text: source, style: style)];
|
||||
return buildTextSegment(source, style);
|
||||
}
|
||||
|
||||
final composingDecorations = [
|
||||
@@ -178,16 +206,80 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
);
|
||||
return [
|
||||
if (localStart > 0)
|
||||
TextSpan(text: source.substring(0, localStart), style: style),
|
||||
...buildTextSegment(source.substring(0, localStart), style),
|
||||
TextSpan(
|
||||
text: source.substring(localStart, localEnd),
|
||||
style: composingStyle,
|
||||
),
|
||||
if (localEnd < source.length)
|
||||
TextSpan(text: source.substring(localEnd), style: style),
|
||||
...buildTextSegment(source.substring(localEnd), style),
|
||||
];
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildAgentMentionSpans(
|
||||
BuildContext context,
|
||||
String source,
|
||||
TextStyle style,
|
||||
) {
|
||||
if (_agentMentionNames.isEmpty) {
|
||||
return [TextSpan(text: source, style: style)];
|
||||
}
|
||||
|
||||
final escapedNames = _agentMentionNames.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
final expression = RegExp(
|
||||
r'(^|\s)@(' +
|
||||
escapedNames.map(RegExp.escape).join('|') +
|
||||
r')(?=\s|[,.!?:;)\]}*_]|$)',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
);
|
||||
final spans = <InlineSpan>[];
|
||||
var offset = 0;
|
||||
for (final match in expression.allMatches(source)) {
|
||||
final prefix = match.group(1)!;
|
||||
if (match.start > offset) {
|
||||
spans.add(
|
||||
TextSpan(text: source.substring(offset, match.start), style: style),
|
||||
);
|
||||
}
|
||||
if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style));
|
||||
|
||||
final label = match.group(2)!;
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _ComposerAgentMentionChip(label: label, textStyle: style),
|
||||
),
|
||||
);
|
||||
// The visual chip replaces the `@` placeholder. Keep the label as
|
||||
// invisible source text so the text span still has one character per
|
||||
// source character, preserving native cursor and deletion behavior.
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: label,
|
||||
semanticsLabel: '',
|
||||
style: _hiddenMentionTextStyle(style),
|
||||
),
|
||||
);
|
||||
offset = match.end;
|
||||
}
|
||||
if (offset < source.length) {
|
||||
spans.add(TextSpan(text: source.substring(offset), style: style));
|
||||
}
|
||||
return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans;
|
||||
}
|
||||
|
||||
TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) =>
|
||||
inheritedStyle.copyWith(
|
||||
color: Colors.transparent,
|
||||
fontSize: 0.01,
|
||||
height: 0.01,
|
||||
letterSpacing: 0,
|
||||
decoration: TextDecoration.none,
|
||||
);
|
||||
|
||||
(int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) {
|
||||
final delimiterLength = switch (markdownStyle) {
|
||||
_MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2,
|
||||
@@ -247,3 +339,60 @@ class _MarkdownEditingController extends TextEditingController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComposerAgentMentionChip extends StatelessWidget {
|
||||
final String label;
|
||||
final TextStyle textStyle;
|
||||
|
||||
const _ComposerAgentMentionChip({
|
||||
required this.label,
|
||||
required this.textStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = textStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
);
|
||||
final fontSize = style.fontSize ?? 16;
|
||||
|
||||
return Semantics(
|
||||
label: 'Agent mention: $label',
|
||||
excludeSemantics: true,
|
||||
child: Container(
|
||||
key: const ValueKey('composer-agent-mention-chip'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.half,
|
||||
Grid.quarter + 1,
|
||||
Grid.half,
|
||||
Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
// The composer surface is already tinted, so the body chip's
|
||||
// low-opacity fill disappears here. Keep the same chip geometry
|
||||
// while giving this editable token enough contrast to read as one.
|
||||
color: context.colors.primary.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.bot,
|
||||
size: fontSize * 0.95,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text(label, style: style),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../shared/relay/nostr_models.dart';
|
||||
import '../../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../profile/user_profile.dart';
|
||||
import '../channel_management_provider.dart';
|
||||
import 'mention_ranking.dart';
|
||||
|
||||
/// A relay agent parsed from its kind:10100 agent-profile event.
|
||||
///
|
||||
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
|
||||
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
|
||||
/// channels it sits in.
|
||||
class AgentDirectoryEntry {
|
||||
final String pubkey;
|
||||
final String? displayName;
|
||||
final String? respondTo;
|
||||
final List<String> respondToAllowlist;
|
||||
final List<String> channelIds;
|
||||
|
||||
const AgentDirectoryEntry({
|
||||
required this.pubkey,
|
||||
this.displayName,
|
||||
this.respondTo,
|
||||
this.respondToAllowlist = const [],
|
||||
this.channelIds = const [],
|
||||
});
|
||||
|
||||
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
|
||||
final content = _tryDecodeJsonMap(event.content);
|
||||
return AgentDirectoryEntry(
|
||||
pubkey: event.pubkey.toLowerCase(),
|
||||
displayName:
|
||||
(content?['display_name'] as String?) ??
|
||||
(content?['name'] as String?),
|
||||
respondTo: content?['respond_to'] as String?,
|
||||
respondToAllowlist: [
|
||||
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
|
||||
if (value is String) value.toLowerCase(),
|
||||
],
|
||||
channelIds: [
|
||||
for (final value in (content?['channel_ids'] as List?) ?? [])
|
||||
if (value is String) value,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
|
||||
try {
|
||||
final decoded = jsonDecode(content);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a non-member relay agent should be mentionable by the current
|
||||
/// user. Mirrors desktop's `relayAgentIsSharedWithUser`:
|
||||
/// - allowlist mode: user must be on the allowlist
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../shared/crypto/nip_oa.dart';
|
||||
import '../../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../profile/user_profile.dart';
|
||||
@@ -10,64 +11,6 @@ import '../channels_provider.dart';
|
||||
import 'mention_candidates.dart';
|
||||
import 'mention_ranking.dart';
|
||||
|
||||
/// Relay agent directory from kind:10100 agent-profile events.
|
||||
///
|
||||
/// Watches the session and only fetches after the WebSocket connects.
|
||||
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
|
||||
ref,
|
||||
) async {
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
if (sessionState.status != SessionStatus.connected) return const [];
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(NostrFilters.agentProfiles());
|
||||
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
|
||||
});
|
||||
|
||||
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
|
||||
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
|
||||
/// desktop's `profile_valid_oa_owner_pubkey`.
|
||||
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
|
||||
final agents = await ref.watch(agentDirectoryProvider.future);
|
||||
if (agents.isEmpty) return const {};
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
|
||||
);
|
||||
final owners = <String, String>{};
|
||||
for (final event in events) {
|
||||
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
|
||||
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
|
||||
}
|
||||
return owners;
|
||||
});
|
||||
|
||||
/// Pubkeys currently known to represent agents for rendered mention chips.
|
||||
///
|
||||
/// Uses the same three identity sources as mention autocomplete: channel bot
|
||||
/// roles, relay agent-directory entries, and verified NIP-OA ownership.
|
||||
final mentionAgentPubkeysProvider = Provider.family<Set<String>, String>((
|
||||
ref,
|
||||
channelId,
|
||||
) {
|
||||
final members =
|
||||
ref.watch(channelMembersProvider(channelId)).asData?.value ??
|
||||
const <ChannelMember>[];
|
||||
final relayAgents =
|
||||
ref.watch(agentDirectoryProvider).asData?.value ??
|
||||
const <AgentDirectoryEntry>[];
|
||||
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
|
||||
return {
|
||||
for (final member in members)
|
||||
if (member.isBot) member.pubkey.toLowerCase(),
|
||||
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
|
||||
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(),
|
||||
};
|
||||
});
|
||||
|
||||
/// Debounce before a mention query hits the relay search endpoint.
|
||||
const _mentionSearchDebounce = Duration(milliseconds: 250);
|
||||
|
||||
|
||||
@@ -145,6 +145,10 @@ class MessageContent extends HookConsumerWidget {
|
||||
final baseTextStyle =
|
||||
baseStyle ??
|
||||
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
|
||||
final resolvedMentionNames = mentionNames;
|
||||
final resolvedAgentMentionPubkeys = {
|
||||
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
||||
};
|
||||
final imetaByUrl = parseImetaTags(tags);
|
||||
final trailingGallery = maxLines == null
|
||||
? _extractTrailingImageGallery(content, imetaByUrl)
|
||||
@@ -154,6 +158,13 @@ class MessageContent extends HookConsumerWidget {
|
||||
customEmojiFromTags(tags),
|
||||
ref.watch(customEmojiListProvider),
|
||||
);
|
||||
final mentionPresentationKey = [
|
||||
for (final entry
|
||||
in (resolvedMentionNames.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key))))
|
||||
'${entry.key}\u0000${entry.value}',
|
||||
...(resolvedAgentMentionPubkeys.toList()..sort()),
|
||||
].join('\u0001');
|
||||
|
||||
// Decided here rather than by the caller: this is where the event's own
|
||||
// emoji tags and the community palette have already been merged, and a
|
||||
@@ -220,7 +231,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
mentionBuf.write('`${mentionParts[i]}`');
|
||||
} else {
|
||||
var segment = mentionParts[i];
|
||||
for (final name in mentionNames.values) {
|
||||
for (final name in resolvedMentionNames.values) {
|
||||
if (name.contains(' ')) {
|
||||
final normalizedName = _markdownMentionName(name);
|
||||
segment = segment.replaceAllMapped(
|
||||
@@ -241,29 +252,35 @@ class MessageContent extends HookConsumerWidget {
|
||||
result = '\u200B$result';
|
||||
}
|
||||
return result;
|
||||
}, [markdownContent, mentionNames]);
|
||||
}, [markdownContent, resolvedMentionNames]);
|
||||
|
||||
final markdown = GptMarkdown(
|
||||
finalContent,
|
||||
style: style,
|
||||
followLinkColor: false,
|
||||
codeBuilder: (context, name, code, closed) =>
|
||||
_MessageCodeBlock(name: name, code: code),
|
||||
linkBuilder: (context, linkText, url, linkStyle) =>
|
||||
_buildLink(context, ref, linkText, url, linkStyle, style),
|
||||
imageBuilder: (context, imageUrl) =>
|
||||
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
|
||||
maxLines: maxLines,
|
||||
inlineComponents: [
|
||||
_MentionMd(
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
onMentionTap: onMentionTap,
|
||||
),
|
||||
CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize),
|
||||
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
|
||||
...MarkdownComponent.inlineComponents,
|
||||
],
|
||||
final markdown = KeyedSubtree(
|
||||
key: ValueKey('$finalContent\u0000$mentionPresentationKey'),
|
||||
child: GptMarkdown(
|
||||
finalContent,
|
||||
style: style,
|
||||
followLinkColor: false,
|
||||
codeBuilder: (context, name, code, closed) =>
|
||||
_MessageCodeBlock(name: name, code: code),
|
||||
linkBuilder: (context, linkText, url, linkStyle) =>
|
||||
_buildLink(context, ref, linkText, url, linkStyle, style),
|
||||
imageBuilder: (context, imageUrl) =>
|
||||
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
|
||||
maxLines: maxLines,
|
||||
inlineComponents: [
|
||||
_MentionMd(
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: resolvedAgentMentionPubkeys,
|
||||
onMentionTap: onMentionTap,
|
||||
),
|
||||
CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize),
|
||||
_ChannelLinkMd(
|
||||
channelNames: channelNames,
|
||||
onChannelTap: onChannelTap,
|
||||
),
|
||||
...MarkdownComponent.inlineComponents,
|
||||
],
|
||||
),
|
||||
);
|
||||
if (trailingGallery == null) return markdown;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
@@ -24,7 +25,6 @@ import 'day_divider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_content.dart';
|
||||
import 'mentions/mention_candidates_provider.dart';
|
||||
import 'reaction_row.dart';
|
||||
import 'read_state/read_state_format.dart';
|
||||
import 'read_state/read_state_provider.dart';
|
||||
@@ -607,7 +607,13 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
profile?.ownerPubkey == currentPubkey?.toLowerCase());
|
||||
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId));
|
||||
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = <String, String>{};
|
||||
final agentMentionPubkeys = <String>{};
|
||||
for (final mpk in message.mentionPubkeys) {
|
||||
@@ -620,6 +626,12 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
agentMentionPubkeys.add(normalizedPubkey);
|
||||
}
|
||||
}
|
||||
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: message.mentionPubkeys,
|
||||
profileMentionNames: mentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
@@ -725,7 +737,7 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: mentionNames,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../channels/message_content.dart';
|
||||
@@ -15,7 +17,7 @@ import 'forum_models.dart';
|
||||
///
|
||||
/// Long-press opens an action sheet (copy, delete) matching the stream
|
||||
/// message pattern from channel_detail_page.dart.
|
||||
class ForumPostCard extends ConsumerWidget {
|
||||
class ForumPostCard extends HookConsumerWidget {
|
||||
final ForumPost post;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onTap;
|
||||
@@ -31,16 +33,57 @@ class ForumPostCard extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mentionPubkeys = useMemoized(
|
||||
() =>
|
||||
post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()).toSet()
|
||||
..remove(post.pubkey.toLowerCase()),
|
||||
[post],
|
||||
);
|
||||
final mentionPubkeysKey = (mentionPubkeys.toList()..sort()).join('\u0000');
|
||||
|
||||
useEffect(() {
|
||||
if (mentionPubkeys.isNotEmpty) {
|
||||
ref.read(userCacheProvider.notifier).preload(mentionPubkeys.toList());
|
||||
}
|
||||
return null;
|
||||
}, [mentionPubkeysKey]);
|
||||
|
||||
final pk = post.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pk);
|
||||
final displayName = profile?.label ?? _shortPubkey(post.pubkey);
|
||||
final mentionNames = ref.watch(
|
||||
final profileMentionNames = ref.watch(
|
||||
userCacheProvider.select(
|
||||
(cache) => _buildMentionNames(post.mentionPubkeys, cache),
|
||||
),
|
||||
);
|
||||
final profileOwnedMentionPubkeys = ref.watch(
|
||||
userCacheProvider.select(
|
||||
(cache) =>
|
||||
(post.mentionPubkeys
|
||||
.where(
|
||||
(pubkey) =>
|
||||
cache[pubkey.toLowerCase()]?.ownerPubkey != null,
|
||||
)
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.toList()
|
||||
..sort())
|
||||
.join('\u0000'),
|
||||
),
|
||||
);
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
|
||||
profileOwnedAgentPubkeys: profileOwnedMentionPubkeys.isEmpty
|
||||
? const <String>[]
|
||||
: profileOwnedMentionPubkeys.split('\u0000'),
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: post.mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
final preview = post.content.length > 200
|
||||
? '${post.content.substring(0, 200)}...'
|
||||
: post.content;
|
||||
@@ -128,6 +171,7 @@ class ForumPostCard extends ConsumerWidget {
|
||||
child: MessageContent(
|
||||
content: preview,
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: post.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
@@ -209,21 +210,27 @@ class _ThreadContent extends HookConsumerWidget {
|
||||
final post = thread.post;
|
||||
final replies = thread.replies;
|
||||
|
||||
// Preload profiles for all participants.
|
||||
// Preload profiles for all participants and tagged mentions.
|
||||
final allPubkeys = useMemoized(() {
|
||||
final pks = <String>{post.pubkey};
|
||||
final pks = <String>{
|
||||
post.pubkey.toLowerCase(),
|
||||
...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
||||
};
|
||||
for (final reply in replies) {
|
||||
pks.add(reply.pubkey);
|
||||
pks
|
||||
..add(reply.pubkey.toLowerCase())
|
||||
..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()));
|
||||
}
|
||||
return pks.toList();
|
||||
return pks.toList()..sort();
|
||||
}, [post, replies]);
|
||||
final allPubkeysKey = allPubkeys.join('\u0000');
|
||||
|
||||
useEffect(() {
|
||||
if (allPubkeys.isNotEmpty) {
|
||||
ref.read(userCacheProvider.notifier).preload(allPubkeys);
|
||||
}
|
||||
return null;
|
||||
}, [allPubkeys]);
|
||||
}, [allPubkeysKey]);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -322,7 +329,19 @@ class _OriginalPost extends ConsumerWidget {
|
||||
final displayName = profile?.label ?? _shortPubkey(post.pubkey);
|
||||
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final mentionNames = _buildMentionNames(post.mentionPubkeys, userCache);
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: post.mentionPubkeys,
|
||||
profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache),
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
@@ -375,6 +394,7 @@ class _OriginalPost extends ConsumerWidget {
|
||||
MessageContent(
|
||||
content: post.content,
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: post.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
@@ -409,7 +429,19 @@ class _ReplyRow extends ConsumerWidget {
|
||||
final displayName = profile?.label ?? _shortPubkey(reply.pubkey);
|
||||
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final mentionNames = _buildMentionNames(reply.mentionPubkeys, userCache);
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: reply.mentionPubkeys,
|
||||
profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache),
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -481,6 +513,7 @@ class _ReplyRow extends ConsumerWidget {
|
||||
child: MessageContent(
|
||||
content: reply.content,
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: reply.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/mentions/mention_tags.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
@@ -597,7 +599,7 @@ class _PeopleSection extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessagesSection extends ConsumerWidget {
|
||||
class _MessagesSection extends HookConsumerWidget {
|
||||
final List<SearchHit> hits;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onResultSelected;
|
||||
@@ -614,8 +616,15 @@ class _MessagesSection extends ConsumerWidget {
|
||||
final channels = ref.watch(channelsProvider).value ?? [];
|
||||
|
||||
// Preload author profiles.
|
||||
final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList();
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
final preloadPubkeys = {
|
||||
for (final hit in hits) hit.pubkey.toLowerCase(),
|
||||
for (final hit in hits) ...mentionedPubkeysFromTags(hit.tags),
|
||||
}.toList()..sort();
|
||||
final preloadPubkeysKey = preloadPubkeys.join('\u0000');
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload(preloadPubkeys);
|
||||
return null;
|
||||
}, [preloadPubkeysKey]);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -635,7 +644,7 @@ class _MessagesSection extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageTile extends StatelessWidget {
|
||||
class _MessageTile extends ConsumerWidget {
|
||||
final SearchHit hit;
|
||||
final UserProfile? authorProfile;
|
||||
final Map<String, UserProfile> userCache;
|
||||
@@ -653,12 +662,34 @@ class _MessageTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey);
|
||||
final timeAgo = relativeTime(hit.createdAt);
|
||||
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
|
||||
final hasChannelName = channelName != null && channelName.isNotEmpty;
|
||||
final isDm = channel?.isDm ?? false;
|
||||
final profileMentionNames = {
|
||||
for (final pubkey in mentionedPubkeysFromTags(hit.tags))
|
||||
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
|
||||
pubkey: userCache[pubkey]!.displayName!.trim(),
|
||||
};
|
||||
final mentionPubkeys = mentionedPubkeysFromTags(hit.tags);
|
||||
final knownAgentPubkeys = channel == null
|
||||
? ref.watch(knownAgentPubkeysProvider)
|
||||
: ref.watch(agentMentionPubkeysProvider(channel!.id));
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: knownAgentPubkeys,
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey('search-message-row-${hit.eventId}'),
|
||||
@@ -730,6 +761,8 @@ class _MessageTile extends StatelessWidget {
|
||||
MessageContent(
|
||||
key: ValueKey('search-message-body-${hit.eventId}'),
|
||||
content: hit.content,
|
||||
mentionNames: mentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: hit.tags,
|
||||
maxLines: 2,
|
||||
baseStyle: activityPreviewTextStyle.copyWith(
|
||||
|
||||
274
mobile/lib/shared/mentions/agent_identity_provider.dart
Normal file
274
mobile/lib/shared/mentions/agent_identity_provider.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/crypto/nip_oa.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
|
||||
/// A relay agent parsed from its kind:10100 agent-profile event.
|
||||
///
|
||||
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
|
||||
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
|
||||
/// channels it sits in.
|
||||
class AgentDirectoryEntry {
|
||||
final String pubkey;
|
||||
final String? displayName;
|
||||
final String? respondTo;
|
||||
final List<String> respondToAllowlist;
|
||||
final List<String> channelIds;
|
||||
|
||||
const AgentDirectoryEntry({
|
||||
required this.pubkey,
|
||||
this.displayName,
|
||||
this.respondTo,
|
||||
this.respondToAllowlist = const [],
|
||||
this.channelIds = const [],
|
||||
});
|
||||
|
||||
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
|
||||
final content = _tryDecodeJsonMap(event.content);
|
||||
return AgentDirectoryEntry(
|
||||
pubkey: event.pubkey.toLowerCase(),
|
||||
displayName:
|
||||
(content?['display_name'] as String?) ??
|
||||
(content?['name'] as String?),
|
||||
respondTo: content?['respond_to'] as String?,
|
||||
respondToAllowlist: [
|
||||
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
|
||||
if (value is String) value.toLowerCase(),
|
||||
],
|
||||
channelIds: [
|
||||
for (final value in (content?['channel_ids'] as List?) ?? [])
|
||||
if (value is String) value,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
|
||||
try {
|
||||
final decoded = jsonDecode(content);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay agent directory from kind:10100 agent-profile events.
|
||||
///
|
||||
/// Watches the session and only fetches after the WebSocket connects.
|
||||
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
|
||||
ref,
|
||||
) async {
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
if (sessionState.status != SessionStatus.connected) return const [];
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(NostrFilters.agentProfiles());
|
||||
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
|
||||
});
|
||||
|
||||
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
|
||||
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
|
||||
/// desktop's `profile_valid_oa_owner_pubkey`.
|
||||
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
|
||||
final agents = await ref.watch(agentDirectoryProvider.future);
|
||||
if (agents.isEmpty) return const {};
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
|
||||
);
|
||||
final owners = <String, String>{};
|
||||
for (final event in events) {
|
||||
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
|
||||
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
|
||||
}
|
||||
return owners;
|
||||
});
|
||||
|
||||
/// Pubkeys currently known to represent agents across the active relay.
|
||||
///
|
||||
/// Message surfaces that do not own channel membership can use this shared
|
||||
/// identity source; channel features add their bot roles separately.
|
||||
final knownAgentPubkeysProvider = Provider<Set<String>>((ref) {
|
||||
final relayAgents =
|
||||
ref.watch(agentDirectoryProvider).asData?.value ??
|
||||
const <AgentDirectoryEntry>[];
|
||||
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
|
||||
return _AgentPubkeySet({
|
||||
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
|
||||
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
|
||||
});
|
||||
});
|
||||
|
||||
/// Directory display names keyed by agent pubkey for mention presentation.
|
||||
final agentDirectoryDisplayNamesProvider = Provider<Map<String, String>>((ref) {
|
||||
final agents =
|
||||
ref.watch(agentDirectoryProvider).asData?.value ??
|
||||
const <AgentDirectoryEntry>[];
|
||||
return Map.unmodifiable({
|
||||
for (final agent in agents)
|
||||
if (agent.displayName?.trim().isNotEmpty == true)
|
||||
agent.pubkey.toLowerCase(): agent.displayName!.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
/// Adds channel bot roles to relay-wide agent identities.
|
||||
Set<String> agentPubkeysWithChannelBots({
|
||||
required Set<String> knownAgentPubkeys,
|
||||
required Iterable<String> channelBotPubkeys,
|
||||
}) => _AgentPubkeySet({
|
||||
...knownAgentPubkeys,
|
||||
...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
||||
});
|
||||
|
||||
/// Adds agent identities derived from locally cached verified profiles.
|
||||
Set<String> agentPubkeysWithProfileOwners({
|
||||
required Set<String> knownAgentPubkeys,
|
||||
required Iterable<String> profileOwnedAgentPubkeys,
|
||||
}) => _AgentPubkeySet({
|
||||
...knownAgentPubkeys,
|
||||
...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
||||
});
|
||||
|
||||
/// Preserves profile labels while filling missing agent mentions from the
|
||||
/// relay's agent directory.
|
||||
Map<String, String> mentionNamesWithDirectoryLabels({
|
||||
required Iterable<String> mentionPubkeys,
|
||||
required Map<String, String> profileMentionNames,
|
||||
required Map<String, String> directoryDisplayNames,
|
||||
required Set<String> agentMentionPubkeys,
|
||||
}) {
|
||||
final names = Map<String, String>.from(profileMentionNames);
|
||||
for (final pubkey in mentionPubkeys) {
|
||||
final normalizedPubkey = pubkey.toLowerCase();
|
||||
if (names[normalizedPubkey]?.trim().isEmpty == true) {
|
||||
names.remove(normalizedPubkey);
|
||||
}
|
||||
final directoryName = directoryDisplayNames[normalizedPubkey];
|
||||
if (!names.containsKey(normalizedPubkey) && directoryName != null) {
|
||||
names[normalizedPubkey] = directoryName;
|
||||
}
|
||||
if (!names.containsKey(normalizedPubkey) &&
|
||||
agentMentionPubkeys.contains(normalizedPubkey)) {
|
||||
names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
String _agentFallbackLabel(String pubkey) =>
|
||||
pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
|
||||
|
||||
/// Keeps the role feed alive for consumers that render mentions outside the
|
||||
/// channel timeline, such as search results. A membership change refreshes the
|
||||
/// shared bot-role lookup below, regardless of which surface owns the channel.
|
||||
class _ChannelBotRoleSubscription extends Notifier<int> {
|
||||
final String channelId;
|
||||
void Function()? _unsubscribe;
|
||||
int _subscriptionVersion = 0;
|
||||
|
||||
_ChannelBotRoleSubscription(this.channelId);
|
||||
|
||||
@override
|
||||
int build() {
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
final subscriptionVersion = ++_subscriptionVersion;
|
||||
_clearSubscription();
|
||||
ref.onDispose(() {
|
||||
_subscriptionVersion++;
|
||||
_clearSubscription();
|
||||
});
|
||||
|
||||
if (sessionState.status != SessionStatus.connected) return 0;
|
||||
Future.microtask(() => _subscribe(channelId, subscriptionVersion));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<void> _subscribe(String channelId, int subscriptionVersion) async {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
try {
|
||||
final unsubscribe = await session.subscribe(
|
||||
NostrFilter(
|
||||
kinds: const [39002],
|
||||
tags: {
|
||||
'#h': [channelId],
|
||||
},
|
||||
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
|
||||
(_) {
|
||||
if (_isCurrent(subscriptionVersion)) {
|
||||
state++;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!_isCurrent(subscriptionVersion)) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
_unsubscribe = unsubscribe;
|
||||
} catch (error) {
|
||||
if (_isCurrent(subscriptionVersion)) {
|
||||
debugPrint(
|
||||
'[ChannelBotRoleSubscription] failed for $channelId: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrent(int subscriptionVersion) =>
|
||||
subscriptionVersion == _subscriptionVersion;
|
||||
|
||||
void _clearSubscription() {
|
||||
_unsubscribe?.call();
|
||||
_unsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonically increments when the channel's kind:39002 membership snapshot
|
||||
/// changes. Channel-member and agent-role views share this source so remote
|
||||
/// membership updates refresh both snapshots together.
|
||||
final channelMembershipUpdateProvider = NotifierProvider.autoDispose
|
||||
.family<_ChannelBotRoleSubscription, int, String>(
|
||||
_ChannelBotRoleSubscription.new,
|
||||
);
|
||||
|
||||
/// Bot pubkeys currently assigned a channel bot role.
|
||||
final channelBotPubkeysProvider = FutureProvider.autoDispose
|
||||
.family<Set<String>, String>((ref, channelId) async {
|
||||
ref.watch(channelMembershipUpdateProvider(channelId));
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
if (sessionState.status != SessionStatus.connected) return const {};
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.channelMembers(channelId),
|
||||
);
|
||||
if (events.isEmpty) return const {};
|
||||
return _AgentPubkeySet({
|
||||
for (final member in membersFromEvent(events.first))
|
||||
if (member.role == 'bot') member.pubkey.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
/// Pubkeys currently known to represent agents in a channel.
|
||||
final agentMentionPubkeysProvider = Provider.autoDispose
|
||||
.family<Set<String>, String>((ref, channelId) {
|
||||
final channelBotPubkeys =
|
||||
ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ??
|
||||
const <String>{};
|
||||
return agentPubkeysWithChannelBots(
|
||||
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
|
||||
channelBotPubkeys: channelBotPubkeys,
|
||||
);
|
||||
});
|
||||
|
||||
class _AgentPubkeySet extends UnmodifiableSetView<String> {
|
||||
_AgentPubkeySet(Iterable<String> pubkeys) : super(Set.unmodifiable(pubkeys));
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Set && length == other.length && every(other.contains);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAllUnordered(this);
|
||||
}
|
||||
6
mobile/lib/shared/mentions/mention_tags.dart
Normal file
6
mobile/lib/shared/mentions/mention_tags.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Pubkeys tagged as message mentions, normalized for profile lookups.
|
||||
Set<String> mentionedPubkeysFromTags(Iterable<List<String>> tags) => {
|
||||
for (final tag in tags)
|
||||
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention'))
|
||||
tag[1].toLowerCase(),
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import 'package:buzz/features/channels/small_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
@@ -188,6 +189,9 @@ Widget _buildTestable({
|
||||
channelMembersProvider(_channelId).overrideWith(
|
||||
(ref) async => loadMembers != null ? loadMembers() : members,
|
||||
),
|
||||
channelBotPubkeysProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) async => const <String>{}),
|
||||
if (createChannelActions != null)
|
||||
channelActionsProvider.overrideWith(createChannelActions),
|
||||
if (readStateNotifier != null)
|
||||
|
||||
@@ -17,11 +17,10 @@ import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/compose_bar.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
|
||||
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
|
||||
import 'package:buzz/features/channels/photo_library.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -2269,6 +2268,11 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Helper Bot'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-mention-chip')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), 'hello @Helper Bot');
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowUp));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -2285,6 +2289,70 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'renders chips only for selected agents outside code and composition',
|
||||
(tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final signer = nostr.Keys.generate();
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: _testUploadService(signer.nsec),
|
||||
currentPubkey: signer.public,
|
||||
relayAgents: [_testAgent('f' * 64)],
|
||||
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await _expandComposer(tester);
|
||||
await tester.enterText(find.byType(TextField), '@Helper Bot');
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-mention-chip')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '@hel');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Helper Bot'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-mention-chip')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.bySemanticsLabel('Agent mention: Helper Bot'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.bySemanticsLabel('Helper Bot'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '`@Helper Bot`');
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-mention-chip')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '@Helper Bot typing');
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
textField.controller!.value = textField.controller!.value.copyWith(
|
||||
composing: const TextRange(start: 12, end: 18),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-mention-chip')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
semantics.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not mutate a DM when mentioning a non-member agent', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
|
||||
final userPubkey = 'a' * 64;
|
||||
final memberPubkey = 'b' * 64;
|
||||
@@ -18,6 +19,20 @@ ChannelMember member(String pubkey, {String role = 'member'}) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('role-only agent mentions fall back to a pubkey prefix label', () {
|
||||
const pubkey = 'deadbeef0123456789';
|
||||
|
||||
expect(
|
||||
mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: const [pubkey],
|
||||
profileMentionNames: const {},
|
||||
directoryDisplayNames: const {},
|
||||
agentMentionPubkeys: const {pubkey},
|
||||
),
|
||||
const {pubkey: 'deadbeef'},
|
||||
);
|
||||
});
|
||||
|
||||
group('agentIsSharedWithUser', () {
|
||||
test('anyone-mode agent is shared when a channel overlaps', () {
|
||||
final agent = AgentDirectoryEntry(
|
||||
|
||||
@@ -1204,6 +1204,43 @@ Photos
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a known agent mention with the bot chip', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Ask @Helper Bot to investigate',
|
||||
mentionNames: {'agent-pubkey': 'Helper Bot'},
|
||||
agentMentionPubkeys: {'agent-pubkey'},
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
|
||||
expect(find.text('@'), findsNothing);
|
||||
expect(find.text('Helper Bot'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('normalizes passed multi-word agent mentions', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Ask @Helper Bot to investigate',
|
||||
mentionNames: {'agent-pubkey': 'Helper Bot'},
|
||||
agentMentionPubkeys: {'agent-pubkey'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
|
||||
expect(find.text('Helper Bot'), findsOneWidget);
|
||||
expect(_allRichText(tester), isNot(contains('Bot Bot')));
|
||||
});
|
||||
|
||||
testWidgets('highlights an entire multi-word display name', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -10,8 +10,10 @@ import 'package:buzz/features/search/recent_searches_provider.dart';
|
||||
import 'package:buzz/features/search/search_page.dart';
|
||||
import 'package:buzz/features/search/search_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
@@ -593,6 +595,72 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(recentSearches.searches, const ['design']);
|
||||
});
|
||||
|
||||
testWidgets('renders channel-role bots in message previews', (tester) async {
|
||||
final channel = Channel(
|
||||
id: 'channel-1',
|
||||
name: 'general',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'test',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 2,
|
||||
isMember: true,
|
||||
);
|
||||
const agentPubkey = 'agent-pubkey';
|
||||
const cachedProfile = UserProfile(pubkey: 'author-pubkey');
|
||||
final state = SearchState(
|
||||
query: 'helper',
|
||||
messageResults: [
|
||||
SearchHit(
|
||||
eventId: 'message-1',
|
||||
content: 'Ask @Helper Bot to investigate',
|
||||
kind: 9,
|
||||
pubkey: 'author-pubkey',
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
createdAt: 1,
|
||||
score: 1,
|
||||
tags: [
|
||||
['p', agentPubkey],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier([channel])),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier(cachedProfile),
|
||||
),
|
||||
knownAgentPubkeysProvider.overrideWith((ref) => const {}),
|
||||
channelBotPubkeysProvider(
|
||||
channel.id,
|
||||
).overrideWith((ref) async => {agentPubkey}),
|
||||
agentDirectoryDisplayNamesProvider.overrideWith(
|
||||
(ref) => const {agentPubkey: 'Helper Bot'},
|
||||
),
|
||||
],
|
||||
child: const SearchPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final content = tester.widget<MessageContent>(
|
||||
find.byKey(const ValueKey('search-message-body-message-1')),
|
||||
);
|
||||
expect(content.mentionNames, const {agentPubkey: 'Helper Bot'});
|
||||
expect(content.agentMentionPubkeys, contains(agentPubkey));
|
||||
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSearchNotifier extends SearchNotifier {
|
||||
@@ -646,8 +714,12 @@ class _FakeProfileNotifier extends ProfileNotifier {
|
||||
}
|
||||
|
||||
class _FakeChannelsNotifier extends ChannelsNotifier {
|
||||
_FakeChannelsNotifier([this.channels = const []]);
|
||||
|
||||
final List<Channel> channels;
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() async => const [];
|
||||
Future<List<Channel>> build() async => channels;
|
||||
}
|
||||
|
||||
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
|
||||
228
mobile/test/shared/mentions/agent_identity_provider_test.dart
Normal file
228
mobile/test/shared/mentions/agent_identity_provider_test.dart
Normal file
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
test('refreshes channel bot roles from live membership updates', () async {
|
||||
final relaySession = _MembershipRelaySessionNotifier([
|
||||
_membershipEvent(role: 'bot'),
|
||||
_membershipEvent(role: 'member'),
|
||||
]);
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final keepAlive = container.listen(
|
||||
channelBotPubkeysProvider(_channelId),
|
||||
(_, _) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
addTearDown(keepAlive.close);
|
||||
|
||||
expect(await container.read(channelBotPubkeysProvider(_channelId).future), {
|
||||
_agentPubkey,
|
||||
});
|
||||
await relaySession.subscribed;
|
||||
expect(relaySession.liveFilters.single.kinds, const [39002]);
|
||||
expect(relaySession.liveFilters.single.tags['#h'], [_channelId]);
|
||||
|
||||
relaySession.emit(_membershipEvent(role: 'member'));
|
||||
await _pumpEventQueue();
|
||||
|
||||
expect(
|
||||
await container.read(channelBotPubkeysProvider(_channelId).future),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshes channel members from live membership updates', () async {
|
||||
final relaySession = _MembershipRelaySessionNotifier([
|
||||
_membershipEvent(role: 'bot'),
|
||||
_membershipEvent(role: 'member'),
|
||||
]);
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final keepAlive = container.listen(
|
||||
channelMembersProvider(_channelId),
|
||||
(_, _) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
addTearDown(keepAlive.close);
|
||||
|
||||
expect(
|
||||
(await container.read(
|
||||
channelMembersProvider(_channelId).future,
|
||||
)).single.role,
|
||||
'bot',
|
||||
);
|
||||
await relaySession.subscribed;
|
||||
|
||||
relaySession.emit(_membershipEvent(role: 'member'));
|
||||
await _pumpEventQueue();
|
||||
|
||||
expect(
|
||||
(await container.read(
|
||||
channelMembersProvider(_channelId).future,
|
||||
)).single.role,
|
||||
'member',
|
||||
);
|
||||
});
|
||||
|
||||
test('disposes the live role subscription without consumers', () async {
|
||||
final relaySession = _MembershipRelaySessionNotifier([
|
||||
_membershipEvent(role: 'bot'),
|
||||
]);
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final keepAlive = container.listen(
|
||||
channelBotPubkeysProvider(_channelId),
|
||||
(_, _) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
await container.read(channelBotPubkeysProvider(_channelId).future);
|
||||
await relaySession.subscribed;
|
||||
keepAlive.close();
|
||||
await container.pump();
|
||||
|
||||
expect(relaySession.unsubscribeCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'does not retain a live role subscription through working bots',
|
||||
() async {
|
||||
final relaySession = _MembershipRelaySessionNotifier([
|
||||
_membershipEvent(role: 'bot'),
|
||||
]);
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final keepAlive = container.listen(
|
||||
workingBotPubkeysProvider(_channelId),
|
||||
(_, _) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
await relaySession.subscribed;
|
||||
keepAlive.close();
|
||||
await container.pump();
|
||||
|
||||
expect(relaySession.unsubscribeCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('blank profile labels defer to the directory label', () {
|
||||
const pubkey = 'deadbeef0123456789';
|
||||
|
||||
expect(
|
||||
mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: const [pubkey],
|
||||
profileMentionNames: const {pubkey: ' '},
|
||||
directoryDisplayNames: const {pubkey: 'Directory bot'},
|
||||
agentMentionPubkeys: const {pubkey},
|
||||
),
|
||||
const {pubkey: 'Directory bot'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const _channelId = '11111111-1111-4111-8111-111111111111';
|
||||
const _agentPubkey =
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
|
||||
NostrEvent _membershipEvent({required String role}) => NostrEvent(
|
||||
id: 'membership-$role',
|
||||
pubkey: 'owner',
|
||||
createdAt: 1,
|
||||
kind: 39002,
|
||||
tags: [
|
||||
['d', _channelId],
|
||||
['h', _channelId],
|
||||
['p', _agentPubkey, 'wss://relay.example', role],
|
||||
],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
);
|
||||
|
||||
Future<void> _pumpEventQueue() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
|
||||
final List<NostrEvent> _memberships;
|
||||
final List<NostrFilter> liveFilters = [];
|
||||
final List<_LiveSubscription> _subscriptions = [];
|
||||
final Completer<void> _subscribed = Completer<void>();
|
||||
var unsubscribeCount = 0;
|
||||
var _membershipIndex = 0;
|
||||
|
||||
_MembershipRelaySessionNotifier(this._memberships);
|
||||
|
||||
Future<void> get subscribed => _subscribed.future;
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
return [_memberships[_membershipIndex++]];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
liveFilters.add(filter);
|
||||
final subscription = _LiveSubscription(filter, onEvent);
|
||||
_subscriptions.add(subscription);
|
||||
if (!_subscribed.isCompleted) _subscribed.complete();
|
||||
return () {
|
||||
unsubscribeCount++;
|
||||
_subscriptions.remove(subscription);
|
||||
};
|
||||
}
|
||||
|
||||
void emit(NostrEvent event) {
|
||||
for (final subscription in List.of(_subscriptions)) {
|
||||
if (_matches(subscription.filter, event)) {
|
||||
subscription.onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveSubscription {
|
||||
final NostrFilter filter;
|
||||
final void Function(NostrEvent) onEvent;
|
||||
|
||||
const _LiveSubscription(this.filter, this.onEvent);
|
||||
}
|
||||
|
||||
bool _matches(NostrFilter filter, NostrEvent event) {
|
||||
if (!filter.kinds.contains(event.kind)) return false;
|
||||
return filter.tags.entries.every((entry) {
|
||||
final tagName = entry.key.substring(1);
|
||||
return event.tags.any(
|
||||
(tag) =>
|
||||
tag.isNotEmpty &&
|
||||
tag.first == tagName &&
|
||||
tag.skip(1).any(entry.value.contains),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user