Skip to main content

tilo_app/
linked_accounts.rs

1//! Accounts elsewhere a person links to who they are in a workspace: their
2//! Discord account in a workspace bridged to a Discord server, say.
3//!
4//! A connector keeps the workspace's record (the bridged server) and publishes
5//! actions on it that any member may ask the workspace's host to run for
6//! themselves ([`tilo_actions::MEMBER_OWN_TAG`]), which the profile card and
7//! Settings use the same way for every service:
8//!
9//! - a read, [`READ_ACTION`], answering a [`Reading`]: whether the workspace is
10//!   connected to the service at all, the reader's linked account if any,
11//!   and the actions below;
12//! - the connect action, when the host offers signing in with the service,
13//!   answering a [`LinkStart`]: the service's consent page, which sends the
14//!   person back to the workspace's host and from there to Tilo with a one-time
15//!   link token;
16//! - the offer read and the accept action, which take such a token (from
17//!   the consent page's return, or from a button the service's side
18//!   handed out): the read answers the account it offers ([`OfferReading`]),
19//!   the accept links it;
20//! - the unlink action.
21//!
22//! The workspace's host runs them: the connector's code stays there, and the
23//! page or shell only asks, through a host's [`WorkspaceRecordActions`].
24//! [`WorkspaceAccounts`] asks the workspace's connectors ([`connector_apps`]) for the
25//! read and keeps what it learns as [`LinkedAccount`]s; a host lends it to
26//! the panels that show them as [`LinkedAccounts`].
27
28use std::cell::{Cell, RefCell};
29use std::collections::{BTreeMap, BTreeSet};
30use std::rc::Rc;
31
32use serde::{Deserialize, Serialize};
33
34/// The mark a connected service's buttons carry, by its connector's app
35/// id: Discord's own, a link for any other.
36#[must_use]
37pub fn service_icon(app: &str) -> crate::icons::Icon {
38    match app {
39        "so.tilo.discord" => crate::icons::Icon::Discord,
40        _ => crate::icons::Icon::Link,
41    }
42}
43
44/// The glyph of a service by the name a profile gives it ("Discord").
45#[must_use]
46pub fn service_name_icon(service: &str) -> crate::icons::Icon {
47    if service.eq_ignore_ascii_case("discord") {
48        crate::icons::Icon::Discord
49    } else {
50        crate::icons::Icon::Link
51    }
52}
53
54/// A linked account as the person's profile names it to everyone: the
55/// service, the handle there, and the account's picture when the service
56/// has one for it.
57#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ProfileLink {
59    /// The service's name ("Discord").
60    pub service: String,
61    /// The account's unique name there, without the `@`.
62    pub handle: String,
63    /// The account's picture as a small `data:` URL.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub picture: Option<String>,
66}
67
68/// The read action a connector's workspace record publishes, answering a
69/// [`Reading`] for the person who reads it.
70pub const READ_ACTION: &str = "my-link";
71
72/// A connector's answer to [`READ_ACTION`].
73#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub struct Reading {
76    /// The service's name ("Discord").
77    pub service: String,
78    /// The workspace is connected to the service (a server is bridged).
79    pub connected: bool,
80    #[serde(default)]
81    pub account: Option<AccountName>,
82    /// The linked account's picture, small, as a `data:` URL: the reader's
83    /// Tilo picture until they upload their own.
84    #[serde(default)]
85    pub picture: Option<String>,
86    /// The action that starts signing in on the service's consent page,
87    /// answering a [`LinkStart`]; none when the host offers no sign-in.
88    #[serde(default)]
89    pub connect: Option<String>,
90    /// How to link from the service's side instead ("In the Discord server,
91    /// type /link."), when it offers that.
92    #[serde(default)]
93    pub elsewhere: Option<String>,
94    /// The action that unlinks the reader's account, when they linked one.
95    #[serde(default)]
96    pub unlink: Option<String>,
97    /// The read that answers the [`OfferReading`] of a link token
98    /// (`{token}`).
99    #[serde(default)]
100    pub offer: String,
101    /// The action that links what a link token offers (`{token}`).
102    #[serde(default)]
103    pub accept: String,
104}
105
106/// How a service names an account: its unique name, and the name it shows
107/// when that is another.
108#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110pub struct AccountName {
111    pub user_name: String,
112    #[serde(default)]
113    pub display_name: Option<String>,
114}
115
116impl AccountName {
117    /// The account as a line shows it: the name it shows and its handle
118    /// when they differ ("Ada L · @ada.l"), else the handle alone.
119    #[must_use]
120    pub fn label(&self) -> String {
121        match &self.display_name {
122            Some(shown) if *shown != self.user_name => format!("{shown} · @{}", self.user_name),
123            _ => self.handle(),
124        }
125    }
126
127    /// The unique name as a handle: `@ada.l`.
128    #[must_use]
129    pub fn handle(&self) -> String {
130        format!("@{}", self.user_name)
131    }
132}
133
134/// What the connect action answers: the service's consent page to go to.
135#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct LinkStart {
137    pub url: String,
138}
139
140/// What the offer read answers: the account a link token offers.
141#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub struct OfferReading {
144    /// The service's name ("Discord").
145    pub service: String,
146    pub account: AccountName,
147    /// The person started it here (signing in on the consent page), so it
148    /// needs no second yes.
149    #[serde(default)]
150    pub started_here: bool,
151}
152
153/// Where a person's account with one service stands.
154#[derive(Clone, Debug, PartialEq, Eq)]
155pub enum LinkState {
156    /// Not linked. `connect`: the host offers signing in here;
157    /// `elsewhere`: how to link from the service's side.
158    Unlinked {
159        connect: bool,
160        elsewhere: Option<String>,
161    },
162    /// On the way to the service's consent page.
163    Connecting,
164    /// A link token came with the page: asking which account it offers.
165    Looking,
166    /// The account a link token offers, to say yes or no to.
167    Offered(OfferReading),
168    Linked(AccountName),
169}
170
171/// One service the workspace is connected to, and the person's account there.
172#[derive(Clone, Debug, PartialEq, Eq)]
173pub struct LinkedAccount {
174    /// The connector's app id, which choices name it by.
175    pub app: String,
176    /// The service's name ("Discord").
177    pub service: String,
178    pub state: LinkState,
179    /// What went wrong last, in words that say what to do.
180    pub problem: Option<String>,
181}
182
183/// What shows and changes a person's linked accounts in a workspace. Asking
184/// never waits: what comes of it shows in [`Self::accounts`] once the workspace's
185/// host answered, and [`Self::revision`] moves.
186pub trait LinkedAccountsHost {
187    /// Every service the workspace is connected to, with the person's account,
188    /// as last heard.
189    fn accounts(&self) -> Vec<LinkedAccount>;
190    /// Moves whenever [`Self::accounts`] changed.
191    fn revision(&self) -> u64;
192    /// The person's linked accounts as their profile names them; `None`
193    /// until the workspace's host answered where they stand.
194    fn profile_links(&self) -> Option<Vec<ProfileLink>> {
195        None
196    }
197    /// Ask again where every account stands.
198    fn refresh(&self);
199    /// Ask again where the account with `app`'s service stands.
200    fn refresh_app(&self, app: &str) {
201        let _ = app;
202        self.refresh();
203    }
204    /// Start linking the person's account with `app`'s service: off to its
205    /// consent page.
206    fn link(&self, app: &str);
207    /// Unlink the person's account with `app`'s service.
208    fn unlink(&self, app: &str);
209    /// A link token came with the page: find the account it offers.
210    fn offer(&self, token: &str) {
211        let _ = token;
212    }
213    /// Say yes to the account `app`'s service offers.
214    fn accept(&self, app: &str) {
215        let _ = app;
216    }
217    /// Say no to it.
218    fn decline(&self, app: &str) {
219        let _ = app;
220    }
221}
222
223/// A host's [`LinkedAccountsHost`], as it lends it to a panel. The default
224/// has none: no service, and asking does nothing.
225#[derive(Clone, Default)]
226pub struct LinkedAccounts(Option<Rc<dyn LinkedAccountsHost>>);
227
228impl LinkedAccounts {
229    #[must_use]
230    pub fn new(host: Rc<dyn LinkedAccountsHost>) -> Self {
231        Self(Some(host))
232    }
233
234    /// Whether a host stands behind it.
235    #[must_use]
236    pub fn is_available(&self) -> bool {
237        self.0.is_some()
238    }
239
240    #[must_use]
241    pub fn accounts(&self) -> Vec<LinkedAccount> {
242        self.0
243            .as_ref()
244            .map(|host| host.accounts())
245            .unwrap_or_default()
246    }
247
248    #[must_use]
249    pub fn revision(&self) -> u64 {
250        self.0.as_ref().map_or(0, |host| host.revision())
251    }
252
253    #[must_use]
254    pub fn profile_links(&self) -> Option<Vec<ProfileLink>> {
255        self.0.as_ref().and_then(|host| host.profile_links())
256    }
257
258    pub fn refresh(&self) {
259        if let Some(host) = &self.0 {
260            host.refresh();
261        }
262    }
263
264    pub fn refresh_app(&self, app: &str) {
265        if let Some(host) = &self.0 {
266            host.refresh_app(app);
267        }
268    }
269
270    pub fn link(&self, app: &str) {
271        if let Some(host) = &self.0 {
272            host.link(app);
273        }
274    }
275
276    pub fn unlink(&self, app: &str) {
277        if let Some(host) = &self.0 {
278            host.unlink(app);
279        }
280    }
281
282    pub fn offer(&self, token: &str) {
283        if let Some(host) = &self.0 {
284            host.offer(token);
285        }
286    }
287
288    pub fn accept(&self, app: &str) {
289        if let Some(host) = &self.0 {
290            host.accept(app);
291        }
292    }
293
294    pub fn decline(&self, app: &str) {
295        if let Some(host) = &self.0 {
296            host.decline(app);
297        }
298    }
299}
300
301/// How often a panel waiting on the workspace's host asks again.
302pub const LINKING_RECHECK: std::time::Duration = std::time::Duration::from_secs(2);
303
304/// How many times a shown panel that knows no service yet asks again, at
305/// [`LINKING_RECHECK`] doubling each time, before it waits to be opened
306/// anew.
307pub const UNANSWERED_RETRIES: u32 = 4;
308
309/// How many times a link token's offer is asked for before the panel says
310/// why it found none. Coming back from a consent page, the workspace's host
311/// records the offer a moment after the page arrives.
312pub const OFFER_TRIES: u32 = 10;
313
314/// Asks again while a panel shows the accounts:
315///
316/// - while a link token waits for its offer: the workspace's host records what
317///   came back from a consent page a moment after the page is back;
318/// - while it knows no service at all: the read it asked on opening can
319///   fail or go unanswered on a busy page or host, and the panel would
320///   offer nothing to link until it was opened again. A workspace with no
321///   connector answers no reading either, so these asks are few and
322///   spaced out ([`UNANSWERED_RETRIES`]).
323#[derive(Debug, Default)]
324pub struct LinkingRecheck {
325    due: Option<ui_core::time::Instant>,
326    unanswered: Option<Unanswered>,
327}
328
329/// The asks again of a shown panel that knows no service yet.
330#[derive(Debug)]
331struct Unanswered {
332    due: ui_core::time::Instant,
333    asked: u32,
334}
335
336impl LinkingRecheck {
337    /// At `now`, with `accounts` shown (or not, `shown` false): asks again
338    /// once [`LINKING_RECHECK`] passed since it last did while a token waits
339    /// for its offer, and with backoff while no service is known. When to
340    /// call again, `None` while there is nothing to ask after.
341    pub fn follow(
342        &mut self,
343        accounts: &LinkedAccounts,
344        shown: bool,
345        now: ui_core::time::Instant,
346    ) -> Option<ui_core::time::Instant> {
347        let known = accounts.accounts();
348        self.follow_offers(accounts, &known, now);
349        self.follow_unanswered(accounts, shown && known.is_empty(), now);
350        self.due()
351    }
352
353    fn follow_offers(
354        &mut self,
355        accounts: &LinkedAccounts,
356        known: &[LinkedAccount],
357        now: ui_core::time::Instant,
358    ) {
359        let waiting: Vec<&str> = known
360            .iter()
361            .filter(|account| account.state == LinkState::Looking)
362            .map(|account| account.app.as_str())
363            .collect();
364        if waiting.is_empty() {
365            self.due = None;
366            return;
367        }
368        match self.due {
369            Some(due) if now < due => {}
370            Some(_) => {
371                for app in waiting {
372                    accounts.refresh_app(app);
373                }
374                self.due = now.checked_add(LINKING_RECHECK);
375            }
376            None => self.due = now.checked_add(LINKING_RECHECK),
377        }
378    }
379
380    fn follow_unanswered(
381        &mut self,
382        accounts: &LinkedAccounts,
383        nothing_known: bool,
384        now: ui_core::time::Instant,
385    ) {
386        if !nothing_known {
387            self.unanswered = None;
388            return;
389        }
390        let Some(unanswered) = &mut self.unanswered else {
391            // The panel asked on opening; the first ask again comes after.
392            self.unanswered = now
393                .checked_add(LINKING_RECHECK)
394                .map(|due| Unanswered { due, asked: 0 });
395            return;
396        };
397        if now < unanswered.due || unanswered.asked >= UNANSWERED_RETRIES {
398            return;
399        }
400        accounts.refresh();
401        if !accounts.accounts().is_empty() {
402            self.unanswered = None;
403            return;
404        }
405        unanswered.asked = unanswered.asked.saturating_add(1);
406        match LINKING_RECHECK
407            .checked_mul(1 << unanswered.asked)
408            .and_then(|wait| now.checked_add(wait))
409        {
410            Some(due) => unanswered.due = due,
411            None => unanswered.asked = UNANSWERED_RETRIES,
412        }
413    }
414
415    /// When to call [`Self::follow`] again.
416    #[must_use]
417    pub fn due(&self) -> Option<ui_core::time::Instant> {
418        let unanswered = self
419            .unanswered
420            .as_ref()
421            .filter(|unanswered| unanswered.asked < UNANSWERED_RETRIES)
422            .map(|unanswered| unanswered.due);
423        match (self.due, unanswered) {
424            (Some(a), Some(b)) => Some(a.min(b)),
425            (a, b) => a.or(b),
426        }
427    }
428}
429
430/// The answer to one action the workspace's host ran.
431pub type ActionAnswer = Result<serde_json::Value, String>;
432
433/// Of `workspace_apps`, the apps whose workspace record the workspace holds, those that
434/// may publish the read ([`READ_ACTION`]): connectors, which bridge a
435/// service elsewhere and so run on the workspace's host (its secret store, its
436/// worker), never in the person's desktop. An app the desktop runs itself
437/// (`runs_here`) publishes no reading, and asking its record would only
438/// have the host open it to answer that it has no such action.
439pub fn connector_apps(
440    workspace_apps: impl IntoIterator<Item = String>,
441    runs_here: impl Fn(&str) -> bool,
442) -> Vec<String> {
443    workspace_apps
444        .into_iter()
445        .filter(|app| !runs_here(app))
446        .collect()
447}
448
449/// How a host asks the workspace's host to run an action of a workspace record for
450/// the person, on the web and natively alike.
451pub trait WorkspaceRecordActions {
452    /// The apps whose workspace record this workspace holds and may publish the read
453    /// ([`READ_ACTION`]): the workspace's connectors ([`connector_apps`]).
454    fn apps(&self) -> Vec<String>;
455    /// Ask the workspace's host to run `action` on `app`'s workspace record (its own
456    /// resource) with `arguments`, as the person; `done` gets the answer,
457    /// later, on this thread.
458    fn invoke(
459        &self,
460        app: &str,
461        action: &str,
462        arguments: serde_json::Map<String, serde_json::Value>,
463        done: Box<dyn FnOnce(ActionAnswer)>,
464    );
465    /// Go to `url` (a service's consent page), leaving this page; it comes
466    /// back with a link token.
467    fn open(&self, url: &str) {
468        let _ = url;
469    }
470}
471
472/// What [`WorkspaceAccounts`] learned so far.
473#[derive(Default)]
474struct Known {
475    /// Each connector's last reading, by app id.
476    readings: BTreeMap<String, Reading>,
477    /// The link token the page came with, until a connector's offer read
478    /// answered it or it was asked for too often.
479    token: Option<String>,
480    /// How often the token's offer was asked for.
481    tries: u32,
482    /// The offer each connector answered for the token.
483    offers: BTreeMap<String, OfferReading>,
484    /// The connectors whose consent page the page is on its way to.
485    connecting: BTreeSet<String>,
486    /// What went wrong last, per connector.
487    problems: BTreeMap<String, String>,
488    /// The connectors asked for the token's offer, or to take it, and not
489    /// answered yet: readings that cross ask only once, so a token is
490    /// never spent twice.
491    asking: BTreeSet<String>,
492    accepting: BTreeSet<String>,
493}
494
495/// Linked accounts read from a workspace's records by its host, through a
496/// [`WorkspaceRecordActions`]. A link token the page came with is kept here, on
497/// this device, until it is used up or declined.
498pub struct WorkspaceAccounts {
499    actions: Rc<dyn WorkspaceRecordActions>,
500    known: Rc<RefCell<Known>>,
501    revision: Rc<Cell<u64>>,
502    /// Called when what is known changed, to draw it again.
503    changed: Rc<dyn Fn()>,
504}
505
506impl WorkspaceAccounts {
507    /// Accounts asked through `actions`; `changed` is called whenever an
508    /// answer changed them.
509    #[must_use]
510    pub fn new(actions: Rc<dyn WorkspaceRecordActions>, changed: Rc<dyn Fn()>) -> Self {
511        Self {
512            actions,
513            known: Rc::default(),
514            revision: Rc::default(),
515            changed,
516        }
517    }
518
519    /// Read `app`'s account again, and the offer of a waiting token.
520    fn read(&self, app: &str) {
521        let this = self.clone_handle();
522        let owned = app.to_owned();
523        self.actions.invoke(
524            app,
525            READ_ACTION,
526            serde_json::Map::new(),
527            Box::new(move |answer| {
528                let reading = answer.and_then(|value| {
529                    serde_json::from_value::<Reading>(value).map_err(|e| e.to_string())
530                });
531                let mut known = this.known.borrow_mut();
532                let before = known.readings.get(&owned).cloned();
533                match reading {
534                    Ok(reading) => {
535                        known.readings.insert(owned.clone(), reading);
536                    }
537                    // No reading: the record is no connector, or the host
538                    // could not answer now; the next refresh asks again.
539                    Err(error) => {
540                        tracing::warn!(app = %owned, %error, "a linked account's reading could not be read");
541                        known.readings.remove(&owned);
542                    }
543                }
544                let after = known.readings.get(&owned).cloned();
545                let ask = known.token.is_some()
546                    && after.as_ref().is_some_and(|reading| reading.connected)
547                    && !known.offers.contains_key(&owned);
548                drop(known);
549                if before != after {
550                    this.bump();
551                }
552                if ask {
553                    this.ask_offer(&owned);
554                }
555            }),
556        );
557    }
558
559    /// Ask `app` which account the waiting token offers.
560    fn ask_offer(&self, app: &str) {
561        let (action, token) = {
562            let mut known = self.known.borrow_mut();
563            let Some(reading) = known.readings.get(app) else {
564                return;
565            };
566            let Some(token) = known.token.clone() else {
567                return;
568            };
569            let action = reading.offer.clone();
570            if action.is_empty() || !known.asking.insert(app.to_owned()) {
571                return;
572            }
573            (action, token)
574        };
575        let this = self.clone_handle();
576        let owned = app.to_owned();
577        self.actions.invoke(
578            app,
579            &action,
580            token_argument(&token),
581            Box::new(move |answer| {
582                let offer = answer.and_then(|value| {
583                    serde_json::from_value::<OfferReading>(value).map_err(|e| e.to_string())
584                });
585                let mut known = this.known.borrow_mut();
586                known.asking.remove(&owned);
587                if known.token.as_deref() != Some(token.as_str()) {
588                    return;
589                }
590                let started_here = match offer {
591                    Ok(offer) => {
592                        let started_here = offer.started_here;
593                        known.offers.insert(owned.clone(), offer);
594                        known.problems.remove(&owned);
595                        started_here
596                    }
597                    Err(problem) => {
598                        known.tries = known.tries.saturating_add(1);
599                        if known.tries >= OFFER_TRIES {
600                            known.token = None;
601                            known.problems.insert(owned.clone(), problem);
602                        }
603                        false
604                    }
605                };
606                drop(known);
607                this.bump();
608                // Signing in on the consent page was the yes.
609                if started_here {
610                    this.accept(&owned);
611                }
612            }),
613        );
614    }
615
616    fn clone_handle(&self) -> Self {
617        Self {
618            actions: Rc::clone(&self.actions),
619            known: Rc::clone(&self.known),
620            revision: Rc::clone(&self.revision),
621            changed: Rc::clone(&self.changed),
622        }
623    }
624
625    fn bump(&self) {
626        self.revision.set(self.revision.get().wrapping_add(1));
627        (self.changed)();
628    }
629}
630
631fn token_argument(token: &str) -> serde_json::Map<String, serde_json::Value> {
632    let mut arguments = serde_json::Map::new();
633    arguments.insert("token".into(), token.into());
634    arguments
635}
636
637impl LinkedAccountsHost for WorkspaceAccounts {
638    fn accounts(&self) -> Vec<LinkedAccount> {
639        let known = self.known.borrow();
640        known
641            .readings
642            .iter()
643            .filter(|(_, reading)| reading.connected)
644            .map(|(app, reading)| LinkedAccount {
645                app: app.clone(),
646                service: reading.service.clone(),
647                state: if let Some(offer) = known.offers.get(app) {
648                    LinkState::Offered(offer.clone())
649                } else if known.token.is_some() {
650                    LinkState::Looking
651                } else if let Some(account) = &reading.account {
652                    LinkState::Linked(account.clone())
653                } else if known.connecting.contains(app) {
654                    LinkState::Connecting
655                } else {
656                    LinkState::Unlinked {
657                        connect: reading.connect.is_some(),
658                        elsewhere: reading.elsewhere.clone(),
659                    }
660                },
661                problem: known.problems.get(app).cloned(),
662            })
663            .collect()
664    }
665
666    fn revision(&self) -> u64 {
667        self.revision.get()
668    }
669
670    fn profile_links(&self) -> Option<Vec<ProfileLink>> {
671        let known = self.known.borrow();
672        if known.readings.is_empty() {
673            return None;
674        }
675        Some(
676            known
677                .readings
678                .values()
679                .filter_map(|reading| {
680                    let account = reading.account.as_ref()?;
681                    Some(ProfileLink {
682                        service: reading.service.clone(),
683                        handle: account.user_name.clone(),
684                        picture: reading.picture.clone(),
685                    })
686                })
687                .collect(),
688        )
689    }
690
691    fn refresh(&self) {
692        for app in self.actions.apps() {
693            self.read(&app);
694        }
695    }
696
697    fn refresh_app(&self, app: &str) {
698        self.read(app);
699    }
700
701    fn link(&self, app: &str) {
702        let Some(action) = self
703            .known
704            .borrow()
705            .readings
706            .get(app)
707            .and_then(|reading| reading.connect.clone())
708        else {
709            return;
710        };
711        {
712            let mut known = self.known.borrow_mut();
713            known.problems.remove(app);
714            known.connecting.insert(app.to_owned());
715        }
716        self.bump();
717        let this = self.clone_handle();
718        let owned = app.to_owned();
719        self.actions.invoke(
720            app,
721            &action,
722            serde_json::Map::new(),
723            Box::new(move |answer| {
724                match answer.and_then(|value| {
725                    serde_json::from_value::<LinkStart>(value).map_err(|e| e.to_string())
726                }) {
727                    Ok(start) => this.actions.open(&start.url),
728                    Err(problem) => {
729                        let mut known = this.known.borrow_mut();
730                        known.connecting.remove(&owned);
731                        known.problems.insert(owned.clone(), problem);
732                        drop(known);
733                        this.bump();
734                    }
735                }
736            }),
737        );
738    }
739
740    fn unlink(&self, app: &str) {
741        let Some(action) = self
742            .known
743            .borrow()
744            .readings
745            .get(app)
746            .and_then(|reading| reading.unlink.clone())
747        else {
748            return;
749        };
750        self.known.borrow_mut().problems.remove(app);
751        let this = self.clone_handle();
752        let owned = app.to_owned();
753        self.actions.invoke(
754            app,
755            &action,
756            serde_json::Map::new(),
757            Box::new(move |answer| {
758                if let Err(problem) = answer {
759                    this.known
760                        .borrow_mut()
761                        .problems
762                        .insert(owned.clone(), problem);
763                }
764                this.read(&owned);
765            }),
766        );
767    }
768
769    fn offer(&self, token: &str) {
770        {
771            let mut known = self.known.borrow_mut();
772            known.token = Some(token.to_owned());
773            known.tries = 0;
774            known.offers.clear();
775        }
776        self.bump();
777        let ready: Vec<String> = self
778            .known
779            .borrow()
780            .readings
781            .iter()
782            .filter(|(_, reading)| reading.connected)
783            .map(|(app, _)| app.clone())
784            .collect();
785        if ready.is_empty() {
786            // Asked once the readings are in.
787            self.refresh();
788        }
789        for app in ready {
790            self.ask_offer(&app);
791        }
792    }
793
794    fn accept(&self, app: &str) {
795        let (action, token) = {
796            let mut known = self.known.borrow_mut();
797            let Some(reading) = known.readings.get(app) else {
798                return;
799            };
800            let Some(token) = known.token.clone() else {
801                return;
802            };
803            let action = reading.accept.clone();
804            if !known.accepting.insert(app.to_owned()) {
805                return;
806            }
807            (action, token)
808        };
809        let this = self.clone_handle();
810        let owned = app.to_owned();
811        self.actions.invoke(
812            app,
813            &action,
814            token_argument(&token),
815            Box::new(move |answer| {
816                {
817                    let mut known = this.known.borrow_mut();
818                    known.accepting.remove(&owned);
819                    known.token = None;
820                    known.offers.remove(&owned);
821                    match answer {
822                        Ok(_) => known.problems.remove(&owned),
823                        Err(problem) => known.problems.insert(owned.clone(), problem),
824                    };
825                }
826                this.bump();
827                this.read(&owned);
828            }),
829        );
830    }
831
832    fn decline(&self, app: &str) {
833        {
834            let mut known = self.known.borrow_mut();
835            known.token = None;
836            known.offers.remove(app);
837        }
838        self.bump();
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    /// A workspace whose host answers at once, from a connector's readings.
847    #[derive(Default)]
848    struct Host {
849        reading: RefCell<Reading>,
850        /// What the token offers, or why nothing.
851        offer: RefCell<Option<Result<OfferReading, String>>>,
852        /// The apps asked, in order.
853        asked: RefCell<Vec<String>>,
854        /// Where the page went.
855        opened: RefCell<Vec<String>>,
856        /// The actions asked, in order.
857        actions: RefCell<Vec<String>>,
858        /// Whether answers wait in `waiting` until let go.
859        holding: Cell<bool>,
860        waiting: RefCell<Vec<Box<dyn FnOnce()>>>,
861    }
862
863    impl WorkspaceRecordActions for Rc<Host> {
864        fn apps(&self) -> Vec<String> {
865            vec!["so.tilo.chat".into(), "so.tilo.discord".into()]
866        }
867
868        fn invoke(
869            &self,
870            app: &str,
871            action: &str,
872            arguments: serde_json::Map<String, serde_json::Value>,
873            done: Box<dyn FnOnce(ActionAnswer)>,
874        ) {
875            self.asked.borrow_mut().push(app.to_owned());
876            self.actions.borrow_mut().push(action.to_owned());
877            if app != "so.tilo.discord" {
878                return done(Err("unknown_action".into()));
879            }
880            let answer = match action {
881                READ_ACTION => {
882                    Ok(serde_json::to_value(self.reading.borrow().clone()).unwrap_or_default())
883                }
884                "connect" => Ok(serde_json::json!({"url": "https://discord.example/authorize"})),
885                "link-offer" => {
886                    assert_eq!(arguments["token"], "T0KEN");
887                    self.offered()
888                        .map(|offer| serde_json::to_value(offer).unwrap_or_default())
889                }
890                "accept-link" => {
891                    let offer = self.offered();
892                    offer.map(|offer| {
893                        self.reading.borrow_mut().account = Some(offer.account);
894                        serde_json::Value::Null
895                    })
896                }
897                _ => {
898                    self.reading.borrow_mut().account = None;
899                    Ok(serde_json::Value::Null)
900                }
901            };
902            if self.holding.get() {
903                self.waiting
904                    .borrow_mut()
905                    .push(Box::new(move || done(answer)));
906            } else {
907                done(answer);
908            }
909        }
910
911        fn open(&self, url: &str) {
912            self.opened.borrow_mut().push(url.to_owned());
913        }
914    }
915
916    fn discord() -> Reading {
917        Reading {
918            service: "Discord".into(),
919            connected: true,
920            connect: Some("connect".into()),
921            elsewhere: Some("In the Discord server, type /link.".into()),
922            offer: "link-offer".into(),
923            accept: "accept-link".into(),
924            ..Reading::default()
925        }
926    }
927
928    fn kim() -> OfferReading {
929        OfferReading {
930            service: "Discord".into(),
931            account: AccountName {
932                user_name: "kim".into(),
933                display_name: None,
934            },
935            started_here: false,
936        }
937    }
938
939    impl Host {
940        fn offered(&self) -> Result<OfferReading, String> {
941            self.offer
942                .borrow()
943                .clone()
944                .unwrap_or_else(|| Err("no offer".into()))
945        }
946    }
947
948    fn host(offer: Result<OfferReading, String>) -> Rc<Host> {
949        Rc::new(Host {
950            reading: RefCell::new(discord()),
951            offer: RefCell::new(Some(offer)),
952            ..Host::default()
953        })
954    }
955
956    #[test]
957    fn only_connectors_are_asked_for_a_reading() {
958        let workspace = ["so.tilo.chat", "so.tilo.contacts", "so.tilo.discord"].map(String::from);
959        let runs_here = |app: &str| matches!(app, "so.tilo.chat" | "so.tilo.contacts");
960        assert_eq!(connector_apps(workspace, runs_here), ["so.tilo.discord"]);
961    }
962
963    #[test]
964    fn connecting_goes_to_the_consent_page() {
965        let host = host(Err("gone".into()));
966        let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
967        assert!(
968            accounts.accounts().is_empty(),
969            "nothing is known before asking"
970        );
971        accounts.refresh();
972        assert_eq!(
973            accounts.accounts()[0].state,
974            LinkState::Unlinked {
975                connect: true,
976                elsewhere: Some("In the Discord server, type /link.".into())
977            }
978        );
979        accounts.link("so.tilo.discord");
980        assert_eq!(*host.opened.borrow(), ["https://discord.example/authorize"]);
981        assert_eq!(accounts.accounts()[0].state, LinkState::Connecting);
982    }
983
984    #[test]
985    fn a_linked_account_is_named_with_its_picture_for_the_profile() {
986        let host = host(Ok(kim()));
987        let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
988        assert_eq!(accounts.profile_links(), None, "nothing answered yet");
989        accounts.refresh();
990        assert_eq!(accounts.profile_links(), Some(Vec::new()), "not linked");
991        {
992            let mut reading = host.reading.borrow_mut();
993            reading.account = Some(kim().account);
994            reading.picture = Some("data:image/png;base64,AAAA".into());
995            reading.unlink = Some("unlink-mine".into());
996        }
997        accounts.refresh();
998        assert_eq!(
999            accounts.profile_links(),
1000            Some(vec![ProfileLink {
1001                service: "Discord".into(),
1002                handle: "kim".into(),
1003                picture: Some("data:image/png;base64,AAAA".into()),
1004            }])
1005        );
1006        accounts.unlink("so.tilo.discord");
1007        accounts.refresh();
1008        assert_eq!(accounts.profile_links(), Some(Vec::new()));
1009    }
1010
1011    #[test]
1012    fn a_token_shows_its_offer_and_a_yes_links_it() {
1013        let host = host(Ok(kim()));
1014        let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1015        accounts.offer("T0KEN");
1016        assert_eq!(accounts.accounts()[0].state, LinkState::Offered(kim()));
1017        accounts.accept("so.tilo.discord");
1018        assert_eq!(
1019            accounts.accounts()[0].state,
1020            LinkState::Linked(kim().account)
1021        );
1022    }
1023
1024    #[test]
1025    fn an_offer_started_here_links_without_a_second_yes() {
1026        let host = host(Ok(OfferReading {
1027            started_here: true,
1028            ..kim()
1029        }));
1030        let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1031        accounts.refresh();
1032        accounts.offer("T0KEN");
1033        assert_eq!(
1034            accounts.accounts()[0].state,
1035            LinkState::Linked(kim().account)
1036        );
1037    }
1038
1039    #[test]
1040    fn readings_that_cross_spend_the_token_once() {
1041        let host = host(Ok(OfferReading {
1042            started_here: true,
1043            ..kim()
1044        }));
1045        host.holding.set(true);
1046        let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1047        // The page came back with a token, and the card opening asked again.
1048        accounts.offer("T0KEN");
1049        accounts.refresh();
1050        loop {
1051            let waiting = std::mem::take(&mut *host.waiting.borrow_mut());
1052            if waiting.is_empty() {
1053                break;
1054            }
1055            for answer in waiting {
1056                answer();
1057            }
1058        }
1059        let asked = host.actions.borrow();
1060        let count = |action: &str| asked.iter().filter(|a| *a == action).count();
1061        assert_eq!(
1062            (count("link-offer"), count("accept-link")),
1063            (1, 1),
1064            "{asked:?}"
1065        );
1066        let account = &accounts.accounts()[0];
1067        assert_eq!(account.state, LinkState::Linked(kim().account));
1068        assert_eq!(account.problem, None);
1069    }
1070
1071    #[test]
1072    fn a_declined_offer_is_dropped() {
1073        let host = host(Ok(kim()));
1074        let accounts = WorkspaceAccounts::new(Rc::new(Rc::clone(&host)), Rc::new(|| {}));
1075        accounts.offer("T0KEN");
1076        accounts.decline("so.tilo.discord");
1077        assert!(matches!(
1078            accounts.accounts()[0].state,
1079            LinkState::Unlinked { .. }
1080        ));
1081    }
1082
1083    #[test]
1084    fn a_token_with_no_offer_is_asked_after_then_says_why() {
1085        let host = host(Err("This link ran out.".into()));
1086        let accounts = LinkedAccounts::new(Rc::new(WorkspaceAccounts::new(
1087            Rc::new(Rc::clone(&host)),
1088            Rc::new(|| {}),
1089        )));
1090        accounts.offer("T0KEN");
1091        assert_eq!(accounts.accounts()[0].state, LinkState::Looking);
1092        let mut recheck = LinkingRecheck::default();
1093        let mut now = ui_core::time::Instant::now();
1094        let mut rounds = 0;
1095        while let Some(due) = recheck.follow(&accounts, true, now) {
1096            now = due;
1097            rounds += 1;
1098            assert!(rounds <= 2 * OFFER_TRIES, "the asks end");
1099        }
1100        let account = &accounts.accounts()[0];
1101        assert!(matches!(account.state, LinkState::Unlinked { .. }));
1102        assert_eq!(account.problem.as_deref(), Some("This link ran out."));
1103    }
1104
1105    #[test]
1106    fn a_workspace_with_no_connector_is_asked_again_a_few_times_then_left() {
1107        let host = host(Err("gone".into()));
1108        host.reading.borrow_mut().connected = false;
1109        let accounts = LinkedAccounts::new(Rc::new(WorkspaceAccounts::new(
1110            Rc::new(Rc::clone(&host)),
1111            Rc::new(|| {}),
1112        )));
1113        let mut recheck = LinkingRecheck::default();
1114        let mut now = ui_core::time::Instant::now();
1115        let mut waits = Vec::new();
1116        while let Some(due) = recheck.follow(&accounts, true, now) {
1117            waits.push(due.duration_since(now));
1118            now = due;
1119            assert!(waits.len() <= 10, "the asks again end");
1120        }
1121        assert_eq!(host.asked.borrow().len() / 2, UNANSWERED_RETRIES as usize);
1122    }
1123}