use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{self, Stdio};
use std::time::{Duration, SystemTime};
use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use futures::StreamExt;
use ratatui::DefaultTerminal;
use ratatui::style::Style;
use ratatui::widgets::Block;
use ratatui_textarea::{DataCursor, TextArea};
use tdlib_rs::enums::{
AuthenticationCodeType, AuthorizationState, ChatList, ChatMemberStatus, MessageSender,
NotificationType, OptionValue, Update, UserType,
};
use tdlib_rs::types::{Message, UpdateNotificationGroup};
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::time::{Instant, sleep_until};
use crate::attach::{self, Attachment, Dropped};
use crate::chats::{Badge, Chats, Peer, Presence};
use crate::clipboard::{Clipboard, ClipboardEvent, Copied, Decoded, Paste, Pasted};
use crate::complete::{self, Commands, Completion, Kind, Suggestion};
use crate::config::{self, ApiKeys};
use crate::images::{ImageEvent, Images};
use crate::messages::{
Editable, Editing, Link, MediaFile, OpenChat, Replied, SendState, Sender, link_host,
};
use crate::notify::{self, Note, Notifications, Notifier};
use crate::picker::{self, ChatPicker, Choice, Purpose};
use crate::poll::{Vote, VoteMenu};
use crate::reactions::{self, ReactMenu, ReactionKind};
use crate::search::{self, MessageSearch};
use crate::settings::{self, Settings, Side};
use crate::stickers::{self, Source, StickerPanel};
use crate::text;
use crate::tg::{Deletable, EditText, Found, Invite, Missed, Page, Tagged, Tg, TgEvent};
use crate::theme::{Colors, Themes};
use crate::ui;
const CHAT_PAGE: i32 = 50;
const HISTORY_PAGE: i32 = 50;
const SEARCH_PAGE: i32 = 50;
const MIN_LOADED: usize = 30;
const LOAD_AHEAD: usize = 10;
const HALF_PAGE: isize = 10;
const CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
const TOAST_TIME: Duration = Duration::from_secs(2);
const CONFIRM_GRACE: Duration = Duration::from_millis(500);
const IDLE_AFTER: Duration = Duration::from_secs(60);
const AWAY_AFTER: Duration = Duration::from_secs(5 * 60);
const TYPING_EVERY: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, PartialEq, Eq)]
enum KeySource {
Env,
Saved,
BuiltIn,
}
pub enum Screen {
Login(Box<Login>),
Main,
}
pub enum LoginStep {
Connecting,
LoggingOut,
ApiId,
ApiHash {
id: i32,
},
Phone,
Code {
sent_via: &'static str,
},
Password {
hint: String,
},
Email,
EmailCode,
OtherDevice {
link: String,
},
Unsupported(&'static str),
}
pub struct Login {
pub step: LoginStep,
pub input: TextArea<'static>,
pub error: Option<String>,
pub busy: bool,
}
impl Login {
pub fn new(step: LoginStep) -> Self {
let mut input = TextArea::default();
input.set_block(Block::bordered());
input.set_cursor_line_style(Style::default());
if let LoginStep::Password { .. } = step {
input.set_mask_char('•');
}
Self {
step,
input,
error: None,
busy: false,
}
}
pub fn takes_input(&self) -> bool {
!matches!(
self.step,
LoginStep::Connecting
| LoginStep::LoggingOut
| LoginStep::OtherDevice { .. }
| LoginStep::Unsupported(_)
)
}
}
pub enum Target {
File(MediaFile),
Link(Link),
Text(String),
}
impl Target {
pub fn label(&self) -> &str {
match self {
Target::File(file) => &file.label,
Target::Link(link) => &link.url,
Target::Text(_) => "Whole message",
}
}
}
pub struct Confirm {
pub title: String,
pub lines: Vec<String>,
pub site: Option<String>,
pub badge: Option<Badge>,
pub action: Confirmed,
pub shown: Instant,
}
impl Confirm {
pub fn new(title: impl Into<String>, lines: Vec<String>, action: Confirmed) -> Self {
Self {
title: title.into(),
lines,
site: None,
badge: None,
action,
shown: Instant::now(),
}
}
}
pub enum Confirmed {
OpenFile(String),
OpenLink(String),
Edit {
id: i64,
text: String,
},
Join(i64),
JoinLink {
link: String,
request: String,
},
Leave(i64),
}
impl Confirmed {
pub fn verb(&self) -> &'static str {
match self {
Confirmed::OpenFile(_) | Confirmed::OpenLink(_) => "open",
Confirmed::Edit { .. } => "edit",
Confirmed::Join(_) | Confirmed::JoinLink { .. } => "join",
Confirmed::Leave(_) => "leave",
}
}
}
const SAFE_TO_OPEN: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif", "bmp", "tif", "tiff", "mp4",
"m4v", "mov", "mkv", "webm", "avi", "3gp", "mpg", "mpeg", "mp3", "m4a", "aac", "ogg", "oga",
"opus", "wav", "flac", "pdf", "txt", "md", "epub", "docx", "xlsx", "pptx", "odt", "ods", "odp",
"zip", "rar", "7z", "tar", "gz", "tgz",
];
fn safe_to_open(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| SAFE_TO_OPEN.contains(&e.to_ascii_lowercase().as_str()))
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MenuAction {
Open,
Copy,
}
pub struct PickMenu {
pub action: MenuAction,
pub targets: Vec<Target>,
pub selected: usize,
}
pub struct Toast {
pub title: String,
pub detail: String,
pub until: Instant,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeleteChoice {
Everyone,
OnlyMe,
}
impl DeleteChoice {
pub fn label(self) -> &'static str {
match self {
DeleteChoice::Everyone => "Delete for everyone",
DeleteChoice::OnlyMe => "Delete for me",
}
}
}
pub struct DeleteMenu {
pub message_id: i64,
pub snippet: String,
pub choices: Vec<DeleteChoice>,
pub selected: usize,
}
impl DeleteMenu {
pub fn set_allowed(&mut self, deletable: Deletable) {
self.choices = [
(deletable.for_everyone, DeleteChoice::Everyone),
(deletable.for_me, DeleteChoice::OnlyMe),
]
.into_iter()
.filter_map(|(allowed, choice)| allowed.then_some(choice))
.collect();
self.selected = self
.choices
.iter()
.position(|&c| c == DeleteChoice::OnlyMe)
.unwrap_or(0);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HelpTab {
Shortcuts,
Settings,
}
pub struct SettingsMenu {
pub tab: HelpTab,
pub scroll: usize,
pub selected: usize,
pub settings_scroll: usize,
pub saved_notifications: Notifications,
}
impl SettingsMenu {
pub const NOTIFICATIONS: usize = 0;
pub const CHAT_GAPS: usize = Self::NOTIFICATIONS + 1;
pub const LIST_RIGHT: usize = Self::CHAT_GAPS + 1;
pub const BLOCK_GAPS: usize = Self::LIST_RIGHT + 1;
pub const AFTER_SEND: usize = Self::BLOCK_GAPS + 1;
pub const THEMES: usize = Self::AFTER_SEND + 1;
pub fn new(tab: HelpTab, settings: &Settings) -> Self {
Self {
tab,
scroll: 0,
selected: 0,
settings_scroll: 0,
saved_notifications: settings.notifications,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PromptKind {
Chats,
Messages,
Command,
Attach,
}
pub struct Prompt {
pub kind: PromptKind,
pub input: TextArea<'static>,
pub completions: Vec<String>,
previous_filter: String,
previous_selected: Option<i64>,
}
impl Prompt {
pub fn query(&self) -> String {
self.input.lines().concat()
}
}
pub struct Finding {
pub request: String,
pub link: Option<Link>,
}
impl Finding {
fn new(request: &str) -> Self {
Self {
request: request.to_string(),
link: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Command {
Leave,
Logout,
}
impl Command {
pub const ALL: [Command; 2] = [Command::Leave, Command::Logout];
pub fn name(self) -> &'static str {
match self {
Command::Leave => "leave",
Command::Logout => "logout",
}
}
pub fn about(self) -> &'static str {
match self {
Command::Leave => "Leave this group or channel (asks first)",
Command::Logout => "Log out of Telegram on this computer",
}
}
pub fn parse(text: &str) -> Option<Command> {
Command::ALL.into_iter().find(|c| c.name() == text)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Chats,
Messages,
Input,
}
pub struct App {
tg: Tg,
pub screen: Screen,
pub focus: Focus,
pub chats: Chats,
pub users: HashMap<i64, String>,
pub selected: Option<i64>,
pub open: Option<OpenChat>,
pub composer: TextArea<'static>,
pub images: Images,
pub opening: HashSet<i32>,
clipboard: Clipboard,
pub copying: HashMap<i32, MediaFile>,
pub pasting: bool,
pub toast: Option<Toast>,
pub menu: Option<PickMenu>,
pub delete_menu: Option<DeleteMenu>,
pub react_menu: Option<ReactMenu>,
pub vote_menu: Option<VoteMenu>,
pub picker: Option<ChatPicker>,
pub finding: Option<Finding>,
pub completion: Option<Completion>,
pub stickers: Option<StickerPanel>,
pub resizing: Option<u16>,
pub confirm: Option<Confirm>,
pub settings: Settings,
settings_path: PathBuf,
pub themes: Themes,
pub colors: Colors,
premium: bool,
env_keys: Option<ApiKeys>,
keys_source: Option<KeySource>,
built_in_rejected: bool,
pub settings_menu: Option<SettingsMenu>,
pub prompt: Option<Prompt>,
pub chats_loading: bool,
all_chats_loaded: bool,
pub status: Option<String>,
pending_g: bool,
pub quit_deadline: Option<Instant>,
terminal_focused: bool,
focus_reported: bool,
last_input: Instant,
last_input_wall: SystemTime,
online: bool,
typing: Option<(i64, Instant)>,
notifier: Notifier,
notify_with: Notifications,
in_tmux: bool,
notifications_sent: u64,
notify_since: i32,
unread_chats: i32,
relogin: bool,
exit: bool,
}
impl App {
pub fn new(
tg: Tg,
images: Images,
clipboard: Clipboard,
settings: Settings,
settings_path: PathBuf,
env_keys: Option<ApiKeys>,
) -> Self {
let mut chats = Chats::default();
chats.set_highlighted(&settings.highlighted_chats);
let notify_with = settings
.notifications
.resolve(|name| std::env::var(name).ok());
let mut app = Self {
tg,
screen: login_screen(LoginStep::Connecting),
focus: Focus::Chats,
chats,
users: HashMap::new(),
selected: None,
open: None,
composer: new_composer(),
images,
opening: HashSet::new(),
clipboard,
copying: HashMap::new(),
pasting: false,
toast: None,
menu: None,
delete_menu: None,
react_menu: None,
vote_menu: None,
picker: None,
finding: None,
completion: None,
stickers: None,
resizing: None,
confirm: None,
settings,
themes: Themes::load(&settings_path.with_file_name("themes")),
settings_path,
colors: Colors::default(),
premium: false,
env_keys,
keys_source: None,
built_in_rejected: false,
settings_menu: None,
prompt: None,
chats_loading: false,
all_chats_loaded: false,
status: config::dotenv_ignored()
.then(|| "./.env is ignored: only development builds read it".into()),
pending_g: false,
quit_deadline: None,
terminal_focused: true,
focus_reported: false,
last_input: Instant::now(),
last_input_wall: SystemTime::now(),
online: false,
typing: None,
notifier: Notifier::default(),
notify_with,
in_tmux: std::env::var_os("TMUX").is_some(),
notifications_sent: 0,
notify_since: i32::MAX,
unread_chats: 0,
relogin: false,
exit: false,
};
app.use_saved_theme();
app
}
fn use_saved_theme(&mut self) {
match self.themes.colors(&self.settings.theme) {
Ok(colors) => self.colors = colors,
Err(e) => {
self.colors = Colors::default();
self.status = Some(format!("Theme not used: {e}"));
}
}
}
pub async fn run(
mut self,
terminal: &mut DefaultTerminal,
mut events: UnboundedReceiver<Tagged>,
mut image_events: UnboundedReceiver<ImageEvent>,
mut clipboard: UnboundedReceiver<ClipboardEvent>,
) -> Result<()> {
let mut keys = EventStream::new();
let mut signals = quit_signals();
let mut failed: Option<anyhow::Error> = None;
while !self.exit {
if self
.toast
.as_ref()
.is_some_and(|t| t.until <= Instant::now())
{
self.toast = None;
}
self.chats.refresh();
if self
.selected
.is_none_or(|id| !self.chats.ids().contains(&id))
{
self.selected = self.chats.ids().first().copied();
}
self.mark_seen();
self.update_online();
self.send_notification();
if let Some(query) = self
.picker
.as_mut()
.and_then(|p| p.due_search(Instant::now()))
{
self.tg.find_chats(query);
}
if let Some(open) = &self.open
&& let Some(query) = self
.completion
.as_mut()
.and_then(|c| c.due_search(Instant::now()))
{
self.tg.find_members(open.chat_id, query);
}
if failed.is_none()
&& let Err(e) = terminal.draw(|frame| ui::draw(frame, &mut self))
{
failed = Some(e.into());
self.hang_up();
}
self.images.fetch(&self.tg);
if let Some(open) = self.open.as_mut() {
for id in open.missing_replied() {
self.tg.get_replied_message(open.chat_id, id);
}
}
let deadline = self.quit_deadline;
let wake = [
self.toast.as_ref().map(|t| t.until),
self.online.then_some(self.last_input + IDLE_AFTER),
self.notifier.next_at(),
self.picker.as_ref().and_then(ChatPicker::search_at),
self.completion.as_ref().and_then(Completion::search_at),
]
.into_iter()
.flatten()
.min();
tokio::select! {
Some(event) = events.recv() => {
self.on_tagged(event);
while let Ok(event) = events.try_recv() {
self.on_tagged(event);
}
}
Some(event) = image_events.recv() => self.images.on_built(event),
Some(event) = clipboard.recv() => self.on_clipboard(event),
Some(event) = keys.next(), if failed.is_none() => match event {
Ok(event) => self.on_terminal_event(event),
Err(e) => {
failed = Some(e.into());
self.hang_up();
}
},
Some(()) = signals.recv() => self.hang_up(),
_ = sleep_until(deadline.unwrap_or_else(Instant::now)), if deadline.is_some() => break,
_ = sleep_until(wake.unwrap_or_else(Instant::now)), if wake.is_some() => {}
else => break,
}
}
match failed {
Some(e) => Err(e),
None => Ok(()),
}
}
fn hang_up(&mut self) {
self.terminal_focused = false;
self.focus_reported = true;
if self.quit_deadline.is_none() {
self.quit();
}
}
fn on_terminal_event(&mut self, event: Event) {
if matches!(event, Event::Key(_) | Event::Paste(_) | Event::FocusGained) {
self.last_input = Instant::now();
self.last_input_wall = SystemTime::now();
}
match event {
Event::Key(key) => self.on_key(key),
Event::FocusGained => {
self.terminal_focused = true;
self.focus_reported = true;
self.notifier.clear();
}
Event::FocusLost => {
self.terminal_focused = false;
self.focus_reported = true;
}
Event::Paste(text) if matches!(self.screen, Screen::Login(_)) => {
if let Screen::Login(login) = &mut self.screen
&& login.takes_input()
{
login.input.insert_str(text.trim());
}
}
Event::Paste(text) if self.picker.is_some() => {
let text = text::clean(&text).replace(['\n', '\t'], " ");
if let Some(picker) = self.picker.as_mut() {
picker.edit_query(|q| q.push_str(text.trim()), Instant::now());
}
}
Event::Paste(text) if self.prompt.is_some() => {
if let Some(prompt) = self.prompt.as_mut() {
prompt.input.insert_str(text.replace(['\r', '\n'], " "));
}
self.on_prompt_edit();
}
Event::Paste(text) if matches!(self.focus, Focus::Input | Focus::Messages) => {
self.on_paste(text)
}
_ => {}
}
}
fn on_tagged(&mut self, (client_id, event): Tagged) {
if client_id == self.tg.client_id() {
self.on_tg(event);
}
}
fn on_tg(&mut self, event: TgEvent) {
match event {
TgEvent::Update(update) => self.on_update(*update),
TgEvent::Error(message) if message.contains("API_ID") => self.reject_api_keys(),
TgEvent::Error(message) => match &mut self.screen {
Screen::Login(login) => {
login.busy = false;
login.error = Some(message);
}
Screen::Main => self.status = Some(message),
},
TgEvent::ChatsLoaded { all, failed } => {
self.chats_loading = false;
self.all_chats_loaded |= all;
if !failed {
self.load_more_chats();
}
}
TgEvent::History {
chat_id,
page,
messages,
} => self.on_history(chat_id, page, messages),
TgEvent::Found {
chat_id,
query,
found,
} => self.on_found(chat_id, &query, found),
TgEvent::Replied {
chat_id,
message_id,
replied,
} => {
if let Some(open) = self.open.as_mut().filter(|o| o.chat_id == chat_id) {
open.set_replied(message_id, replied.map(|m| *m));
}
}
TgEvent::Deletable {
chat_id,
message_id,
deletable,
} => self.on_deletable(chat_id, message_id, deletable),
TgEvent::Editable {
chat_id,
message_id,
editable,
text,
} => self.on_editable(chat_id, message_id, editable, text),
TgEvent::Reactions {
chat_id,
message_id,
available,
} => self.on_available_reactions(chat_id, message_id, available),
TgEvent::Stickers { source, stickers } => self.on_stickers(source, stickers),
TgEvent::StickerSets(sets) => {
if let Some(panel) = self.stickers.as_mut() {
panel.set_sets(sets.unwrap_or_default());
self.load_sticker_set();
}
}
TgEvent::StickersFound { query, stickers } => {
let premium = self.premium;
if let Some(panel) = self.stickers.as_mut() {
let found = stickers::convert(&stickers.unwrap_or_default(), premium);
panel.set_found(&query, found);
}
}
TgEvent::Downloaded { file_id, path } => {
if self.opening.remove(&file_id) {
match &path {
Some(path) => self.open_downloaded(path.clone()),
None => self.status = Some("Download failed".into()),
}
}
if let Some(file) = self.copying.remove(&file_id) {
match &path {
Some(path) => {
mark_downloaded(path);
self.copy_downloaded(file, path)
}
None => self.status = Some("Download failed".into()),
}
}
self.images.on_downloaded(file_id, path);
}
TgEvent::Members {
chat_id,
query,
user_ids,
} => {
let open = self.open.as_ref().is_some_and(|o| o.chat_id == chat_id);
let Some(completion) = self.completion.as_mut().filter(|_| open) else {
return;
};
if completion.set_found(&query, user_ids) {
let (query, found) = (completion.word.query.clone(), completion.found.clone());
let items = self.mention_items(&query, &found);
if let Some(completion) = self.completion.as_mut() {
completion.set_items(items);
}
}
}
TgEvent::Commands { chat_id, commands } => {
let Some(open) = self.open.as_mut().filter(|o| o.chat_id == chat_id) else {
return;
};
open.commands = Commands::Known(commands);
let query = self
.completion
.as_ref()
.filter(|c| c.word.kind == Kind::Command)
.map(|c| c.word.query.clone());
if let Some(query) = query {
let items = self.command_items(&query);
if let Some(completion) = self.completion.as_mut() {
completion.set_items(items);
}
}
}
TgEvent::ChatsFound {
query,
chat_ids,
user_ids,
} => {
if let Some(picker) = self.picker.as_mut() {
picker.set_found(&query, chat_ids, user_ids);
}
}
TgEvent::ChatFound { request, found } => {
if !self.finding.as_ref().is_some_and(|f| f.request == request) {
return;
}
let link = self.finding.take().and_then(|f| f.link);
if self.busy() {
if found.is_ok() {
self.status = Some(format!("Found {request}: press s to open it"));
}
return;
}
match (found, link) {
(Ok((chat_id, message_id)), _) => {
self.open_chat(chat_id);
if let Some(id) = message_id {
self.jump_to_message(id);
}
}
(Err(Missed::Quiet), _) => {}
(Err(Missed::Said(why)), _) => self.status = Some(why),
(Err(Missed::Elsewhere), Some(link)) => self.open_link_outside(link),
(Err(Missed::Elsewhere), None) => {
self.status = Some("tuigram can't open this kind of link".into());
}
}
}
TgEvent::Forwarded { chat_id } => {
let title = self.chats.title(chat_id).unwrap_or_default().to_string();
self.show_toast("Forwarded", &format!("to {title}"));
}
TgEvent::Joined { chat_id } => {
let title = self.chats.title(chat_id).unwrap_or_default().to_string();
self.show_toast("Joined", &title);
}
TgEvent::Left { chat_id } => {
let title = self.chats.title(chat_id).unwrap_or_default().to_string();
self.show_toast("Left", &title);
}
TgEvent::Invite {
request,
link,
invite,
} => {
if self.finding.as_ref().is_some_and(|f| f.request == request) && !self.busy() {
self.confirm_invite(link, request, invite);
}
}
}
}
fn on_history(
&mut self,
chat_id: i64,
page: Page,
messages: Option<Vec<tdlib_rs::types::Message>>,
) {
let Some(open) = self
.open
.as_mut()
.filter(|o| o.chat_id == chat_id && o.loading == Some(page))
else {
return;
};
open.loading = None;
let Some(messages) = messages else {
return;
};
open.add_page(
page,
messages.into_iter().map(|m| (m.id, m.into())).collect(),
);
if open.messages.len() < MIN_LOADED {
self.load_older_messages();
}
}
fn on_found(&mut self, chat_id: i64, query: &str, found: Option<Found>) {
let Some(open) = self.open.as_mut().filter(|o| o.chat_id == chat_id) else {
return;
};
let Some(search) = open
.search
.as_mut()
.filter(|s| s.query == query && s.loading)
else {
return;
};
search.loading = false;
let wanted = search.wanted.take();
match found {
Some(found) => {
search.add(found);
if let Some(index) = wanted {
self.go_to_match(index);
}
}
None if search.results.is_empty() => open.search = None,
None => {}
}
}
fn on_update(&mut self, update: Update) {
match update {
Update::AuthorizationState(u) => self.on_auth_state(u.authorization_state),
Update::NewChat(u) => self.chats.insert(u.chat),
Update::ChatPosition(u) => self.chats.set_position(u.chat_id, &u.position),
Update::ChatLastMessage(u) => {
self.chats
.set_last_message(u.chat_id, u.last_message.as_ref(), &u.positions)
}
Update::ChatTitle(u) => self.chats.set_title(u.chat_id, u.title),
Update::ChatNotificationSettings(u) => self
.chats
.set_notifications(u.chat_id, u.notification_settings),
Update::ScopeNotificationSettings(u) => self
.chats
.set_default_mute(&u.scope, u.notification_settings.mute_for),
Update::ChatAction(u) => self.chats.set_action(u.chat_id, &u.sender_id, &u.action),
Update::NotificationGroup(u) => self.on_notifications(u),
Update::UnreadChatCount(u) if matches!(u.chat_list, ChatList::Main) => {
self.set_unread_chats(u.unread_unmuted_count)
}
Update::ChatPhoto(u) => self.chats.set_photo(u.chat_id, u.photo.as_ref()),
Update::ChatAccentColors(u) => self.chats.set_accent(u.chat_id, u.accent_color_id),
Update::AccentColors(u) => self.chats.set_accent_colors(&u.colors),
Update::ChatReadInbox(u) => self.chats.set_unread(u.chat_id, u.unread_count),
Update::ChatReadOutbox(u) => {
self.chats
.set_read_outbox(u.chat_id, u.last_read_outbox_message_id);
}
Update::Option(u) if u.name == "is_premium" => {
self.premium = matches!(u.value, OptionValue::Boolean(v) if v.value);
}
Update::Option(u) if u.name == "my_id" => {
if let OptionValue::Integer(v) = u.value {
self.chats.set_my_id(v.value);
}
}
Update::User(u) => {
let name = format!("{} {}", u.user.first_name, u.user.last_name);
self.users.insert(u.user.id, text::clean(name.trim()));
let badge = Badge::of(u.user.verification_status.as_ref(), u.user.is_support);
self.chats.set_badge(Peer::User(u.user.id), badge);
self.chats
.set_username(Peer::User(u.user.id), u.user.usernames.as_ref());
self.chats
.set_presence(u.user.id, Presence::of(&u.user.status));
let bot = matches!(u.user.r#type, UserType::Bot(_));
self.chats.set_bot(u.user.id, bot);
}
Update::UserStatus(u) => {
self.chats.set_presence(u.user_id, Presence::of(&u.status));
}
Update::Supergroup(u) => {
let group = &u.supergroup;
let badge = Badge::of(group.verification_status.as_ref(), false);
self.chats.set_badge(Peer::Supergroup(group.id), badge);
self.chats
.set_username(Peer::Supergroup(group.id), group.usernames.as_ref());
let member = !matches!(group.status, ChatMemberStatus::Left);
self.chats.set_member(group.id, member);
}
Update::NewMessage(u) => {
if let Some(open) = self
.open
.as_mut()
.filter(|o| o.chat_id == u.message.chat_id && o.at_newest)
{
open.insert(u.message);
}
}
Update::MessageSendSucceeded(u) => {
if let Some(open) = self
.open
.as_mut()
.filter(|o| o.chat_id == u.message.chat_id)
{
open.replace(u.old_message_id, u.message);
}
}
Update::MessageSendFailed(u) => {
self.status = Some(format!("Message not sent: {}", u.error.message));
if let Some(open) = self
.open
.as_mut()
.filter(|o| o.chat_id == u.message.chat_id)
{
open.replace(u.old_message_id, u.message);
}
}
Update::File(u) => {
if let Some(open) = self.open.as_mut() {
open.set_upload(&u.file);
}
}
Update::MessageContent(u) => {
if let Some(open) = self.open.as_mut().filter(|o| o.chat_id == u.chat_id) {
open.set_content(u.message_id, &u.new_content);
}
}
Update::MessageEdited(u) => {
if let Some(open) = self.open.as_mut().filter(|o| o.chat_id == u.chat_id) {
open.set_edited(u.message_id);
}
}
Update::MessageInteractionInfo(u) => {
if let Some(open) = self.open.as_mut().filter(|o| o.chat_id == u.chat_id) {
open.set_reactions(u.message_id, u.interaction_info.as_ref());
}
}
Update::DeleteMessages(u) if u.is_permanent => {
if let Some(open) = self.open.as_mut().filter(|o| o.chat_id == u.chat_id) {
open.remove(&u.message_ids);
}
}
_ => {}
}
}
fn on_auth_state(&mut self, state: AuthorizationState) {
let step = match state {
AuthorizationState::WaitTdlibParameters => match self.api_keys() {
Some((source, keys)) => {
self.keys_source = Some(source);
self.tg.set_tdlib_parameters(keys);
return;
}
None => LoginStep::ApiId,
},
AuthorizationState::WaitPhoneNumber => LoginStep::Phone,
AuthorizationState::WaitCode(s) => LoginStep::Code {
sent_via: code_destination(&s.code_info.r#type),
},
AuthorizationState::WaitPassword(s) => LoginStep::Password {
hint: s.password_hint,
},
AuthorizationState::WaitEmailAddress(_) => LoginStep::Email,
AuthorizationState::WaitEmailCode(_) => LoginStep::EmailCode,
AuthorizationState::WaitOtherDeviceConfirmation(s) => {
LoginStep::OtherDevice { link: s.link }
}
AuthorizationState::WaitRegistration(_) => LoginStep::Unsupported(
"This number has no Telegram account. Sign up in the official app first.",
),
AuthorizationState::WaitPremiumPurchase(_) => LoginStep::Unsupported(
"Telegram requires a Premium purchase to log in with this number. Use the official app.",
),
AuthorizationState::Ready => {
self.screen = Screen::Main;
self.load_more_chats();
self.tg.enable_notifications();
self.notify_since = unix_now();
return;
}
AuthorizationState::LoggingOut => {
self.relogin = true;
if let Screen::Login(_) = self.screen {
return;
}
LoginStep::LoggingOut
}
AuthorizationState::Closing => return,
AuthorizationState::Closed if self.relogin && self.quit_deadline.is_none() => {
self.relogin = false;
self.keys_source = None;
self.forget_session();
self.tg.reopen();
self.screen = login_screen(LoginStep::Connecting);
return;
}
AuthorizationState::Closed => {
self.exit = true;
return;
}
};
self.screen = login_screen(step);
}
fn on_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl
&& key.code == KeyCode::Char('c')
&& self.focus != Focus::Input
&& self.prompt.is_none()
&& self.picker.is_none()
{
self.quit();
return;
}
self.status = None;
match self.screen {
Screen::Login(_) => self.on_login_key(key),
Screen::Main if self.confirm.is_some() => self.on_confirm_key(key),
Screen::Main if self.settings_menu.is_some() => self.on_settings_key(key, ctrl),
Screen::Main if self.delete_menu.is_some() => self.on_delete_key(key),
Screen::Main if self.react_menu.is_some() => self.on_react_key(key, ctrl),
Screen::Main if self.vote_menu.is_some() => self.on_vote_key(key),
Screen::Main if self.menu.is_some() => self.on_menu_key(key),
Screen::Main if self.picker.is_some() => self.on_picker_key(key, ctrl),
Screen::Main if self.resizing.is_some() => self.on_resize_key(key, ctrl),
Screen::Main if self.prompt.is_some() => self.on_prompt_key(key, ctrl),
Screen::Main if self.focus == Focus::Input && self.stickers.is_some() => {
self.on_sticker_key(key, ctrl)
}
Screen::Main if self.focus == Focus::Input => self.on_insert_key(key, ctrl),
Screen::Main => self.on_normal_key(key, ctrl),
}
if self.focus != Focus::Input {
self.stickers = None;
self.completion = None;
}
if self.busy() {
self.finding = None;
}
}
fn on_login_key(&mut self, key: KeyEvent) {
let Screen::Login(login) = &mut self.screen else {
return;
};
match (&login.step, key.code) {
(LoginStep::Phone, KeyCode::Tab) if !login.busy => {
login.busy = true;
login.error = None;
self.tg.request_qr_code();
return;
}
(LoginStep::OtherDevice { .. }, KeyCode::Esc) => {
self.tg.log_out();
self.relogin = true;
self.screen = login_screen(LoginStep::Connecting);
return;
}
_ => {}
}
if !login.takes_input() {
return;
}
if key.code != KeyCode::Enter {
login.input.input(key);
return;
}
let value = login.input.lines().concat().trim().to_string();
if value.is_empty() || login.busy {
return;
}
match login.step {
LoginStep::ApiId => {
match config::parse_api_id(&value) {
Some(id) => self.screen = login_screen(LoginStep::ApiHash { id }),
None => login.error = Some("The API ID is a number, like 1234567".into()),
}
return;
}
LoginStep::ApiHash { id } => {
if !config::is_api_hash(&value) {
login.error = Some("The API hash is 32 letters and digits".into());
return;
}
let keys = ApiKeys { id, hash: value };
self.settings.api_keys = Some(keys.clone());
if let Err(e) = self.settings.save(&self.settings_path) {
login.error = Some(format!("Couldn't save them: {e:#}"));
return;
}
if self.keys_source.is_some() {
self.relogin = true;
self.tg.close();
} else {
self.keys_source = Some(KeySource::Saved);
self.tg.set_tdlib_parameters(keys);
}
self.screen = login_screen(LoginStep::Connecting);
return;
}
_ => {}
}
login.busy = true;
login.error = None;
match login.step {
LoginStep::Phone => self.tg.send_phone_number(value),
LoginStep::Code { .. } => self.tg.send_code(value),
LoginStep::Password { .. } => self.tg.send_password(value),
LoginStep::Email => self.tg.send_email(value),
LoginStep::EmailCode => self.tg.send_email_code(value),
LoginStep::Connecting
| LoginStep::LoggingOut
| LoginStep::ApiId
| LoginStep::ApiHash { .. }
| LoginStep::OtherDevice { .. }
| LoginStep::Unsupported(_) => {}
}
}
fn api_keys(&self) -> Option<(KeySource, ApiKeys)> {
let built_in = config::built_in_keys().filter(|_| !self.built_in_rejected);
let env = self.env_keys.clone().map(|k| (KeySource::Env, k));
env.or_else(|| {
self.settings
.api_keys
.clone()
.map(|k| (KeySource::Saved, k))
})
.or_else(|| built_in.map(|k| (KeySource::BuiltIn, k)))
}
fn reject_api_keys(&mut self) {
let message = match self.keys_source {
Some(KeySource::Env) => "Telegram rejected TG_API_ID / TG_API_HASH.".to_string(),
Some(KeySource::BuiltIn) => {
self.built_in_rejected = true;
"Telegram rejected tuigram's built-in key. Use your own.".into()
}
Some(KeySource::Saved) | None => {
self.settings.api_keys = None;
match self.settings.save(&self.settings_path) {
Ok(()) => "Telegram rejected that API ID and hash.".into(),
Err(e) => format!("Telegram rejected the API key; can't forget it: {e:#}"),
}
}
};
match &mut self.screen {
Screen::Login(_) if self.keys_source != Some(KeySource::Env) => {
let mut login = Login::new(LoginStep::ApiId);
login.error = Some(message);
self.screen = Screen::Login(Box::new(login));
}
Screen::Login(login) => {
login.busy = false;
login.error = Some(message);
}
Screen::Main => self.status = Some(message),
}
}
fn on_normal_key(&mut self, key: KeyEvent, ctrl: bool) {
let pending_g = std::mem::take(&mut self.pending_g);
let (to_chat, to_list) = match self.settings.chat_list_side {
Side::Left => ('l', 'h'),
Side::Right => ('h', 'l'),
};
let motion = match key.code {
KeyCode::Char('j') | KeyCode::Down => Some(1),
KeyCode::Char('k') | KeyCode::Up => Some(-1),
KeyCode::Char('d') if ctrl => Some(HALF_PAGE),
KeyCode::Char('u') if ctrl => Some(-HALF_PAGE),
KeyCode::Char('g') if pending_g => Some(isize::MIN),
KeyCode::Char('G') => Some(isize::MAX),
_ => None,
};
if let Some(delta) = motion {
match self.focus {
Focus::Chats => self.move_chat_cursor(delta),
Focus::Messages => self.move_message_cursor(delta),
Focus::Input => {}
}
return;
}
match (self.focus, key.code) {
(_, KeyCode::Char('r')) if ctrl => {
self.resizing = Some(self.settings.chat_list_width);
}
(_, KeyCode::Char('g')) => self.pending_g = true,
(_, KeyCode::Char('q')) => self.quit(),
(_, KeyCode::Char('H')) => self.toggle_highlight(),
(_, KeyCode::Char('?')) => {
if let Some(dir) = self.themes.dir.clone() {
self.themes = Themes::load(&dir);
}
self.use_saved_theme();
self.settings_menu = Some(SettingsMenu::new(HelpTab::Shortcuts, &self.settings));
}
(_, KeyCode::Char(':')) => self.open_prompt(PromptKind::Command),
(_, KeyCode::Char('s')) => self.picker = Some(ChatPicker::new(Purpose::Open)),
(Focus::Chats, KeyCode::Char('/')) => self.open_prompt(PromptKind::Chats),
(Focus::Messages, KeyCode::Char('/')) => self.open_prompt(PromptKind::Messages),
(Focus::Messages, KeyCode::Char('n')) => self.next_match(1),
(Focus::Messages, KeyCode::Char('N')) => self.next_match(-1),
(_, KeyCode::Esc) if self.finding.is_some() => self.finding = None,
(Focus::Chats, KeyCode::Esc) => self.chats.set_filter(""),
(Focus::Chats, KeyCode::Char('p')) if !ctrl => self.toggle_pin(),
(Focus::Chats, KeyCode::Char('m')) if !ctrl => self.toggle_mute(),
(Focus::Messages, KeyCode::Esc)
if self.open.as_ref().is_some_and(|o| o.search.is_some()) =>
{
if let Some(open) = self.open.as_mut() {
open.search = None;
}
}
(Focus::Messages, KeyCode::Esc)
if self.open.as_ref().is_some_and(|o| o.editing.is_some()) =>
{
self.end_edit();
}
(Focus::Messages, KeyCode::Esc)
if self
.open
.as_ref()
.is_some_and(|o| !o.attachments.is_empty()) =>
{
if let Some(open) = self.open.as_mut() {
open.attachments.clear();
open.dropped = None;
open.as_files = false;
}
}
(Focus::Messages, KeyCode::Esc)
if self.open.as_ref().is_some_and(|o| o.reply.is_some()) =>
{
if let Some(open) = self.open.as_mut() {
open.reply = None;
}
}
(Focus::Messages, KeyCode::Char('r')) => self.reply_to_selected(),
(Focus::Messages, KeyCode::Char('e')) => self.edit_selected(),
(Focus::Messages, KeyCode::Char('y')) => self.copy_selected(),
(Focus::Messages, KeyCode::Char('a')) => self.open_prompt(PromptKind::Attach),
(Focus::Messages, KeyCode::Char('p')) => self.paste_clipboard(),
(Focus::Messages, KeyCode::Char('t')) if ctrl => self.toggle_as_files(),
(Focus::Messages, KeyCode::Char('d')) if pending_g => self.go_to_replied(),
(Focus::Messages, KeyCode::Char('d')) => self.open_delete_menu(),
(Focus::Messages, KeyCode::Char('R')) => self.open_react_menu(),
(Focus::Messages, KeyCode::Char('X')) => self.remove_reactions(),
(Focus::Messages, KeyCode::Char('f')) => self.forward_selected(),
(Focus::Messages, KeyCode::Char('o')) if ctrl => self.jump_back(),
(Focus::Chats, KeyCode::Enter) => self.open_selected_chat(),
(Focus::Chats, KeyCode::Char(c)) if c == to_chat => self.open_selected_chat(),
(Focus::Chats, KeyCode::Char('i')) => {
self.open_selected_chat();
self.start_writing();
}
(Focus::Messages, KeyCode::Char('i')) => self.start_writing(),
(Focus::Messages, KeyCode::Enter) => self.open_selected_message(),
(Focus::Messages, KeyCode::Esc) => self.focus = Focus::Chats,
(Focus::Messages, KeyCode::Char(c)) if c == to_list => self.focus = Focus::Chats,
_ => {}
}
}
fn on_insert_key(&mut self, key: KeyEvent, ctrl: bool) {
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if let Some(completion) = self.completion.as_mut().filter(|c| !c.items.is_empty()) {
match key.code {
KeyCode::Tab => {
self.accept_completion();
return;
}
KeyCode::Up | KeyCode::BackTab => {
completion.move_by(-1);
return;
}
KeyCode::Down => {
completion.move_by(1);
return;
}
KeyCode::Char('p') if ctrl => {
completion.move_by(-1);
return;
}
KeyCode::Char('n') if ctrl => {
completion.move_by(1);
return;
}
_ => {}
}
}
match key.code {
KeyCode::Esc => self.leave_insert(),
KeyCode::Char('c') if ctrl => self.leave_insert(),
KeyCode::Enter if alt || shift => {
self.composer.insert_newline();
self.on_composer_edit();
}
KeyCode::Char('j') if ctrl => {
self.composer.insert_newline();
self.on_composer_edit();
}
KeyCode::Enter => self.send(),
KeyCode::Char('v') if ctrl => self.paste_clipboard(),
KeyCode::Char('z') if ctrl => self.undo_drop(),
KeyCode::Char('t') if ctrl => self.toggle_as_files(),
KeyCode::Char('u') if ctrl => {
if self.composer.delete_line_by_head() {
self.on_composer_edit();
}
}
KeyCode::Tab => self.open_stickers(),
_ => {
if self.composer.input(key) {
self.on_composer_edit();
}
}
}
self.update_completion();
}
fn update_completion(&mut self) {
let DataCursor(row, col) = self.composer.cursor();
let word = self
.composer
.lines()
.get(row)
.and_then(|line| complete::word_at(line, col));
let group = self
.open
.as_ref()
.and_then(|o| self.chats.get(o.chat_id))
.is_some_and(|c| !c.is_private);
let Some(word) = word.filter(|w| match w.kind {
Kind::Emoji => true,
Kind::Mention => group,
Kind::Command => row == 0 && col == w.chars,
}) else {
self.completion = None;
return;
};
if word.kind == Kind::Command {
self.ask_bot_commands();
}
if self.completion.as_ref().is_some_and(|c| c.word == word) {
return;
}
let completion = self
.completion
.get_or_insert_with(|| Completion::new(word.clone()));
completion.retype(word.clone(), Instant::now());
let items = match word.kind {
Kind::Emoji => complete::emoji(&word.query),
Kind::Mention => {
let found = completion.found.clone();
self.mention_items(&word.query, &found)
}
Kind::Command => self.command_items(&word.query),
};
if let Some(completion) = self.completion.as_mut() {
completion.set_items(items);
}
}
fn ask_bot_commands(&mut self) {
let Some(open) = self
.open
.as_mut()
.filter(|o| o.commands == Commands::NotAsked)
else {
return;
};
let peer = self.chats.get(open.chat_id).and_then(|c| c.peer);
open.commands = match peer {
Some(Peer::User(id)) if !self.chats.is_bot(id) => Commands::Known(Vec::new()),
Some(peer) => {
self.tg.bot_commands(open.chat_id, peer);
Commands::Asked
}
None => Commands::Known(Vec::new()),
};
}
fn command_items(&self, query: &str) -> Vec<Suggestion> {
let Some(Commands::Known(list)) = self.open.as_ref().map(|o| &o.commands) else {
return Vec::new();
};
complete::commands(list, query, |bot| {
self.chats.user_username(bot).map(String::from)
})
}
fn mention_items(&self, query: &str, found: &[i64]) -> Vec<Suggestion> {
let Some(open) = &self.open else {
return Vec::new();
};
let senders = open.messages.values().rev().filter_map(|m| match m.sender {
Sender::User(id) => Some(id),
Sender::Chat(_) => None,
});
let mut seen = HashSet::new();
let mut items = Vec::new();
for user_id in senders.chain(found.iter().copied()) {
if self.chats.is_saved(user_id) || !seen.insert(user_id) {
continue;
}
let Some(name) = self.users.get(&user_id) else {
continue;
};
let username = self.chats.user_username(user_id);
let matches = query.is_empty()
|| !search::find(name, query).is_empty()
|| username.is_some_and(|u| !search::find(u, query).is_empty());
if matches {
items.push(complete::mention(user_id, name, username));
}
if items.len() == complete::MAX_SUGGESTIONS {
break;
}
}
items
}
fn accept_completion(&mut self) {
let Some(completion) = self.completion.take() else {
return;
};
let Some(suggestion) = completion.current() else {
return;
};
for _ in 0..completion.word.chars {
self.composer.delete_char();
}
self.composer.insert_str(&suggestion.insert);
self.on_composer_edit();
}
fn start_writing(&mut self) {
let Some(open) = &self.open else {
return;
};
if self.chats.joined(open.chat_id) {
self.focus = Focus::Input;
return;
}
let title = self.chats.title(open.chat_id).unwrap_or("this chat");
let channel = self.chats.get(open.chat_id).is_some_and(|c| c.is_channel);
let (ask, why) = if channel {
(
"Join this channel?",
"Join it to have it in your chat list.",
)
} else {
(
"Join to write here?",
"Join it to write, and to have it in your chat list.",
)
};
let mut confirm = Confirm::new(
ask,
vec![format!("You're not in {title}."), why.into()],
Confirmed::Join(open.chat_id),
);
confirm.badge = self.chats.badge(open.chat_id);
self.confirm = Some(confirm);
}
fn leave_insert(&mut self) {
self.focus = Focus::Messages;
self.set_typing(false);
}
fn on_composer_edit(&mut self) {
let editing = self.open.as_ref().is_some_and(|o| o.editing.is_some());
let typing = !editing && self.composer.lines().iter().any(|l| !l.trim().is_empty());
self.set_typing(typing);
}
fn set_typing(&mut self, typing: bool) {
let chat_id = self.open.as_ref().map(|o| o.chat_id);
if typing && let Some(chat_id) = chat_id {
let told = self
.typing
.is_some_and(|(id, at)| id == chat_id && at.elapsed() < TYPING_EVERY);
if !told {
self.tg.send_typing(chat_id, true);
self.typing = Some((chat_id, Instant::now()));
}
} else if let Some((chat_id, _)) = self.typing.take() {
self.tg.send_typing(chat_id, false);
}
}
fn send(&mut self) {
if self.open.as_ref().is_some_and(|o| o.editing.is_some()) {
self.save_edit();
return;
}
let Some(open) = self.open.as_mut() else {
return;
};
let text = self.composer.lines().join("\n");
let text = text.trim().to_string();
if text.is_empty() && open.attachments.is_empty() {
return;
}
if let Some(changed) = open.attachments.iter().find(|a| a.swapped()) {
self.status = Some(format!(
"{} changed since it was attached. Drop the files (Esc in Normal mode) and attach it again",
changed.name
));
return;
}
let reply_to = open.reply.take().map(|r| r.id);
if open.attachments.is_empty() {
self.tg.send_text(open.chat_id, text, reply_to);
} else {
let as_files = open.as_files;
let groups = attach::albums(&open.attachments, as_files)
.into_iter()
.map(|album| album.iter().map(|a| a.upload(as_files)).collect())
.collect();
self.tg.send_files(open.chat_id, groups, text, reply_to);
open.attachments.clear();
open.dropped = None;
open.as_files = false;
}
self.composer = new_composer();
self.typing = None;
if self.settings.normal_after_send {
self.focus = Focus::Messages;
}
self.jump_to_newest();
}
fn open_prompt(&mut self, kind: PromptKind) {
let input = prompt_input("");
if kind == PromptKind::Attach && !self.can_attach() {
return;
}
self.prompt = Some(Prompt {
kind,
input,
completions: Vec::new(),
previous_filter: self.chats.filter().to_string(),
previous_selected: self.selected,
});
}
fn on_prompt_key(&mut self, key: KeyEvent, ctrl: bool) {
let Some(prompt) = self.prompt.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => self.close_prompt(false),
KeyCode::Char('c') if ctrl => self.close_prompt(false),
KeyCode::Enter => self.close_prompt(true),
KeyCode::Char('m' | 'j') if ctrl => self.close_prompt(true),
KeyCode::Backspace if prompt.query().is_empty() => self.close_prompt(false),
KeyCode::Tab if prompt.kind == PromptKind::Attach => {
let completion = attach::complete(&prompt.query());
prompt.input = prompt_input(&completion.text);
prompt.completions = completion.matches;
}
_ => {
prompt.input.input(key);
prompt.completions.clear();
self.on_prompt_edit();
}
}
}
fn on_prompt_edit(&mut self) {
let Some(prompt) = self.prompt.as_ref() else {
return;
};
if prompt.kind == PromptKind::Chats {
self.chats.set_filter(prompt.query().trim());
self.chats.refresh();
self.selected = self.chats.ids().first().copied();
}
}
fn close_prompt(&mut self, submit: bool) {
let Some(prompt) = self.prompt.take() else {
return;
};
let query = prompt.query().trim().to_string();
match prompt.kind {
PromptKind::Chats => {
if submit && (query.is_empty() || !self.chats.ids().is_empty()) {
return;
}
if submit {
self.status = Some(format!("No chats match \"{query}\""));
}
self.chats.set_filter(&prompt.previous_filter);
self.selected = prompt.previous_selected;
}
PromptKind::Messages => {
let Some(open) = self.open.as_mut().filter(|_| submit && !query.is_empty()) else {
return;
};
open.search = Some(MessageSearch::new(query));
self.go_to_match(0);
}
PromptKind::Attach if !submit || query.is_empty() => {}
PromptKind::Attach => {
let path = attach::expand_home(&query);
let paths = match attach::pasted_paths(&query) {
Some(paths) if !path.exists() => paths,
_ => vec![path],
};
self.attach(paths, None);
}
PromptKind::Command if !submit || query.is_empty() => {}
PromptKind::Command => match Command::parse(&query) {
Some(Command::Leave) => self.ask_to_leave(),
Some(Command::Logout) => self.log_out(),
None => self.status = Some(format!("Not a command: {query}")),
},
}
}
fn mark_seen(&mut self) {
let watched = self.open.as_ref().map(|o| o.chat_id);
if !watched.is_some_and(|id| self.watching(id)) {
return;
}
let Some(open) = self.open.as_mut() else {
return;
};
let newest = open.messages.iter().rev().find(|(_, m)| !m.outgoing);
if let Some((&id, _)) = newest
&& id > open.seen
{
open.seen = id;
self.tg.view_messages(open.chat_id, vec![id]);
}
}
fn idle(&self) -> Duration {
let wall = SystemTime::now()
.duration_since(self.last_input_wall)
.unwrap_or_default();
self.last_input.elapsed().max(wall)
}
fn watching(&self, chat_id: i64) -> bool {
let idle = self.idle();
matches!(self.screen, Screen::Main)
&& self.terminal_focused
&& (idle < IDLE_AFTER || (self.focus_reported && idle < AWAY_AFTER))
&& matches!(self.focus, Focus::Messages | Focus::Input)
&& self.settings_menu.is_none()
&& self
.open
.as_ref()
.is_some_and(|o| o.chat_id == chat_id && o.at_newest && o.selected.is_none())
}
fn sees(&self, chat_id: i64) -> bool {
if self.focus_reported {
self.terminal_focused
} else {
self.watching(chat_id)
}
}
fn update_online(&mut self) {
let online = matches!(self.screen, Screen::Main)
&& self.quit_deadline.is_none()
&& self.terminal_focused
&& self.idle() < IDLE_AFTER;
if online != self.online {
self.online = online;
self.tg.set_online(online);
}
}
fn on_notifications(&mut self, update: UpdateNotificationGroup) {
self.notifier.remove(&update.removed_notification_ids);
if self.notify_with == Notifications::Off {
return;
}
let chat_id = update.chat_id;
for notification in update.added_notifications {
let NotificationType::NewMessage(new) = notification.r#type else {
continue;
};
if notification.date < self.notify_since || self.sees(chat_id) {
continue;
}
let text = if new.show_preview {
self.notification_text(&new.message)
} else {
"New message".into()
};
let note = Note {
id: notification.id,
chat_id,
chat: format!(
"{}{}",
self.chats.title(chat_id).unwrap_or("Telegram"),
self.chats.badge(chat_id).map_or("", Badge::mark)
),
text,
silent: notification.is_silent,
};
self.notifier.add(note, Instant::now());
}
}
fn notification_text(&self, message: &Message) -> String {
let text = crate::chats::content_text(&message.content);
match &message.sender_id {
MessageSender::User(sender) if sender.user_id != message.chat_id => {
match self.users.get(&sender.user_id) {
Some(name) => format!("{name}: {text}"),
None => text,
}
}
_ => text,
}
}
fn send_notification(&mut self) {
let Some(alert) = self.notifier.due(Instant::now()) else {
return;
};
self.notifications_sent += 1;
let id = self.notifications_sent;
if let Some(code) = notify::escape(self.notify_with, &alert, id, self.in_tmux) {
notify::send(&code);
}
}
fn set_unread_chats(&mut self, count: i32) {
if count != self.unread_chats {
self.unread_chats = count;
notify::send(¬ify::title(count));
}
}
fn ask_to_leave(&mut self) {
let chat_id = match self.focus {
Focus::Chats => self.selected,
_ => self.open.as_ref().map(|o| o.chat_id),
};
let Some((chat_id, chat)) = chat_id.and_then(|id| Some((id, self.chats.get(id)?))) else {
self.status = Some("Open the group or channel to leave first".into());
return;
};
if chat.is_private {
self.status = Some("A chat with one person can't be left".into());
return;
}
if !self.chats.joined(chat_id) {
self.status = Some("You're not in this chat".into());
return;
}
let title = self.chats.title(chat_id).unwrap_or_default();
let (ask, why) = if chat.is_channel {
("Leave this channel?", "Its posts stop coming to you.")
} else {
("Leave this group?", "Its messages stop coming to you.")
};
let mut confirm = Confirm::new(
ask,
vec![title.to_string(), why.into()],
Confirmed::Leave(chat_id),
);
confirm.badge = self.chats.badge(chat_id);
self.confirm = Some(confirm);
}
fn log_out(&mut self) {
self.tg.log_out();
self.relogin = true;
self.screen = login_screen(LoginStep::LoggingOut);
}
fn forget_session(&mut self) {
self.settings.highlighted_chats.clear();
if let Err(e) = self.settings.save(&self.settings_path) {
self.status = Some(format!("Couldn't save settings: {e:#}"));
}
self.chats = Chats::default();
self.users.clear();
self.selected = None;
self.open = None;
self.focus = Focus::Chats;
self.composer = new_composer();
self.images.forget_files();
self.opening.clear();
self.copying.clear();
self.menu = None;
self.delete_menu = None;
self.react_menu = None;
self.vote_menu = None;
self.picker = None;
self.finding = None;
self.completion = None;
self.stickers = None;
self.premium = false;
self.confirm = None;
self.settings_menu = None;
self.prompt = None;
self.chats_loading = false;
self.all_chats_loaded = false;
self.pending_g = false;
self.online = false;
self.typing = None;
self.notifier.clear();
self.notify_since = i32::MAX;
self.set_unread_chats(0);
}
fn next_match(&mut self, step: isize) {
let Some(search) = self.open.as_ref().and_then(|o| o.search.as_ref()) else {
return;
};
let Some(current) = search.current else {
return;
};
match current.checked_add_signed(step) {
Some(index) => self.go_to_match(index),
None => self.status = Some("No newer matches".into()),
}
}
fn go_to_match(&mut self, index: usize) {
let Some(open) = self.open.as_mut() else {
return;
};
let Some(search) = open.search.as_mut() else {
return;
};
if let Some(&id) = search.results.get(index) {
search.current = Some(index);
self.jump_to_message(id);
} else if !search.done {
search.wanted = Some(index);
if !search.loading {
search.loading = true;
let (query, from) = (search.query.clone(), search.next_from);
self.tg
.search_messages(open.chat_id, query, from, SEARCH_PAGE);
}
} else if search.results.is_empty() {
self.status = Some(format!("No messages match \"{}\"", search.query));
open.search = None;
} else {
self.status = Some("No older matches".into());
}
}
fn jump_to_message(&mut self, id: i64) {
let Some(open) = self.open.as_mut() else {
return;
};
if open.messages.contains_key(&id) {
open.selected = Some(id);
if matches!(open.loading, Some(Page::Around(_))) {
open.loading = None;
}
return;
}
let page = Page::Around(id);
open.loading = Some(page);
self.tg.load_history(open.chat_id, page, HISTORY_PAGE);
}
fn jump_to_newest(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
open.selected = None;
if open.at_newest {
return;
}
open.messages.clear();
open.scroll = None;
open.at_newest = true;
open.all_loaded = false;
open.loading = None;
self.load_older_messages();
}
fn open_selected_chat(&mut self) {
if let Some(chat_id) = self.selected {
self.open_chat(chat_id);
}
}
fn open_chat(&mut self, chat_id: i64) {
self.finding = None;
self.menu = None;
self.delete_menu = None;
self.react_menu = None;
self.vote_menu = None;
if self.chats.listed(chat_id) && self.selected != Some(chat_id) {
if !self.chats.ids().contains(&chat_id) {
self.chats.set_filter("");
}
self.selected = Some(chat_id);
}
self.focus = Focus::Messages;
if self.open.as_ref().is_some_and(|o| o.chat_id == chat_id) {
return;
}
self.set_typing(false);
if let Some(old) = self.open.take() {
self.tg.close_chat(old.chat_id);
self.images.clear();
}
self.tg.open_chat(chat_id);
self.chats.opened(chat_id);
self.open = Some(OpenChat::new(chat_id));
self.composer = new_composer();
self.load_older_messages();
}
fn open_selected_message(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
if let Some(id) = open.cursor_id()
&& open.reveal_spoilers(id)
{
return;
}
if let Some((&id, msg)) = open
.cursor_id()
.and_then(|id| open.messages.get_key_value(&id))
&& let Some(poll) = &msg.poll
{
match (msg.state, poll.cant_vote()) {
(SendState::Sent, None) => self.vote_menu = Some(VoteMenu::new(id, poll)),
(SendState::Sent, Some(why)) => self.status = Some(why.into()),
_ => self.status = Some("Wait until it's sent".into()),
}
return;
}
let Some(msg) = open.cursor_id().and_then(|id| open.messages.get(&id)) else {
return;
};
let mut targets: Vec<Target> = msg.file.clone().map(Target::File).into_iter().collect();
targets.extend(msg.links.iter().cloned().map(Target::Link));
match targets.len() {
0 => self.status = Some("Nothing to open in this message".into()),
1 => self.open_target(targets.remove(0)),
_ => {
self.menu = Some(PickMenu {
action: MenuAction::Open,
targets,
selected: 0,
})
}
}
}
fn reply_to_selected(&mut self) {
self.end_edit();
let Some(open) = self.open.as_mut() else {
return;
};
let Some((&id, msg)) = open
.cursor_id()
.and_then(|id| open.messages.get_key_value(&id))
else {
return;
};
if msg.state == SendState::Failed {
self.status = Some("Can't reply to a message that wasn't sent".into());
return;
}
open.reply = Some(Replied::new(id, msg));
self.focus = Focus::Input;
}
fn edit_selected(&mut self) {
let Some(open) = &self.open else {
return;
};
let Some(id) = open.edit_target() else {
return;
};
match open.cant_edit(id) {
Some(why) => self.status = Some(why.into()),
None => self.tg.check_editable(open.chat_id, id),
}
}
fn on_editable(
&mut self,
chat_id: i64,
message_id: i64,
editable: Option<bool>,
text: Option<EditText>,
) {
let Some(open) = self
.open
.as_ref()
.filter(|o| o.chat_id == chat_id && o.edit_target() == Some(message_id))
else {
return;
};
let Some(msg) = open.messages.get(&message_id) else {
return;
};
let (text, loses) = match text {
Some(text) => (text.markdown, text.loses),
None => (msg.source_text.clone(), msg.formatted),
};
match editable {
None => {}
Some(false) => self.status = Some("You can't edit this message".into()),
Some(true) if loses => {
self.confirm = Some(Confirm::new(
"Edit and lose some formatting?",
vec![
"Some of its formatting can't be written in Markdown".into(),
"(underline, custom emoji…), so an edit would lose it.".into(),
],
Confirmed::Edit {
id: message_id,
text,
},
));
}
Some(true) => self.start_edit(message_id, text),
}
}
fn start_edit(&mut self, id: i64, text: String) {
self.end_edit();
let Some(open) = self.open.as_mut() else {
return;
};
let Some(msg) = open.messages.get(&id) else {
return;
};
open.editing = Some(Editing {
id,
snippet: msg.snippet(),
original: text.clone(),
editable: msg.editable,
draft: self.composer.lines().join("\n"),
reply: open.reply.take(),
attachments: std::mem::take(&mut open.attachments),
});
open.dropped = None;
self.set_typing(false);
self.composer = new_composer();
self.composer.insert_str(text);
self.focus = Focus::Input;
}
fn end_edit(&mut self) {
let Some(editing) = self.open.as_mut().and_then(|o| o.editing.take()) else {
return;
};
self.composer = new_composer();
self.composer.insert_str(editing.draft);
if let Some(open) = self.open.as_mut() {
open.reply = editing.reply;
open.attachments = editing.attachments;
}
}
fn save_edit(&mut self) {
let Some(open) = &self.open else {
return;
};
let Some(editing) = &open.editing else {
return;
};
let text = self.composer.lines().join("\n");
let text = text.trim();
if text.is_empty() && editing.editable == Editable::Text {
self.status = Some("A message can't be empty (d deletes it)".into());
return;
}
if text != editing.original.trim() {
let (chat_id, id, text) = (open.chat_id, editing.id, text.to_string());
match editing.editable {
Editable::Text => self.tg.edit_text(chat_id, id, text),
Editable::Caption { above } => self.tg.edit_caption(chat_id, id, text, above),
Editable::No => {}
}
}
self.end_edit();
if self.settings.normal_after_send {
self.focus = Focus::Messages;
}
}
fn open_delete_menu(&mut self) {
let Some(open) = &self.open else {
return;
};
let Some((&message_id, msg)) = open
.cursor_id()
.and_then(|id| open.messages.get_key_value(&id))
else {
return;
};
self.delete_menu = Some(DeleteMenu {
message_id,
snippet: msg.snippet(),
choices: Vec::new(),
selected: 0,
});
self.tg.check_deletable(open.chat_id, message_id);
}
fn on_deletable(&mut self, chat_id: i64, message_id: i64, deletable: Option<Deletable>) {
if self.open.as_ref().is_none_or(|o| o.chat_id != chat_id) {
return;
}
let Some(menu) = self
.delete_menu
.as_mut()
.filter(|m| m.message_id == message_id)
else {
return;
};
let Some(deletable) = deletable else {
self.delete_menu = None;
return;
};
menu.set_allowed(deletable);
if menu.choices.is_empty() {
self.delete_menu = None;
self.status = Some("You can't delete this message".into());
}
}
fn on_delete_key(&mut self, key: KeyEvent) {
let Some(menu) = self.delete_menu.as_mut() else {
return;
};
let last = menu.choices.len().saturating_sub(1);
let pick = match key.code {
KeyCode::Char('j') | KeyCode::Down => {
menu.selected = (menu.selected + 1).min(last);
None
}
KeyCode::Char('k') | KeyCode::Up => {
menu.selected = menu.selected.saturating_sub(1);
None
}
KeyCode::Enter | KeyCode::Char('l') => Some(menu.selected),
KeyCode::Char(c @ '1'..='9') => Some(c as usize - '1' as usize),
KeyCode::Esc | KeyCode::Char('q' | 'h') => {
self.delete_menu = None;
return;
}
_ => None,
};
let Some(choice) = pick.and_then(|i| menu.choices.get(i).copied()) else {
return;
};
let message_id = menu.message_id;
self.delete_menu = None;
if let Some(open) = &self.open {
let revoke = choice == DeleteChoice::Everyone;
self.tg.delete_message(open.chat_id, message_id, revoke);
}
}
fn open_react_menu(&mut self) {
let Some(open) = &self.open else {
return;
};
let Some((&message_id, msg)) = open
.react_target()
.and_then(|id| open.messages.get_key_value(&id))
else {
return;
};
match msg.state {
SendState::Pending => self.status = Some("Wait until it's sent".into()),
SendState::Failed => self.status = Some("This message wasn't sent".into()),
SendState::Sent => {
self.react_menu = Some(ReactMenu::new(message_id, msg.snippet()));
self.tg.available_reactions(open.chat_id, message_id);
}
}
}
fn on_available_reactions(
&mut self,
chat_id: i64,
message_id: i64,
available: Option<reactions::Available>,
) {
if self.open.as_ref().is_none_or(|o| o.chat_id != chat_id)
|| self
.react_menu
.as_ref()
.is_none_or(|m| m.message_id != message_id)
{
return;
}
let Some(available) = available else {
self.react_menu = None;
return;
};
if available.emoji.is_empty() {
self.react_menu = None;
let reason = available.reason.unwrap_or("Reactions are off in this chat");
self.status = Some(reason.into());
return;
}
let yours: Vec<String> = self
.open
.iter()
.flat_map(|o| o.your_emoji(message_id))
.map(|(_, emoji)| emoji)
.collect();
if let Some(menu) = self.react_menu.as_mut() {
menu.set_choices(available.emoji, &yours);
}
}
fn on_react_key(&mut self, key: KeyEvent, ctrl: bool) {
let Some(menu) = self.react_menu.as_mut() else {
return;
};
let row = reactions::COLUMNS as isize;
let searching = menu.query.is_some();
match key.code {
KeyCode::Enter => self.react_with_selected(),
KeyCode::Left => menu.move_by(-1),
KeyCode::Right | KeyCode::Tab => menu.move_by(1),
KeyCode::BackTab => menu.move_by(-1),
KeyCode::Up => menu.move_by(-row),
KeyCode::Down => menu.move_by(row),
KeyCode::Char('n') if ctrl => menu.move_by(1),
KeyCode::Char('p') if ctrl => menu.move_by(-1),
KeyCode::Esc if searching => menu.leave_search(),
KeyCode::Backspace if menu.query.as_ref().is_some_and(|q| q.is_empty()) => {
menu.leave_search();
}
KeyCode::Backspace if searching => menu.edit_query(|q| {
q.pop();
}),
KeyCode::Char('u' | 'w') if ctrl && searching => menu.edit_query(String::clear),
KeyCode::Char(c) if searching && !ctrl => menu.edit_query(|q| q.push(c)),
KeyCode::Char('h') => menu.move_by(-1),
KeyCode::Char('l') => menu.move_by(1),
KeyCode::Char('k') => menu.move_by(-row),
KeyCode::Char('j') => menu.move_by(row),
KeyCode::Char('/') => menu.edit_query(|_| {}),
KeyCode::Char('X') => {
self.react_menu = None;
self.remove_reactions();
}
KeyCode::Esc | KeyCode::Char('q' | 'R') => self.react_menu = None,
_ => {}
}
}
fn react_with_selected(&mut self) {
let Some((message_id, emoji)) = self
.react_menu
.as_ref()
.and_then(|m| Some((m.message_id, m.current()?.to_string())))
else {
return;
};
self.react_menu = None;
let Some(open) = &self.open else {
return;
};
let yours = open
.your_emoji(message_id)
.into_iter()
.find(|(_, e)| *e == emoji);
let kind = ReactionKind::Emoji(emoji);
match yours {
Some((on, _)) => self.tg.react(open.chat_id, on, &kind, false),
None => self.tg.react(open.chat_id, message_id, &kind, true),
}
}
fn on_vote_key(&mut self, key: KeyEvent) {
let Some(menu) = self.vote_menu.as_mut() else {
return;
};
match key.code {
KeyCode::Char('j') | KeyCode::Down => menu.move_by(1),
KeyCode::Char('k') | KeyCode::Up => menu.move_by(-1),
KeyCode::Char(' ') => menu.tick(),
KeyCode::Char(c @ '1'..='9') => {
let index = c as usize - '1' as usize;
if index < menu.answers.len() {
menu.selected = index;
if menu.several {
menu.tick();
} else {
self.cast_vote();
}
}
}
KeyCode::Enter | KeyCode::Char('l') => self.cast_vote(),
KeyCode::Esc | KeyCode::Char('q' | 'h') => self.vote_menu = None,
_ => {}
}
}
fn cast_vote(&mut self) {
let Some(menu) = self.vote_menu.take() else {
return;
};
let Some(open) = &self.open else {
return;
};
let answers = match menu.vote() {
Vote::For(answers) => answers,
Vote::Retract => Vec::new(),
};
self.tg.vote(open.chat_id, menu.message_id, answers);
}
fn forward_selected(&mut self) {
let Some(open) = &self.open else {
return;
};
let Some((&id, msg)) = open
.cursor_id()
.and_then(|id| open.messages.get_key_value(&id))
else {
return;
};
match msg.state {
SendState::Pending => self.status = Some("Wait until it's sent".into()),
SendState::Failed => self.status = Some("This message wasn't sent".into()),
SendState::Sent => {
self.picker = Some(ChatPicker::new(Purpose::Forward {
from: open.chat_id,
message_ids: open.forward_ids(id),
snippet: msg.snippet(),
}));
}
}
}
fn on_picker_key(&mut self, key: KeyEvent, ctrl: bool) {
let choices = self
.picker
.as_ref()
.map_or(Vec::new(), |p| p.choices(&self.chats));
let Some(picker) = self.picker.as_mut() else {
return;
};
let now = Instant::now();
match key.code {
KeyCode::Enter => self.pick_chat(),
KeyCode::Esc => self.picker = None,
KeyCode::Char('c') if ctrl => self.picker = None,
KeyCode::Up | KeyCode::BackTab => picker.move_by(-1, &choices),
KeyCode::Down | KeyCode::Tab => picker.move_by(1, &choices),
KeyCode::Char('p') if ctrl => picker.move_by(-1, &choices),
KeyCode::Char('n') if ctrl => picker.move_by(1, &choices),
KeyCode::PageUp => picker.move_by(-HALF_PAGE, &choices),
KeyCode::PageDown => picker.move_by(HALF_PAGE, &choices),
KeyCode::Backspace => picker.edit_query(
|q| {
q.pop();
},
now,
),
KeyCode::Char('u' | 'w') if ctrl => picker.edit_query(String::clear, now),
KeyCode::Char(c) if !ctrl => picker.edit_query(|q| q.push(c), now),
_ => {}
}
}
fn pick_chat(&mut self) {
let Some(picker) = self.picker.as_ref() else {
return;
};
let Some(choice) = picker.current(&picker.choices(&self.chats)) else {
return;
};
let Some(picker) = self.picker.take() else {
return;
};
match (picker.purpose, choice) {
(
Purpose::Forward {
from, message_ids, ..
},
Choice::Chat(to),
) => self.tg.forward(to, from, message_ids),
(Purpose::Forward { .. }, _) => {}
(Purpose::Open, Choice::Chat(id)) => self.open_chat(id),
(Purpose::Open, Choice::User(user_id)) => {
let name = self
.users
.get(&user_id)
.cloned()
.unwrap_or_else(|| "your contact".into());
self.finding = Some(Finding::new(&name));
self.tg.find_private_chat(user_id, name);
}
(Purpose::Open, Choice::Username(name)) => {
let request = format!("@{name}");
self.finding = Some(Finding::new(&request));
self.tg.find_username(name, request);
}
(Purpose::Open, Choice::Link(link)) => {
self.finding = Some(Finding::new(&link));
self.tg.find_link(link.clone(), link);
}
}
}
fn confirm_invite(&mut self, link: String, request: String, invite: Invite) {
let kind = if invite.channel { "channel" } else { "group" };
let mut lines = vec![
invite.title,
format!("A {kind} with {} members.", invite.members),
];
if invite.by_request {
lines.push("An admin has to let you in.".into());
}
let title = if invite.by_request {
format!("Ask to join this {kind}?")
} else {
format!("Join this {kind}?")
};
let mut confirm = Confirm::new(title, lines, Confirmed::JoinLink { link, request });
confirm.badge = invite.badge;
self.confirm = Some(confirm);
}
fn remove_reactions(&mut self) {
let Some(open) = &self.open else {
return;
};
let yours = open.your_reactions();
if yours.is_empty() {
self.status = Some("You haven't reacted to this message".into());
return;
}
for (message_id, kind) in yours {
self.tg.react(open.chat_id, message_id, &kind, false);
}
}
fn on_resize_key(&mut self, key: KeyEvent, ctrl: bool) {
let Some(before) = self.resizing else {
return;
};
let left = match self.settings.chat_list_side {
Side::Left => -1,
Side::Right => 1,
};
match key.code {
KeyCode::Char('h') | KeyCode::Left => self.settings.resize_list(left),
KeyCode::Char('l') | KeyCode::Right => self.settings.resize_list(-left),
KeyCode::Char('=') => self.settings.chat_list_width = settings::DEFAULT_LIST_WIDTH,
KeyCode::Esc => {
self.settings.chat_list_width = before;
self.resizing = None;
}
KeyCode::Enter => self.end_resize(),
KeyCode::Char('r') if ctrl => self.end_resize(),
_ => {}
}
}
fn end_resize(&mut self) {
let before = self.resizing.take();
if before != Some(self.settings.chat_list_width)
&& let Err(e) = self.settings.save(&self.settings_path)
{
self.status = Some(format!("Couldn't save settings: {e:#}"));
}
}
fn open_stickers(&mut self) {
let Some(open) = &self.open else {
return;
};
if open.editing.is_some() {
self.status = Some("An edit can't change a message into a sticker".into());
return;
}
self.stickers = Some(StickerPanel::new(open.chat_id));
self.tg.stickers(Source::Recent);
self.tg.stickers(Source::Favorites);
self.tg.sticker_sets();
}
fn on_sticker_key(&mut self, key: KeyEvent, ctrl: bool) {
let Some(panel) = self.stickers.as_mut() else {
return;
};
let searching = panel.query.is_some();
let before = panel.search_query();
match key.code {
KeyCode::Enter => self.send_sticker(),
KeyCode::Char('c') if ctrl => self.leave_insert(),
KeyCode::Left => panel.move_by(-1),
KeyCode::Right => panel.move_by(1),
KeyCode::Up => panel.move_rows(-1),
KeyCode::Down => panel.move_rows(1),
KeyCode::Char('n') if ctrl => panel.move_by(1),
KeyCode::Char('p') if ctrl => panel.move_by(-1),
KeyCode::Esc if searching => panel.leave_search(),
KeyCode::Backspace if panel.query.as_ref().is_some_and(|q| q.is_empty()) => {
panel.leave_search();
}
KeyCode::Backspace if searching => panel.edit_query(|q| {
q.pop();
}),
KeyCode::Char('u' | 'w') if ctrl && searching => panel.edit_query(String::clear),
KeyCode::Char(c) if searching && !ctrl => panel.edit_query(|q| q.push(c)),
KeyCode::Char('h') => panel.move_by(-1),
KeyCode::Char('l') => panel.move_by(1),
KeyCode::Char('k') => panel.move_rows(-1),
KeyCode::Char('j') => panel.move_rows(1),
KeyCode::Char('H') => panel.switch(-1),
KeyCode::Char('L') => panel.switch(1),
KeyCode::Char('/') => panel.edit_query(|_| {}),
KeyCode::Esc | KeyCode::Tab | KeyCode::Char('i' | 'q') => self.stickers = None,
_ => {}
}
self.find_stickers(before);
self.load_sticker_set();
}
fn find_stickers(&mut self, before: Option<String>) {
if let Some(panel) = &self.stickers
&& let Some(query) = panel.search_query()
&& Some(&query) != before.as_ref()
{
self.tg.find_stickers(panel.chat_id, query);
}
}
fn load_sticker_set(&mut self) {
if let Some(set_id) = self.stickers.as_mut().and_then(StickerPanel::pending_set) {
self.tg.stickers(Source::Set(set_id));
}
}
fn on_stickers(&mut self, source: Source, stickers: Option<Vec<tdlib_rs::types::Sticker>>) {
let premium = self.premium;
let Some(panel) = self.stickers.as_mut() else {
return;
};
let stickers = stickers::convert(&stickers.unwrap_or_default(), premium);
panel.set_stickers(source, stickers);
self.load_sticker_set();
}
fn send_sticker(&mut self) {
let Some(panel) = &self.stickers else {
return;
};
let Some(sticker) = panel.current().cloned() else {
return;
};
let Some(open) = self.open.as_mut().filter(|o| o.chat_id == panel.chat_id) else {
return;
};
let reply_to = open.reply.take().map(|r| r.id);
self.tg.send_sticker(open.chat_id, &sticker, reply_to);
self.stickers = None;
self.typing = None;
if self.settings.normal_after_send {
self.focus = Focus::Messages;
}
self.jump_to_newest();
}
fn go_to_replied(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
match open.replied_jump() {
Ok((from, to)) => {
open.jumps.push(from);
self.jump_to_message(to);
}
Err(why) => self.status = Some(why.into()),
}
}
fn jump_back(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
match open.jumps.pop() {
Some(id) => self.jump_to_message(id),
None => self.status = Some("Nothing to go back to".into()),
}
}
fn open_target(&mut self, target: Target) {
match target {
Target::File(file) => {
if self.opening.insert(file.id) {
self.tg.download(file.id);
}
}
Target::Link(link) if picker::telegram_link(&link.url) => {
self.finding = Some(Finding {
request: link.url.clone(),
link: Some(link.clone()),
});
self.tg.find_link(link.url.clone(), link.url);
}
Target::Link(link) => self.open_link_outside(link),
Target::Text(_) => {}
}
}
fn open_link_outside(&mut self, link: Link) {
match link {
Link {
url,
disguise: Some(shown),
} => {
let site = link_host(&url).map(|host| match host.is_ascii() {
true => host,
false => format!("{host} (has non-Latin letters)"),
});
let mut confirm = Confirm::new(
"Open this link?",
vec![
format!("The text says: {shown}"),
format!("Full address: {url}"),
],
Confirmed::OpenLink(url),
);
confirm.site = site;
self.confirm = Some(confirm);
}
link => self.open_externally(&link.url),
}
}
fn open_downloaded(&mut self, path: String) {
mark_downloaded(&path);
if safe_to_open(&path) {
self.open_externally(&path);
return;
}
let name = text::clean(
&Path::new(&path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
);
if self.busy() {
self.status = Some(format!(
"{name} downloaded: press Enter on it again to open it"
));
return;
}
self.confirm = Some(Confirm::new(
format!("Open {name}?"),
vec![
"Files like this can run programs on your computer.".into(),
"Only open it if you trust whoever sent it.".into(),
],
Confirmed::OpenFile(path),
));
}
fn busy(&self) -> bool {
self.confirm.is_some()
|| self.settings_menu.is_some()
|| self.delete_menu.is_some()
|| self.react_menu.is_some()
|| self.vote_menu.is_some()
|| self.menu.is_some()
|| self.picker.is_some()
|| self.resizing.is_some()
|| self.prompt.is_some()
|| self.focus == Focus::Input
}
fn open_externally(&mut self, target: &str) {
if let Err(e) = open_externally(target) {
self.status = Some(format!("Couldn't open it: {e}"));
}
}
fn on_confirm_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('y')
if self
.confirm
.as_ref()
.is_some_and(|c| c.shown.elapsed() >= CONFIRM_GRACE) =>
{
if let Some(confirm) = self.confirm.take() {
match confirm.action {
Confirmed::OpenFile(target) | Confirmed::OpenLink(target) => {
self.open_externally(&target)
}
Confirmed::Edit { id, text } => self.start_edit(id, text),
Confirmed::Join(chat_id) => self.tg.join_chat(chat_id),
Confirmed::Leave(chat_id) => self.tg.leave_chat(chat_id),
Confirmed::JoinLink { link, request } => {
self.finding = Some(Finding::new(&request));
self.tg.join_by_link(link, request);
}
}
}
}
KeyCode::Char('n' | 'q') | KeyCode::Esc => self.confirm = None,
_ => {}
}
}
fn copy_selected(&mut self) {
let Some(open) = &self.open else {
return;
};
let Some(msg) = open.cursor_id().and_then(|id| open.messages.get(&id)) else {
return;
};
let mut targets = Vec::new();
if !msg.source_text.is_empty() {
targets.push(Target::Text(msg.source_text.clone()));
}
targets.extend(msg.links.iter().cloned().map(Target::Link));
targets.extend(msg.file.clone().map(Target::File));
match targets.len() {
0 => self.status = Some("Nothing to copy in this message".into()),
1 => self.copy_target(targets.remove(0)),
_ => {
self.menu = Some(PickMenu {
action: MenuAction::Copy,
targets,
selected: 0,
})
}
}
}
fn copy_target(&mut self, target: Target) {
let text = match target {
Target::Text(text) => text,
Target::Link(link) => link.url,
Target::File(file) => {
let id = file.id;
if self.copying.insert(id, file).is_none() {
self.tg.download(id);
}
return;
}
};
match self.clipboard.copy_text(&text) {
Ok(Copied::System) => self.show_toast("Copied", &text),
Ok(Copied::Terminal) => self.show_toast("Sent to the terminal's clipboard", &text),
Err(e) => self.status = Some(format!("Couldn't copy: {e}")),
}
}
fn copy_downloaded(&mut self, file: MediaFile, path: &str) {
if file.photo {
self.clipboard.decode_image(path.to_string(), file.label);
return;
}
match self.clipboard.copy_file(Path::new(path)) {
Ok(()) => self.show_toast("Copied", &file.label),
Err(e) => self.status = Some(format!("Couldn't copy: {e}")),
}
}
fn on_clipboard(&mut self, event: ClipboardEvent) {
match event {
ClipboardEvent::Decoded(decoded) => self.on_decoded(decoded),
ClipboardEvent::Pasted(pasted) => self.on_pasted(pasted),
}
}
fn can_attach(&mut self) -> bool {
match &self.open {
None => false,
Some(open) if open.editing.is_some() => {
self.status = Some("Files can't be added to an edit".into());
false
}
Some(_) => true,
}
}
fn attach(&mut self, paths: Vec<PathBuf>, pasted: Option<String>) -> usize {
if !self.can_attach() {
return 0;
}
let Some(open) = self.open.as_mut() else {
return 0;
};
let mut added = 0;
for path in paths {
match Attachment::new(&path) {
Ok(attachment) => {
open.attachments.push(attachment);
added += 1;
}
Err(e) => self.status = Some(e),
}
}
if added == 0 {
return 0;
}
open.dropped = pasted.map(|text| Dropped { text, count: added });
self.focus = Focus::Input;
added
}
fn on_paste(&mut self, text: String) {
let editing = self.open.as_ref().is_some_and(|o| o.editing.is_some());
if !editing && let Some(paths) = attach::pasted_paths(&text) {
self.attach(paths, Some(text));
return;
}
if self.focus == Focus::Input
&& let Some(panel) = self.stickers.as_mut()
{
let before = panel.search_query();
let line = text::clean(&text)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
panel.edit_query(|q| q.push_str(&line));
self.find_stickers(before);
return;
}
if self.focus == Focus::Input {
self.composer.insert_str(text.replace('\r', ""));
self.on_composer_edit();
}
}
fn paste_clipboard(&mut self) {
let Some(open) = &self.open else {
return;
};
self.clipboard.paste(open.chat_id);
self.pasting = true;
}
fn on_pasted(&mut self, pasted: Pasted) {
self.pasting = false;
if self
.open
.as_ref()
.is_none_or(|o| o.chat_id != pasted.chat_id)
{
return;
}
match pasted.content {
Ok(Paste::Files(paths)) => {
let (paths, relative): (Vec<_>, Vec<_>) =
paths.into_iter().partition(|p| p.is_absolute());
if !relative.is_empty() {
self.status = Some("Copied files without a full path were left out".into());
}
self.attach(paths, None);
}
Ok(Paste::Image(path)) => {
if self.attach(vec![path], None) == 1
&& let Some(last) = self.open.as_mut().and_then(|o| o.attachments.last_mut())
{
last.name = "Pasted image".into();
}
}
Ok(Paste::Text(text)) => {
self.focus = Focus::Input;
self.on_paste(text);
}
Err(e) => self.status = Some(e),
}
}
fn toggle_as_files(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
if open.attachments.iter().any(|a| a.kind.is_photo()) {
open.as_files = !open.as_files;
} else if !open.attachments.is_empty() {
self.status = Some("These go as files already: only photos can go either way".into());
}
}
fn undo_drop(&mut self) {
let Some(text) = self.open.as_mut().and_then(OpenChat::undo_drop) else {
return;
};
self.composer.insert_str(text.replace('\r', ""));
self.on_composer_edit();
}
fn on_decoded(&mut self, decoded: Decoded) {
let result = decoded
.image
.and_then(|image| self.clipboard.copy_image(image).map_err(|e| e.to_string()));
match result {
Ok(()) => self.show_toast("Copied", &decoded.label),
Err(e) => self.status = Some(format!("Couldn't copy: {e}")),
}
}
fn show_toast(&mut self, title: &str, detail: &str) {
self.toast = Some(Toast {
title: title.into(),
detail: detail.split_whitespace().collect::<Vec<_>>().join(" "),
until: Instant::now() + TOAST_TIME,
});
}
fn on_menu_key(&mut self, key: KeyEvent) {
let Some(menu) = self.menu.as_mut() else {
return;
};
let last = menu.targets.len() - 1;
match key.code {
KeyCode::Char('j') | KeyCode::Down => menu.selected = (menu.selected + 1).min(last),
KeyCode::Char('k') | KeyCode::Up => menu.selected = menu.selected.saturating_sub(1),
KeyCode::Enter | KeyCode::Char('l') => {
let index = menu.selected;
self.pick(index);
}
KeyCode::Char(c @ '1'..='9') => {
let index = c as usize - '1' as usize;
if index <= last {
self.pick(index);
}
}
KeyCode::Esc | KeyCode::Char('q' | 'h') => self.menu = None,
_ => {}
}
}
fn pick(&mut self, index: usize) {
let Some(mut menu) = self.menu.take() else {
return;
};
let target = menu.targets.swap_remove(index);
match menu.action {
MenuAction::Open => self.open_target(target),
MenuAction::Copy => self.copy_target(target),
}
}
fn on_settings_key(&mut self, key: KeyEvent, ctrl: bool) {
let Some(menu) = self.settings_menu.as_mut() else {
return;
};
match key.code {
KeyCode::Tab | KeyCode::BackTab | KeyCode::Char('h' | 'l') => {
menu.tab = match menu.tab {
HelpTab::Shortcuts => HelpTab::Settings,
HelpTab::Settings => HelpTab::Shortcuts,
};
return;
}
KeyCode::Enter | KeyCode::Char(' ') if menu.tab == HelpTab::Settings => {
self.change_setting();
return;
}
KeyCode::Esc | KeyCode::Char('q' | '?') => {
self.settings_menu = None;
return;
}
_ => {}
}
let delta = match key.code {
KeyCode::Char('j') | KeyCode::Down => 1,
KeyCode::Char('k') | KeyCode::Up => -1,
KeyCode::Char('d') if ctrl => HALF_PAGE,
KeyCode::Char('u') if ctrl => -HALF_PAGE,
KeyCode::Char('g') => isize::MIN,
KeyCode::Char('G') => isize::MAX,
_ => return,
};
match menu.tab {
HelpTab::Shortcuts => menu.scroll = menu.scroll.saturating_add_signed(delta),
HelpTab::Settings => {
let last = SettingsMenu::THEMES + self.themes.list.len() - 1;
menu.selected = menu.selected.saturating_add_signed(delta).min(last);
}
}
}
fn change_setting(&mut self) {
let Some(menu) = self.settings_menu.as_mut() else {
return;
};
let settings = &mut self.settings;
match menu.selected {
i if i >= SettingsMenu::THEMES => {
let Some(theme) = self.themes.list.get(i - SettingsMenu::THEMES) else {
return;
};
match &theme.colors {
Ok(colors) => {
self.colors = *colors;
settings.theme = theme.id.clone();
}
Err(e) => {
self.status = Some(format!("Theme not used: {e}"));
return;
}
}
}
SettingsMenu::NOTIFICATIONS => {
let on = match menu.saved_notifications {
Notifications::Off => Notifications::Auto,
saved => saved,
};
let next = match settings.notifications {
Notifications::Off => on,
_ => Notifications::Off,
};
self.set_notifications(next);
}
SettingsMenu::CHAT_GAPS => settings.chat_gaps = !settings.chat_gaps,
SettingsMenu::LIST_RIGHT => {
settings.chat_list_side = match settings.chat_list_side {
Side::Left => Side::Right,
Side::Right => Side::Left,
};
}
SettingsMenu::BLOCK_GAPS => settings.block_gaps = !settings.block_gaps,
SettingsMenu::AFTER_SEND => settings.normal_after_send = !settings.normal_after_send,
_ => return,
}
if let Err(e) = self.settings.save(&self.settings_path) {
self.status = Some(format!("Settings not saved: {e:#}"));
}
}
fn set_notifications(&mut self, notifications: Notifications) {
self.settings.notifications = notifications;
self.notify_with = notifications.resolve(|name| std::env::var(name).ok());
if notifications == Notifications::Off {
self.notifier.clear();
}
}
fn load_older_messages(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
if open.loading.is_some() || open.all_loaded {
return;
}
let page = open.oldest_id().map_or(Page::Latest, Page::Older);
open.loading = Some(page);
self.tg.load_history(open.chat_id, page, HISTORY_PAGE);
}
fn load_newer_messages(&mut self) {
let Some(open) = self.open.as_mut() else {
return;
};
if open.loading.is_some() || open.at_newest {
return;
}
let Some(newest) = open.newest_id() else {
return;
};
let page = Page::Newer(newest);
open.loading = Some(page);
self.tg.load_history(open.chat_id, page, HISTORY_PAGE);
}
fn move_message_cursor(&mut self, delta: isize) {
let Some(open) = self.open.as_mut() else {
return;
};
if delta == isize::MAX && !open.at_newest {
self.jump_to_newest();
return;
}
let Some(index) = open.move_cursor(delta) else {
return;
};
let len = open.messages.len();
if index < LOAD_AHEAD {
self.load_older_messages();
}
if index + LOAD_AHEAD >= len {
self.load_newer_messages();
}
}
fn move_chat_cursor(&mut self, delta: isize) {
let ids = self.chats.ids();
if ids.is_empty() {
return;
}
let last = ids.len() - 1;
let index = match self
.selected
.and_then(|id| ids.iter().position(|&x| x == id))
{
Some(current) => current.saturating_add_signed(delta).min(last),
None => 0,
};
self.selected = Some(ids[index]);
if index + LOAD_AHEAD >= last {
self.load_more_chats();
}
}
fn load_more_chats(&mut self) {
if self.chats_loading || self.all_chats_loaded {
return;
}
self.chats_loading = true;
self.tg.load_chats(CHAT_PAGE);
}
fn toggle_pin(&mut self) {
let Some(chat_id) = self.selected else {
return;
};
let pinned = self.chats.get(chat_id).is_some_and(|c| c.pinned);
self.tg.pin_chat(chat_id, !pinned);
}
fn toggle_mute(&mut self) {
let Some(chat_id) = self.selected else {
return;
};
let mute = !self.chats.muted(chat_id);
if let Some(settings) = self.chats.with_mute(chat_id, mute) {
self.tg.set_notifications(chat_id, settings);
}
}
fn toggle_highlight(&mut self) {
let Some(chat_id) = self.selected else {
return;
};
self.settings.highlighted_chats = self.chats.toggle_highlight(chat_id);
if let Err(e) = self.settings.save(&self.settings_path) {
self.status = Some(format!("Highlight not saved: {e:#}"));
}
}
fn quit(&mut self) {
if self.quit_deadline.is_some() {
self.exit = true;
return;
}
self.tg.close();
self.quit_deadline = Some(Instant::now() + CLOSE_TIMEOUT);
}
}
fn quit_signals() -> UnboundedReceiver<()> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
#[cfg(unix)]
tokio::spawn(async move {
use tokio::signal::unix::{SignalKind, signal};
let (Ok(mut hangup), Ok(mut terminate)) = (
signal(SignalKind::hangup()),
signal(SignalKind::terminate()),
) else {
return;
};
loop {
tokio::select! {
_ = hangup.recv() => {}
_ = terminate.recv() => {}
}
if tx.send(()).is_err() {
return;
}
}
});
#[cfg(not(unix))]
drop(tx);
rx
}
fn open_externally(target: &str) -> std::io::Result<()> {
if cfg!(target_os = "macos") {
let target = target.to_string();
std::thread::spawn(move || open::that_detached(target));
Ok(())
} else {
open::that_detached(target)
}
}
fn mark_downloaded(path: &str) {
#[cfg(target_os = "macos")]
{
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let _ = process::Command::new("/usr/bin/xattr")
.args([
"-w",
"com.apple.quarantine",
&format!("0081;{now:x};tuigram;"),
path,
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(windows)]
{
let _ = std::fs::write(
format!("{path}:Zone.Identifier"),
"[ZoneTransfer]\r\nZoneId=3\r\n",
);
}
#[cfg(not(any(target_os = "macos", windows)))]
let _ = path;
}
fn prompt_input(text: &str) -> TextArea<'static> {
let mut input = TextArea::new(vec![text.to_string()]);
input.set_cursor_line_style(Style::default());
input.set_cursor_style(Style::default().reversed());
input.move_cursor(ratatui_textarea::CursorMove::End);
input
}
fn login_screen(step: LoginStep) -> Screen {
Screen::Login(Box::new(Login::new(step)))
}
pub fn new_composer() -> TextArea<'static> {
let mut composer = TextArea::default();
composer.set_cursor_line_style(Style::default());
composer
}
fn code_destination(kind: &AuthenticationCodeType) -> &'static str {
match kind {
AuthenticationCodeType::TelegramMessage(_) => "to your Telegram app on another device",
AuthenticationCodeType::Sms(_)
| AuthenticationCodeType::SmsWord(_)
| AuthenticationCodeType::SmsPhrase(_) => "by SMS",
AuthenticationCodeType::Call(_) => "by phone call",
AuthenticationCodeType::MissedCall(_) | AuthenticationCodeType::FlashCall(_) => {
"as a missed call: enter the last digits of the calling number"
}
AuthenticationCodeType::Fragment(_) => "to fragment.com",
_ => "to one of your devices",
}
}
fn unix_now() -> i32 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i32)
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui_image::picker::Picker;
use tokio::sync::mpsc::unbounded_channel;
use super::*;
fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
app.on_key(KeyEvent::new(code, modifiers));
}
fn screen(app: &mut App) -> Vec<String> {
screen_of(app, 100)
}
fn screen_of(app: &mut App, width: u16) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap();
terminal.draw(|f| ui::draw(f, app)).unwrap();
let buf = terminal.backend().buffer();
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
#[test]
fn the_chat_list_can_go_on_the_right_where_h_and_l_follow_it() {
let dir = std::env::temp_dir().join(format!("tuigram-side-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
let tg = Tg::detached(unbounded_channel().0);
let mut app = crate::demo::demo_app(tg, images, &dir);
app.focus = Focus::Chats;
let none = KeyModifiers::NONE;
let saved = || Settings::load(&settings::path(&dir)).unwrap();
let halves = |app: &mut App| {
let top = screen(app).remove(0);
let (left, right) = top.split_once('┐').unwrap();
(left.to_string(), right.to_string())
};
press(&mut app, KeyCode::Char('?'), none);
press(&mut app, KeyCode::Tab, none);
app.settings_menu.as_mut().unwrap().selected = SettingsMenu::LIST_RIGHT;
press(&mut app, KeyCode::Char(' '), none);
assert_eq!(saved().chat_list_side, Side::Right, "saved at once");
press(&mut app, KeyCode::Enter, none);
assert_eq!(saved().chat_list_side, Side::Left, "Enter toggles too");
assert!(app.settings_menu.is_some(), "the popup stays open");
press(&mut app, KeyCode::Enter, none);
press(&mut app, KeyCode::Esc, none);
assert!(app.settings_menu.is_none());
assert_eq!(saved().chat_list_side, Side::Right, "Esc keeps it");
let (left, right) = halves(&mut app);
assert!(
left.contains("Weekend Hike") && right.contains("Chats ("),
"{left}┐{right}"
);
press(&mut app, KeyCode::Char('l'), none);
assert!(app.focus == Focus::Chats, "l points away from the chat");
press(&mut app, KeyCode::Char('h'), none);
assert!(
app.focus == Focus::Messages,
"h goes to the chat, on the left"
);
let status = screen_of(&mut app, 300).pop().unwrap();
assert!(
status.contains("l back") && !status.contains("h back"),
"{status}"
);
press(&mut app, KeyCode::Char('h'), none);
assert!(app.focus == Focus::Messages);
press(&mut app, KeyCode::Char('l'), none);
assert!(app.focus == Focus::Chats, "l goes back to the list");
press(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
press(&mut app, KeyCode::Char('h'), none);
assert_eq!(app.settings.list_width(), 40);
press(&mut app, KeyCode::Esc, none);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn without_focus_reports_a_minute_without_a_key_counts_as_away() {
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
let tg = Tg::detached(unbounded_channel().0);
let mut app = crate::demo::demo_app(tg, images, &std::env::temp_dir());
app.focus = Focus::Messages;
let chat = app.open.as_ref().unwrap().chat_id;
assert!(
app.watching(chat) && app.sees(chat),
"a key was just pressed"
);
app.last_input_wall = SystemTime::now() - (IDLE_AFTER + Duration::from_secs(1));
assert!(!app.watching(chat), "new messages aren't marked read");
assert!(!app.sees(chat), "and they notify");
app.focus_reported = true;
assert!(app.watching(chat) && app.sees(chat));
app.last_input_wall = SystemTime::now() - AWAY_AFTER;
assert!(
!app.watching(chat),
"not for good: the screen may be left on"
);
assert!(app.sees(chat), "the window has focus, so no notification");
app.last_input_wall = SystemTime::now();
app.terminal_focused = false;
assert!(!app.watching(chat) && !app.sees(chat));
}
#[test]
fn time_the_computer_slept_counts_as_time_away() {
let mut app = test_app("sleep");
app.focus = Focus::Messages;
let chat = app.open.as_ref().unwrap().chat_id;
app.last_input = Instant::now();
app.last_input_wall = SystemTime::now() - Duration::from_secs(2 * 3600);
assert!(!app.watching(chat), "nothing is marked read on waking");
app.online = false;
app.update_online();
assert!(!app.online, "and you aren't shown online");
app.last_input_wall = SystemTime::now() + Duration::from_secs(3600);
assert!(app.watching(chat));
}
#[test]
fn once_the_terminal_is_gone_nothing_more_is_marked_read() {
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
let tg = Tg::detached(unbounded_channel().0);
let mut app = crate::demo::demo_app(tg, images, &std::env::temp_dir());
app.focus = Focus::Messages;
let chat = app.open.as_ref().unwrap().chat_id;
assert!(app.watching(chat));
app.quit_deadline = Some(Instant::now() + CLOSE_TIMEOUT);
app.hang_up();
assert!(!app.watching(chat) && !app.sees(chat));
}
#[test]
fn a_theme_is_used_and_saved_as_soon_as_it_is_picked() {
let dir = std::env::temp_dir().join(format!("tuigram-theme-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
let tg = Tg::detached(unbounded_channel().0);
let mut app = crate::demo::demo_app(tg, images, &dir);
let none = KeyModifiers::NONE;
press(&mut app, KeyCode::Char('?'), none);
press(&mut app, KeyCode::Tab, none);
press(&mut app, KeyCode::Char('G'), none);
assert_eq!(app.settings.theme, "mocha", "moving doesn't change it");
press(&mut app, KeyCode::Char(' '), none);
assert_eq!(app.settings.theme, "rose-pine");
assert_eq!(app.colors, app.themes.colors("rose-pine").unwrap());
let saved = Settings::load(&settings::path(&dir)).unwrap();
assert_eq!(saved.theme, "rose-pine");
press(&mut app, KeyCode::Char('q'), none);
assert_eq!(app.settings.theme, "rose-pine", "closing keeps it");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn themes_in_the_data_folder_are_read_again_when_the_popup_opens() {
let dir = std::env::temp_dir().join(format!("tuigram-own-theme-{}", std::process::id()));
std::fs::create_dir_all(dir.join("themes")).unwrap();
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
let tg = Tg::detached(unbounded_channel().0);
let mut app = crate::demo::demo_app(tg, images, &dir);
let none = KeyModifiers::NONE;
let file = dir.join("themes/mine.toml");
app.settings.theme = "mine".into();
std::fs::write(&file, "inherits = \"mocha\"\n[colors]\nbg = \"#000000\"").unwrap();
press(&mut app, KeyCode::Char('?'), none);
assert_eq!(app.colors.bg, Color::Rgb(0, 0, 0));
assert_eq!(app.status, None);
press(&mut app, KeyCode::Esc, none);
std::fs::write(&file, "inherits = 3").unwrap();
press(&mut app, KeyCode::Char('?'), none);
assert_eq!(app.colors, Colors::default());
let status = app.status.clone().unwrap();
assert!(status.contains("mine.toml: line 1"), "{status}");
assert_eq!(app.settings.theme, "mine", "kept, for when it's fixed");
press(&mut app, KeyCode::Tab, none);
press(&mut app, KeyCode::Char('G'), none);
press(&mut app, KeyCode::Enter, none);
assert!(app.status.as_ref().unwrap().contains("mine.toml"));
assert_eq!(app.colors, Colors::default());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ctrl_r_resizes_the_panes_and_enter_keeps_it_for_next_time() {
let dir = std::env::temp_dir().join(format!("tuigram-resize-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
let tg = Tg::detached(unbounded_channel().0);
let mut app = crate::demo::demo_app(tg, images, &dir);
app.focus = Focus::Messages;
let edge = |rows: &[String]| rows[0].chars().position(|c| c == '┐').unwrap();
let none = KeyModifiers::NONE;
assert_eq!(edge(&screen(&mut app)), 34, "35% of 100 columns");
press(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
assert!(app.open.as_ref().unwrap().reply.is_none(), "not a reply");
press(&mut app, KeyCode::Char('l'), none);
press(&mut app, KeyCode::Char('l'), none);
let rows = screen(&mut app);
assert_eq!(edge(&rows), 44, "wider as you press");
let status = rows.last().unwrap();
assert!(
status.contains(" RESIZE ") && status.contains("chat list 45%"),
"{status}"
);
press(&mut app, KeyCode::Enter, none);
assert!(app.resizing.is_none());
let saved = Settings::load(&settings::path(&dir)).unwrap();
assert_eq!(saved.chat_list_width, 45);
press(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
press(&mut app, KeyCode::Char('h'), none);
assert_eq!(edge(&screen(&mut app)), 39);
press(&mut app, KeyCode::Esc, none);
assert_eq!(edge(&screen(&mut app)), 44, "Esc puts it back");
assert!(app.focus == Focus::Messages, "and stays in the chat");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn only_files_that_cant_run_code_open_without_asking() {
for path in [
"/d/photo.JPG",
"/d/clip.mp4",
"/d/report.pdf",
"/d/notes.txt",
] {
assert!(safe_to_open(path), "{path}");
}
for path in [
"/d/setup.exe",
"/d/run.bat",
"/d/x.command",
"/d/x.terminal",
"/d/app.jar",
"/d/page.html",
"/d/invoice.pdf.exe",
"/d/no-extension",
"/d/data.csv",
] {
assert!(!safe_to_open(path), "{path}");
}
}
fn test_app(tag: &str) -> App {
let dir = std::env::temp_dir().join(format!("tuigram-test-{tag}"));
std::fs::create_dir_all(&dir).unwrap();
let images = Images::new(Picker::halfblocks(), unbounded_channel().0);
crate::demo::demo_app(Tg::detached(unbounded_channel().0), images, &dir)
}
#[test]
fn f_opens_a_list_of_your_chats_to_forward_to_saved_messages_first() {
let mut app = test_app("forward");
app.focus = Focus::Messages;
let none = KeyModifiers::NONE;
press(&mut app, KeyCode::Char('f'), none);
let picker = app.picker.as_ref().expect("the picker is open");
let Purpose::Forward {
from, message_ids, ..
} = &picker.purpose
else {
panic!("forwarding");
};
let open = app.open.as_ref().unwrap();
assert_eq!(*from, open.chat_id);
assert_eq!(message_ids, &[open.cursor_id().unwrap()]);
assert!(
matches!(picker.choices(&app.chats)[0], Choice::Chat(id) if app.chats.is_saved(id)),
"Saved Messages first"
);
let rows = screen(&mut app);
assert!(rows.iter().any(|r| r.contains("Forward to")), "{rows:#?}");
assert!(rows.iter().any(|r| r.contains("Saved Messages")));
for c in "tokyo".chars() {
press(&mut app, KeyCode::Char(c), none);
}
let picker = app.picker.as_ref().unwrap();
assert_eq!(picker.query, "tokyo");
let titles: Vec<&str> = picker
.choices(&app.chats)
.iter()
.map(|c| match c {
Choice::Chat(id) => app.chats.title(*id).unwrap(),
_ => "",
})
.collect();
assert_eq!(titles, ["Tokyo Trip"]);
press(&mut app, KeyCode::Esc, none);
assert!(app.picker.is_none());
}
#[test]
fn s_finds_a_chat_by_username_or_link_and_pastes_go_into_its_search() {
let mut app = test_app("find");
let none = KeyModifiers::NONE;
press(&mut app, KeyCode::Char('s'), none);
assert!(app.picker.as_ref().is_some_and(|p| !p.forwarding()));
for c in "@durov".chars() {
press(&mut app, KeyCode::Char(c), none);
}
let rows = screen(&mut app);
assert!(rows.iter().any(|r| r.contains("Open @durov")), "{rows:#?}");
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
app.on_terminal_event(Event::Paste("t.me/+AbCdEf\n".into()));
let picker = app.picker.as_ref().unwrap();
assert_eq!(picker.query, "t.me/+AbCdEf", "not attached as a file");
assert_eq!(
picker.choices(&app.chats)[0],
Choice::Link("https://t.me/+AbCdEf".into())
);
assert!(picker.search_at().is_none(), "links aren't searched for");
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(app.picker.is_none(), "Ctrl-c closes it");
assert!(app.quit_deadline.is_none(), "without quitting");
}
#[test]
fn ctrl_u_deletes_back_to_the_start_of_the_line_and_then_the_line_break() {
let mut app = test_app("ctrl-u");
let chat_id = app.open.as_ref().unwrap().chat_id;
app.focus = Focus::Input;
app.typing = Some((chat_id, Instant::now()));
app.composer.insert_str("first line");
app.composer.insert_newline();
app.composer.insert_str("second line");
app.composer
.move_cursor(ratatui_textarea::CursorMove::WordBack);
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
assert_eq!(app.composer.lines(), ["first line", "line"]);
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
assert_eq!(app.composer.lines(), ["first lineline"]);
assert!(app.focus == Focus::Input);
}
#[test]
fn a_chat_found_while_you_write_does_not_open_over_what_you_type() {
let mut app = test_app("late-find");
let chat_id = app.open.as_ref().unwrap().chat_id;
app.focus = Focus::Input;
app.composer.insert_str("see you at 5");
app.finding = Some(Finding::new("@bob"));
app.on_tg(TgEvent::ChatFound {
request: "@bob".into(),
found: Ok((999, None)),
});
assert_eq!(app.open.as_ref().unwrap().chat_id, chat_id);
assert!(app.focus == Focus::Input);
assert_eq!(app.composer.lines(), ["see you at 5"]);
assert!(app.finding.is_none());
assert_eq!(
app.status.as_deref(),
Some("Found @bob: press s to open it")
);
app.finding = Some(Finding::new("https://t.me/+x"));
let invite = Invite {
title: "Group".into(),
members: 3,
channel: false,
badge: None,
by_request: false,
};
app.on_tg(TgEvent::Invite {
request: "https://t.me/+x".into(),
link: "https://t.me/+x".into(),
invite,
});
assert!(app.confirm.is_none());
}
#[test]
fn writing_a_popup_or_esc_stops_a_lookup() {
let mut app = test_app("stop-find");
app.focus = Focus::Messages;
app.finding = Some(Finding::new("@bob"));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.finding.is_none());
assert!(app.focus == Focus::Messages, "only the lookup stopped");
app.finding = Some(Finding::new("@bob"));
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
assert!(app.finding.is_none(), "writing in this chat instead");
}
#[test]
fn e_edits_your_message_as_markdown_and_an_untouched_edit_sends_nothing() {
let mut app = test_app("edit-markdown");
app.focus = Focus::Messages;
let open = app.open.as_ref().unwrap();
let (chat_id, id) = (open.chat_id, open.edit_target().unwrap());
let text = EditText {
markdown: "On my **way**!".into(),
loses: false,
};
app.on_editable(chat_id, id, Some(true), Some(text));
assert!(app.focus == Focus::Input);
assert_eq!(app.composer.lines(), ["On my **way**!"]);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(app.open.as_ref().unwrap().editing.is_none());
app.focus = Focus::Messages;
let text = EditText {
markdown: "On my way!".into(),
loses: true,
};
app.on_editable(chat_id, id, Some(true), Some(text));
let confirm = app.confirm.as_ref().expect("asks");
assert!(matches!(&confirm.action, Confirmed::Edit { text, .. } if text == "On my way!"));
}
#[test]
fn at_and_colon_suggest_people_and_emoji_and_tab_puts_one_in() {
let mut app = test_app("complete");
let chat_id = app.open.as_ref().unwrap().chat_id;
app.focus = Focus::Input;
app.typing = Some((chat_id, Instant::now()));
app.composer.insert_str("hi @ma");
app.update_completion();
let completion = app.completion.as_ref().expect("suggestions");
let labels: Vec<&str> = completion.items.iter().map(|s| s.label.as_str()).collect();
assert_eq!(labels, ["Maya Chen"], "senders of the loaded messages");
assert!(
completion.search_at().is_some(),
"members are searched once typing pauses"
);
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(app.composer.lines(), ["hi [Maya Chen](tg://user?id=2) "]);
assert!(
app.stickers.is_none(),
"Tab took the suggestion, not the stickers"
);
app.composer.insert_str(":tada");
app.update_completion();
assert_eq!(app.completion.as_ref().unwrap().items[0].label, "🎉");
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(app.composer.lines(), ["hi [Maya Chen](tg://user?id=2) 🎉"]);
assert!(app.completion.is_none());
app.composer.insert_str(" @sam");
app.update_completion();
assert!(
app.completion.as_ref().is_none_or(|c| c.items.is_empty()),
"not yourself"
);
}
#[test]
fn slash_starting_a_message_suggests_the_bots_commands() {
let mut app = test_app("commands");
let chat_id = app.open.as_ref().unwrap().chat_id;
app.focus = Focus::Input;
app.typing = Some((chat_id, Instant::now()));
let command = |name: &str| complete::Command {
bot: 9,
name: name.into(),
description: format!("{name} the bot"),
};
app.open.as_mut().unwrap().commands =
Commands::Known(vec![command("start"), command("help")]);
app.composer.insert_str("/he");
app.update_completion();
let completion = app.completion.as_ref().expect("suggestions");
assert_eq!(completion.items[0].label, "/help");
assert_eq!(completion.items[0].detail, "help the bot");
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(app.composer.lines(), ["/help "]);
app.composer.insert_str("see /st");
app.update_completion();
assert!(app.completion.is_none(), "only at the start of a message");
}
#[test]
fn a_telegram_link_tuigram_cant_open_goes_to_the_browser_with_its_warning() {
let mut app = test_app("link-elsewhere");
let url = "https://t.me/addstickers/cats";
app.finding = Some(Finding {
request: url.into(),
link: Some(Link {
url: url.into(),
disguise: Some("cute cats".into()),
}),
});
app.on_tg(TgEvent::ChatFound {
request: url.into(),
found: Err(Missed::Elsewhere),
});
let confirm = app.confirm.as_ref().expect("the disguised link asks first");
assert!(matches!(&confirm.action, Confirmed::OpenLink(u) if u == url));
app.confirm = None;
app.finding = Some(Finding::new(url));
app.on_tg(TgEvent::ChatFound {
request: url.into(),
found: Err(Missed::Elsewhere),
});
assert!(app.confirm.is_none());
assert_eq!(
app.status.as_deref(),
Some("tuigram can't open this kind of link")
);
}
#[test]
fn enter_on_a_poll_opens_its_answers_unless_it_is_closed() {
let mut app = test_app("poll");
app.focus = Focus::Messages;
let open = app.open.as_mut().unwrap();
let id = open.cursor_id().unwrap();
let mut poll = crate::poll::Poll {
question: "Lunch?".into(),
answers: vec![crate::poll::Answer {
text: "Pizza".into(),
voters: 0,
percent: 0,
chosen: false,
}],
voters: 0,
several: false,
quiz: false,
correct: None,
anonymous: true,
closed: false,
};
open.messages.get_mut(&id).unwrap().poll = Some(poll.clone());
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
let menu = app.vote_menu.as_ref().expect("the vote popup");
assert_eq!(menu.answers, ["Pizza"]);
let rows = screen(&mut app);
assert!(rows.iter().any(|r| r.contains("Vote")), "{rows:#?}");
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.vote_menu.is_none());
poll.closed = true;
let open = app.open.as_mut().unwrap();
open.messages.get_mut(&id).unwrap().poll = Some(poll);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(app.vote_menu.is_none());
assert_eq!(app.status.as_deref(), Some("This poll is closed"));
}
#[test]
fn leave_asks_first_and_only_for_groups_and_channels() {
let mut app = test_app("leave");
let none = KeyModifiers::NONE;
let leave = |app: &mut App| {
press(app, KeyCode::Char(':'), none);
for c in "leave".chars() {
press(app, KeyCode::Char(c), none);
}
press(app, KeyCode::Enter, none);
};
app.focus = Focus::Messages;
leave(&mut app);
let confirm = app.confirm.as_ref().expect("asks");
let chat_id = app.open.as_ref().unwrap().chat_id;
assert!(matches!(confirm.action, Confirmed::Leave(id) if id == chat_id));
assert_eq!(confirm.title, "Leave this group?");
press(&mut app, KeyCode::Esc, none);
app.focus = Focus::Chats;
let mom = app
.chats
.ids()
.iter()
.copied()
.find(|&id| app.chats.title(id) == Some("Mom"))
.unwrap();
app.selected = Some(mom);
leave(&mut app);
assert!(app.confirm.is_none());
assert_eq!(
app.status.as_deref(),
Some("A chat with one person can't be left")
);
}
#[test]
fn i_in_a_public_group_you_are_not_in_asks_to_join_first() {
let mut app = test_app("join");
let chat_id = app.open.as_ref().unwrap().chat_id;
app.chats.add_local(chat_id, "Weekend Hike", None).peer = Some(Peer::Supergroup(77));
app.chats.set_member(77, false);
app.focus = Focus::Messages;
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
assert!(app.focus == Focus::Messages, "not writing yet");
let confirm = app.confirm.as_ref().expect("asks");
assert!(matches!(confirm.action, Confirmed::Join(id) if id == chat_id));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
app.chats.set_member(77, true);
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
assert!(app.focus == Focus::Input, "once you're in");
}
#[test]
fn a_y_typed_just_before_a_warning_came_up_does_not_answer_it() {
let mut app = test_app("grace");
let edit = Confirmed::Edit {
id: -1,
text: String::new(),
};
app.confirm = Some(Confirm::new("Edit?", vec![], edit));
press(&mut app, KeyCode::Char('y'), KeyModifiers::NONE);
assert!(app.confirm.is_some(), "too soon to count");
let confirm = app.confirm.as_mut().unwrap();
confirm.shown = Instant::now().checked_sub(CONFIRM_GRACE).unwrap();
press(&mut app, KeyCode::Char('y'), KeyModifiers::NONE);
assert!(
app.confirm.is_none(),
"answered once it has been up a moment"
);
}
#[test]
fn a_download_finishing_while_you_type_waits_for_enter_instead_of_asking() {
let mut app = test_app("busy");
let dir = std::env::temp_dir().join("tuigram-no-such-folder");
let downloaded = |app: &mut App, name: &str| {
app.opening.insert(77);
let path = dir.join(name).to_string_lossy().into_owned();
app.on_tg(TgEvent::Downloaded {
file_id: 77,
path: Some(path),
});
};
app.focus = Focus::Input;
downloaded(&mut app, "run me.sh");
assert!(app.confirm.is_none(), "no popup over the composer");
let status = app.status.clone().unwrap_or_default();
assert!(status.contains("run me.sh downloaded"), "{status}");
app.focus = Focus::Messages;
downloaded(&mut app, "evil\u{202e}txt.sh");
let title = &app
.confirm
.as_ref()
.expect("asks when nothing else is up")
.title;
assert!(
!title.contains('\u{202e}'),
"the sender's name is cleaned: {title:?}"
);
}
}