use std::time::{SystemTime, UNIX_EPOCH};
use crate::discord::ids::{
Id,
marker::{ChannelMarker, ForumTagMarker, GuildMarker, MessageMarker, UserMarker},
};
use crate::discord::{
ChannelInfo, ChannelRecipientInfo, ForumTagInfo, PermissionOverwriteInfo, PresenceStatus,
VoiceScope,
};
use crate::discord::state::DiscordState;
const ACTIVE_CHANNELS_REMOVED: u64 = 1 << 2;
const DISCORD_EPOCH_MILLIS: u64 = 1_420_070_400_000;
const MILLIS_PER_MINUTE: u64 = 60_000;
pub(crate) fn is_thread_kind(kind: &str) -> bool {
matches!(
kind,
"thread"
| "GuildPublicThread"
| "GuildPrivateThread"
| "GuildNewsThread"
| "private-thread"
)
}
pub(crate) fn is_private_thread_kind(kind: &str) -> bool {
matches!(kind, "GuildPrivateThread" | "private-thread")
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChannelState {
pub id: Id<ChannelMarker>,
pub guild_id: Option<Id<GuildMarker>>,
pub parent_id: Option<Id<ChannelMarker>>,
pub owner_id: Option<Id<UserMarker>>,
pub position: Option<i32>,
pub last_message_id: Option<Id<MessageMarker>>,
pub name: String,
pub kind: String,
pub message_count: Option<u64>,
pub member_count: Option<u64>,
pub total_message_sent: Option<u64>,
pub thread_metadata: Option<crate::discord::ThreadMetadataInfo>,
pub flags: Option<u64>,
pub rate_limit_per_user: Option<u64>,
pub available_tags: Vec<ForumTagInfo>,
pub applied_tags: Vec<Id<ForumTagMarker>>,
pub recipients: Vec<ChannelRecipientState>,
pub permission_overwrites: Vec<PermissionOverwriteInfo>,
pub is_message_request: Option<bool>,
pub is_spam: Option<bool>,
}
impl ChannelState {
pub fn is_category(&self) -> bool {
matches!(self.kind.as_str(), "category" | "GuildCategory")
}
pub fn is_thread(&self) -> bool {
is_thread_kind(&self.kind)
}
pub fn is_forum(&self) -> bool {
matches!(self.kind.as_str(), "forum" | "media" | "GuildForum")
}
pub fn is_dm(&self) -> bool {
matches!(self.kind.as_str(), "dm" | "Private")
}
pub fn dm_request_tag(&self) -> Option<&'static str> {
if !self.is_dm() {
return None;
}
if self.is_spam == Some(true) {
return Some("spam");
}
if self.is_message_request == Some(true) {
return Some("request");
}
None
}
pub fn is_voice(&self) -> bool {
matches!(self.kind.as_str(), "voice" | "GuildVoice")
}
pub fn is_dm_or_group_dm(&self) -> bool {
matches!(self.kind.as_str(), "dm" | "Private" | "group-dm" | "Group")
}
pub fn supports_voice_call(&self) -> bool {
self.is_voice() || self.is_dm_or_group_dm()
}
pub fn voice_scope(&self) -> VoiceScope {
match self.guild_id {
Some(guild_id) => VoiceScope::Guild(guild_id),
None => VoiceScope::Private(self.id),
}
}
pub fn is_private_thread(&self) -> bool {
is_private_thread_kind(&self.kind)
}
pub fn thread_archived(&self) -> Option<bool> {
self.thread_metadata
.as_ref()
.map(|metadata| metadata.archived)
}
pub fn thread_locked(&self) -> Option<bool> {
self.thread_metadata
.as_ref()
.map(|metadata| metadata.locked)
}
pub fn thread_pinned(&self) -> Option<bool> {
self.flags.map(|flags| flags & (1 << 1) != 0)
}
pub fn removed_from_active_channels(&self) -> bool {
self.flags
.is_some_and(|flags| flags & ACTIVE_CHANNELS_REMOVED != 0)
}
pub(in crate::discord) fn is_active_thread(&self) -> bool {
self.is_thread()
&& !self.thread_archived().unwrap_or(false)
&& !self.removed_from_active_channels()
}
pub(in crate::discord) fn shows_in_active_channel_tree(&self) -> bool {
let now_millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
self.shows_in_active_channel_tree_at(now_millis)
}
fn shows_in_active_channel_tree_at(&self, now_millis: u64) -> bool {
if !self.is_active_thread() {
return false;
}
if self.thread_pinned() == Some(true) {
return true;
}
let Some(auto_archive_minutes) = self
.thread_metadata
.as_ref()
.and_then(|metadata| metadata.auto_archive_duration)
else {
return true;
};
let activity_snowflake = self
.last_message_id
.map(Id::get)
.unwrap_or_else(|| self.id.get());
let snowflake_millis = (activity_snowflake >> 22).saturating_add(DISCORD_EPOCH_MILLIS);
let archive_change_millis = self
.thread_metadata
.as_ref()
.and_then(|metadata| metadata.archive_timestamp.as_deref())
.and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok())
.and_then(|timestamp| u64::try_from(timestamp.timestamp_millis()).ok())
.unwrap_or(0);
let activity_millis = snowflake_millis.max(archive_change_millis);
let deadline =
activity_millis.saturating_add(auto_archive_minutes.saturating_mul(MILLIS_PER_MINUTE));
now_millis < deadline
}
pub fn requires_forum_tag(&self) -> bool {
const REQUIRE_TAG: u64 = 1 << 4;
self.flags
.is_some_and(|flags| flags & REQUIRE_TAG == REQUIRE_TAG)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChannelRecipientState {
pub user_id: Id<UserMarker>,
pub display_name: String,
pub username: Option<String>,
pub is_bot: bool,
pub avatar_url: Option<String>,
pub status: PresenceStatus,
}
impl ChannelRecipientState {
pub(in crate::discord) fn from_info(
recipient: &ChannelRecipientInfo,
previous: Option<&Self>,
ready_user: Option<&ChannelRecipientInfo>,
known_status: Option<PresenceStatus>,
display_name: String,
) -> Self {
Self {
user_id: recipient.user_id,
display_name,
username: recipient
.username
.clone()
.or_else(|| ready_user.and_then(|user| user.username.clone()))
.or_else(|| previous.and_then(|user| user.username.clone())),
is_bot: recipient.is_bot
|| ready_user.is_some_and(|user| user.is_bot)
|| previous.is_some_and(|user| user.is_bot),
avatar_url: recipient
.avatar_url
.clone()
.or_else(|| ready_user.and_then(|user| user.avatar_url.clone()))
.or_else(|| previous.and_then(|user| user.avatar_url.clone())),
status: recipient
.status
.or_else(|| previous.map(|user| user.status))
.or(known_status)
.unwrap_or(PresenceStatus::Unknown),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ChannelVisibilityStats {
pub visible: usize,
pub hidden: usize,
}
impl DiscordState {
pub fn channels_for_guild(&self, guild_id: Option<Id<GuildMarker>>) -> Vec<&ChannelState> {
self.navigation
.channels
.values()
.filter(|channel| channel.guild_id == guild_id)
.collect()
}
pub fn viewable_channels_for_guild(
&self,
guild_id: Option<Id<GuildMarker>>,
) -> Vec<&ChannelState> {
self.navigation
.channels
.values()
.filter(|channel| channel.guild_id == guild_id)
.filter(|channel| self.can_view_channel(channel))
.collect()
}
pub fn sidebar_channels_for_guild(
&self,
guild_id: Option<Id<GuildMarker>>,
) -> Vec<&ChannelState> {
self.viewable_channels_for_guild(guild_id)
.into_iter()
.filter(|channel| self.channel_visible_in_notification_settings(channel.id))
.collect()
}
pub fn channel_visibility_stats(
&self,
guild_id: Option<Id<GuildMarker>>,
) -> ChannelVisibilityStats {
let mut visible: usize = 0;
let mut hidden: usize = 0;
for channel in self.navigation.channels.values() {
if channel.guild_id != guild_id || channel.is_thread() {
continue;
}
if self.can_view_channel(channel) {
visible += 1;
} else {
hidden += 1;
}
}
ChannelVisibilityStats { visible, hidden }
}
pub fn channel(&self, channel_id: Id<ChannelMarker>) -> Option<&ChannelState> {
self.navigation.channels.get(&channel_id)
}
pub fn thread_is_sidebar_active(&self, channel_id: Id<ChannelMarker>) -> bool {
self.threads.is_active(channel_id)
&& self.threads.current_user_member(channel_id).is_some()
&& self
.navigation
.channels
.get(&channel_id)
.is_some_and(ChannelState::shows_in_active_channel_tree)
}
pub(in crate::discord) fn channel_guild_id(
&self,
channel_id: Id<ChannelMarker>,
) -> Option<Id<GuildMarker>> {
self.navigation
.channels
.get(&channel_id)
.and_then(|channel| channel.guild_id)
}
pub(in crate::discord) fn upsert_channel(&mut self, channel: &ChannelInfo) {
let existing = self.navigation.channels.get(&channel.channel_id);
let last_message_id = existing
.and_then(|existing| existing.last_message_id)
.max(channel.last_message_id);
let recipients = channel
.recipients
.as_ref()
.map(|recipients| {
recipients
.iter()
.map(|recipient| {
let previous = existing.and_then(|existing| {
existing
.recipients
.iter()
.find(|existing| existing.user_id == recipient.user_id)
});
let ready_user = self.session.ready_users.get(&recipient.user_id);
let known_status = self
.presence
.user_presences
.get(&recipient.user_id)
.copied();
let fallback_display_name = if recipient.username.is_none()
&& recipient.display_name == "unknown"
{
ready_user
.map(|user| user.display_name.as_str())
.or_else(|| previous.map(|user| user.display_name.as_str()))
.unwrap_or(recipient.display_name.as_str())
} else {
recipient.display_name.as_str()
};
let display_name = self.private_user_display_name(
recipient.user_id,
Some(fallback_display_name),
recipient
.username
.as_deref()
.or_else(|| ready_user.and_then(|user| user.username.as_deref()))
.or_else(|| previous.and_then(|user| user.username.as_deref())),
);
ChannelRecipientState::from_info(
recipient,
previous,
ready_user,
known_status,
display_name,
)
})
.collect()
})
.or_else(|| existing.map(|existing| existing.recipients.clone()))
.unwrap_or_default();
let incoming_recipient_names: Vec<String> = channel
.recipients
.as_ref()
.map(|recipients| {
recipients
.iter()
.map(|recipient| recipient.display_name.clone())
.collect()
})
.unwrap_or_default();
let existing_name_follows_recipients = existing.is_some_and(|existing| {
private_channel_name_follows_recipients(
&existing.kind,
&existing.name,
existing.id,
&existing
.recipients
.iter()
.map(|recipient| recipient.display_name.clone())
.collect::<Vec<_>>(),
)
});
let name = if channel.guild_id.is_none()
&& !recipients.is_empty()
&& (private_channel_name_follows_recipients(
&channel.kind,
&channel.name,
channel.channel_id,
&incoming_recipient_names,
) || existing_name_follows_recipients)
{
joined_recipient_display_names(&recipients)
} else {
channel.name.clone()
};
let permission_overwrites = if is_thread_kind(&channel.kind) {
existing
.map(|existing| existing.permission_overwrites.clone())
.unwrap_or_default()
} else {
channel.permission_overwrites.clone()
};
let thread_metadata = channel
.thread_metadata
.clone()
.or_else(|| existing.and_then(|existing| existing.thread_metadata.clone()));
let flags = channel
.flags
.or_else(|| existing.and_then(|existing| existing.flags));
let state = ChannelState {
id: channel.channel_id,
guild_id: channel.guild_id,
parent_id: channel.parent_id,
owner_id: channel.owner_id,
position: channel.position,
last_message_id,
name,
kind: channel.kind.clone(),
message_count: channel.message_count,
member_count: channel.member_count,
total_message_sent: channel.total_message_sent,
thread_metadata,
flags,
rate_limit_per_user: channel
.rate_limit_per_user
.or_else(|| existing.and_then(|existing| existing.rate_limit_per_user)),
available_tags: channel.available_tags.clone(),
applied_tags: channel.applied_tags.clone(),
recipients,
permission_overwrites,
is_message_request: channel
.is_message_request
.or_else(|| existing.and_then(|existing| existing.is_message_request)),
is_spam: channel
.is_spam
.or_else(|| existing.and_then(|existing| existing.is_spam)),
};
self.navigation_mut()
.channels
.insert(channel.channel_id, state);
}
pub(in crate::discord) fn set_thread_member_count(
&mut self,
channel_id: Id<ChannelMarker>,
member_count: u64,
) {
if let Some(channel) = self
.navigation_mut()
.channels
.get_mut(&channel_id)
.filter(|channel| channel.is_thread())
{
channel.member_count = Some(member_count);
}
}
pub(in crate::discord) fn refresh_dm_channel_info_from_profile(
&mut self,
user_id: Id<UserMarker>,
display_name: &str,
username: Option<&str>,
avatar_url: Option<&str>,
) {
for channel in self.navigation_mut().channels.values_mut() {
if channel.guild_id.is_some() {
continue;
}
let previous_names: Vec<String> = channel
.recipients
.iter()
.map(|recipient| recipient.display_name.clone())
.collect();
let mut updated = false;
for recipient in &mut channel.recipients {
if recipient.user_id == user_id {
recipient.display_name = display_name.to_owned();
if let Some(username) = username {
recipient.username = Some(username.to_owned());
}
if avatar_url.is_some() || recipient.avatar_url.is_none() {
recipient.avatar_url = avatar_url.map(str::to_owned);
}
updated = true;
}
}
if updated {
refresh_private_channel_name_from_recipients(channel, &previous_names);
}
}
}
pub(in crate::discord) fn update_channel_recipient_presence(
&mut self,
user_id: Id<UserMarker>,
status: PresenceStatus,
) {
for channel in self.navigation_mut().channels.values_mut() {
for recipient in &mut channel.recipients {
if recipient.user_id == user_id {
recipient.status = status;
}
}
}
}
pub(in crate::discord) fn record_channel_message_id(
&mut self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
) {
if let Some(channel) = self.navigation_mut().channels.get_mut(&channel_id) {
channel.last_message_id = channel.last_message_id.max(Some(message_id));
}
}
pub(in crate::discord) fn increment_thread_message_counts(
&mut self,
channel_id: Id<ChannelMarker>,
) {
let Some(channel) = self
.navigation_mut()
.channels
.get_mut(&channel_id)
.filter(|channel| channel.is_thread())
else {
return;
};
if let Some(count) = channel.message_count.as_mut() {
*count = count.saturating_add(1);
}
if let Some(count) = channel.total_message_sent.as_mut() {
*count = count.saturating_add(1);
}
}
}
pub(super) fn joined_recipient_display_names(recipients: &[ChannelRecipientState]) -> String {
recipients
.iter()
.map(|recipient| recipient.display_name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub(super) fn private_channel_name_follows_recipients(
kind: &str,
current_name: &str,
channel_id: Id<ChannelMarker>,
recipient_names: &[String],
) -> bool {
matches!(kind, "dm" | "Private")
|| current_name == format!("dm-{}", channel_id.get())
|| current_name == recipient_names.join(", ")
}
pub(in crate::discord) fn refresh_private_channel_name_from_recipients(
channel: &mut ChannelState,
previous_names: &[String],
) {
if channel.guild_id.is_some() {
return;
}
if !private_channel_name_follows_recipients(
&channel.kind,
&channel.name,
channel.id,
previous_names,
) {
return;
}
let new_name = joined_recipient_display_names(&channel.recipients);
channel.name = if new_name.is_empty() {
format!("dm-{}", channel.id.get())
} else {
new_name
};
}
#[cfg(test)]
mod tests {
use super::{is_private_thread_kind, is_thread_kind};
#[test]
fn classifies_all_thread_channel_kinds() {
for kind in [
"thread",
"GuildPublicThread",
"GuildPrivateThread",
"GuildNewsThread",
"private-thread",
] {
assert!(is_thread_kind(kind), "{kind} should be a thread kind");
}
for kind in [
"dm",
"Private",
"voice",
"GuildVoice",
"forum",
"GuildForum",
] {
assert!(!is_thread_kind(kind), "{kind} should not be a thread kind");
}
}
#[test]
fn classifies_private_thread_channel_kinds() {
assert!(is_private_thread_kind("GuildPrivateThread"));
assert!(is_private_thread_kind("private-thread"));
assert!(!is_private_thread_kind("GuildPublicThread"));
}
}