use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
path::PathBuf,
};
use crate::discord::ids::{
Id,
marker::{ChannelMarker, ForumTagMarker, GuildMarker, MessageMarker, UserMarker},
};
use crate::discord::{
AppCommand, MessageAttachmentUpload, ReactionEmoji, StreamCaptureTarget,
VoiceParticipantPlaybackSettings, VoiceScope,
};
use crate::discord::{PresenceStatus, ProfileAvatarUpload};
use crate::discord::ReactionUserInfo;
use crate::tui::keybindings::{
KeyBindings, KeyChord, LeaderShortcutItem, SelectionAction, UiAction,
};
use crate::tui::text_input::TextInputState;
mod attachment_viewer;
mod channel_actions;
mod channel_switcher;
mod diagnostics;
mod forum_post;
mod guild_actions;
mod message_actions;
mod notification_inbox;
mod options;
mod polls;
mod reactions;
mod search;
mod thread_actions;
mod thread_edit;
mod user;
mod voice_participant_audio;
pub(in crate::tui) use voice_participant_audio::VoiceParticipantAudioField;
use voice_participant_audio::VoiceParticipantAudioPopupState;
use super::scroll::clamp_list_scroll;
use super::{
DashboardState, EmojiReactionItem, FocusPane, MessageUrlItem, PollVotePickerItem,
ThreadEditField,
};
use channel_switcher::ChannelSwitcherState;
use notification_inbox::NotificationInboxState;
pub use notification_inbox::{
NotificationInboxChannelLoad, NotificationInboxItem, NotificationInboxLoad,
NotificationInboxMessage, NotificationInboxTab, NotificationInboxUnreadItem,
};
use search::SearchPopupState;
const SELECTABLE_POPUP_PAGE_STEP: usize = 10;
#[derive(Debug, Default)]
pub(super) struct PopupUiState {
pub(super) modal: Option<ModalPopup>,
pub(super) confirmation_button: ConfirmationButton,
pub(super) inbox_request_generation: u64,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(in crate::tui) enum ConfirmationButton {
#[default]
Confirm,
Cancel,
}
impl ConfirmationButton {
fn next(self) -> Self {
match self {
Self::Confirm => Self::Cancel,
Self::Cancel => Self::Confirm,
}
}
}
macro_rules! define_modal_popups {
($($variant:ident $(($state:ty))?,)*) => {
#[derive(Debug)]
pub(super) enum ModalPopup {
$($variant $(($state))?,)*
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::tui) enum ActiveModalPopupKind {
$($variant,)*
}
impl ModalPopup {
fn kind(&self) -> ActiveModalPopupKind {
match self {
$(define_modal_popups!(@pattern $variant $(($state))?)
=> ActiveModalPopupKind::$variant,)*
}
}
}
};
(@pattern $variant:ident ($state:ty)) => { Self::$variant(_) };
(@pattern $variant:ident) => { Self::$variant };
}
define_modal_popups! {
MessageActionMenu(MessageActionMenuState),
GuildActionMenu(GuildActionMenuState),
ChannelActionMenu(ChannelActionMenuState),
MemberActionMenu(MemberActionMenuState),
MessageUrlPicker(MessageUrlPickerState),
MessageConfirmation(MessageConfirmationState),
QuitConfirmation,
GuildLeaveConfirmation(GuildLeaveConfirmationState),
Options(OptionsPopupState),
AttachmentViewer(AttachmentViewerState),
Leader(LeaderPopupState),
UserProfile(UserProfilePopupState),
EmojiReactionPicker(EmojiReactionPickerState),
PollVotePicker(PollVotePickerState),
ReactionUsers(ReactionUsersPopupState),
DebugLog,
KeymapHelp(KeymapPopupState),
ChannelSwitcher(ChannelSwitcherState),
NotificationInbox(NotificationInboxState),
Search(SearchPopupState),
ForumPostComposer(ForumPostComposerState),
ThreadEdit(ThreadEditState),
ThreadActionMenu(ThreadActionMenuState),
ThreadDeleteConfirmation(ThreadDeleteConfirmationState),
VoiceParticipantAudio(VoiceParticipantAudioPopupState),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::tui::state) enum ForumPostComposerFieldState {
Title,
Body,
Attachments,
Tags,
Submit,
Cancel,
}
#[derive(Debug)]
pub(super) struct ForumPostComposerState {
pub(super) channel_id: Id<ChannelMarker>,
pub(super) title: TextInputState,
pub(super) body: TextInputState,
pub(super) edit_input: TextInputState,
pub(super) active_field: ForumPostComposerFieldState,
pub(super) editing: Option<ForumPostComposerFieldState>,
pub(super) selected_tag_index: usize,
pub(super) tag_scroll: usize,
pub(super) tag_order: Vec<Id<ForumTagMarker>>,
pub(super) selected_tag_ids: Vec<Id<ForumTagMarker>>,
pub(super) attachments: Vec<MessageAttachmentUpload>,
pub(super) attachment_previews: Vec<super::local_upload_preview::LocalUploadPreviewState>,
pub(super) attachment_preview_generation: u64,
pub(super) status: Option<String>,
pub(super) scroll: ScrollablePopupState,
pub(super) pending_scroll_reveal: bool,
}
impl ForumPostComposerState {
fn new(channel_id: Id<ChannelMarker>) -> Self {
Self {
channel_id,
title: TextInputState::default(),
body: TextInputState::default(),
edit_input: TextInputState::default(),
active_field: ForumPostComposerFieldState::Title,
editing: None,
selected_tag_index: 0,
tag_scroll: 0,
tag_order: Vec::new(),
selected_tag_ids: Vec::new(),
attachments: Vec::new(),
attachment_previews: Vec::new(),
attachment_preview_generation: 0,
status: None,
scroll: ScrollablePopupState::default(),
pending_scroll_reveal: true,
}
}
}
#[derive(Debug)]
pub(super) struct ThreadEditState {
pub(super) channel_id: Id<ChannelMarker>,
pub(super) is_forum_post: bool,
pub(super) title: TextInputState,
pub(super) editing_title: bool,
pub(super) edit_input: TextInputState,
pub(super) selected_tag_ids: Vec<Id<ForumTagMarker>>,
pub(super) tag_order: Vec<Id<ForumTagMarker>>,
pub(super) selected_tag_index: usize,
pub(super) tag_scroll: usize,
pub(super) editing_tags: bool,
pub(super) rate_limit_index: usize,
pub(super) auto_archive_index: usize,
pub(super) can_set_slow_mode: bool,
pub(super) active_field: ThreadEditField,
pub(super) status: Option<String>,
pub(super) scroll: ScrollablePopupState,
pub(super) pending_scroll_reveal: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum ThreadActionMenuState {
Actions {
guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>,
selection: SelectablePopupState,
},
MuteDuration {
guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>,
selection: SelectablePopupState,
},
NotificationSettings {
guild_id: Id<GuildMarker>,
channel_id: Id<ChannelMarker>,
selection: SelectablePopupState,
},
}
#[derive(Debug)]
pub(super) struct LeaderPopupState {
pub(super) keymap_prefix: Vec<KeyChord>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct SelectablePopupState {
selected: usize,
scroll: usize,
view_height: usize,
}
impl SelectablePopupState {
pub(super) fn selected(&self) -> usize {
self.selected
}
pub(super) fn selected_for_len(&self, len: usize) -> usize {
self.selected.min(len.saturating_sub(1))
}
pub(super) fn scroll(&self) -> usize {
self.scroll
}
pub(super) fn select(&mut self, row: usize) {
self.selected = row;
}
pub(super) fn move_down(&mut self, len: usize) {
if len == 0 {
return;
}
self.selected = self.selected.saturating_add(1).min(len - 1);
}
pub(super) fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub(super) fn set_view_height_and_sync(&mut self, height: usize, len: usize) {
self.view_height = height.max(1);
self.scroll = clamp_list_scroll(
self.selected_for_len(len),
self.scroll,
self.view_height,
len,
);
}
pub(super) fn page(&mut self, len: usize, action: SelectionAction) {
match action {
SelectionAction::Next => {
if len > 0 {
self.selected = self
.selected
.saturating_add(SELECTABLE_POPUP_PAGE_STEP)
.min(len - 1);
}
}
SelectionAction::Previous => {
self.selected = self.selected.saturating_sub(SELECTABLE_POPUP_PAGE_STEP);
}
}
}
}
#[cfg(test)]
mod selectable_popup_viewport_tests {
use super::SelectablePopupState;
#[test]
fn selection_keeps_scrolloff_margin_like_panes() {
let mut sel = SelectablePopupState::default();
for _ in 0..6 {
sel.move_down(15);
}
sel.set_view_height_and_sync(5, 15);
assert_eq!(sel.selected(), 6);
assert_eq!(sel.scroll(), 4);
for _ in 0..6 {
sel.move_down(15);
}
sel.set_view_height_and_sync(5, 15);
assert_eq!(sel.selected(), 12);
assert_eq!(sel.scroll(), 10);
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct ScrollablePopupState {
scroll: usize,
view_height: usize,
total_lines: usize,
}
impl ScrollablePopupState {
pub(super) fn scroll(&self) -> usize {
self.scroll
}
pub(super) fn set_view_height(&mut self, height: usize) {
self.view_height = height;
self.clamp_scroll();
}
pub(super) fn set_total_lines(&mut self, total_lines: usize) {
self.total_lines = total_lines;
self.clamp_scroll();
}
pub(super) fn scroll_down(&mut self) {
self.scroll = self.scroll.saturating_add(1);
self.clamp_scroll();
}
pub(super) fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
pub(super) fn page_down(&mut self) {
self.scroll = self.scroll.saturating_add((self.view_height / 2).max(1));
self.clamp_scroll();
}
pub(super) fn page_up(&mut self) {
self.scroll = self.scroll.saturating_sub((self.view_height / 2).max(1));
}
pub(super) fn page(&mut self, action: SelectionAction) {
match action {
SelectionAction::Next => self.page_down(),
SelectionAction::Previous => self.page_up(),
}
}
pub(super) fn reveal(&mut self, start: usize, end: usize) {
if start < self.scroll {
self.scroll = start;
} else if self.view_height > 0 && end > self.scroll + self.view_height {
self.scroll = end.saturating_sub(self.view_height);
}
self.clamp_scroll();
}
fn clamp_scroll(&mut self) {
let visible = self.view_height.min(self.total_lines);
self.scroll = self.scroll.min(self.total_lines.saturating_sub(visible));
}
pub(super) fn scroll_to_top(&mut self) {
self.scroll = 0;
}
pub(super) fn is_near_bottom(&self, threshold: usize) -> bool {
self.scroll
.saturating_add(self.view_height)
.saturating_add(threshold)
>= self.total_lines
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MessageActionMenuState {
pub(super) selection: SelectablePopupState,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct KeymapPopupState {
pub(super) scroll: ScrollablePopupState,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MessageUrlPickerState {
pub(super) selection: SelectablePopupState,
pub(super) items: Vec<MessageUrlItem>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::tui) enum MessageConfirmationKind {
Delete,
RemoveEmbeds,
Pin { pinned: bool },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct MessageConfirmationState {
pub(super) kind: MessageConfirmationKind,
pub(super) channel_id: Id<ChannelMarker>,
pub(super) message_id: Id<MessageMarker>,
pub(super) author: String,
pub(super) content: Option<String>,
}
impl MessageConfirmationState {
pub(super) fn delete(
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
author: String,
content: Option<String>,
) -> Self {
Self {
kind: MessageConfirmationKind::Delete,
channel_id,
message_id,
author,
content,
}
}
pub(super) fn pin(
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
pinned: bool,
author: String,
content: Option<String>,
) -> Self {
Self {
kind: MessageConfirmationKind::Pin { pinned },
channel_id,
message_id,
author,
content,
}
}
pub(super) fn remove_embeds(
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
author: String,
content: Option<String>,
) -> Self {
Self {
kind: MessageConfirmationKind::RemoveEmbeds,
channel_id,
message_id,
author,
content,
}
}
}
impl MessageConfirmationKind {
pub(in crate::tui) fn title(self) -> &'static str {
match self {
Self::Delete => "Delete message?",
Self::RemoveEmbeds => "Remove embeds?",
Self::Pin { pinned: true } => "Pin message?",
Self::Pin { pinned: false } => "Unpin message?",
}
}
pub(in crate::tui) fn prompt(self) -> String {
match self {
Self::Delete => "Delete this message?".to_owned(),
Self::RemoveEmbeds => "Remove embeds from this message?".to_owned(),
Self::Pin { pinned: true } => "Pin this message?".to_owned(),
Self::Pin { pinned: false } => "Unpin this message?".to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GuildLeaveConfirmationState {
pub(super) guild_id: Id<GuildMarker>,
pub(super) name: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ThreadDeleteConfirmationState {
pub(super) channel_id: Id<ChannelMarker>,
pub(super) name: String,
pub(super) is_forum_post: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OptionsCategory {
Display,
Composer,
Notifications,
Voice,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct OptionsPopupState {
pub(super) selection: SelectablePopupState,
pub(super) category: Option<OptionsCategory>,
pub(super) capturing_push_to_talk_shortcut: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct AttachmentViewerState {
pub(super) message_id: Id<MessageMarker>,
pub(super) selection: SelectablePopupState,
pub(super) zoom: AttachmentViewerZoom,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum AttachmentViewerZoom {
#[default]
Default,
Large,
Fullscreen,
}
impl AttachmentViewerZoom {
pub(super) fn zoom_in(self) -> Self {
match self {
Self::Default => Self::Large,
Self::Large | Self::Fullscreen => Self::Fullscreen,
}
}
pub(super) fn zoom_out(self) -> Self {
match self {
Self::Fullscreen => Self::Large,
Self::Large | Self::Default => Self::Default,
}
}
pub(super) fn toggle_fullscreen(self) -> Self {
match self {
Self::Fullscreen => Self::Default,
Self::Default | Self::Large => Self::Fullscreen,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum GuildActionMenuState {
Actions { selection: SelectablePopupState },
MuteDuration { selection: SelectablePopupState },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct UserProfilePopupState {
pub(super) user_id: Id<UserMarker>,
pub(super) guild_id: Option<Id<GuildMarker>>,
pub(super) load_error: Option<String>,
pub(super) settings: UserProfileSettingsState,
pub(super) scroll: ScrollablePopupState,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum UserProfileSettingsTab {
#[default]
Global,
Guild,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UserProfileSettingsField {
CurrentStatus,
ManualActivity,
GlobalDisplayName,
GlobalPronouns,
GlobalAvatarPath,
GuildNickname,
GuildPronouns,
Save,
Cancel,
SignOut,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct UserProfileSettingsState {
pub(super) tab: UserProfileSettingsTab,
pub(super) selected_global: usize,
pub(super) selected_guild: usize,
pub(super) editing: Option<UserProfileSettingsField>,
pub(super) edit_input: TextInputState,
pub(super) global_display_name: Option<String>,
pub(super) global_pronouns: Option<String>,
pub(super) global_avatar_path: Option<String>,
pub(super) global_avatar_upload: Option<ProfileAvatarUpload>,
pub(super) global_avatar_preview_key: Option<String>,
pub(super) presence_status: Option<PresenceStatus>,
pub(super) manual_activity: Option<String>,
pub(super) status_picker: Option<SelectablePopupState>,
pub(super) activity_picker: Option<SelectablePopupState>,
pub(super) guild_nickname: Option<String>,
pub(super) guild_pronouns: Option<String>,
pub(super) saving: bool,
pub(super) status: Option<String>,
}
impl UserProfileSettingsState {
const GLOBAL_FIELDS: [UserProfileSettingsField; 5] = [
UserProfileSettingsField::GlobalDisplayName,
UserProfileSettingsField::GlobalPronouns,
UserProfileSettingsField::GlobalAvatarPath,
UserProfileSettingsField::CurrentStatus,
UserProfileSettingsField::ManualActivity,
];
const GLOBAL_ACTIONS: [UserProfileSettingsField; 3] = [
UserProfileSettingsField::Save,
UserProfileSettingsField::Cancel,
UserProfileSettingsField::SignOut,
];
const GUILD_FIELDS: [UserProfileSettingsField; 2] = [
UserProfileSettingsField::GuildNickname,
UserProfileSettingsField::GuildPronouns,
];
const GUILD_ACTIONS: [UserProfileSettingsField; 3] = [
UserProfileSettingsField::Save,
UserProfileSettingsField::Cancel,
UserProfileSettingsField::SignOut,
];
pub(super) fn active_field(&self) -> UserProfileSettingsField {
match self.tab {
UserProfileSettingsTab::Global => profile_settings_field_at(
self.selected_global,
&Self::GLOBAL_FIELDS,
&Self::GLOBAL_ACTIONS,
),
UserProfileSettingsTab::Guild => profile_settings_field_at(
self.selected_guild,
&Self::GUILD_FIELDS,
&Self::GUILD_ACTIONS,
),
}
}
pub(super) fn next_field(&mut self) {
match self.tab {
UserProfileSettingsTab::Global => {
self.selected_global = (self.selected_global + 1)
% (Self::GLOBAL_FIELDS.len() + Self::GLOBAL_ACTIONS.len());
}
UserProfileSettingsTab::Guild => {
self.selected_guild = (self.selected_guild + 1)
% (Self::GUILD_FIELDS.len() + Self::GUILD_ACTIONS.len());
}
}
}
pub(super) fn previous_field(&mut self) {
match self.tab {
UserProfileSettingsTab::Global => {
self.selected_global = if self.selected_global == 0 {
Self::GLOBAL_FIELDS.len() + Self::GLOBAL_ACTIONS.len() - 1
} else {
self.selected_global - 1
};
}
UserProfileSettingsTab::Guild => {
self.selected_guild = if self.selected_guild == 0 {
Self::GUILD_FIELDS.len() + Self::GUILD_ACTIONS.len() - 1
} else {
self.selected_guild - 1
};
}
}
}
pub(super) fn set_field_value(&mut self, field: UserProfileSettingsField, value: String) {
match field {
UserProfileSettingsField::CurrentStatus => {}
UserProfileSettingsField::ManualActivity => self.manual_activity = Some(value),
UserProfileSettingsField::GlobalDisplayName => self.global_display_name = Some(value),
UserProfileSettingsField::GlobalPronouns => self.global_pronouns = Some(value),
UserProfileSettingsField::GlobalAvatarPath => {
let trimmed = value.trim();
let upload = (!trimmed.is_empty())
.then(|| ProfileAvatarUpload::from_path(PathBuf::from(trimmed)));
self.global_avatar_preview_key = upload.as_ref().map(profile_avatar_preview_key);
self.global_avatar_path = Some(value);
self.global_avatar_upload = None;
}
UserProfileSettingsField::GuildNickname => self.guild_nickname = Some(value),
UserProfileSettingsField::GuildPronouns => self.guild_pronouns = Some(value),
UserProfileSettingsField::Save
| UserProfileSettingsField::Cancel
| UserProfileSettingsField::SignOut => {}
}
}
pub(super) fn set_avatar_upload(&mut self, upload: ProfileAvatarUpload) {
self.global_avatar_preview_key = Some(profile_avatar_preview_key(&upload));
self.global_avatar_path = None;
self.global_avatar_upload = Some(upload);
}
pub(super) fn pending_global_avatar_upload(&self) -> Option<ProfileAvatarUpload> {
self.global_avatar_upload.clone().or_else(|| {
self.global_avatar_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(ProfileAvatarUpload::from_path)
})
}
pub(super) fn pending_global_avatar_preview_key(&self) -> Option<&str> {
self.global_avatar_preview_key.as_deref()
}
pub(super) fn clear_after_save(&mut self) {
self.global_display_name = None;
self.global_pronouns = None;
self.global_avatar_path = None;
self.global_avatar_upload = None;
self.global_avatar_preview_key = None;
self.guild_nickname = None;
self.guild_pronouns = None;
self.editing = None;
self.edit_input.clear();
self.saving = false;
self.status = Some("Saved profile changes".to_owned());
}
}
fn profile_settings_field_at(
selected: usize,
fields: &[UserProfileSettingsField],
actions: &[UserProfileSettingsField],
) -> UserProfileSettingsField {
let field_count = fields.len();
let selected = selected.min(field_count + actions.len() - 1);
if selected < field_count {
fields[selected]
} else {
actions[selected - field_count]
}
}
fn profile_avatar_preview_key(upload: &ProfileAvatarUpload) -> String {
let mut hasher = DefaultHasher::new();
upload.filename.hash(&mut hasher);
upload.size_bytes.hash(&mut hasher);
if let Some(path) = upload.path() {
path.hash(&mut hasher);
}
if let Some(bytes) = upload.bytes() {
bytes.hash(&mut hasher);
}
format!("concord-profile-avatar-preview://{:016x}", hasher.finish())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct MemberActionMenuState {
pub(super) user_id: Id<UserMarker>,
pub(super) guild_id: Option<Id<GuildMarker>>,
pub(super) selection: SelectablePopupState,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum ChannelActionMenuState {
Actions {
channel_id: Id<ChannelMarker>,
selection: SelectablePopupState,
},
ParticipantActions {
channel_id: Id<ChannelMarker>,
user_id: Id<UserMarker>,
display_name: String,
selection: SelectablePopupState,
},
MuteDuration {
channel_id: Id<ChannelMarker>,
selection: SelectablePopupState,
},
StreamTargets {
scope: VoiceScope,
channel_id: Id<ChannelMarker>,
targets: Vec<StreamCaptureTarget>,
selection: SelectablePopupState,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EmojiReactionPickerState {
pub(super) selection: SelectablePopupState,
pub(super) filter: Option<String>,
pub(super) filter_editing: bool,
pub(super) items: Vec<EmojiReactionItem>,
pub(super) filtered_items: Vec<EmojiReactionItem>,
pub(super) existing_reactions: Vec<ReactionEmoji>,
pub(super) own_reactions: Vec<ReactionEmoji>,
pub(super) guild_id: Option<Id<GuildMarker>>,
pub(super) channel_id: Id<ChannelMarker>,
pub(super) message_id: Id<MessageMarker>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PollVotePickerState {
pub(super) selection: SelectablePopupState,
pub(super) allow_multiselect: bool,
pub(super) channel_id: Id<ChannelMarker>,
pub(super) message_id: Id<MessageMarker>,
pub(super) answers: Vec<PollVotePickerItem>,
}
impl PollVotePickerState {
pub fn answers(&self) -> &[PollVotePickerItem] {
&self.answers
}
}
const REACTION_USERS_LOAD_MORE_THRESHOLD: usize = 3;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReactionUsersEntry {
pub(super) emoji: ReactionEmoji,
pub(super) count: u64,
pub(super) users: Vec<ReactionUserInfo>,
pub(super) next_after: Option<Id<UserMarker>>,
pub(super) loading: bool,
pub(super) loaded_once: bool,
}
impl ReactionUsersEntry {
pub fn emoji(&self) -> &ReactionEmoji {
&self.emoji
}
pub fn count(&self) -> u64 {
self.count
}
pub fn users(&self) -> &[ReactionUserInfo] {
&self.users
}
pub fn is_loading(&self) -> bool {
self.loading
}
pub fn loaded_once(&self) -> bool {
self.loaded_once
}
pub fn has_more(&self) -> bool {
self.next_after.is_some()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReactionUsersPopupState {
pub(super) channel_id: Id<ChannelMarker>,
pub(super) message_id: Id<MessageMarker>,
pub(super) entries: Vec<ReactionUsersEntry>,
pub(super) list: SelectablePopupState,
pub(super) viewing: Option<usize>,
pub(super) user_scroll: ScrollablePopupState,
}
impl ReactionUsersPopupState {
pub(super) fn new(
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
reactions: Vec<(ReactionEmoji, u64)>,
) -> Self {
let entries = reactions
.into_iter()
.map(|(emoji, count)| ReactionUsersEntry {
emoji,
count,
users: Vec::new(),
next_after: None,
loading: false,
loaded_once: false,
})
.collect();
Self {
channel_id,
message_id,
entries,
list: SelectablePopupState::default(),
viewing: None,
user_scroll: ScrollablePopupState::default(),
}
}
pub fn entries(&self) -> &[ReactionUsersEntry] {
&self.entries
}
pub fn is_viewing_users(&self) -> bool {
self.viewing.is_some()
}
pub fn list_selected(&self) -> usize {
self.list.selected_for_len(self.entries.len())
}
pub fn list_scroll(&self) -> usize {
self.list.scroll()
}
pub(super) fn move_selection(&mut self, action: SelectionAction) {
match action {
SelectionAction::Next => self.list.move_down(self.entries.len()),
SelectionAction::Previous => self.list.move_up(),
}
}
pub(super) fn set_list_view_height(&mut self, height: usize) {
self.list
.set_view_height_and_sync(height, self.entries.len());
}
pub fn viewed_entry(&self) -> Option<&ReactionUsersEntry> {
self.viewing.and_then(|index| self.entries.get(index))
}
pub fn user_scroll(&self) -> usize {
self.user_scroll.scroll()
}
pub fn user_line_count(&self) -> usize {
self.viewed_entry()
.map(|entry| entry.users.len().max(1))
.unwrap_or(1)
}
pub(super) fn set_user_view_height(&mut self, height: usize) {
let total = self.user_line_count();
self.user_scroll.set_view_height(height);
self.user_scroll.set_total_lines(total);
}
pub(super) fn open_selected(&mut self) -> Option<ReactionEmoji> {
let index = self.list_selected();
if index >= self.entries.len() {
return None;
}
self.viewing = Some(index);
self.user_scroll.scroll_to_top();
let total = self.user_line_count();
self.user_scroll.set_total_lines(total);
self.begin_load(index)
}
pub(super) fn back_to_list(&mut self) -> bool {
if self.viewing.is_some() {
self.viewing = None;
true
} else {
false
}
}
fn begin_load(&mut self, index: usize) -> Option<ReactionEmoji> {
let entry = self.entries.get_mut(index)?;
if entry.loaded_once || entry.loading {
return None;
}
entry.loading = true;
Some(entry.emoji.clone())
}
pub(super) fn take_load_more(&mut self) -> Option<(ReactionEmoji, Id<UserMarker>)> {
if !self
.user_scroll
.is_near_bottom(REACTION_USERS_LOAD_MORE_THRESHOLD)
{
return None;
}
let index = self.viewing?;
let entry = self.entries.get_mut(index)?;
if entry.loading {
return None;
}
let after = entry.next_after?;
entry.loading = true;
Some((entry.emoji.clone(), after))
}
pub(super) fn apply_loaded(
&mut self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
emoji: &ReactionEmoji,
users: Vec<ReactionUserInfo>,
next_after: Option<Id<UserMarker>>,
after: Option<Id<UserMarker>>,
) {
if self.channel_id != channel_id || self.message_id != message_id {
return;
}
let Some(entry) = self.entries.iter_mut().find(|entry| &entry.emoji == emoji) else {
return;
};
if after.is_none() {
entry.users = users;
} else {
entry.users.extend(users);
}
entry.next_after = next_after;
entry.loading = false;
entry.loaded_once = true;
let total = self.user_line_count();
self.user_scroll.set_total_lines(total);
}
pub(super) fn apply_load_failed(
&mut self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
emoji: &ReactionEmoji,
) {
if self.channel_id != channel_id || self.message_id != message_id {
return;
}
if let Some(entry) = self.entries.iter_mut().find(|entry| &entry.emoji == emoji) {
entry.loading = false;
}
}
}
#[cfg(test)]
type ReactionUsersTestEntry = (
ReactionEmoji,
u64,
Vec<ReactionUserInfo>,
Option<Id<UserMarker>>,
);
#[cfg(test)]
impl ReactionUsersPopupState {
pub(crate) fn test_list(
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
reactions: Vec<(ReactionEmoji, u64)>,
) -> Self {
Self::new(channel_id, message_id, reactions)
}
pub(crate) fn test_viewing(
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
entries: Vec<ReactionUsersTestEntry>,
viewing: usize,
) -> Self {
let entries = entries
.into_iter()
.map(|(emoji, count, users, next_after)| ReactionUsersEntry {
emoji,
count,
users,
next_after,
loading: false,
loaded_once: true,
})
.collect();
let mut state = Self {
channel_id,
message_id,
entries,
list: SelectablePopupState::default(),
viewing: Some(viewing),
user_scroll: ScrollablePopupState::default(),
};
let total = state.user_line_count();
state.user_scroll.set_total_lines(total);
state
}
}
macro_rules! modal_popup_accessors {
($get:ident, $get_mut:ident, $variant:ident, $state:ty, $binding:ident) => {
pub(super) fn $get(&self) -> Option<&$state> {
match &self.modal {
Some(ModalPopup::$variant($binding)) => Some($binding),
_ => None,
}
}
pub(super) fn $get_mut(&mut self) -> Option<&mut $state> {
match &mut self.modal {
Some(ModalPopup::$variant($binding)) => Some($binding),
_ => None,
}
}
};
}
impl PopupUiState {
pub(super) fn clear_modal(&mut self) {
self.modal = None;
}
modal_popup_accessors!(
message_action_menu,
message_action_menu_mut,
MessageActionMenu,
MessageActionMenuState,
menu
);
modal_popup_accessors!(
guild_action_menu,
guild_action_menu_mut,
GuildActionMenu,
GuildActionMenuState,
menu
);
modal_popup_accessors!(
channel_action_menu,
channel_action_menu_mut,
ChannelActionMenu,
ChannelActionMenuState,
menu
);
modal_popup_accessors!(
member_action_menu,
member_action_menu_mut,
MemberActionMenu,
MemberActionMenuState,
menu
);
modal_popup_accessors!(
message_url_picker,
message_url_picker_mut,
MessageUrlPicker,
MessageUrlPickerState,
picker
);
pub(super) fn message_confirmation(&self) -> Option<&MessageConfirmationState> {
match &self.modal {
Some(ModalPopup::MessageConfirmation(confirmation)) => Some(confirmation),
_ => None,
}
}
pub(super) fn take_message_confirmation(&mut self) -> Option<MessageConfirmationState> {
match self.modal.take() {
Some(ModalPopup::MessageConfirmation(confirmation)) => Some(confirmation),
other => {
self.modal = other;
None
}
}
}
pub(super) fn guild_leave_confirmation(&self) -> Option<&GuildLeaveConfirmationState> {
match &self.modal {
Some(ModalPopup::GuildLeaveConfirmation(confirmation)) => Some(confirmation),
_ => None,
}
}
pub(super) fn take_guild_leave_confirmation(&mut self) -> Option<GuildLeaveConfirmationState> {
match self.modal.take() {
Some(ModalPopup::GuildLeaveConfirmation(confirmation)) => Some(confirmation),
other => {
self.modal = other;
None
}
}
}
pub(super) fn thread_delete_confirmation(&self) -> Option<&ThreadDeleteConfirmationState> {
match &self.modal {
Some(ModalPopup::ThreadDeleteConfirmation(confirmation)) => Some(confirmation),
_ => None,
}
}
pub(super) fn take_thread_delete_confirmation(
&mut self,
) -> Option<ThreadDeleteConfirmationState> {
match self.modal.take() {
Some(ModalPopup::ThreadDeleteConfirmation(confirmation)) => Some(confirmation),
other => {
self.modal = other;
None
}
}
}
modal_popup_accessors!(
options_popup,
options_popup_mut,
Options,
OptionsPopupState,
popup
);
modal_popup_accessors!(
voice_participant_audio,
voice_participant_audio_mut,
VoiceParticipantAudio,
VoiceParticipantAudioPopupState,
popup
);
modal_popup_accessors!(
attachment_viewer,
attachment_viewer_mut,
AttachmentViewer,
AttachmentViewerState,
viewer
);
modal_popup_accessors!(leader, leader_mut, Leader, LeaderPopupState, leader);
modal_popup_accessors!(
user_profile_popup,
user_profile_popup_mut,
UserProfile,
UserProfilePopupState,
popup
);
modal_popup_accessors!(
emoji_reaction_picker,
emoji_reaction_picker_mut,
EmojiReactionPicker,
EmojiReactionPickerState,
picker
);
modal_popup_accessors!(
poll_vote_picker,
poll_vote_picker_mut,
PollVotePicker,
PollVotePickerState,
picker
);
pub(super) fn take_poll_vote_picker(&mut self) -> Option<PollVotePickerState> {
match self.modal.take() {
Some(ModalPopup::PollVotePicker(picker)) => Some(picker),
other => {
self.modal = other;
None
}
}
}
modal_popup_accessors!(
reaction_users_popup,
reaction_users_popup_mut,
ReactionUsers,
ReactionUsersPopupState,
popup
);
modal_popup_accessors!(
keymap_popup,
keymap_popup_mut,
KeymapHelp,
KeymapPopupState,
popup
);
modal_popup_accessors!(
channel_switcher,
channel_switcher_mut,
ChannelSwitcher,
ChannelSwitcherState,
switcher
);
modal_popup_accessors!(
notification_inbox,
notification_inbox_mut,
NotificationInbox,
NotificationInboxState,
inbox
);
modal_popup_accessors!(
search_popup,
search_popup_mut,
Search,
SearchPopupState,
search
);
modal_popup_accessors!(
forum_post_composer,
forum_post_composer_mut,
ForumPostComposer,
ForumPostComposerState,
composer
);
modal_popup_accessors!(
thread_edit,
thread_edit_mut,
ThreadEdit,
ThreadEditState,
popup
);
modal_popup_accessors!(
thread_action_menu,
thread_action_menu_mut,
ThreadActionMenu,
ThreadActionMenuState,
menu
);
}
impl DashboardState {
pub(in crate::tui) fn active_modal_popup_kind(&self) -> Option<ActiveModalPopupKind> {
self.popups.modal.as_ref().map(ModalPopup::kind)
}
pub(in crate::tui) fn is_active_modal_popup(&self, kind: ActiveModalPopupKind) -> bool {
self.active_modal_popup_kind() == Some(kind)
}
pub fn is_leader_active(&self) -> bool {
self.popups.leader().is_some()
}
pub fn open_leader(&mut self) {
self.popups.modal = Some(ModalPopup::Leader(LeaderPopupState {
keymap_prefix: self.options.key_bindings.leader_keymap_prefix(),
}));
}
pub(in crate::tui) fn open_keymap_prefix(&mut self, prefix: Vec<KeyChord>) {
self.popups.modal = Some(ModalPopup::Leader(LeaderPopupState {
keymap_prefix: prefix,
}));
}
pub fn close_leader(&mut self) {
if self.popups.leader().is_some() {
self.popups.clear_modal();
}
}
pub(in crate::tui) fn leader_keymap_prefix(&self) -> &[KeyChord] {
self.popups
.leader()
.map(|leader| leader.keymap_prefix.as_slice())
.unwrap_or_default()
}
pub(in crate::tui) fn push_leader_keymap_key(&mut self, key: KeyChord) {
if let Some(leader) = self.popups.leader_mut() {
leader.keymap_prefix.push(key);
}
}
pub fn leader_keymap_shortcuts(&self) -> Vec<LeaderShortcutItem> {
let mut shortcuts = self
.options
.key_bindings
.leader_keymap_children(self.leader_keymap_prefix());
for shortcut in &mut shortcuts {
if shortcut.action == Some(UiAction::VoiceStream)
&& shortcut.label == UiAction::VoiceStream.label()
{
shortcut.label = self.current_voice_stream_action_label().to_owned();
}
}
shortcuts
}
pub(in crate::tui) fn leader_keymap_title(&self) -> String {
self.options
.key_bindings
.keymap_prefix_title(self.leader_keymap_prefix())
}
pub fn open_focused_pane_actions(&mut self) {
self.close_all_action_contexts();
self.close_leader();
if self.open_selected_thread_actions() {
return;
}
match self.navigation.focus {
FocusPane::Guilds => {
if let Some(menu) = self.selected_guild_action_context() {
self.popups.modal = Some(ModalPopup::GuildActionMenu(menu));
}
}
FocusPane::Channels => {
if let Some(menu) = self.selected_channel_action_context() {
self.popups.modal = Some(ModalPopup::ChannelActionMenu(menu));
}
}
FocusPane::Messages => self.open_selected_message_actions(),
FocusPane::Members => {
if let Some(menu) = self.selected_member_action_context() {
self.popups.modal = Some(ModalPopup::MemberActionMenu(menu));
}
}
}
}
pub fn close_all_action_contexts(&mut self) {
if matches!(
self.popups.modal,
Some(
ModalPopup::MessageActionMenu(_)
| ModalPopup::GuildActionMenu(_)
| ModalPopup::ChannelActionMenu(_)
| ModalPopup::MemberActionMenu(_)
)
) {
self.popups.clear_modal();
}
}
pub fn open_quit_confirmation(&mut self) {
self.popups.confirmation_button = ConfirmationButton::default();
self.popups.modal = Some(ModalPopup::QuitConfirmation);
}
pub fn close_quit_confirmation(&mut self) {
if self.is_active_modal_popup(ActiveModalPopupKind::QuitConfirmation) {
self.popups.clear_modal();
}
}
pub fn confirm_quit(&mut self) {
self.close_quit_confirmation();
self.quit();
}
pub(in crate::tui) fn active_confirmation_button(&self) -> ConfirmationButton {
self.popups.confirmation_button
}
pub(in crate::tui) fn next_confirmation_button(&mut self) {
self.popups.confirmation_button = self.popups.confirmation_button.next();
}
pub(in crate::tui) fn page_active_popup_down(&mut self) -> bool {
self.page_active_popup(SelectionAction::Next)
}
pub(in crate::tui) fn page_active_popup_up(&mut self) -> bool {
self.page_active_popup(SelectionAction::Previous)
}
fn page_active_popup(&mut self, action: SelectionAction) -> bool {
match self.active_modal_popup_kind() {
Some(ActiveModalPopupKind::KeymapHelp) => {
if let Some(popup) = self.popups.keymap_popup_mut() {
popup.scroll.page(action);
}
true
}
Some(ActiveModalPopupKind::ReactionUsers) => {
if let Some(popup) = self.popups.reaction_users_popup_mut() {
if popup.viewing.is_some() {
popup.user_scroll.page(action);
} else {
let len = popup.entries.len();
popup.list.page(len, action);
}
}
true
}
Some(ActiveModalPopupKind::UserProfile) if self.is_user_profile_popup_editing() => {
false
}
Some(ActiveModalPopupKind::UserProfile) => {
if let Some(popup) = self.popups.user_profile_popup_mut() {
popup.scroll.page(action);
}
true
}
Some(ActiveModalPopupKind::Options) => {
let len = self.options_popup_item_count();
if let Some(popup) = self.popups.options_popup_mut() {
popup.selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::ChannelSwitcher) => {
self.page_channel_switcher_selection(action);
true
}
Some(ActiveModalPopupKind::NotificationInbox) => {
self.page_notification_inbox_selection(action);
true
}
Some(ActiveModalPopupKind::PollVotePicker) => {
if let Some(picker) = self.popups.poll_vote_picker_mut() {
picker.selection.page(picker.answers.len(), action);
}
true
}
Some(ActiveModalPopupKind::EmojiReactionPicker) => {
let len = self.filtered_emoji_reaction_items().len();
if let Some(picker) = self.popups.emoji_reaction_picker_mut() {
picker.selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::MessageUrlPicker) => {
if let Some(picker) = self.popups.message_url_picker_mut() {
picker.selection.page(picker.items.len(), action);
}
true
}
Some(ActiveModalPopupKind::MessageActionMenu) => {
let len = self.selected_message_action_items().len();
if let Some(menu) = self.popups.message_action_menu_mut() {
menu.selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::GuildActionMenu) => {
let len = self.guild_action_row_count();
if let Some(selection) = self.guild_action_selection_mut() {
selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::ChannelActionMenu) => {
let len = self.channel_action_row_count();
if let Some(selection) = self.channel_action_selection_mut() {
selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::MemberActionMenu) => {
let len = self.selected_member_action_items().len();
if let Some(menu) = self.popups.member_action_menu_mut() {
menu.selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::ThreadActionMenu) => {
let len = self.thread_action_row_count();
if let Some(selection) = self.thread_action_selection_mut() {
selection.page(len, action);
}
true
}
Some(ActiveModalPopupKind::MessageConfirmation)
| Some(ActiveModalPopupKind::QuitConfirmation)
| Some(ActiveModalPopupKind::GuildLeaveConfirmation)
| Some(ActiveModalPopupKind::AttachmentViewer)
| Some(ActiveModalPopupKind::Leader)
| Some(ActiveModalPopupKind::DebugLog)
| Some(ActiveModalPopupKind::Search)
| Some(ActiveModalPopupKind::ForumPostComposer)
| Some(ActiveModalPopupKind::ThreadEdit)
| Some(ActiveModalPopupKind::ThreadDeleteConfirmation)
| Some(ActiveModalPopupKind::VoiceParticipantAudio)
| None => false,
}
}
pub(in crate::tui) fn message_action_shortcut_matches(&self, shortcut: KeyChord) -> bool {
if self.popups.message_action_menu().is_none() {
return false;
}
let actions = self.selected_message_action_items();
action_shortcut_matches(
self.key_bindings(),
&actions,
shortcut,
|key_bindings, actions, index| key_bindings.message_action_shortcuts(actions, index),
|action| action.is_enabled(),
)
}
pub(in crate::tui) fn thread_action_shortcut_matches(&self, shortcut: KeyChord) -> bool {
match self.popups.thread_action_menu() {
Some(ThreadActionMenuState::Actions { .. }) => {
let actions = self.selected_thread_action_items();
action_shortcut_matches(
self.key_bindings(),
&actions,
shortcut,
|key_bindings, actions, index| {
key_bindings.thread_action_shortcuts(actions, index)
},
|action| action.is_enabled(),
)
}
Some(ThreadActionMenuState::MuteDuration { .. }) => indexed_shortcut_matches(
self.key_bindings(),
shortcut,
self.selected_thread_mute_duration_items().len(),
),
Some(ThreadActionMenuState::NotificationSettings { .. }) => indexed_shortcut_matches(
self.key_bindings(),
shortcut,
self.selected_thread_notification_items().len(),
),
None => false,
}
}
pub(in crate::tui) fn channel_action_shortcut_matches(&self, shortcut: KeyChord) -> bool {
if !self.is_channel_action_menu_active() {
return false;
}
if self.is_channel_action_mute_duration_phase() {
return indexed_shortcut_matches(
self.key_bindings(),
shortcut,
self.selected_channel_mute_duration_items().len(),
);
}
let actions = self.selected_channel_action_items();
action_shortcut_matches(
self.key_bindings(),
&actions,
shortcut,
|key_bindings, actions, index| key_bindings.channel_action_shortcuts(actions, index),
|action| action.is_enabled(),
)
}
pub(in crate::tui) fn guild_action_shortcut_matches(&self, shortcut: KeyChord) -> bool {
if !self.is_guild_action_menu_active() {
return false;
}
if self.is_guild_action_mute_duration_phase() {
return indexed_shortcut_matches(
self.key_bindings(),
shortcut,
self.selected_guild_mute_duration_items().len(),
);
}
let actions = self.selected_guild_action_items();
action_shortcut_matches(
self.key_bindings(),
&actions,
shortcut,
|key_bindings, actions, index| key_bindings.guild_action_shortcuts(actions, index),
|action| action.is_enabled(),
)
}
pub(in crate::tui) fn member_action_shortcut_matches(&self, shortcut: KeyChord) -> bool {
if !self.is_member_action_menu_active() {
return false;
}
let actions = self.selected_member_action_items();
action_shortcut_matches(
self.key_bindings(),
&actions,
shortcut,
|key_bindings, actions, index| key_bindings.member_action_shortcuts(actions, index),
|action| action.is_enabled(),
)
}
}
fn action_shortcut_matches<A>(
key_bindings: &KeyBindings,
actions: &[A],
shortcut: KeyChord,
shortcuts: impl Fn(&KeyBindings, &[A], usize) -> Vec<KeyChord>,
is_enabled: impl Fn(&A) -> bool,
) -> bool {
key_bindings
.matching_action_shortcut_index(actions, shortcut, shortcuts, is_enabled)
.is_some()
}
fn indexed_shortcut_matches(key_bindings: &KeyBindings, shortcut: KeyChord, len: usize) -> bool {
key_bindings
.matching_indexed_shortcut_index(shortcut, len)
.is_some()
}
impl DashboardState {
pub fn is_message_action_menu_active(&self) -> bool {
self.popups.message_action_menu().is_some()
}
pub fn is_channel_action_menu_active(&self) -> bool {
self.popups.channel_action_menu().is_some()
}
pub fn is_guild_action_menu_active(&self) -> bool {
self.popups.guild_action_menu().is_some()
}
pub fn is_channel_action_mute_duration_phase(&self) -> bool {
matches!(
self.popups.channel_action_menu(),
Some(ChannelActionMenuState::MuteDuration { .. })
)
}
pub fn is_guild_action_mute_duration_phase(&self) -> bool {
matches!(
self.popups.guild_action_menu(),
Some(GuildActionMenuState::MuteDuration { .. })
)
}
}