Skip to main content

concord/discord/member/
state.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::time::Instant;
3
4use crate::discord::ids::{
5    Id,
6    marker::{ChannelMarker, GuildMarker, RoleMarker, UserMarker},
7};
8use crate::discord::{ActivityInfo, MemberInfo, PresenceStatus, RoleInfo};
9
10use crate::discord::state::{
11    DiscordState, MAX_RECENT_MEMBER_GUILDS, TYPING_INDICATOR_TTL, is_fallback_identity,
12};
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct TypingUserState {
16    pub user_id: Id<UserMarker>,
17    pub display_name: Option<String>,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct GuildMemberState {
22    pub user_id: Id<UserMarker>,
23    pub display_name: String,
24    /// Discord login handle. Mirrors `MemberInfo::username`. The @-mention
25    /// picker matches against this in addition to `display_name`.
26    pub username: Option<String>,
27    pub is_bot: bool,
28    pub avatar_url: Option<String>,
29    pub role_ids: Vec<Id<RoleMarker>>,
30    pub status: PresenceStatus,
31}
32
33#[cfg(test)]
34#[allow(dead_code)]
35impl GuildMemberState {
36    pub(crate) fn test(user_id: Id<UserMarker>, display_name: impl Into<String>) -> Self {
37        Self {
38            user_id,
39            display_name: display_name.into(),
40            username: None,
41            is_bot: false,
42            avatar_url: None,
43            role_ids: Vec::new(),
44            status: PresenceStatus::Offline,
45        }
46    }
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct RoleState {
51    pub id: Id<RoleMarker>,
52    pub name: String,
53    pub color: Option<u32>,
54    pub position: i64,
55    pub hoist: bool,
56    /// Discord permission bitfield for the role. Used to compute the
57    /// authenticated user's base permissions and detect ADMINISTRATOR.
58    pub permissions: u64,
59}
60
61impl DiscordState {
62    pub fn typing_users(&self, channel_id: Id<ChannelMarker>) -> Vec<TypingUserState> {
63        let now = Instant::now();
64        let Some(channel_typers) = self.presence.typing.get(&channel_id) else {
65            return Vec::new();
66        };
67        let mut fresh: Vec<(Id<UserMarker>, Instant, Option<String>)> = channel_typers
68            .iter()
69            .filter(|(_, indicator)| now.duration_since(indicator.started) <= TYPING_INDICATOR_TTL)
70            .map(|(user_id, indicator)| {
71                (*user_id, indicator.started, indicator.display_name.clone())
72            })
73            .collect();
74        // Newest typer first so the "X is typing…" label tends to surface the
75        // person who just hit a key.
76        fresh.sort_by_key(|(_, started, _)| std::cmp::Reverse(*started));
77        fresh
78            .into_iter()
79            .map(|(user_id, _, display_name)| TypingUserState {
80                user_id,
81                display_name,
82            })
83            .collect()
84    }
85
86    pub fn user_presence(&self, user_id: Id<UserMarker>) -> Option<PresenceStatus> {
87        self.user_presence_for_guild(None, user_id)
88    }
89
90    pub fn user_presence_for_guild(
91        &self,
92        guild_id: Option<Id<GuildMarker>>,
93        user_id: Id<UserMarker>,
94    ) -> Option<PresenceStatus> {
95        guild_id
96            .and_then(|guild_id| {
97                self.presence
98                    .guild_user_presences
99                    .get(&(guild_id, user_id))
100                    .copied()
101            })
102            .or_else(|| self.presence.user_presences.get(&user_id).copied())
103    }
104
105    pub fn user_activities(&self, user_id: Id<UserMarker>) -> &[ActivityInfo] {
106        self.user_activities_for_guild(None, user_id)
107    }
108
109    pub fn user_activities_for_guild(
110        &self,
111        guild_id: Option<Id<GuildMarker>>,
112        user_id: Id<UserMarker>,
113    ) -> &[ActivityInfo] {
114        guild_id
115            .and_then(|guild_id| {
116                self.presence
117                    .guild_user_activities
118                    .get(&(guild_id, user_id))
119            })
120            .or_else(|| self.presence.user_activities.get(&user_id))
121            .map(Vec::as_slice)
122            .unwrap_or_default()
123    }
124
125    pub fn members_for_guild(&self, guild_id: Id<GuildMarker>) -> Vec<&GuildMemberState> {
126        self.guild_details
127            .members
128            .get(&guild_id)
129            .map(|map| map.values().collect())
130            .unwrap_or_default()
131    }
132
133    pub fn roles_for_guild(&self, guild_id: Id<GuildMarker>) -> Vec<&RoleState> {
134        self.guild_details
135            .roles
136            .get(&guild_id)
137            .map(|map| map.values().collect())
138            .unwrap_or_default()
139    }
140
141    pub fn member_role_color(
142        &self,
143        guild_id: Id<GuildMarker>,
144        user_id: Id<UserMarker>,
145    ) -> Option<u32> {
146        let member = self.guild_details.members.get(&guild_id)?.get(&user_id)?;
147        let roles = self.guild_details.roles.get(&guild_id)?;
148        selected_member_role_color(member, roles)
149    }
150
151    pub fn member_display_name(
152        &self,
153        guild_id: Id<GuildMarker>,
154        user_id: Id<UserMarker>,
155    ) -> Option<&str> {
156        self.guild_details
157            .members
158            .get(&guild_id)
159            .and_then(|members| members.get(&user_id))
160            .map(|member| member.display_name.as_str())
161    }
162
163    pub fn member_has_known_name(
164        &self,
165        guild_id: Id<GuildMarker>,
166        user_id: Id<UserMarker>,
167    ) -> bool {
168        self.guild_details
169            .members
170            .get(&guild_id)
171            .and_then(|members| members.get(&user_id))
172            .map(|member| member.username.is_some())
173            .unwrap_or(false)
174    }
175
176    pub(in crate::discord) fn update_user_activities(
177        &mut self,
178        user_id: Id<UserMarker>,
179        activities: &[ActivityInfo],
180    ) {
181        if activities.is_empty() {
182            self.presence.user_activities.remove(&user_id);
183        } else {
184            self.presence
185                .user_activities
186                .insert(user_id, activities.to_vec());
187        }
188    }
189
190    pub(in crate::discord) fn update_guild_user_activities(
191        &mut self,
192        guild_id: Id<GuildMarker>,
193        user_id: Id<UserMarker>,
194        activities: &[ActivityInfo],
195    ) {
196        let key = (guild_id, user_id);
197        if activities.is_empty() {
198            self.presence.guild_user_activities.remove(&key);
199        } else {
200            self.presence
201                .guild_user_activities
202                .insert(key, activities.to_vec());
203        }
204    }
205
206    pub(in crate::discord) fn update_cached_guild_activities_for_user(
207        &mut self,
208        user_id: Id<UserMarker>,
209        activities: &[ActivityInfo],
210    ) {
211        let guild_ids: Vec<_> = self
212            .presence
213            .guild_user_activities
214            .keys()
215            .filter_map(|(guild_id, activity_user_id)| {
216                (*activity_user_id == user_id).then_some(*guild_id)
217            })
218            .collect();
219        for guild_id in guild_ids {
220            self.update_guild_user_activities(guild_id, user_id, activities);
221        }
222    }
223
224    pub(in crate::discord) fn upsert_guild_member(
225        &mut self,
226        guild_id: Id<GuildMarker>,
227        member: &MemberInfo,
228    ) -> bool {
229        let was_known = self
230            .guild_details
231            .members
232            .get(&guild_id)
233            .is_some_and(|members| members.contains_key(&member.user_id));
234        let previous_status = self
235            .guild_details
236            .members
237            .get(&guild_id)
238            .and_then(|members| members.get(&member.user_id))
239            .map(|member| member.status);
240        let preserve_current_user_roles = self.session.current_user_id == Some(member.user_id)
241            && member.role_ids.is_empty()
242            && is_fallback_identity(member.username.as_deref(), &member.display_name);
243        let protected_role_ids = preserve_current_user_roles
244            .then(|| {
245                self.guild_details
246                    .current_user_role_ids
247                    .get(&guild_id)
248                    .cloned()
249            })
250            .flatten();
251
252        let entry = self.guild_details.members.entry(guild_id).or_default();
253        upsert_member(entry, member, previous_status);
254
255        if self.session.current_user_id == Some(member.user_id) {
256            if let Some(cached_role_ids) = protected_role_ids {
257                if let Some(current_member) = entry.get_mut(&member.user_id) {
258                    current_member.role_ids = cached_role_ids.clone();
259                }
260                self.guild_details
261                    .current_user_role_ids
262                    .insert(guild_id, cached_role_ids);
263            } else if let Some(current_member) = entry.get(&member.user_id) {
264                self.guild_details
265                    .current_user_role_ids
266                    .insert(guild_id, current_member.role_ids.clone());
267            }
268        }
269
270        was_known
271    }
272
273    pub(in crate::discord) fn refresh_current_user_role_cache(&mut self) {
274        let Some(current_user_id) = self.session.current_user_id else {
275            return;
276        };
277        for (guild_id, members) in &self.guild_details.members {
278            if let Some(member) = members.get(&current_user_id) {
279                self.guild_details
280                    .current_user_role_ids
281                    .insert(*guild_id, member.role_ids.clone());
282            }
283        }
284    }
285
286    pub(in crate::discord) fn current_user_role_ids_for_guild(
287        &self,
288        guild_id: Id<GuildMarker>,
289    ) -> Option<&[Id<RoleMarker>]> {
290        self.guild_details
291            .current_user_role_ids
292            .get(&guild_id)
293            .map(Vec::as_slice)
294            .or_else(|| {
295                let current_user_id = self.session.current_user_id?;
296                self.guild_details
297                    .members
298                    .get(&guild_id)
299                    .and_then(|members| members.get(&current_user_id))
300                    .map(|member| member.role_ids.as_slice())
301            })
302    }
303
304    pub(in crate::discord) fn record_selected_member_guild(
305        &mut self,
306        guild_id: Option<Id<GuildMarker>>,
307    ) {
308        if let Some(guild_id) = guild_id {
309            self.guild_details
310                .member_cache_guild_order
311                .retain(|existing| *existing != guild_id);
312            self.guild_details
313                .member_cache_guild_order
314                .push_back(guild_id);
315        }
316        self.prune_member_cache(guild_id);
317    }
318
319    fn prune_member_cache(&mut self, selected_guild_id: Option<Id<GuildMarker>>) {
320        let mut keep_guilds: BTreeSet<Id<GuildMarker>> = self
321            .guild_details
322            .member_cache_guild_order
323            .iter()
324            .rev()
325            .take(MAX_RECENT_MEMBER_GUILDS)
326            .copied()
327            .collect();
328        if let Some(selected_guild_id) = selected_guild_id {
329            keep_guilds.insert(selected_guild_id);
330        }
331        self.guild_details
332            .member_cache_guild_order
333            .retain(|guild_id| keep_guilds.contains(guild_id));
334
335        let current_user_id = self.session.current_user_id;
336        let message_authors = self.message_author_ids_by_guild();
337        self.guild_details.members.retain(|guild_id, members| {
338            if keep_guilds.contains(guild_id) {
339                return true;
340            }
341            members.retain(|user_id, _| {
342                current_user_id == Some(*user_id)
343                    || message_authors
344                        .get(guild_id)
345                        .is_some_and(|authors| authors.contains(user_id))
346            });
347            !members.is_empty()
348        });
349        self.prune_presence_activity_cache();
350    }
351
352    fn message_author_ids_by_guild(&self) -> BTreeMap<Id<GuildMarker>, BTreeSet<Id<UserMarker>>> {
353        let mut authors: BTreeMap<Id<GuildMarker>, BTreeSet<Id<UserMarker>>> = BTreeMap::new();
354        for message in self
355            .message_cache
356            .messages
357            .values()
358            .chain(self.message_cache.pinned_messages.values())
359            .flat_map(|messages| messages.iter())
360        {
361            if let Some(guild_id) = message.guild_id {
362                authors
363                    .entry(guild_id)
364                    .or_default()
365                    .insert(message.author_id);
366            }
367            collect_nested_message_authors(&mut authors, message.guild_id, &message.reply);
368        }
369        authors
370    }
371
372    fn prune_presence_activity_cache(&mut self) {
373        let retained_pairs = self.retained_guild_presence_keys();
374        self.presence
375            .guild_user_presences
376            .retain(|key, _| retained_pairs.contains(key));
377        self.presence
378            .guild_user_activities
379            .retain(|key, _| retained_pairs.contains(key));
380
381        let retained_users = self.retained_presence_user_ids();
382        self.presence
383            .user_presences
384            .retain(|user_id, _| retained_users.contains(user_id));
385        self.presence
386            .user_activities
387            .retain(|user_id, _| retained_users.contains(user_id));
388    }
389
390    fn retained_presence_user_ids(&self) -> BTreeSet<Id<UserMarker>> {
391        let mut retained = BTreeSet::new();
392        if let Some(current_user_id) = self.session.current_user_id {
393            retained.insert(current_user_id);
394        }
395        for members in self.guild_details.members.values() {
396            retained.extend(members.keys().copied());
397        }
398        for channel in self
399            .navigation
400            .channels
401            .values()
402            .filter(|channel| channel.guild_id.is_none())
403        {
404            retained.extend(channel.recipients.iter().map(|recipient| recipient.user_id));
405        }
406        for profile_key in self.profiles.user_profiles.keys() {
407            retained.insert(profile_key.user_id);
408        }
409        retained
410    }
411
412    fn retained_guild_presence_keys(&self) -> BTreeSet<(Id<GuildMarker>, Id<UserMarker>)> {
413        let mut retained = BTreeSet::new();
414        for (guild_id, members) in &self.guild_details.members {
415            retained.extend(members.keys().map(|user_id| (*guild_id, *user_id)));
416        }
417        retained
418    }
419}
420
421fn collect_nested_message_authors(
422    authors: &mut BTreeMap<Id<GuildMarker>, BTreeSet<Id<UserMarker>>>,
423    guild_id: Option<Id<GuildMarker>>,
424    reply: &Option<crate::discord::ReplyInfo>,
425) {
426    let (Some(guild_id), Some(reply)) = (guild_id, reply) else {
427        return;
428    };
429    if let Some(author_id) = reply.author_id {
430        authors.entry(guild_id).or_default().insert(author_id);
431    }
432}
433
434pub(in crate::discord) fn upsert_member(
435    map: &mut BTreeMap<Id<UserMarker>, GuildMemberState>,
436    member: &MemberInfo,
437    previous_status: Option<PresenceStatus>,
438) {
439    let status = previous_status.unwrap_or(PresenceStatus::Unknown);
440
441    let is_fallback = is_fallback_identity(member.username.as_deref(), &member.display_name);
442    let existing_complete = is_fallback
443        .then(|| map.get(&member.user_id))
444        .flatten()
445        .filter(|e| e.username.is_some());
446    let (display_name, username, avatar_url) = match existing_complete {
447        Some(existing) => (
448            existing.display_name.clone(),
449            existing.username.clone(),
450            existing.avatar_url.clone(),
451        ),
452        None => (
453            member.display_name.clone(),
454            member.username.clone(),
455            member.avatar_url.clone(),
456        ),
457    };
458
459    map.insert(
460        member.user_id,
461        GuildMemberState {
462            user_id: member.user_id,
463            display_name,
464            username,
465            is_bot: member.is_bot,
466            avatar_url,
467            role_ids: member.role_ids.clone(),
468            status,
469        },
470    );
471}
472
473pub(in crate::discord) fn role_map(roles: &[RoleInfo]) -> BTreeMap<Id<RoleMarker>, RoleState> {
474    roles
475        .iter()
476        .map(|role| (role.id, role_state(role)))
477        .collect()
478}
479
480pub(in crate::discord) fn role_state(role: &RoleInfo) -> RoleState {
481    RoleState {
482        id: role.id,
483        name: role.name.clone(),
484        color: role.color,
485        position: role.position,
486        hoist: role.hoist,
487        permissions: role.permissions,
488    }
489}
490
491pub(in crate::discord) fn selected_member_role_color(
492    member: &GuildMemberState,
493    roles: &BTreeMap<Id<RoleMarker>, RoleState>,
494) -> Option<u32> {
495    selected_role_ids_color(&member.role_ids, roles)
496}
497
498pub(in crate::discord) fn selected_role_ids_color(
499    role_ids: &[Id<RoleMarker>],
500    roles: &BTreeMap<Id<RoleMarker>, RoleState>,
501) -> Option<u32> {
502    role_ids
503        .iter()
504        .filter_map(|role_id| roles.get(role_id))
505        .filter(|role| role.color.is_some_and(|color| color != 0))
506        .min_by(|left, right| role_display_order(left, right))
507        .and_then(|role| role.color)
508}
509
510fn role_display_order(left: &RoleState, right: &RoleState) -> std::cmp::Ordering {
511    right
512        .position
513        .cmp(&left.position)
514        .then(left.id.get().cmp(&right.id.get()))
515}