1use std::cell::{Cell, RefCell};
29use std::collections::{BTreeMap, BTreeSet};
30use std::rc::Rc;
31
32use serde::{Deserialize, Serialize};
33
34#[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ProfileLink {
59 pub service: String,
61 pub handle: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub picture: Option<String>,
66}
67
68pub const READ_ACTION: &str = "my-link";
71
72#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub struct Reading {
76 pub service: String,
78 pub connected: bool,
80 #[serde(default)]
81 pub account: Option<AccountName>,
82 #[serde(default)]
85 pub picture: Option<String>,
86 #[serde(default)]
89 pub connect: Option<String>,
90 #[serde(default)]
93 pub elsewhere: Option<String>,
94 #[serde(default)]
96 pub unlink: Option<String>,
97 #[serde(default)]
100 pub offer: String,
101 #[serde(default)]
103 pub accept: String,
104}
105
106#[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 #[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 #[must_use]
129 pub fn handle(&self) -> String {
130 format!("@{}", self.user_name)
131 }
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct LinkStart {
137 pub url: String,
138}
139
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub struct OfferReading {
144 pub service: String,
146 pub account: AccountName,
147 #[serde(default)]
150 pub started_here: bool,
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
155pub enum LinkState {
156 Unlinked {
159 connect: bool,
160 elsewhere: Option<String>,
161 },
162 Connecting,
164 Looking,
166 Offered(OfferReading),
168 Linked(AccountName),
169}
170
171#[derive(Clone, Debug, PartialEq, Eq)]
173pub struct LinkedAccount {
174 pub app: String,
176 pub service: String,
178 pub state: LinkState,
179 pub problem: Option<String>,
181}
182
183pub trait LinkedAccountsHost {
187 fn accounts(&self) -> Vec<LinkedAccount>;
190 fn revision(&self) -> u64;
192 fn profile_links(&self) -> Option<Vec<ProfileLink>> {
195 None
196 }
197 fn refresh(&self);
199 fn refresh_app(&self, app: &str) {
201 let _ = app;
202 self.refresh();
203 }
204 fn link(&self, app: &str);
207 fn unlink(&self, app: &str);
209 fn offer(&self, token: &str) {
211 let _ = token;
212 }
213 fn accept(&self, app: &str) {
215 let _ = app;
216 }
217 fn decline(&self, app: &str) {
219 let _ = app;
220 }
221}
222
223#[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 #[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
301pub const LINKING_RECHECK: std::time::Duration = std::time::Duration::from_secs(2);
303
304pub const UNANSWERED_RETRIES: u32 = 4;
308
309pub const OFFER_TRIES: u32 = 10;
313
314#[derive(Debug, Default)]
324pub struct LinkingRecheck {
325 due: Option<ui_core::time::Instant>,
326 unanswered: Option<Unanswered>,
327}
328
329#[derive(Debug)]
331struct Unanswered {
332 due: ui_core::time::Instant,
333 asked: u32,
334}
335
336impl LinkingRecheck {
337 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 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 #[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
430pub type ActionAnswer = Result<serde_json::Value, String>;
432
433pub 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
449pub trait WorkspaceRecordActions {
452 fn apps(&self) -> Vec<String>;
455 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 fn open(&self, url: &str) {
468 let _ = url;
469 }
470}
471
472#[derive(Default)]
474struct Known {
475 readings: BTreeMap<String, Reading>,
477 token: Option<String>,
480 tries: u32,
482 offers: BTreeMap<String, OfferReading>,
484 connecting: BTreeSet<String>,
486 problems: BTreeMap<String, String>,
488 asking: BTreeSet<String>,
492 accepting: BTreeSet<String>,
493}
494
495pub struct WorkspaceAccounts {
499 actions: Rc<dyn WorkspaceRecordActions>,
500 known: Rc<RefCell<Known>>,
501 revision: Rc<Cell<u64>>,
502 changed: Rc<dyn Fn()>,
504}
505
506impl WorkspaceAccounts {
507 #[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 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 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 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 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 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 #[derive(Default)]
848 struct Host {
849 reading: RefCell<Reading>,
850 offer: RefCell<Option<Result<OfferReading, String>>>,
852 asked: RefCell<Vec<String>>,
854 opened: RefCell<Vec<String>>,
856 actions: RefCell<Vec<String>>,
858 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 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}