concord 2.5.0

A terminal user interface client for Discord
//! Lightweight redraw gate.
//!
//! Foreground input always redraws immediately, so it does not need a gate.
//! Background Discord traffic (presence, typing, off-screen messages) is
//! different: most of it does not change what is currently on screen, and
//! redrawing for it just rebuilds an identical frame. To avoid that, we hash the
//! parts of the dashboard that a *background* event can change and only redraw
//! when that hash moves.
//!
//! This deliberately ignores purely input-driven state (scroll offsets,
//! selection indices, which popup is open, composer text, option values): those
//! only change in response to a key or mouse event, which already triggers an
//! immediate redraw. Leaving them out keeps the hash small. Media-cache changes
//! (an inline preview or avatar finishing or failing to load) live outside the
//! dashboard state, so they are handled separately by `effect_forces_redraw`.

use std::collections::hash_map::DefaultHasher;
use std::fmt::{self, Write as _};
use std::hash::{Hash as _, Hasher as _};

use crate::tui::state::{ChannelPaneRow, DashboardState, NotificationInboxTab};

/// Hash a value's `Debug` output into the running hasher. Lets us fingerprint
/// view state without requiring every involved type to implement `Hash`.
fn hash_dbg<T: fmt::Debug>(hasher: &mut DefaultHasher, value: &T) {
    struct DebugSink<'a>(&'a mut DefaultHasher);
    impl fmt::Write for DebugSink<'_> {
        fn write_str(&mut self, value: &str) -> fmt::Result {
            self.0.write(value.as_bytes());
            Ok(())
        }
    }
    write!(DebugSink(hasher), "{value:?}").expect("writing into view hasher cannot fail");
}

/// Fingerprint of everything a background event could change on the visible
/// dashboard. Two frames with the same signature look identical, so a background
/// event that leaves it unchanged needs no redraw.
pub(super) fn view_signature(state: &DashboardState) -> u64 {
    let mut hasher = DefaultHasher::new();

    // Selection context, so the hash is compared against the right baseline when
    // the view switches channels or opens a popup.
    hash_dbg(&mut hasher, &state.message_pane_source());
    hash_dbg(&mut hasher, &state.selected_guild_id());
    hash_dbg(&mut hasher, &state.selected_channel_id());
    hash_dbg(&mut hasher, &state.active_modal_popup_kind());

    // Header.
    hash_dbg(&mut hasher, &state.current_user());
    hash_dbg(&mut hasher, &state.current_voice_self_status());
    hash_dbg(&mut hasher, &state.active_voice_connection_label());
    hash_dbg(&mut hasher, &state.update_available_version());
    hash_dbg(&mut hasher, &state.stream_info_sections());

    // Message pane: the live chat plus its footers.
    hash_dbg(&mut hasher, &state.visible_messages());
    hash_dbg(&mut hasher, &state.visible_thread_card_items());
    hash_dbg(&mut hasher, &state.typing_footer_for_selected_channel());
    hash_dbg(&mut hasher, &state.composer_lock());
    state.new_messages_count().hash(&mut hasher);

    // Guild sidebar with its unread badges.
    state.direct_message_unread_count().hash(&mut hasher);
    for entry in state.visible_guild_pane_entries() {
        hash_dbg(&mut hasher, &entry);
        if let Some(guild) = entry.guild_state() {
            hash_dbg(&mut hasher, &state.sidebar_guild_unread(guild.id));
        }
    }

    // Channel sidebar with its unread badges.
    for row in state.visible_channel_pane_rows() {
        match row {
            ChannelPaneRow::Entry { entry, .. } => {
                hash_dbg(&mut hasher, &entry);
                if let Some(channel) = entry.channel_state() {
                    hash_dbg(&mut hasher, &state.channel_unread(channel.id));
                    state
                        .channel_unread_message_count(channel.id)
                        .hash(&mut hasher);
                }
            }
            ChannelPaneRow::Activity {
                owner_entry_index,
                recipient_id,
                activity,
                ..
            } => {
                hash_dbg(&mut hasher, &(owner_entry_index, recipient_id, activity));
            }
        }
    }

    // Member pane: presence and roster updates arrive in the background.
    let member_start = state.member_scroll();
    let member_take = state.member_content_height();
    for entry in state
        .flattened_members()
        .into_iter()
        .skip(member_start)
        .take(member_take)
    {
        hash_dbg(
            &mut hasher,
            &(
                entry.user_id(),
                entry.display_name(),
                entry.username(),
                entry.is_bot(),
                entry.status(),
            ),
        );
    }

    // Popups whose contents load or update from the background. (Their open/close
    // and navigation are input-driven and covered by the immediate redraw.)
    hash_dbg(&mut hasher, &state.selected_attachment_viewer_item());
    hash_dbg(&mut hasher, &state.user_profile_popup_data());
    hash_dbg(&mut hasher, &state.user_profile_popup_status());
    hash_dbg(&mut hasher, &state.user_profile_popup_load_error());
    hash_dbg(&mut hasher, &state.user_profile_popup_avatar_url());
    hash_dbg(&mut hasher, &state.user_profile_popup_activities());
    hash_dbg(&mut hasher, &state.user_profile_activity_picker_rows());
    hash_dbg(&mut hasher, &state.attachment_downloads());
    hash_dbg(&mut hasher, &state.reaction_users_popup());
    hash_dbg(&mut hasher, &state.existing_emoji_reactions());
    hash_dbg(&mut hasher, &state.own_emoji_reactions());
    hash_dbg(&mut hasher, &state.filtered_emoji_reaction_items());
    hash_dbg(&mut hasher, &state.poll_vote_picker_items());
    hash_dbg(&mut hasher, &state.composer_mention_candidates());
    hash_dbg(&mut hasher, &state.composer_emoji_candidates());
    hash_dbg(&mut hasher, &state.composer_command_candidates());

    // Search results and the forum loading placeholder both change from
    // background responses rather than direct input.
    hash_dbg(&mut hasher, &state.search_popup_view());
    state.selected_forum_posts_loading().hash(&mut hasher);

    // Notification inbox: only hash it while open. The active items, unread
    // mention badge, and loading state can all change from background events.
    if let Some(tab) = state.notification_inbox_tab() {
        hash_dbg(&mut hasher, &state.notification_inbox_items());
        if tab == NotificationInboxTab::Mentions {
            hash_dbg(&mut hasher, &state.notification_inbox_mentions_status());
        }
        state
            .notification_inbox_unread_mention_count()
            .hash(&mut hasher);
        state
            .notification_inbox_has_visible_loading_indicator()
            .hash(&mut hasher);
    }

    hasher.finish()
}

#[cfg(test)]
mod tests {
    use super::view_signature;
    use crate::discord::ids::Id;
    use crate::discord::{
        ActivityInfo, ActivityKind, AppCommand, AppEvent, ChannelInfo, ChannelRecipientInfo,
        MessageHistoryLoadTarget, MessageInfo, MessageSearchPage, PresenceEventFields,
        PresenceStatus, StreamCreateInfo, StreamUpdateInfo, VoiceScope,
    };
    use crate::tui::state::DashboardState;

    #[test]
    fn view_signature_is_stable_and_tracks_visible_changes() {
        let state = DashboardState::new();
        let before = view_signature(&state);
        // Recomputing over unchanged state yields the same fingerprint, so a
        // background event that changes nothing visible will not redraw.
        assert_eq!(before, view_signature(&state));

        // A header-visible change (the update banner) moves the fingerprint.
        let mut state = state;
        state.push_event(AppEvent::UpdateAvailable {
            latest_version: "9.9.9".to_owned(),
        });
        assert_ne!(before, view_signature(&state));

        let mut search_state = DashboardState::new();
        search_state.open_message_search_popup();
        search_state.push_search_char('x');
        let command = search_state
            .activate_search_popup()
            .expect("search should start");
        let AppCommand::SearchMessages { query } = command else {
            panic!("expected message search command");
        };
        let loading = view_signature(&search_state);
        search_state.push_event(AppEvent::MessageSearchLoaded {
            page: MessageSearchPage {
                query,
                messages: vec![MessageInfo::test(Id::new(20), Id::new(30))],
                total_results: Some(1),
                has_more: false,
            },
        });
        assert_ne!(loading, view_signature(&search_state));
    }

    #[test]
    fn view_signature_tracks_composer_history_state_changes() {
        let mut state = DashboardState::new();
        state.push_event(AppEvent::ChannelUpsert(ChannelInfo::test(
            Id::new(20),
            "dm",
        )));
        state.confirm_selected_guild();
        state.confirm_selected_channel();

        let loading = view_signature(&state);
        state.push_event(AppEvent::MessageHistoryLoaded {
            channel_id: Id::new(20),
            before: None,
            messages: Vec::new(),
        });
        let loaded = view_signature(&state);
        assert_ne!(loading, loaded);

        state.push_event(AppEvent::MessageHistoryLoadFailed {
            channel_id: Id::new(20),
            target: MessageHistoryLoadTarget::Latest,
            message: "offline".to_owned(),
        });
        assert_ne!(loaded, view_signature(&state));
    }

    #[test]
    fn view_signature_tracks_visible_dm_activity_changes() {
        let user_id = Id::new(10);
        let mut state = DashboardState::new();
        state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
            recipients: Some(vec![ChannelRecipientInfo {
                status: Some(PresenceStatus::Online),
                ..ChannelRecipientInfo::test(user_id, "alice")
            }]),
            ..ChannelInfo::test(Id::new(20), "dm")
        }));
        state.confirm_selected_guild();
        state.set_channel_view_height(4);

        state.push_event(AppEvent::PresenceUpdate {
            guild_id: None,
            presence: PresenceEventFields {
                user_id,
                status: PresenceStatus::Online,
                activities: vec![ActivityInfo::test(ActivityKind::Playing, "Game A")],
            },
        });
        let game_a = view_signature(&state);

        state.push_event(AppEvent::PresenceUpdate {
            guild_id: None,
            presence: PresenceEventFields {
                user_id,
                status: PresenceStatus::Online,
                activities: vec![ActivityInfo::test(ActivityKind::Playing, "Game B")],
            },
        });

        assert_ne!(game_a, view_signature(&state));
    }

    #[test]
    fn view_signature_tracks_stream_viewer_changes() {
        let guild_id = Id::new(1);
        let channel_id = Id::new(10);
        let current_user_id = Id::new(20);
        let viewer_id = Id::new(30);
        let scope = VoiceScope::Guild(guild_id);
        let mut state = DashboardState::new();
        state.push_event(AppEvent::Ready {
            user: "Me".to_owned(),
            user_id: Some(current_user_id),
        });
        state.push_event(AppEvent::ReadyUserDirectory {
            users: vec![ChannelRecipientInfo::test(viewer_id, "Viewer")],
        });
        state.push_event(AppEvent::StreamCreate {
            stream: StreamCreateInfo {
                stream_key: "guild:1:10:20".to_owned(),
                rtc_server_id: "100".to_owned(),
                rtc_channel_id: Id::new(101),
                viewer_ids: vec![viewer_id],
                paused: false,
            },
        });
        state.show_stream_broadcast_preparing_toast(scope, channel_id);
        state.push_effect(AppEvent::StreamBroadcastStarted { scope, channel_id });
        let with_viewer = view_signature(&state);

        state.push_event(AppEvent::StreamUpdate {
            stream: StreamUpdateInfo {
                stream_key: "guild:1:10:20".to_owned(),
                viewer_ids: Vec::new(),
                paused: false,
            },
        });

        assert_ne!(with_viewer, view_signature(&state));
    }
}