Skip to main content

tilo_app/framework/
people.rs

1//! Who the participants are, by name: what a view or surface shows for an
2//! id an action recorded ([`ActionCx::author`](tilo_resource::ActionCx::author))
3//! or the participant reading ([`RecordEnv::me`](super::RecordEnv::me)).
4//!
5//! The document records ids — the same on every replica, whatever anyone is
6//! called — and the host knows the names: the workspace's roster announces them
7//! with the people who are present. A view resolves an id when it shows it,
8//! so a renamed person, or one whose name arrived after their edit, reads
9//! correctly everywhere at once. Names are the workspace's labels: people who
10//! share a name carry the number presence tells them apart by
11//! ([`ui_core::presence::ParticipantLabels`]), and the participant reading
12//! is shown their own name like anyone else's.
13
14use std::cell::RefCell;
15use std::collections::HashMap;
16use std::rc::Rc;
17
18use tilo_resource::{ActionKey, Binds, Resource};
19use ui_core::LocalState;
20use ui_core::icons::Icon;
21use ui_core::presence::{ParticipantKind, PresenceCtx};
22use ui_core::widget::mention_picker::{MentionCandidate, merged_roster};
23use ui_core::widget::{Button, ButtonRole, Element, Stack, Tone};
24
25use super::ViewCx;
26
27/// The names the host knows for the participants of the window's workspace.
28/// Cloneable; clones read the same roster.
29#[derive(Clone)]
30pub struct People {
31    roster: Rc<RefCell<Roster>>,
32    revision: LocalState<u64>,
33    /// The participant this window is for, when someone edits it.
34    me: Option<Rc<str>>,
35    /// The name this device knows its person by, for `me` until the workspace
36    /// names them.
37    local: Option<Rc<str>>,
38}
39
40#[derive(Default, PartialEq)]
41struct Roster {
42    /// Each participant the workspace names, as presence labels them.
43    names: HashMap<String, String>,
44    /// The label this session's participant goes by, when the workspace said.
45    own: Option<String>,
46    /// Everyone the workspace names, by name, as `@` offers them.
47    members: Vec<MentionCandidate>,
48}
49
50/// A participant as a view shows them: what the workspace calls the id an
51/// action recorded, the id presence knows them by (which a face takes its
52/// colour and photograph from), and whether the workspace offers them as an
53/// agent.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Actor {
56    /// The id presence names them by: an agent's `agent:<id>`, a person's
57    /// bare id.
58    pub id: String,
59    /// What the workspace calls them, or their id where it names them not.
60    pub name: String,
61    /// Whether they are an agent of the workspace, not a person in it.
62    pub agent: bool,
63}
64
65impl People {
66    pub(crate) fn new(me: Option<&str>) -> Self {
67        Self {
68            roster: Rc::default(),
69            revision: LocalState::new(0),
70            local: me.and_then(|_| {
71                ui_core::local_identity::bound()
72                    .map(|identity| identity.display_name.trim().to_owned())
73                    .filter(|name| !name.is_empty())
74                    .map(Rc::from)
75            }),
76            me: me.map(Rc::from),
77        }
78    }
79
80    /// The name to show for `id`: the roster's label; for the window's own
81    /// participant before the workspace names them, the name this device knows
82    /// its person by; else the id itself — what a record written before ids
83    /// were names holds, and what a capture's fixture people are called.
84    pub fn name(&self, id: &str) -> String {
85        let roster = self.roster.borrow();
86        if let Some(name) = roster.names.get(id) {
87            return name.clone();
88        }
89        if self.me.as_deref() == Some(id)
90            && let Some(own) = roster.own.as_deref().or(self.local.as_deref())
91        {
92            return own.to_owned();
93        }
94        id.to_owned()
95    }
96
97    /// The workspace's people and agents, this window's own participant among
98    /// them, sorted by name: whom a picker of members offers
99    /// ([`ViewCx::person_picker`]).
100    pub fn members(&self) -> Vec<MentionCandidate> {
101        self.roster.borrow().members.clone()
102    }
103
104    /// Who the id an action recorded names, however the record spells it: a
105    /// person as an assignee holds them (`person:<id>`), an agent as the
106    /// action door names it (`agent:<id>`), or the bare id. The name is the
107    /// workspace's, else the id without its prefix: a view never shows a
108    /// record's spelling of a participant.
109    pub fn actor(&self, id: &str) -> Actor {
110        let (identity, bare, agent) = if let Some(bare) = id.strip_prefix("person:") {
111            (bare, bare, false)
112        } else if let Some(bare) = id.strip_prefix("agent:") {
113            (id, bare, true)
114        } else {
115            (id, id, false)
116        };
117        let name = [identity, bare]
118            .into_iter()
119            .map(|key| self.name(key))
120            .find(|name| name != identity && name != bare)
121            .unwrap_or_else(|| bare.to_owned());
122        Actor {
123            id: identity.to_owned(),
124            name,
125            agent,
126        }
127    }
128
129    /// Whether another person is in the workspace with this window now.
130    pub fn others_here(&self) -> bool {
131        self.roster.borrow().members.iter().any(|member| {
132            !member.is_self && member.kind == ui_core::presence::ParticipantKind::Person
133        })
134    }
135
136    /// Whether `id` is the participant this window is for.
137    pub fn is_me(&self, id: &str) -> bool {
138        self.me.as_deref() == Some(id)
139    }
140
141    /// Increases whenever a name changes, for a view to show names that
142    /// follow the roster (`Text::from_state(&people.revision(), ..)`).
143    pub fn revision(&self) -> LocalState<u64> {
144        self.revision.clone()
145    }
146
147    /// Take the names the host's roster holds now. Whether any changed.
148    pub(crate) fn follow(&self, presence: &PresenceCtx) -> bool {
149        let next = Roster {
150            names: presence
151                .names
152                .keys()
153                .map(|user| (user.0.clone(), presence.display_name(user)))
154                .collect(),
155            own: presence.self_label(),
156            members: merged_roster(presence, &[]),
157        };
158        if *self.roster.borrow() == next {
159            return false;
160        }
161        *self.roster.borrow_mut() = next;
162        self.revision.set(self.revision.get().wrapping_add(1));
163        true
164    }
165
166    /// People named as `names` gives them, for a capture whose fixture
167    /// places collaborators, or a test.
168    #[cfg(any(test, feature = "test-support"))]
169    #[must_use]
170    pub fn named(me: Option<&str>, names: &[(&str, &str)]) -> Self {
171        let people = Self::new(me);
172        people.roster.borrow_mut().names = names
173            .iter()
174            .map(|(id, name)| ((*id).to_owned(), (*name).to_owned()))
175            .collect();
176        people
177    }
178}
179
180impl<R: Resource, T: 'static> ViewCx<R, T> {
181    /// A chip showing whom the resource assigns `read` to — their avatar and
182    /// name, or `nobody` while it names no one — that opens the workspace's
183    /// people and agents, each with their avatar, and invokes `action` with
184    /// the value the record stores for the one picked (`None` for nobody).
185    /// `value_of` is how the record names a member, `None` leaving one out;
186    /// `label` names the control for assistive technology. Built again as the
187    /// roster changes, so it offers whom the workspace names now, and as the
188    /// record's value changes, so the chip follows an assignment made
189    /// anywhere.
190    pub fn person_picker<B: Binds<R, Option<String>> + Clone>(
191        &self,
192        id: &'static str,
193        label: String,
194        nobody: String,
195        value_of: impl Fn(&MentionCandidate) -> Option<String> + 'static,
196        read: impl Fn(&T) -> Option<String> + 'static,
197        action: B,
198    ) -> Element {
199        let people = self.people();
200        let revision = people.revision();
201        let label = Rc::new(label);
202        let nobody = Rc::new(nobody);
203        let value_of = Rc::new(value_of);
204        let read = Rc::new(read);
205        self.switch(&revision, move |cx, _| {
206            let members = people.members();
207            let held = {
208                let read = Rc::clone(&read);
209                cx.value(move |state: &T| read(state))
210            };
211            let people = people.clone();
212            let (label, nobody, value_of) =
213                (Rc::clone(&label), Rc::clone(&nobody), Rc::clone(&value_of));
214            let held_action = action.clone();
215            cx.switch(&held, move |cx, held| {
216                let who = held.as_deref().map(|value| people.actor(value));
217                let mut choices = Stack::new();
218                choices = choices.push(cx.person_choice(
219                    &format!("{id}-none"),
220                    &nobody,
221                    None,
222                    None,
223                    false,
224                    held_action.clone(),
225                ));
226                for member in &members {
227                    let Some(value) = value_of(member) else {
228                        continue;
229                    };
230                    choices = choices.push(cx.person_choice(
231                        &format!("{id}-{}", member.id),
232                        &member.name,
233                        Some(value),
234                        Some((&member.name, &member.id)),
235                        member.kind == ParticipantKind::Agent,
236                        held_action.clone(),
237                    ));
238                }
239                let (name, agent, chip) = match &who {
240                    Some(who) => (
241                        who.name.clone(),
242                        who.agent,
243                        Button::new(id, &who.name)
244                            .with_avatar(&who.name, &who.id)
245                            .showing_choice(),
246                    ),
247                    None => (
248                        nobody.to_string(),
249                        false,
250                        Button::new(id, nobody.as_str())
251                            .with_icon(Icon::User)
252                            .with_placeholder()
253                            .showing_choice(),
254                    ),
255                };
256                let chip = chip.with_accessible_name(&format!("{label}: {name}"));
257                let chip = if agent { chip.tone(Tone::Info) } else { chip };
258                Box::new(chip.with_popover(choices)) as Element
259            })
260        })
261    }
262
263    /// One person the picker offers: their face and name, invoking `action`
264    /// with `value` when they are pressed.
265    fn person_choice<B: Binds<R, Option<String>>>(
266        &self,
267        id: &str,
268        label: &str,
269        value: Option<String>,
270        face: Option<(&str, &str)>,
271        agent: bool,
272        action: B,
273    ) -> Element {
274        let mut button = Button::new(id, label).role(ButtonRole::Plain);
275        button = match face {
276            Some((name, identity)) => button.with_avatar(name, identity),
277            None => button.with_icon(Icon::User).with_placeholder(),
278        };
279        if agent {
280            button = button.tone(Tone::Info);
281        }
282        let binding = self.actions().binding(Chosen {
283            binds: action,
284            value,
285        });
286        match binding {
287            Some(binding) => Box::new(button.on_action(binding)),
288            None => Box::new(button.with_enabled(false)),
289        }
290    }
291}
292
293/// [`Binds`] for a button that names one value of an action taking an optional
294/// one: the picker's choices.
295struct Chosen<B> {
296    binds: B,
297    value: Option<String>,
298}
299
300impl<R: Resource, B: Binds<R, Option<String>>> Binds<R, ()> for Chosen<B> {
301    fn action_id(&self) -> &'static str {
302        self.binds.action_id()
303    }
304
305    fn action_key(&self) -> ActionKey {
306        self.binds.action_key()
307    }
308
309    fn arguments(&self, (): ()) -> Result<serde_json::Map<String, serde_json::Value>, String> {
310        self.binds.arguments(self.value.clone())
311    }
312}
313
314impl std::fmt::Debug for People {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("People")
317            .field("me", &self.me)
318            .field("local", &self.local)
319            .field("known", &self.roster.borrow().names.len())
320            .field("revision", &self.revision.get())
321            .finish()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use ui_core::permissions::UserId;
329
330    #[test]
331    fn an_id_reads_as_the_rosters_name_or_itself() {
332        let people = People::new(Some("me-id"));
333        assert_eq!(
334            people.name("me-id"),
335            "me-id",
336            "nobody is called \"You\": an unnamed self reads as its id"
337        );
338        assert_eq!(
339            people.name("Alice"),
340            "Alice",
341            "an unknown id reads as itself"
342        );
343
344        let mut presence = PresenceCtx::default();
345        presence.set_display_name(UserId("peer-id".into()), "Bob");
346        presence.set_self_participant(UserId("me-id".into()), "Ada");
347        assert!(people.follow(&presence));
348        assert!(
349            !people.follow(&presence),
350            "an unchanged roster changes nothing"
351        );
352        assert_eq!(people.name("peer-id"), "Bob");
353        assert_eq!(people.name("me-id"), "Ada");
354        assert!(people.is_me("me-id"));
355        assert_eq!(people.revision().get(), 1);
356
357        presence.set_display_name(UserId("peer-id".into()), "ada");
358        assert!(people.follow(&presence));
359        assert_eq!(people.name("me-id"), "Ada (1)");
360        assert_eq!(people.name("peer-id"), "ada (2)");
361    }
362
363    #[test]
364    fn an_actors_record_spelling_reads_as_the_name_the_workspace_gives() {
365        let people = People::new(None);
366        let mut presence = PresenceCtx::default();
367        presence.set_display_name(UserId("bob".into()), "Bob");
368        presence.set_display_name(UserId("agent:scribe".into()), "Scribe");
369        presence.set_participant_kind(UserId("agent:scribe".into()), ParticipantKind::Agent);
370        assert!(people.follow(&presence));
371
372        assert_eq!(
373            people.actor("person:bob"),
374            Actor {
375                id: "bob".into(),
376                name: "Bob".into(),
377                agent: false,
378            },
379            "an assignee's spelling is not a name"
380        );
381        assert_eq!(
382            people.actor("agent:scribe"),
383            Actor {
384                id: "agent:scribe".into(),
385                name: "Scribe · Agent".into(),
386                agent: true,
387            },
388            "an agent keeps the id presence colours its face by"
389        );
390        assert_eq!(
391            people.actor("person:stranger"),
392            Actor {
393                id: "stranger".into(),
394                name: "stranger".into(),
395                agent: false,
396            },
397            "an id the workspace names not reads without its prefix"
398        );
399        assert_eq!(people.actor("bob").name, "Bob", "a bare id reads the same");
400    }
401}