Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions crates/chatd/src/adapters/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<info::MessageSearch>,
}

/// The user id a token starts with (its first part is the id in base64), so the account is named
Expand Down Expand Up @@ -253,6 +259,7 @@ pub fn start(ctx: Context, session: &str, inbox: Receiver<Command>) -> 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()));
Expand Down Expand Up @@ -283,6 +290,17 @@ pub fn start(ctx: Context, session: &str, inbox: Receiver<Command>) -> 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),
Expand Down Expand Up @@ -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()))
}
Expand Down
134 changes: 131 additions & 3 deletions crates/chatd/src/adapters/discord/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<Instant>,
remembered: std::collections::VecDeque<(SearchScope, String, Vec<MessageHit>)>,
}

fn refused(why: impl std::fmt::Display) -> Refusal {
Refusal::Refused(why.to_string())
Expand Down Expand Up @@ -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<Discord>,
query: String,
scope: SearchScope,
at: Instant,
reply: Reply<Vec<MessageHit>>,
) {
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<Vec<MessageHit>, 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<Vec<MessageHit>, 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)
}
}
2 changes: 1 addition & 1 deletion crates/chatd/src/adapters/discord/members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
28 changes: 26 additions & 2 deletions crates/chatd/src/adapters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Download> },
/// 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<Vec<MessageHit>>,
},
/// 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.
Expand Down Expand Up @@ -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()));
}
Expand Down Expand Up @@ -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`).
Expand Down
2 changes: 2 additions & 0 deletions crates/chatd/src/demo/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } => {
Expand Down
20 changes: 19 additions & 1 deletion crates/chatd/src/server.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"),
Expand Down
16 changes: 15 additions & 1 deletion crates/chatd/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>().join(" ");
let needle = query.to_lowercase();
// A lowercased byte index can sit off a boundary or past the end by a character or two
Expand Down Expand Up @@ -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<bool> {
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.
Expand Down
Loading
Loading