diff --git a/README.md b/README.md index 066b17b..574252e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ A few behaviours that are easy to miss: - **Voice messages play**, with their waveform, at 1× to 2× — Telegram's and Discord's, through `mediad --play`, one at a time. - **Discord's server list** waits until chats have loaded before it shows cached servers, so a cold start does not flash an empty or stale tree. - **Signal's Note to Self.** A cached self-conversation is relabelled when the account reconnects. Its messages stay. -- **Search.** `tinyclient --text --search` searches message text in the local cache, newest hits first. In the window, the Edit menu's Find opens the search pane: this conversation (Command-F), everywhere (Shift-Command-F; Ctrl-F, Ctrl-Shift-F elsewhere), and the switcher's "Search messages" row lands there too. A hit opens its conversation on that message. The networks themselves are not asked yet. +- **Search.** `tinyclient --text --search` searches message text in the local cache, newest hits first. In the window, the Edit menu's Find opens the search pane: this conversation (Command-F), everywhere (Shift-Command-F; Ctrl-F, Ctrl-Shift-F elsewhere), and the switcher's "Search messages" row lands there too. A hit opens its conversation on that message. Enter asks Discord itself — the server's own search for a Space, the channel's for one conversation — and its hits join the list; everywhere still reads the cache. - **Terminal.** `tinyclient --text`, or any build without the `gui` feature, prints account status and the inbox as lines of text. An interactive terminal UI is not built. The socket protocol is enough to write one. - **Platforms.** CI builds and tests macOS, Linux, and Windows on every push (`.github/workflows/ci.yml`). Most development happens on macOS, so reports and fixes from Linux and Windows are especially welcome. The main window closes cleanly on all three. Linux web sign-in, including Discord's session capture, works with WebKitGTK. diff --git a/crates/chatd/src/adapters/discord.rs b/crates/chatd/src/adapters/discord.rs index 4102fa7..53e2807 100644 --- a/crates/chatd/src/adapters/discord.rs +++ b/crates/chatd/src/adapters/discord.rs @@ -41,6 +41,9 @@ use std::time::{Duration, Instant}; /// The pause after a history fetch. const PACE: Duration = Duration::from_millis(700); +/// A search asked and not answered within this does not reach Discord at all: the shell has +/// already been told the network did not answer. +const SEARCH_PATIENCE: Duration = Duration::from_secs(25); /// Unread conversations fetched ahead of being opened, once per run. const WARM: usize = 5; /// A page of history, as the web client asks for it. @@ -210,6 +213,9 @@ struct Discord { refused_sessions: Mutex<(u32, String)>, /// No session is asked for until the user does something (`gateway.rs::hold`). held: AtomicBool, + /// This account's message searches: one in flight at a time, paced from when the last + /// finished, with recent answers kept in memory. + message_search: Mutex, } /// The user id a token starts with (its first part is the id in base64), so the account is named @@ -253,6 +259,7 @@ pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<() sequence: AtomicU64::new(0), refused_sessions: Mutex::new((0, String::new())), held: AtomicBool::new(false), + message_search: Mutex::new(info::MessageSearch::default()), }); let socket = discord.clone(); std::thread::spawn(move || keep_connected(&socket.ctx, || socket.connect())); @@ -283,6 +290,17 @@ pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<() sending.send_with(&channel, &client, &text, reply_to.as_deref(), &with, up) }); } + // A search can wait on Discord's tight search limits: it runs on a thread of its + // own, so the sends and reactions behind it are not held up, and one queued past + // the shell's patience does not ask at all. + Command::Search { query, scope, at, reply } => { + discord.asked(); + if at.elapsed() < SEARCH_PATIENCE { + Discord::search_queued(&discord, query, scope, at, reply); + } else { + drop(reply); + } + } // A profile waits its turn (one per 3 s) on a thread of its own, so a send behind it // is never held up, and one superseded while it waits is dropped. Command::Profile { person_id, reply } => Discord::profile_queued(&discord, person_id, None, reply), @@ -984,6 +1002,7 @@ impl Discord { Command::SearchShared { conversation_id, kind, before, limit, reply } => { answer(reply, self.search_shared(&conversation_id, kind, before.as_deref(), limit)) } + Command::Search { reply, .. } => drop(reply.send(Err(Refusal::Unsupported))), Command::Catalog { kind, space_id, query, cursor, reply } => { answer(reply, self.catalog(kind, space_id.as_deref(), &query, cursor.as_deref())) } diff --git a/crates/chatd/src/adapters/discord/info.rs b/crates/chatd/src/adapters/discord/info.rs index 0dc1668..c560e5c 100644 --- a/crates/chatd/src/adapters/discord/info.rs +++ b/crates/chatd/src/adapters/discord/info.rs @@ -3,11 +3,11 @@ //! the server-side search for what was shared. Each is one request at most, and only because //! the user opened or clicked something. -use super::super::{About, ConversationAbout, Refusal, Reply, SharedPage, SpaceAbout}; +use super::super::{About, ConversationAbout, Refusal, Reply, SearchScope, SharedPage, SpaceAbout}; use super::model::{self, number, GuildSettings, Posted}; -use super::{refusal, Discord}; +use super::{escaped, refusal, Discord}; use crate::hub::now; -use protocol::{ConversationKind, MediaKind, Message, Notify, SharedItem, SharedKind, Style}; +use protocol::{ConversationKind, MediaKind, Message, MessageHit, Notify, SharedItem, SharedKind, Style}; use serde::Deserialize; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -19,6 +19,18 @@ const PROFILE_EVERY: Duration = Duration::from_secs(3); static PROFILE_TURN: Mutex<()> = Mutex::new(()); /// Discord's search answers 25 messages a page at most. const SEARCH_PAGE: u32 = 25; +/// Most characters Discord's message search accepts in `content`. +const SEARCH_MAX_CHARS: usize = 1024; +/// Recent message searches kept in memory per account, so the same scope and words are not +/// asked twice in a session. +const REMEMBERED_SEARCHES: usize = 32; + +#[derive(Default)] +pub(super) struct MessageSearch { + in_flight: bool, + last: Option, + remembered: std::collections::VecDeque<(SearchScope, String, Vec)>, +} fn refused(why: impl std::fmt::Display) -> Refusal { Refusal::Refused(why.to_string()) @@ -332,4 +344,120 @@ impl Discord { items, }) } + + /// Runs a message search off the command thread, one at a time per account. + pub(super) fn search_queued( + discord: &Arc, + query: String, + scope: SearchScope, + at: Instant, + reply: Reply>, + ) { + let searching = discord.clone(); + let run = move || { + let _ = reply.send(searching.search_messages(&query, &scope, at)); + }; + if let Err(err) = std::thread::Builder::new().name("discord-search".into()).spawn(run) { + eprintln!("chatd: Discord: no thread for a search: {err}"); + } + } + + /// What the network itself finds for the pane's words, from Discord's own search: the + /// server's when a Space is the scope, the channel's when one conversation is. The hits + /// are mapped like the cache's, so the pane can merge the two lists and tell them apart. + pub(super) fn search_messages(&self, query: &str, scope: &SearchScope, at: Instant) -> Result, Refusal> { + if at.elapsed() >= super::SEARCH_PATIENCE { + return Err(refused("That took too long. Try again.")); + } + let capped: String = query.chars().take(SEARCH_MAX_CHARS).collect(); + let query = capped.trim(); + let key = query.to_lowercase(); + let path = match scope { + SearchScope::Space(space) => { + // A Space's id is a guild's: one the account is in, as space_info checks. + if !crate::hub::lock(&self.state).guilds.contains_key(space) { + return Err(refused("This server is gone.")); + } + format!("/guilds/{space}/messages/search?content={}", escaped(query)) + } + SearchScope::Conversation(channel) => { + let known = self.ctx.known(channel).map_err(refused)?.ok_or_else(|| refused("This conversation is gone."))?; + match self.guild_of(&known) { + Some(guild) => { + format!("/guilds/{guild}/messages/search?channel_id={channel}&content={}", escaped(query)) + } + None => format!("/channels/{channel}/messages/search?content={}", escaped(query)), + } + } + }; + // One search in flight per account, and at least SEARCH_GAP after the previous finished: + // no client turns Enter into a burst against Discord's tight search limits. + { + let mut search = crate::hub::lock(&self.message_search); + if let Some((_, _, hits)) = search.remembered.iter().find(|(s, q, _)| s == scope && *q == key) { + return Ok(hits.clone()); + } + if search.in_flight || search.last.is_some_and(|last| last.elapsed() < super::members::SEARCH_GAP) { + return Err(refused("Discord search is paced. Try again in a moment.")); + } + search.in_flight = true; + } + let outcome = self.run_message_search(&path, query, scope); + let mut search = crate::hub::lock(&self.message_search); + search.in_flight = false; + search.last = Some(Instant::now()); + if let Ok(hits) = &outcome { + if search.remembered.len() >= REMEMBERED_SEARCHES { + search.remembered.pop_front(); + } + search.remembered.push_back((scope.clone(), key, hits.clone())); + } + outcome + } + + fn run_message_search(&self, path: &str, query: &str, scope: &SearchScope) -> Result, Refusal> { + let answer = self.call("GET", path, None).map_err(refusal)?; + let found: Found = serde_json::from_str(&answer).map_err(refused)?; + // A 202 with no hits: the index is still being built. + let Some(hits) = found.messages.filter(|_| found.code.is_none()) else { + return Err(refused("Discord search isn't ready for this server yet. Try again in a moment.")); + }; + let space_id = match scope { + SearchScope::Space(space) => Some(space.clone()), + SearchScope::Conversation(channel) => { + self.ctx.known(channel).ok().flatten().and_then(|known| self.guild_of(&known)) + } + }; + let mut found_hits = Vec::new(); + for posted in hits.iter().filter_map(|hit| hit.first()) { + // Hidden conversations and message requests stay out of it, as they stay out of + // the cache's own search. + if !self.ctx.listable(&posted.channel_id) { + continue; + } + let known = self.ctx.known(&posted.channel_id).ok().flatten(); + let kind = known.as_ref().map_or(ConversationKind::Channel, |row| row.kind); + let title = known.as_ref().map_or_else(String::new, |row| row.title.clone()); + let space_title = known.as_ref().map_or_else(String::new, |row| row.space_title.clone()); + let (message, _) = self.convert(posted, space_id.as_deref(), kind); + let text = if !message.text.is_empty() { + message.text + } else if let Some(first) = message.attachments.first() { + if first.name.is_empty() { "Attachment".to_owned() } else { first.name.clone() } + } else { + posted.content.clone().unwrap_or_default() + }; + found_hits.push(MessageHit { + conversation: self.ctx.key(&posted.channel_id), + message_id: posted.id.clone(), + sent: model::seconds(&posted.id), + sender: message.sender.name, + snippet: crate::store::snippet(&text, query), + title, + space_title, + outgoing: message.outgoing, + }); + } + Ok(found_hits) + } } diff --git a/crates/chatd/src/adapters/discord/members.rs b/crates/chatd/src/adapters/discord/members.rs index 7ec64e0..6453218 100644 --- a/crates/chatd/src/adapters/discord/members.rs +++ b/crates/chatd/src/adapters/discord/members.rs @@ -33,7 +33,7 @@ const LINGER: Duration = Duration::from_secs(600); /// A server's members are searched (op 8) at most once in this long. const SEARCH_AGAIN: Duration = Duration::from_secs(30); /// And the account as a whole at most once in this long. -const SEARCH_GAP: Duration = Duration::from_millis(400); +pub(super) const SEARCH_GAP: Duration = Duration::from_millis(400); /// Searches remembered as answered, so the same words are never sent twice in a session. const SEARCHED: usize = 512; diff --git a/crates/chatd/src/adapters/mod.rs b/crates/chatd/src/adapters/mod.rs index 0787624..95d1c32 100644 --- a/crates/chatd/src/adapters/mod.rs +++ b/crates/chatd/src/adapters/mod.rs @@ -33,8 +33,8 @@ use crate::store::{ }; use protocol::{ AccountKey, Activity, CallFailure, CallState, Capabilities, CatalogItem, CatalogKind, Change, - Contact, Group, Key, Link, MediaFile, MemberSection, Message, Network, Notify, Occupant, - Occupants, Outgoing, Participant, Person, Presence, Reaction, RelateAction, Relation, + Contact, Group, Key, Link, MediaFile, MemberSection, Message, MessageHit, Network, Notify, + Occupant, Occupants, Outgoing, Participant, Person, Presence, Reaction, RelateAction, Relation, SendState, SharedItem, SharedKind, Typist, VoiceJoin, Warning, Watching, }; use std::collections::{HashMap, VecDeque}; @@ -63,6 +63,13 @@ pub struct Download { pub headers: Vec<(String, String)>, } +/// What the search's ask names: a Space's own search, or one conversation's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SearchScope { + Space(String), + Conversation(String), +} + /// One window of a member list. #[derive(Debug, Clone)] pub struct Members { @@ -305,6 +312,15 @@ pub enum Command { }, /// A registered file is wanted: answer with where and how to fetch it now. Download { id: String, url: String, reply: Reply }, + /// The pane's search asked the network itself: a Space's own search, or one conversation's. + /// Answer with the hits, mapped like the cache's, or why not. `at` is when the shell + /// asked: one queued past the shell's patience does not ask the network at all. + Search { + query: String, + scope: SearchScope, + at: Instant, + reply: Reply>, + }, /// The shell asked for these files and the media cache knows none of them. An adapter that /// fetches files over its own session (Telegram) fetches those that are its own, such as the /// original a picture's viewer opened, and records each as the cache would. Others ignore it. @@ -356,6 +372,7 @@ impl Command { // Nothing to end on the network: the session is simply forgotten. Command::SignOut(reply) => drop(reply.send(Ok(()))), Command::Download { reply, .. } => drop(reply.send(Err(Refusal::Unsupported))), + Command::Search { reply, .. } => drop(reply.send(Err(Refusal::Unsupported))), Command::Send { conversation_id, client_id, .. } => { ctx.sent(&conversation_id, &client_id, Err("Sending is not built for this network.".to_owned())); } @@ -433,6 +450,13 @@ impl Context { Key::new(&self.account, id) } + /// Whether a conversation may appear where the user asked for what is listed: hidden + /// conversations and message requests stay out of it, as they stay out of the cache's own + /// search. A conversation the store does not know is not listed. + pub fn listable(&self, conversation_id: &str) -> bool { + self.hub.store().conversation_listed(&self.key(conversation_id)).unwrap_or_default() + } + // The account. /// The socket is up (`Connected`) or down (`Offline`). diff --git a/crates/chatd/src/demo/adapter.rs b/crates/chatd/src/demo/adapter.rs index d83e4d9..98e7539 100644 --- a/crates/chatd/src/demo/adapter.rs +++ b/crates/chatd/src/demo/adapter.rs @@ -656,6 +656,8 @@ impl Demo { Command::SearchShared { conversation_id, kind, before, limit, reply } => { let _ = reply.send(demo.search_shared(&conversation_id, kind, before.as_deref(), limit)); } + // The demo's words are all in the cache: its network is not asked. + Command::Search { reply, .. } => drop(reply.send(Err(Refusal::Unsupported))), Command::Catalog { kind, query, cursor, reply, .. } => drop(reply.send(Ok(demo.catalog(kind, &query, cursor.as_deref())))), Command::ReactionRules { conversation_id, reply } => drop(reply.send(Ok(demo.reaction_rules(&conversation_id)))), Command::SendWith { conversation_id, client_id, text, reply_to, with } => { diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index 63325a5..f155a2e 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -1,4 +1,4 @@ -use crate::adapters::{self, About, Adapter, Command, Refusal, Reply}; +use crate::adapters::{self, About, Adapter, Command, Refusal, Reply, SearchScope}; use crate::blobs::Blobs; use crate::hub::{lock, now, Hub, KEYCHAIN_PROMPT}; use crate::media::CallError; @@ -1422,6 +1422,24 @@ impl Chat { Err(err) => internal(err), } } + Request::SearchNetwork { conversation, space, query } => { + let query = query.trim().to_owned(); + if query.is_empty() { + Response::MessageHits { hits: Vec::new() } + } else { + let (account, scope) = match (&conversation, &space) { + (Some(key), _) => (key.account(), SearchScope::Conversation(key.id.clone())), + (None, Some(key)) => (key.account(), SearchScope::Space(key.id.clone())), + (None, None) => { + return error(ErrorCode::Unsupported, "a search on the network names a conversation or a Space") + } + }; + match self.ask(&account, |reply| Command::Search { query, scope, at: std::time::Instant::now(), reply }) { + Ok(hits) => Response::MessageHits { hits }, + Err(refused) => *refused, + } + } + } Request::SetDraft { conversation, text } => match self.hub.store().set_draft(&conversation, &text) { Ok(true) => Response::Ok, Ok(false) => error(ErrorCode::NotFound, "no such conversation"), diff --git a/crates/chatd/src/store.rs b/crates/chatd/src/store.rs index 61f2406..817b666 100644 --- a/crates/chatd/src/store.rs +++ b/crates/chatd/src/store.rs @@ -505,7 +505,7 @@ fn like_prefix(query: &str) -> String { /// The text on one line, cut to a window that starts a little before the first match of /// `query`, at most 200 characters. Nothing marks the match here; the shell draws that. -fn snippet(text: &str, query: &str) -> String { +pub(crate) fn snippet(text: &str, query: &str) -> String { let flat = text.split_whitespace().collect::>().join(" "); let needle = query.to_lowercase(); // A lowercased byte index can sit off a boundary or past the end by a character or two @@ -1207,6 +1207,20 @@ impl Store { .optional() } + /// Whether a conversation may appear where the user asked for what is listed: hidden + /// conversations and message requests stay out of it, as they stay out of the cache's own + /// search. A conversation the store does not know is not listed. + pub fn conversation_listed(&self, conversation: &Key) -> rusqlite::Result { + self.db + .query_row( + "SELECT NOT c.hidden AND c.special != 'request' \ + FROM conversation c WHERE c.network = ?1 AND c.account = ?2 AND c.id = ?3", + params![word(&conversation.network), conversation.account, conversation.id], + |row| row.get::<_, bool>(0), + ) + .or_else(|err| if err == rusqlite::Error::QueryReturnedNoRows { Ok(false) } else { Err(err) }) + } + /// What the Inbox holds (`IN_INBOX`, said in words on `Request::Inbox`). All: starred /// first, then newest first. Unread and Mentions: mentions first, then newest. Also how /// many rows match in all. diff --git a/crates/chatd/tests/discord.rs b/crates/chatd/tests/discord.rs index 21387a0..0d13a92 100644 --- a/crates/chatd/tests/discord.rs +++ b/crates/chatd/tests/discord.rs @@ -269,14 +269,29 @@ fn respond(shared: &Shared, seen: &Seen) -> (u16, Value) { } ("GET", ["guilds", _, "messages", "search"]) | ("GET", ["channels", _, "messages", "search"]) => { let channel = if parts[0] == "channels" { parts[1].to_owned() } else { "4101".to_owned() }; - let hit = json!({ - "id": "1300000000000000600", "channel_id": channel, "type": 0, "author": me(), "content": "the map", - "timestamp": "2026-10-13T08:15:00.000000+00:00", "edited_timestamp": null, "mentions": [], "mention_roles": [], - "mention_everyone": false, "embeds": [], "pinned": false, - "attachments": [{"id": "9001", "filename": "map.png", "size": 2048, "url": "https://cdn.discordapp.com/attachments/1/9001/map.png", - "proxy_url": "https://media.discordapp.net/attachments/1/9001/map.png", "width": 640, "height": 480, "content_type": "image/png"}], - }); - (200, json!({"analytics_id": "a", "doing_deep_historical_index": false, "total_results": 37, "messages": [[hit]]})) + // The pane's words: text hits, and a 202 while the words ask for one being indexed. + if seen.path.contains("content=") { + if seen.path.contains("index") { + return (202, json!({"code": 110000, "message": "Index not yet available"})); + } + let hit = json!({ + "id": "1300000000000000600", "channel_id": channel, "type": 0, + "author": {"id": "200000000000000001", "username": "Ines Marchetti", "global_name": "Ines Marchetti", "discriminator": "0", "avatar": null}, + "content": "read the rules, they are short", + "timestamp": "2026-10-13T08:15:00.000000+00:00", "edited_timestamp": null, "mentions": [], "mention_roles": [], + "mention_everyone": false, "embeds": [], "pinned": false, "attachments": [], + }); + (200, json!({"analytics_id": "a", "doing_deep_historical_index": false, "total_results": 1, "messages": [[hit]]})) + } else { + let hit = json!({ + "id": "1300000000000000600", "channel_id": channel, "type": 0, "author": me(), "content": "the map", + "timestamp": "2026-10-13T08:15:00.000000+00:00", "edited_timestamp": null, "mentions": [], "mention_roles": [], + "mention_everyone": false, "embeds": [], "pinned": false, + "attachments": [{"id": "9001", "filename": "map.png", "size": 2048, "url": "https://cdn.discordapp.com/attachments/1/9001/map.png", + "proxy_url": "https://media.discordapp.net/attachments/1/9001/map.png", "width": 640, "height": 480, "content_type": "image/png"}], + }); + (200, json!({"analytics_id": "a", "doing_deep_historical_index": false, "total_results": 37, "messages": [[hit]]})) + } } ("GET", ["guilds", guild]) => ( 200, @@ -2868,6 +2883,54 @@ fn info_mute_leave_pins_and_the_shared_search_ask_discord_once_each_and_only_whe assert!(world.discord.requests_to("GET", "/guilds/4000/messages/search")[1].path.contains("max_id=1300000000000000599")); } +#[test] +fn search_asks_discord_itself_for_a_space_and_a_conversation_and_tells_of_an_index_still_building() { + let world = polished(); + // A Space's own search: the server's, one request, the hits mapped like the cache's. + let hits = match world.ask(Request::SearchNetwork { conversation: None, space: Some(world.key("4000")), query: "rules".into() }) { + Response::MessageHits { hits } => hits, + other => panic!("{other:?}"), + }; + assert_eq!(hits.len(), 1); + assert_eq!( + (hits[0].message_id.as_str(), hits[0].sender.as_str(), hits[0].snippet.as_str()), + ("1300000000000000600", "Ines Marchetti", "read the rules, they are short") + ); + assert_eq!(hits[0].conversation, world.key("4101")); + assert!(!hits[0].outgoing); + let asked = world.discord.requests_to("GET", "/guilds/4000/messages/search"); + assert!(asked[0].path.contains("content=rules"), "{}", asked[0].path); + + // The searches are paced per account, as people search is: a scope switch waits its gap. + sleep(Duration::from_millis(400)); + + // A server channel as the conversation: the server's search narrowed to the channel. + let before = world.discord.requests_to("GET", "/guilds/4000/messages/search").len(); + let answer = world.ask(Request::SearchNetwork { conversation: Some(world.key("4101")), space: None, query: "hello".into() }); + assert!(matches!(answer, Response::MessageHits { .. }), "{answer:?}"); + let asked = world.discord.requests_to("GET", "/guilds/4000/messages/search"); + assert_eq!(asked.len(), before + 1, "the channel search is asked"); + assert!(asked[asked.len() - 1].path.contains("channel_id=4101") && asked[asked.len() - 1].path.contains("content=hello"), "{}", asked[asked.len() - 1].path); + + // One conversation: the DM's own channel search. + sleep(Duration::from_millis(400)); + let before = world.discord.requests_to("GET", "/channels/3001/messages/search").len(); + let answer = world.ask(Request::SearchNetwork { conversation: Some(world.key("3001")), space: None, query: "hello".into() }); + assert!(matches!(answer, Response::MessageHits { .. }), "{answer:?}"); + let asked = world.discord.requests_to("GET", "/channels/3001/messages/search"); + assert_eq!(asked.len(), before + 1, "the DM's channel search is asked"); + assert!(asked[asked.len() - 1].path.contains("content=hello"), "{}", asked[asked.len() - 1].path); + + // A 202: the index is still building, and the words say so. + sleep(Duration::from_millis(400)); + let not_ready = world.ask(Request::SearchNetwork { conversation: None, space: Some(world.key("4000")), query: "index".into() }); + assert!(matches!(¬_ready, Response::Error { detail, .. } if detail.contains("isn't ready")), "{not_ready:?}"); + + // Paced: Enter again at once is refused in words, so no client can turn it into a burst. + let paced = world.ask(Request::SearchNetwork { conversation: None, space: Some(world.key("4000")), query: "again".into() }); + assert!(matches!(&paced, Response::Error { detail, .. } if detail.contains("paced")), "{paced:?}"); +} + /// A file of `size` bytes beside the world's directory: chatd never sends from its own. fn staged(world: &World, name: &str, size: usize) -> String { let dir = world.dir.with_extension("files"); diff --git a/crates/chatd/tests/slack.rs b/crates/chatd/tests/slack.rs index a26bf5b..99a3f1e 100644 --- a/crates/chatd/tests/slack.rs +++ b/crates/chatd/tests/slack.rs @@ -2190,6 +2190,17 @@ fn info_mute_pins_files_emoji_threads_and_a_scheduled_send_ask_slack_once_each() assert!(world.fake.calls(ALPHA, "chat.postMessage").is_empty()); } +#[test] +fn search_asks_the_network_only_where_it_can() { + let world = World::start(); + world.ready(); + // A Space on Slack: the adapter cannot search messages yet, and says so in words. Nothing + // is asked of the fake. + let refused = world.ask(Request::SearchNetwork { conversation: None, space: Some(alpha("T01ALPHA")), query: "x".into() }); + assert!(matches!(&refused, Response::Error { detail, .. } if detail.contains("cannot do this yet")), "{refused:?}"); + assert!(world.fake.calls(ALPHA, "search.all").is_empty() && world.fake.calls(ALPHA, "search.messages").is_empty()); +} + #[test] fn files_go_up_from_their_paths_and_the_socket_names_the_message() { let world = World::start(); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 7136610..64a6ea9 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -998,6 +998,17 @@ pub enum Request { query: String, limit: u32, }, + /// Ask the network itself for messages, as the pane's Enter does: a server's own search + /// when a Space is the scope, the channel's when one conversation is. The hits are mapped + /// like the cache's, so the pane can merge the two lists and tell them apart; a network + /// that cannot search here answers why. + SearchNetwork { + #[serde(default, skip_serializing_if = "Option::is_none")] + conversation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + space: Option, + query: String, + }, /// Keeps what the user typed and did not send. Empty text forgets it. Local: no network /// hears of it and no change is announced. SetDraft { @@ -1778,6 +1789,21 @@ mod tests { serde_json::to_string(&Response::MessageHits { hits: vec![hit] }).unwrap(), r#"{"op":"message_hits","hits":[{"conversation":{"network":"discord","account":"1","id":"C2"},"message_id":"9","sent":20,"sender":"mara","snippet":"hello world"}]}"# ); + // The network's own ask: the scope is a conversation or a Space, the words ride along. + let ask: Request = serde_json::from_str(r#"{"op":"search_network","query":"lichen"}"#).unwrap(); + let Request::SearchNetwork { conversation, space, query } = &ask else { + panic!("not a network search") + }; + assert!(conversation.is_none() && space.is_none() && query == "lichen"); + let asked = Request::SearchNetwork { + conversation: Some(Key { network: Network::Discord, account: "1".into(), id: "C2".into() }), + space: None, + query: "lichen".into(), + }; + assert_eq!( + serde_json::to_string(&asked).unwrap(), + r#"{"op":"search_network","conversation":{"network":"discord","account":"1","id":"C2"},"query":"lichen"}"# + ); } #[test] diff --git a/crates/tinyclient/src/gui/conversation/side.rs b/crates/tinyclient/src/gui/conversation/side.rs index 534012b..7205b56 100644 --- a/crates/tinyclient/src/gui/conversation/side.rs +++ b/crates/tinyclient/src/gui/conversation/side.rs @@ -17,7 +17,7 @@ use crate::gui::theme::{self, Type}; use crate::gui::time; use crate::view::{age, now}; use gpui::{div, prelude::*, px, AnyElement, Context, Entity, Focusable, FontWeight, HighlightStyle, MouseButton, SharedString, StyledText, Window}; -use protocol::{ConversationKind, Key, Link, Message, MessageHit, Request, Response}; +use protocol::{ConversationKind, Key, Link, Message, MessageHit, Network, Request, Response}; use std::ops::Range; use std::rc::Rc; @@ -62,10 +62,27 @@ pub struct SearchPage { pub hits: Option>, /// The cache could not be asked. pub failed: bool, - /// The hit the keyboard is on: drawn lit, opened on Enter. - pub picked: Option, + /// The hit the keyboard is on (`(conversation, message_id)`): drawn lit, opened on Enter. + pub picked: Option<(Key, String)>, /// How often the cache was asked, so an older answer never lands over a newer one. asked: u32, + /// The shell's ask count the network ask last carried, so a word or scope change drops a + /// late answer on the way. + network_asked: u32, + /// The words the network ask carried: Enter asks once for them while in flight or after a + /// successful answer, until they or the scope change. + network_asked_for: Option, + /// Where the network is being asked right now, while an ask is in flight. + pub network_asking: Option, + /// What the network answered on Enter, with where it was asked. `None` until asked, gone + /// when the words or the scope change. + pub network: Option, +} + +/// What the network said on Enter: where that was, and its hits, or its word for why not. +pub struct NetworkFound { + pub where_at: String, + pub result: Result, String>, } /// How far a search asks: the conversation open now, its Space, or the whole cache. @@ -113,6 +130,71 @@ fn fresh_page(scope: Scope, words: &str) -> SearchPage { SearchPage { scope, words: words.to_owned(), ..SearchPage::default() } } +/// The user's word for what went wrong asking the network. +fn asked_words(code: protocol::ErrorCode, detail: &str) -> String { + if detail.contains("does not understand") { + crate::client::OTHER_ANSWER.to_owned() + } else if detail == "the network did not answer" { + "The network did not answer. Try again.".to_owned() + } else if let Some(network) = detail.strip_suffix(" is not signed in") { + format!("{network} isn't signed in.") + } else { + crate::client::refusal_words(code, detail) + } +} + +impl SearchPage { + /// The hits as listed: the cache's and the network's own merged by message id — the + /// network's not doubling what the cache had — newest first, each with whether the cache + /// said it. + fn listed(&self) -> Vec<(&MessageHit, bool)> { + let cache = self.hits.as_deref().unwrap_or_default(); + let mut listed: Vec<(&MessageHit, bool)> = cache.iter().map(|hit| (hit, true)).collect(); + if let Some(Ok(found)) = self.network.as_ref().map(|found| &found.result) { + let seen: std::collections::HashSet<(&Key, &str)> = + listed.iter().map(|(hit, _)| (&hit.conversation, hit.message_id.as_str())).collect(); + listed.extend( + found + .iter() + .filter(|hit| !seen.contains(&(&hit.conversation, hit.message_id.as_str()))) + .map(|hit| (hit, false)), + ); + } + listed.sort_by_key(|(hit, _)| std::cmp::Reverse(hit.sent)); + listed + } + + fn reset_network(&mut self) { + self.network = None; + self.network_asking = None; + self.network_asked_for = None; + self.network_asked = 0; + } + + /// Moves the keyboard pick up or down in the merged list. Up from row 0 clears the pick so + /// Enter can ask the network again. + fn move_pick(&mut self, down: bool) { + let listed = self.listed(); + if listed.is_empty() { + return; + } + let current = self.picked.as_ref().and_then(|(conv, id)| { + listed.iter().position(|(hit, _)| &hit.conversation == conv && hit.message_id == *id) + }); + let next = match (current, down) { + (Some(at), true) => Some((at + 1).min(listed.len() - 1)), + (Some(0), false) => None, + (Some(at), false) => Some(at - 1), + (None, true) => Some(0), + (None, false) => Some(listed.len() - 1), + }; + self.picked = next.map(|ix| { + let (hit, _) = listed[ix]; + (hit.conversation.clone(), hit.message_id.clone()) + }); + } +} + /// The letters of the query inside a hit's text, for the heavier drawing. fn matched(text: &str, words: &str) -> Vec> { let (lower, needle) = (text.to_lowercase(), words.to_lowercase()); @@ -442,6 +524,7 @@ impl Shell { page.scope = scope; page.hits = None; page.picked = None; + page.reset_network(); } self.reask_search(cx); if let Some(field) = &self.conversation_state.side.search_input { @@ -465,7 +548,7 @@ impl Shell { let input = cx.new(|cx| TextInput::new("Search messages", cx)); let changed = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, _, cx| match event { InputEvent::Changed => this.search_typed(cx), - InputEvent::Submit => this.open_picked_hit(cx), + InputEvent::Submit => this.enter_search(cx), }); let keys = cx.subscribe_in(&input, window, |this, _, event: &InputKey, window, cx| match event { InputKey::Escape => this.close_search(window, cx), @@ -522,6 +605,7 @@ impl Shell { page.words = words; page.hits = None; page.picked = None; + page.reset_network(); self.reask_search(cx); } @@ -541,6 +625,7 @@ impl Shell { if words.trim().is_empty() { if let Some(page) = self.conversation_state.side.search.as_mut() { page.hits = None; + page.reset_network(); } cx.notify(); return; @@ -582,35 +667,120 @@ impl Shell { Some((key, space)) } - /// The keyboard moves over the hits; Enter opens the one it is on, or the first. - fn move_search_pick(&mut self, down: bool, cx: &mut Context) { - let Some(page) = self.conversation_state.side.search.as_mut() else { - return; + /// Enter in the field: with a hit picked, open it; otherwise the network itself is asked + /// when it can search here, or the first hit opens. + fn enter_search(&mut self, cx: &mut Context) { + let (scope, first, picked) = match self.conversation_state.side.search.as_ref() { + Some(page) => { + let listed = page.listed(); + let first = listed.first().map(|(hit, _)| (*hit).clone()); + let picked = page.picked.as_ref().and_then(|(conv, id)| { + listed.iter().find(|(hit, _)| &hit.conversation == conv && hit.message_id == *id).map(|(hit, _)| (*hit).clone()) + }); + (page.scope, first, picked) + } + None => return, }; - let Some(hits) = page.hits.as_ref() else { + if let Some(hit) = picked { + self.open_search_hit(&hit, cx); + return; + } + let can_ask_network = scope != Scope::Everywhere + && self.open_key().is_some_and(|key| key.network == Network::Discord); + if !can_ask_network { + if let Some(hit) = first { + self.open_search_hit(&hit, cx); + } + return; + } + self.ask_network(cx); + } + + /// Asks the network itself for the page's words: a Space's own search, or the open + /// conversation's channel search. + fn ask_network(&mut self, cx: &mut Context) { + let Some(page) = self.conversation_state.side.search.as_ref() else { return; }; - if hits.is_empty() { + let (words, scope) = (page.words.clone(), page.scope); + let trimmed: String = words.trim().chars().take(1024).collect(); + if trimmed.is_empty() { return; } - page.picked = Some(match page.picked { - Some(at) if down => (at + 1).min(hits.len() - 1), - Some(at) => at.saturating_sub(1), - None if down => 0, - None => hits.len() - 1, - }); + // The same words in the same scope are asked once while in flight or after a + // successful answer: Enter again, key repeats included, changes nothing until the + // words or the scope change. + if page.network_asked_for.as_deref() == Some(trimmed.as_str()) { + return; + } + let (conversation, space, where_at) = match scope { + Scope::Conversation => { + let Some(open) = self.snapshot.as_ref().and_then(|s| s.open.as_ref()) else { + return; + }; + let key = open.key.clone(); + let name = if open.title.is_empty() { key.id.clone() } else { open.title.clone() }; + let where_at = format!("{} · {}", key.network.label(), name); + (Some(key), None, where_at) + } + Scope::Space => { + let Some((key, Some(space))) = self.open_with_space() else { + return; + }; + let known = self + .spaces + .iter() + .find(|known| known.key.network == key.network && known.key.account == key.account && known.key.id == space) + .map_or_else(String::new, |known| known.name.clone()); + let where_at = format!("{} · {}", key.network.label(), if known.is_empty() { space.clone() } else { known }); + let space = Key { network: key.network, account: key.account.clone(), id: space }; + (None, Some(space), where_at) + } + Scope::Everywhere => return, + }; + let asked = { + let side = &mut self.conversation_state.side; + side.search_asks += 1; + if let Some(page) = side.search.as_mut() { + page.network_asked = side.search_asks; + page.network_asked_for = Some(trimmed.clone()); + page.network_asking = Some(where_at.clone()); + } + side.search_asks + }; cx.notify(); + let request = Request::SearchNetwork { conversation, space, query: trimmed }; + self.off_thread( + cx, + move || ask(&request), + move |this, answer, cx| { + let Some(page) = this.conversation_state.side.search.as_mut().filter(|page| page.network_asked == asked) else { + return; + }; + page.network_asking = None; + let result = match answer { + Ok(Response::MessageHits { hits }) => Ok(hits), + Ok(Response::Error { code, detail }) => Err(asked_words(code, &detail)), + Ok(_) => Err(crate::client::OTHER_ANSWER.to_owned()), + Err(err) => Err(crate::client::trouble_words(&err)), + }; + if result.is_err() { + page.network_asked_for = None; + } + page.network = Some(NetworkFound { where_at, result }); + cx.notify(); + }, + ); } - fn open_picked_hit(&mut self, cx: &mut Context) { - let page = self.conversation_state.side.search.as_ref(); - let picked = page - .and_then(|page| page.hits.as_ref()) - .and_then(|hits| hits.get(page.and_then(|page| page.picked).unwrap_or(0))) - .cloned(); - if let Some(hit) = picked { - self.open_search_hit(&hit, cx); - } + /// The keyboard moves over the hits; Up from row 0 steps off the list so Enter can ask + /// the network again. + fn move_search_pick(&mut self, down: bool, cx: &mut Context) { + let Some(page) = self.conversation_state.side.search.as_mut() else { + return; + }; + page.move_pick(down); + cx.notify(); } /// A search hit was clicked: its conversation opens on the message, or, being open already, @@ -928,6 +1098,17 @@ fn draw_search(shell: &Shell, _window: &mut Window, cx: &mut Context) -> .child(candidate.label()), ); } + // What the network said on Enter: a line that names what is being searched, what was + // searched, or its word for why nothing came. + let said = if let Some(where_at) = &page.network_asking { + Some(format!("Asking {where_at}…")) + } else { + page.network.as_ref().map(|found| match &found.result { + Ok(_) => format!("Searched {}.", found.where_at), + Err(why) => why.clone(), + }) + }; + let listed = page.listed(); let body = if page.words.trim().is_empty() { empty_state("icons/search.svg", "Search the cache.", "What was said in what tinyclient has read.").into_any_element() } else if page.failed { @@ -935,14 +1116,25 @@ fn draw_search(shell: &Shell, _window: &mut Window, cx: &mut Context) -> .into_any_element() } else if page.hits.is_none() { empty_state("icons/search.svg", "Asking the cache…", "The hits land here as they are read.").into_any_element() - } else if page.hits.as_deref().is_none_or(|hits| hits.is_empty()) { - empty_state("icons/search.svg", "Nothing in the cache yet.", "The networks are not asked yet.").into_any_element() + } else if listed.is_empty() { + let why = if page.network_asking.is_some() { + "Waiting for the network's answer." + } else { + match page.network.as_ref().map(|found| &found.result) { + Some(Ok(_)) => "The network found nothing here.", + Some(Err(why)) => why.as_str(), + None => "The networks are not asked yet.", + } + }; + empty_state("icons/search.svg", "Nothing in the cache yet.", why).into_any_element() } else { - let hits = page.hits.as_deref().unwrap_or_default(); let mut rows = div().id("search-hits").flex_1().min_h_0().overflow_y_scroll().flex().flex_col().p(px(4.)); - for (ix, hit) in hits.iter().enumerate() { - let clicked = hit.clone(); - let picked = page.picked == Some(ix); + for (ix, (hit, cached)) in listed.iter().enumerate() { + let clicked = (*hit).clone(); + let picked = page + .picked + .as_ref() + .is_some_and(|(conv, id)| conv == &hit.conversation && *id == hit.message_id); rows = rows.child( div() .id(("hit", ix)) @@ -958,7 +1150,7 @@ fn draw_search(shell: &Shell, _window: &mut Window, cx: &mut Context) -> .hover(|style| style.bg(theme::hover())) .on_click(cx.listener(move |this, _, _, cx| { if let Some(page) = this.conversation_state.side.search.as_mut() { - page.picked = Some(ix); + page.picked = Some((clicked.conversation.clone(), clicked.message_id.clone())); } this.open_search_hit(&clicked, cx); })) @@ -987,7 +1179,9 @@ fn draw_search(shell: &Shell, _window: &mut Window, cx: &mut Context) -> .text_color(theme::text_soft()) .child(StyledText::new(SharedString::from(hit.snippet.clone())).with_highlights(highlights)) }) - .child(div().flex_none().type_micro().text_color(theme::text_soft()).child("cached")), + .when(*cached, |row| { + row.child(div().flex_none().type_micro().text_color(theme::text_soft()).child("cached")) + }), ), ); } @@ -1000,6 +1194,7 @@ fn draw_search(shell: &Shell, _window: &mut Window, cx: &mut Context) -> .bg(theme::canvas()) .child(head) .child(chips) + .children(said.map(|line| div().flex_none().px(px(12.)).pb(px(4.)).type_small().text_color(theme::text_soft()).child(line))) .child(body) .into_any_element() } @@ -1146,4 +1341,39 @@ mod tests { assert_eq!((page.scope, page.words.as_str()), (Scope::Everywhere, "rules")); assert!(page.hits.is_none() && page.picked.is_none()); } + + #[test] + fn the_networks_hits_join_the_cache_s_and_the_picked_message_keeps_its_identity() { + let mut page = SearchPage { words: "rules".into(), ..SearchPage::default() }; + let hit_of = |id: &str, sent: i64, conversation: &Key| MessageHit { + conversation: conversation.clone(), + message_id: id.into(), + sent, + sender: "mara".into(), + snippet: "the rules".into(), + title: String::new(), + space_title: String::new(), + outgoing: false, + }; + page.hits = Some(vec![hit_of("1", 20, &key("C_a"))]); + assert_eq!(page.listed().iter().map(|(hit, cached)| (hit.message_id.as_str(), *cached)).collect::>(), [("1", true)]); + // Picking the first row records its (conversation, message_id), and Up from row 0 clears it + // so Enter can ask the network again. + page.move_pick(true); + assert_eq!(page.picked, Some((key("C_a"), "1".into()))); + page.move_pick(false); + assert_eq!(page.picked, None); + page.move_pick(true); + // The network's own join newest first, and the one the cache already had dropped by id; + // the picked message stays "1" even when a newer network hit lands above it. + page.network = Some(NetworkFound { + where_at: "Discord · Moss & Lichen".into(), + result: Ok(vec![hit_of("1", 20, &key("C_a")), hit_of("2", 25, &key("C_b")), hit_of("3", 15, &key("C_c"))]), + }); + assert_eq!( + page.listed().iter().map(|(hit, cached)| (hit.message_id.as_str(), *cached)).collect::>(), + [("2", false), ("1", true), ("3", false)] + ); + assert_eq!(page.picked, Some((key("C_a"), "1".into()))); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 601d6e1..64ba498 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ How the processes fit together and who may edit what. The contract itself is the | `InVoice {network}`, `Around {network}` | `Conversations`; `Around {requests, in_call, online, recent}` | hub (occupants, presence, activity) and store | | `Find {network, query, limit}` | `Conversations` (title contains, newest first, cache only) | store | | `SearchMessages {conversation, network, space, query, limit}` | `MessageHits {hits (where, snippet, sender, title)}` (text contains, newest first, cache only) | store | +| `SearchNetwork {conversation, space, query}` | `MessageHits` (the network's own hits, mapped like the cache's) | `Command::Search` (`Unsupported` until built; Discord: the server's search for a Space, the channel's for one conversation, 202 while indexing) | | `Spaces {network}`, `Tree {space, offset, limit, peek}` | `Spaces`; `Tree {groups (kind, count), conversations, total}` | store; `Command::OpenSpace` | | `Conversation`, `Collapse`, `Star`, `SetDraft` | `Conversation`; `Ok` | store (local only) | | `People {network, relation, query, offset, limit}` | `People {people, total}` | store; `Command::SearchPeople`, debounced |