teams-cli 0.2.4__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {teams_cli-0.2.4 → teams_cli-0.2.5}/CHANGELOG.md +10 -0
  2. {teams_cli-0.2.4 → teams_cli-0.2.5}/Cargo.lock +5 -5
  3. {teams_cli-0.2.4 → teams_cli-0.2.5}/Cargo.toml +1 -1
  4. {teams_cli-0.2.4 → teams_cli-0.2.5}/PKG-INFO +1 -1
  5. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop.js +32 -7
  6. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop.rs +3 -2
  7. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop_cache.js +9 -2
  8. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/tui.rs +91 -2
  9. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/desktop_scripts.test.cjs +67 -7
  10. {teams_cli-0.2.4 → teams_cli-0.2.5}/.github/workflows/ci.yml +0 -0
  11. {teams_cli-0.2.4 → teams_cli-0.2.5}/.github/workflows/dependencies.yml +0 -0
  12. {teams_cli-0.2.4 → teams_cli-0.2.5}/.github/workflows/release.yml +0 -0
  13. {teams_cli-0.2.4 → teams_cli-0.2.5}/.gitignore +0 -0
  14. {teams_cli-0.2.4 → teams_cli-0.2.5}/.mise.toml +0 -0
  15. {teams_cli-0.2.4 → teams_cli-0.2.5}/LICENSE +0 -0
  16. {teams_cli-0.2.4 → teams_cli-0.2.5}/Makefile +0 -0
  17. {teams_cli-0.2.4 → teams_cli-0.2.5}/README.md +0 -0
  18. {teams_cli-0.2.4 → teams_cli-0.2.5}/build.rs +0 -0
  19. {teams_cli-0.2.4 → teams_cli-0.2.5}/docs/releases.md +0 -0
  20. {teams_cli-0.2.4 → teams_cli-0.2.5}/examples/validate_schema.rs +0 -0
  21. {teams_cli-0.2.4 → teams_cli-0.2.5}/pyproject.toml +0 -0
  22. {teams_cli-0.2.4 → teams_cli-0.2.5}/scripts/smoke-release.py +0 -0
  23. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/auth/pass.rs +0 -0
  24. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/auth/process.rs +0 -0
  25. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/auth/wsl.rs +0 -0
  26. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/auth/wsl_credentials.ps1 +0 -0
  27. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/auth.rs +0 -0
  28. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/backend.rs +0 -0
  29. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/cli.rs +0 -0
  30. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/config.rs +0 -0
  31. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/delivery.rs +0 -0
  32. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/avatars.rs +0 -0
  33. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/browser.rs +0 -0
  34. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/browser_children.rs +0 -0
  35. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/browser_core.rs +0 -0
  36. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/browser_install.rs +0 -0
  37. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/browser_lifetime.rs +0 -0
  38. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/browser_setup.rs +0 -0
  39. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/cdp.rs +0 -0
  40. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/connection.ps1 +0 -0
  41. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/connection.rs +0 -0
  42. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/helper.rs +0 -0
  43. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/install.rs +0 -0
  44. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/macos.rs +0 -0
  45. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/owner.rs +0 -0
  46. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/session.rs +0 -0
  47. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop/windows.rs +0 -0
  48. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop_core.js +0 -0
  49. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop_people.js +0 -0
  50. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/desktop_send.js +0 -0
  51. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/error.rs +0 -0
  52. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/graph.rs +0 -0
  53. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/lib.rs +0 -0
  54. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/main.rs +0 -0
  55. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/output.rs +0 -0
  56. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/render.rs +0 -0
  57. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/schema.rs +0 -0
  58. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/setup/capabilities.rs +0 -0
  59. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/setup.rs +0 -0
  60. {teams_cli-0.2.4 → teams_cli-0.2.5}/src/teams_cli_alias.rs +0 -0
  61. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/browser_lifetime.rs +0 -0
  62. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/cli.rs +0 -0
  63. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/connection.rs +0 -0
  64. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/connection.test.ps1 +0 -0
  65. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/credentials.rs +0 -0
  66. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/desktop.rs +0 -0
  67. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/desktop_scripts.rs +0 -0
  68. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/fixtures/clispec-v0.3.json +0 -0
  69. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/js/people.test.mjs +0 -0
  70. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/package_alias.rs +0 -0
  71. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/pass_credentials.sh +0 -0
  72. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/release_smoke.rs +0 -0
  73. {teams_cli-0.2.4 → teams_cli-0.2.5}/tests/wsl_credentials.test.ps1 +0 -0
  74. {teams_cli-0.2.4 → teams_cli-0.2.5}/vership.toml +0 -0
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.5](https://github.com/rvben/teams-cli/compare/v0.2.4...v0.2.5) - 2026-10-21
11
+
12
+ ### Fixed
13
+
14
+ - Update TLS certificate verification and bundled root certificates to address RUSTSEC-2026-0382 and RUSTSEC-2026-0383.
15
+ - Desktop chats whose messages Teams keeps in a secondary reply-chain store (`replychains-2`, ...) no longer look empty. All such stores are now read and merged, which also fills in member names for those 1:1 chats.
16
+ - Desktop chat messages missing from the current cache are filled in from caches the same user and tenant left under earlier display languages. Those caches no longer update, so messages edited or deleted since can appear as they were.
17
+ - In the TUI, a chat whose messages are missing now shows its latest message from the chat list and why, instead of "No messages in this chat yet." With the desktop backend it says no messages for the chat were found in the Teams cache on this PC.
18
+ - **deps**: update TLS certificate trust dependencies ([435b11c](https://github.com/rvben/teams-cli/commit/435b11c39113e165e843d73579bc28505ef05620))
19
+
10
20
  ## [0.2.4](https://github.com/rvben/teams-cli/compare/v0.2.3...v0.2.4) - 2026-10-20
11
21
 
12
22
  ### Fixed
@@ -2815,9 +2815,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
2815
2815
 
2816
2816
  [[package]]
2817
2817
  name = "rustls-webpki"
2818
- version = "0.103.15"
2818
+ version = "0.103.16"
2819
2819
  source = "registry+https://github.com/rust-lang/crates.io-index"
2820
- checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
2820
+ checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
2821
2821
  dependencies = [
2822
2822
  "aws-lc-rs",
2823
2823
  "ring",
@@ -3187,7 +3187,7 @@ dependencies = [
3187
3187
 
3188
3188
  [[package]]
3189
3189
  name = "teams-cli"
3190
- version = "0.2.4"
3190
+ version = "0.2.5"
3191
3191
  dependencies = [
3192
3192
  "assert_cmd",
3193
3193
  "base64 0.23.1",
@@ -3843,9 +3843,9 @@ dependencies = [
3843
3843
 
3844
3844
  [[package]]
3845
3845
  name = "webpki-root-certs"
3846
- version = "1.0.9"
3846
+ version = "1.0.10"
3847
3847
  source = "registry+https://github.com/rust-lang/crates.io-index"
3848
- checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
3848
+ checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
3849
3849
  dependencies = [
3850
3850
  "rustls-pki-types",
3851
3851
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.2.4"
3
+ version = "0.2.5"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -7,13 +7,16 @@ async (p, select) => {
7
7
  q.onsuccess = () => resolve(q.result);
8
8
  q.onerror = () => reject(q.error);
9
9
  });
10
- const open = k => {
11
- if (!cache[k]) throw Error('layout');
10
+ const openName = name => {
12
11
  // If the cache disappeared after enumeration, abort rather than create a database.
13
- const q = indexedDB.open(cache[k]);
12
+ const q = indexedDB.open(name);
14
13
  q.onupgradeneeded = () => q.transaction.abort();
15
14
  return req(q);
16
15
  };
16
+ const open = k => {
17
+ if (!cache[k]) throw Error('layout');
18
+ return openName(cache[k]);
19
+ };
17
20
  // Missing cache timestamps can be -1. Keep sort keys valid for CLI cursors.
18
21
  const time = t => Number.isFinite(+t) && +t > 0 && +t <= 8640000000000000 ? Math.trunc(+t) : 0;
19
22
  const iso = t => time(t) ? new Date(time(t)).toISOString() : null;
@@ -64,8 +67,16 @@ async (p, select) => {
64
67
  }
65
68
  return { names, me };
66
69
  };
67
- const chain = async (db, id) => (await req(db.transaction('replychains').objectStore('replychains')
68
- .getAll(IDBKeyRange.bound([id], [id, []])))).flatMap(rc => Object.values(rc.messageMap || {}));
70
+ // Teams can split reply chains across several stores (replychains, replychains-2, ...).
71
+ // Numeric order keeps the primary store's copy of a message found in more than one.
72
+ const chain = async (db, id) => {
73
+ const rank = n => +(n.split('-')[1] || 1);
74
+ const names = [...db.objectStoreNames].filter(n => /^replychains(-\d+)?$/.test(n)).sort((a, b) => rank(a) - rank(b));
75
+ if (!names.length) throw Error('layout');
76
+ const tx = db.transaction(names);
77
+ const lists = await Promise.all(names.map(n => req(tx.objectStore(n).getAll(IDBKeyRange.bound([id], [id, []])))));
78
+ return lists.flat().flatMap(rc => Object.values((rc && rc.messageMap) || {}));
79
+ };
69
80
 
70
81
  const cdb = await open('conversation');
71
82
  let pg;
@@ -124,10 +135,24 @@ async (p, select) => {
124
135
  }
125
136
  return { me, items: chats.map(x => x.value), next: pg.next };
126
137
  }
138
+ // The current cache wins; older caches of the same account only add messages it lacks.
139
+ const messages = await chain(rdb, p.chat);
140
+ for (const name of cache.older || []) {
141
+ let db;
142
+ try {
143
+ db = await openName(name);
144
+ messages.push(...await chain(db, p.chat));
145
+ } catch (_) {
146
+ // Older caches are optional; one that vanished or changed layout is skipped.
147
+ } finally {
148
+ if (db) db.close();
149
+ }
150
+ }
127
151
  const rows = [], seen = new Set();
128
- for (const m of await chain(rdb, p.chat)) {
129
- if (system(m.messageType) || seen.has(m.id)) continue;
152
+ for (const m of messages) {
153
+ if (seen.has(m.id)) continue;
130
154
  seen.add(m.id);
155
+ if (system(m.messageType)) continue;
131
156
  rows.push({ t: time(m.originalArrivalTime), id: m.id, value: {
132
157
  id: m.id, createdDateTime: iso(m.originalArrivalTime),
133
158
  clientMessageId: typeof m.clientMessageId === 'string' ? m.clientMessageId : null,
@@ -1271,12 +1271,13 @@ mod tests {
1271
1271
  .collect::<String>()
1272
1272
  });
1273
1273
  // Protocol 5 selects the signed-in cache and reports remaining ambiguity apart from surface selection.
1274
+ // Older same-account caches are optional; a helper without them reads as before.
1274
1275
  assert_eq!(
1275
1276
  scripts,
1276
1277
  [
1277
- "32ca0293d2d5",
1278
+ "9c000776f04a",
1278
1279
  "baa0bc29c44b",
1279
- "025a8edc46b8",
1280
+ "7d2b70751d6a",
1280
1281
  "23b1a0e4b293"
1281
1282
  ]
1282
1283
  );
@@ -29,7 +29,7 @@ all => {
29
29
  }
30
30
  if (conversations.length !== 1) return { error: 'layout' };
31
31
  const conversation = conversations[0].name;
32
- const account = conversation.split(':')[4];
32
+ const [, , , tenant, account] = conversation.split(':');
33
33
  if (!account) return { error: 'layout' };
34
34
  const related = kind => {
35
35
  const sibling = conversation.replace(':conversation-manager:', ':' + kind + ':');
@@ -37,5 +37,12 @@ all => {
37
37
  const matches = candidates(kind).filter(db => db.name.split(':')[4] === account);
38
38
  return matches.length === 1 ? matches[0].name : null;
39
39
  };
40
- return { account, conversation, replies: related('replychain-manager'), profiles: related('profiles') };
40
+ const replies = related('replychain-manager');
41
+ // Caches the same user and tenant left under earlier display languages. Teams stopped
42
+ // updating them, so they may only fill in messages the current cache lacks.
43
+ const older = candidates('replychain-manager').map(db => db.name).filter(name => {
44
+ const part = name.split(':');
45
+ return name !== replies && part[3] === tenant && part[4] === account;
46
+ }).sort();
47
+ return { account, conversation, replies, profiles: related('profiles'), older };
41
48
  }
@@ -532,6 +532,16 @@ impl App {
532
532
  }
533
533
  }
534
534
 
535
+ /// Why a chat shows only its latest message, said for where messages come from.
536
+ fn missing_history_note(&self) -> &'static str {
537
+ match self.backend().as_deref() {
538
+ Some(TeamsBackend::Desktop(_)) => {
539
+ "No messages for this chat were found in the Teams cache on this PC. Latest message:"
540
+ }
541
+ _ => "Only the latest message is available.",
542
+ }
543
+ }
544
+
535
545
  fn can_send(&self) -> bool {
536
546
  match &self.source {
537
547
  Source::Live { can_send, .. } => *can_send,
@@ -2030,7 +2040,21 @@ impl App {
2030
2040
  }
2031
2041
  Load::Ready => {}
2032
2042
  }
2033
- let entries = render::conversation(&thread.items, &self.view(), self.viewer.as_deref());
2043
+ let mut entries = render::conversation(&thread.items, &self.view(), self.viewer.as_deref());
2044
+ // The chat list can carry a latest message whose history was never read.
2045
+ let mut missing = false;
2046
+ if entries.is_empty()
2047
+ && thread.cursor.is_none()
2048
+ && let Some(chat) = self.chats.iter().find(|chat| chat["id"] == id.as_str())
2049
+ && chat["lastMessagePreview"].is_object()
2050
+ {
2051
+ entries = render::conversation(
2052
+ std::slice::from_ref(&chat["lastMessagePreview"]),
2053
+ &self.view(),
2054
+ self.viewer.as_deref(),
2055
+ );
2056
+ missing = !entries.is_empty();
2057
+ }
2034
2058
  let mut lines = Vec::new();
2035
2059
  if thread.loading_older {
2036
2060
  lines.push(
@@ -2050,7 +2074,12 @@ impl App {
2050
2074
  .alignment(Alignment::Center),
2051
2075
  );
2052
2076
  }
2053
- if entries.is_empty() {
2077
+ if missing {
2078
+ lines.push(Line::raw(""));
2079
+ for note in render::wrap(self.missing_history_note(), usize::from(body.width)) {
2080
+ lines.push(Line::styled(note, theme.muted()).centered());
2081
+ }
2082
+ } else if entries.is_empty() {
2054
2083
  lines.push(Line::raw(""));
2055
2084
  lines.push(Line::styled("No messages in this chat yet.", theme.muted()).centered());
2056
2085
  }
@@ -3706,6 +3735,66 @@ mod tests {
3706
3735
  App::new(source, Theme { color: false }, now)
3707
3736
  }
3708
3737
 
3738
+ #[test]
3739
+ fn a_chat_without_history_shows_its_latest_message_and_why() {
3740
+ let empty = |app: &mut App| {
3741
+ let id = app.selected_row().unwrap().id.clone();
3742
+ app.threads.insert(
3743
+ id,
3744
+ Thread {
3745
+ items: Vec::new(),
3746
+ cursor: None,
3747
+ load: Load::Ready,
3748
+ loading_older: false,
3749
+ fetching: false,
3750
+ },
3751
+ );
3752
+ frame_text(app)
3753
+ };
3754
+ let mut app = demo_app();
3755
+ let text = empty(&mut app);
3756
+ assert!(
3757
+ text.contains("Only the latest message is available."),
3758
+ "{text}"
3759
+ );
3760
+ assert!(text.contains("release candidate is green"), "{text}");
3761
+ assert!(!text.contains("No messages in this chat yet."), "{text}");
3762
+
3763
+ let now = app.now.clone();
3764
+ let mut app = live_app(false, "work");
3765
+ app.source = Source::Live {
3766
+ backend: Arc::new(TeamsBackend::Desktop(Box::new(
3767
+ crate::desktop::DesktopClient::new(19224).unwrap(),
3768
+ ))),
3769
+ profile: "work".into(),
3770
+ can_send: false,
3771
+ };
3772
+ app.apply(Outcome::Chats {
3773
+ more: false,
3774
+ result: Ok(demo_chats(&now)),
3775
+ });
3776
+ let text = empty(&mut app);
3777
+ assert!(text.contains("were found in the Teams cache"), "{text}");
3778
+ // The note wraps with the pane rather than being cut off.
3779
+ let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
3780
+ app.focus = Focus::Thread;
3781
+ terminal.draw(|frame| app.draw(frame)).unwrap();
3782
+ let narrow = buffer_text(terminal.backend().buffer());
3783
+ assert!(narrow.contains("Latest message:"), "{narrow}");
3784
+ assert!(text.contains("release candidate is green"), "{text}");
3785
+
3786
+ // Older pages may still hold messages, so nothing is claimed missing.
3787
+ let id = app.selected_row().unwrap().id.clone();
3788
+ app.threads.get_mut(&id).unwrap().cursor = Some("older".into());
3789
+ let text = frame_text(&mut app);
3790
+ assert!(text.contains("No messages in this chat yet."), "{text}");
3791
+
3792
+ // Without a preview there is nothing to show.
3793
+ app.chats[0]["lastMessagePreview"] = Value::Null;
3794
+ let text = empty(&mut app);
3795
+ assert!(text.contains("No messages in this chat yet."), "{text}");
3796
+ }
3797
+
3709
3798
  #[test]
3710
3799
  fn the_header_shows_the_profile_send_mode_freshness_and_counts() {
3711
3800
  let mut app = demo_app();
@@ -29,7 +29,7 @@ function cursor(records) {
29
29
  return q;
30
30
  }
31
31
 
32
- function fixture({ ambiguous = false, foreignOnly = false, mutate, timeout = false, core = false } = {}) {
32
+ function fixture({ ambiguous = false, foreignOnly = false, older = [], stores = {}, mutate, timeout = false, core = false } = {}) {
33
33
  const databases = new Map([
34
34
  [name('conversation-manager', 'account-a'), [conversation]],
35
35
  [name('replychain-manager', 'account-b'), [{ chat, messageMap: { b: { ...message, content: 'Wrong account' } } }]],
@@ -40,6 +40,9 @@ function fixture({ ambiguous = false, foreignOnly = false, mutate, timeout = fal
40
40
  databases.set(name('profiles', 'account-a'), [{ mri: '8:orgid:sender', displayName: 'Correct Name' }]);
41
41
  }
42
42
  if (ambiguous) databases.set(name('conversation-manager', 'account-b'), [conversation]);
43
+ for (const [locale, records] of older) databases.set(name('replychain-manager', 'account-a') + ':' + locale, records);
44
+ // Extra object stores of the current account's reply-chain database, beside its default store.
45
+ const extra = new Map([[name('replychain-manager', 'account-a'), stores]]);
43
46
  const opened = [], mutations = [];
44
47
  let inspections = 0;
45
48
  const client = { mutate: options => {
@@ -55,11 +58,15 @@ function fixture({ ambiguous = false, foreignOnly = false, mutate, timeout = fal
55
58
  open: database => {
56
59
  assert.ok(databases.has(database), `Unknown database: ${database}`);
57
60
  opened.push(database);
58
- return request({ name: database, close() {}, transaction: () => ({ objectStore: () => ({
59
- get: id => request(databases.get(database).find(record => (record.mri || record.id) === id)),
60
- getAll: range => request(databases.get(database).filter(record => !range || record.chat === range.chat)),
61
- openCursor: () => cursor(databases.get(database)),
62
- }) }) });
61
+ const named = extra.get(database) || {};
62
+ const records = store => store in named ? named[store] : databases.get(database);
63
+ return request({ name: database, close() {},
64
+ objectStoreNames: database.includes(':replychain-manager:') ? ['replychains', ...Object.keys(named)] : [],
65
+ transaction: () => ({ objectStore: store => ({
66
+ get: id => request(records(store).find(record => (record.mri || record.id) === id)),
67
+ getAll: range => request(records(store).filter(record => !range || record.chat === range.chat)),
68
+ openCursor: () => cursor(records(store)),
69
+ }) }) });
63
70
  },
64
71
  },
65
72
  IDBKeyRange: { bound: a => ({ chat: a[0] }) },
@@ -225,6 +232,57 @@ for (const query of ['chats', 'messages']) {
225
232
  });
226
233
  }
227
234
 
235
+ test('older caches of the same account only add messages the current cache lacks', async () => {
236
+ const earlier = { ...message, id: '1690000000000', originalArrivalTime: 1690000000000, content: 'Earlier' };
237
+ const f = fixture({ older: [
238
+ ['nl-nl', [{ chat, messageMap: { stale: { ...message, content: 'Stale' }, earlier } }]],
239
+ ['de-de', [{ chat: '19:other@thread.v2', messageMap: { other: { ...message, id: '1680000000000', content: 'Other chat' } } }]],
240
+ ] });
241
+ const result = await f.read();
242
+ assert.deepEqual([...result.items.map(item => item.body.content)], [message.content, 'Earlier']);
243
+ assert.ok(f.opened.every(database => !database.includes(':account-b')));
244
+ });
245
+
246
+ test('messages filed only in a later reply-chain store are read', async () => {
247
+ const f = fixture({ stores: {
248
+ 'replychains-2': [{ chat, messageMap: { a: message } }],
249
+ 'unrelated': [{ chat, messageMap: { x: { ...message, id: '1710000000000', content: 'Unrelated store' } } }],
250
+ } });
251
+ f.databases.set(name('replychain-manager', 'account-a'), []);
252
+ const result = await f.read();
253
+ assert.deepEqual([...result.items.map(item => item.body.content)], [message.content]);
254
+ });
255
+
256
+ test('messages split across reply-chain stores are merged newest first without duplicates', async () => {
257
+ const earlier = { ...message, id: '1690000000000', originalArrivalTime: 1690000000000, content: 'Earlier' };
258
+ const later = { ...message, id: '1710000000000', originalArrivalTime: 1710000000000, content: 'Later' };
259
+ const f = fixture({ stores: { 'replychains-2': [{ chat, messageMap: { a: message, earlier, later } }] } });
260
+ const result = await f.read();
261
+ assert.deepEqual([...result.items.map(item => item.body.content)], ['Later', message.content, 'Earlier']);
262
+ });
263
+
264
+ test('reply-chain stores are read in numeric order, so the primary copy of a message wins', async () => {
265
+ const f = fixture({ stores: {
266
+ 'replychains-10': [{ chat, messageMap: { a: { ...message, content: 'Tenth store' } } }],
267
+ 'replychains-2': [{ chat, messageMap: { a: { ...message, content: 'Second store' } } }],
268
+ } });
269
+ assert.deepEqual([...(await f.read()).items.map(item => item.body.content)], [message.content]);
270
+ f.databases.set(name('replychain-manager', 'account-a'), []);
271
+ assert.deepEqual([...(await f.read()).items.map(item => item.body.content)], ['Second store']);
272
+ });
273
+
274
+ test('1:1 member names come from messages in a later reply-chain store', async () => {
275
+ const one = '19:account-a_peer@unq.gbl.spaces';
276
+ const f = fixture({ stores: { 'replychains-2': [{ chat: one, messageMap: {
277
+ p: { ...message, creator: '8:orgid:peer', imDisplayName: 'Peer Name' },
278
+ } }] } });
279
+ f.databases.set(name('conversation-manager', 'account-a'), [{ ...conversation, id: one, members: [] }]);
280
+ f.databases.set(name('replychain-manager', 'account-a'), []);
281
+ const result = await f.read({ query: 'chats', chat: '' });
282
+ assert.equal(result.me, 'account-a');
283
+ assert.equal(result.items[0].members.find(m => m.userId === 'peer').displayName, 'Peer Name');
284
+ });
285
+
228
286
  test('a missing account database never falls back to another account', async () => {
229
287
  const f = fixture({ foreignOnly: true });
230
288
  await assert.rejects(f.read(), /layout/);
@@ -251,10 +309,12 @@ test('the signed-in user, tenant and display language select among coexisting ca
251
309
  ...caches('home', 'me', 'en-us'), ...caches('home', 'me', 'nl-nl'),
252
310
  ...caches('guest', 'me-as-guest'), ...caches('home', 'colleague'),
253
311
  ];
254
- for (const [tenant, user, locale] of [['home', 'me', 'nl-nl'], ['home', 'me', 'en-us'], ['guest', 'me-as-guest', 'en-us']]) {
312
+ for (const [tenant, user, locale, older] of [['home', 'me', 'nl-nl', ['en-us']], ['home', 'me', 'en-us', ['nl-nl']], ['guest', 'me-as-guest', 'en-us', []]]) {
255
313
  assert.deepEqual(selectWith(names, signedIn(tenant, user, locale)), {
256
314
  account: user, conversation: teams('conversation-manager', tenant, user, locale),
257
315
  replies: teams('replychain-manager', tenant, user, locale), profiles: teams('profiles', tenant, user, locale),
316
+ // Only the same user in the same tenant may fill in messages.
317
+ older: older.map(other => teams('replychain-manager', tenant, user, other)),
258
318
  });
259
319
  }
260
320
  // Only one cache for the signed-in account needs no display language.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes