Skip to content

Commit 2a1b08c

Browse files
committed
feat(discord): broadcast typing indicator while composing a message
1 parent 7c27b9b commit 2a1b08c

9 files changed

Lines changed: 90 additions & 3 deletions

File tree

src/app/command_dispatch.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ impl CommandDispatcher {
9090
.await;
9191
}
9292
command @ (AppCommand::SendMessage { .. }
93+
| AppCommand::TriggerTyping { .. }
9394
| AppCommand::CreateForumPost { .. }
9495
| AppCommand::SetThreadArchived { .. }
9596
| AppCommand::SetThreadLocked { .. }

src/app/message_commands.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub(super) async fn handle(client: DiscordClient, command: AppCommand) {
2424
Ok(message) => client.publish_event(message_create_event(message)).await,
2525
Err(error) => publish_app_error(&client, "send message failed", &error).await,
2626
},
27+
AppCommand::TriggerTyping { channel_id } => client.trigger_typing(channel_id),
2728
AppCommand::SendTtsMessage {
2829
channel_id,
2930
content,

src/discord/client/rest_actions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ impl DiscordClient {
3131
attachments: &[MessageAttachmentUpload],
3232
) -> Result<MessageInfo> {
3333
self.ensure_can_send_message(channel_id, attachments)?;
34+
self.rest.spawn_typing(channel_id);
3435
let upload_limit = self.attachment_size_limit(channel_id);
3536
self.rest
3637
.send_message(channel_id, content, reply_to, attachments, upload_limit)
@@ -43,9 +44,14 @@ impl DiscordClient {
4344
content: &str,
4445
) -> Result<MessageInfo> {
4546
self.ensure_can_send_tts_message(channel_id)?;
47+
self.rest.spawn_typing(channel_id);
4648
self.rest.send_tts_message(channel_id, content).await
4749
}
4850

51+
pub fn trigger_typing(&self, channel_id: Id<ChannelMarker>) {
52+
self.rest.spawn_typing(channel_id);
53+
}
54+
4955
pub async fn create_forum_post(&self, post: &ForumPostCreate) -> Result<CreatedForumPost> {
5056
self.ensure_can_create_forum_post(post)?;
5157
let upload_limit = self.attachment_size_limit(post.channel_id);

src/discord/commands.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,9 @@ pub enum AppCommand {
474474
SetSelectedMessageChannel {
475475
channel_id: Option<Id<ChannelMarker>>,
476476
},
477+
TriggerTyping {
478+
channel_id: Id<ChannelMarker>,
479+
},
477480
SubscribeDirectMessage {
478481
channel_id: Id<ChannelMarker>,
479482
},

src/discord/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ pub use channel::{
5353
};
5454
pub use client::DiscordClient;
5555
pub(crate) use client::validate_token_header;
56-
pub(crate) use fingerprint::refresh_client_build_number;
5756
pub use commands::{
5857
AppCommand, AttachmentDownloadId, DownloadAttachmentSource, ForumPostArchiveState,
5958
ForumPostCreate, GlobalUserProfileUpdate, GuildUserProfileUpdate, MediaPlaybackRequestId,
@@ -73,6 +72,7 @@ pub use events::{
7372
PresenceEventFields, SequencedAppEvent, ThreadListSyncInfo, ThreadMemberUpdateInfo,
7473
ThreadMembersUpdateInfo, UserGuildSettingsInfo,
7574
};
75+
pub(crate) use fingerprint::refresh_client_build_number;
7676
pub use guild::{CustomEmojiInfo, GuildFolder};
7777
pub use ids::{Id, marker};
7878
pub use member::{MemberInfo, RoleInfo};

src/discord/rest/messages.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ impl DiscordRest {
3636
.await
3737
}
3838

39+
/// Fires a typing indicator without blocking the send it precedes. A
40+
/// message that arrives with no prior typing looks automated.
41+
pub fn spawn_typing(&self, channel_id: Id<ChannelMarker>) {
42+
let rest = self.clone();
43+
tokio::spawn(async move {
44+
let _ = rest.trigger_typing(channel_id).await;
45+
});
46+
}
47+
48+
async fn trigger_typing(&self, channel_id: Id<ChannelMarker>) -> Result<()> {
49+
self.send_unit(
50+
self.raw_http.post(format!(
51+
"https://discord.com/api/v9/channels/{}/typing",
52+
channel_id.get()
53+
)),
54+
"trigger typing",
55+
)
56+
.await
57+
}
58+
3959
pub async fn send_tts_message(
4060
&self,
4161
channel_id: Id<ChannelMarker>,

src/tui/input/keyboard/composer.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub(super) fn handle_composer_key(state: &mut DashboardState, key: KeyEvent) ->
2222
}
2323
ComposerAction::InsertNewline => {
2424
state.push_composer_char('\n');
25-
None
25+
state.note_composer_typing()
2626
}
2727
ComposerAction::Submit => state.submit_composer(),
2828
ComposerAction::Close => {
@@ -85,7 +85,7 @@ pub(super) fn handle_composer_key(state: &mut DashboardState, key: KeyEvent) ->
8585
if value != ':' || !state.open_composer_reaction_picker_from_plus_colon() {
8686
state.push_composer_char(value);
8787
}
88-
None
88+
state.note_composer_typing()
8989
}
9090
ComposerAction::Ignore => None,
9191
}

src/tui/state/composer/state.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::ops::Range;
2+
use std::time::{Duration, Instant};
23

34
use crate::discord::ids::{
45
Id,
@@ -46,6 +47,10 @@ pub enum DmComposerLock {
4647
NeverMessaged,
4748
}
4849

50+
/// Discord keeps a typing indicator alive for about ten seconds, so resend a
51+
/// little sooner while the user keeps typing.
52+
const COMPOSER_TYPING_INTERVAL: Duration = Duration::from_secs(8);
53+
4954
#[derive(Debug, Default)]
5055
pub(in crate::tui::state) struct ComposerUiState {
5156
pub(in crate::tui::state) composer_input: TextInputState,
@@ -71,6 +76,9 @@ pub(in crate::tui::state) struct ComposerUiState {
7176
/// Mutually exclusive with those pickers, enforced in
7277
/// `refresh_active_mention_query`.
7378
pub(in crate::tui::state) emoji_completion: EmojiCompletionState,
79+
/// Channel and time of the last typing indicator sent while composing, used
80+
/// to throttle resends.
81+
pub(in crate::tui::state) last_typing_sent: Option<(Id<ChannelMarker>, Instant)>,
7482
}
7583

7684
#[derive(Debug, Default)]
@@ -462,6 +470,32 @@ impl DashboardState {
462470
self.insert_composer_text_at_cursor(&text);
463471
}
464472

473+
pub fn note_composer_typing(&mut self) -> Option<AppCommand> {
474+
self.note_composer_typing_at(Instant::now())
475+
}
476+
477+
pub(in crate::tui::state) fn note_composer_typing_at(
478+
&mut self,
479+
now: Instant,
480+
) -> Option<AppCommand> {
481+
// Discord does not broadcast typing while editing an existing message.
482+
if self.composer.edit_target_message.is_some() {
483+
return None;
484+
}
485+
let channel_id = self.selected_channel_id()?;
486+
let due = match self.composer.last_typing_sent {
487+
Some((last_channel, at)) if last_channel == channel_id => {
488+
now.saturating_duration_since(at) >= COMPOSER_TYPING_INTERVAL
489+
}
490+
_ => true,
491+
};
492+
if !due {
493+
return None;
494+
}
495+
self.composer.last_typing_sent = Some((channel_id, now));
496+
Some(AppCommand::TriggerTyping { channel_id })
497+
}
498+
465499
pub fn insert_composer_text_at_cursor(&mut self, value: &str) {
466500
if value.is_empty() {
467501
return;

src/tui/state/tests/composer.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,3 +2206,25 @@ fn composer_sends_to_opened_thread_channel() {
22062206
})
22072207
);
22082208
}
2209+
2210+
#[test]
2211+
fn composer_typing_indicator_throttles_and_resends() {
2212+
use std::time::{Duration, Instant};
2213+
2214+
let mut state = state_with_writable_channel();
2215+
state.start_composer();
2216+
let typing = Some(AppCommand::TriggerTyping {
2217+
channel_id: Id::new(2),
2218+
});
2219+
2220+
let start = Instant::now();
2221+
assert_eq!(state.note_composer_typing_at(start), typing);
2222+
assert_eq!(
2223+
state.note_composer_typing_at(start + Duration::from_secs(1)),
2224+
None
2225+
);
2226+
assert_eq!(
2227+
state.note_composer_typing_at(start + Duration::from_secs(8)),
2228+
typing
2229+
);
2230+
}

0 commit comments

Comments
 (0)