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};
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");
}
pub(super) fn view_signature(state: &DashboardState) -> u64 {
let mut hasher = DefaultHasher::new();
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());
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());
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);
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));
}
}
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));
}
}
}
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(),
),
);
}
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());
hash_dbg(&mut hasher, &state.search_popup_view());
state.selected_forum_posts_loading().hash(&mut hasher);
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);
assert_eq!(before, view_signature(&state));
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));
}
}