@awiki/dsh-plugin 0.3.17 → 0.3.18

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 (87) hide show
  1. package/README.md +4 -0
  2. package/THIRD_PARTY_NOTICES.md +6 -0
  3. package/lib/client.js +3543 -746
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +218 -41
  6. package/lib/{sdk-adapter-DGcTiEZz.mjs → package-version.generated-D0MSDRZn.mjs} +83 -10
  7. package/lib/provider.js +5 -3
  8. package/lib/typert.host.js +481 -127
  9. package/lib/typert.remote-client.d.ts +9 -3
  10. package/lib/typert.remote-client.d.ts.map +1 -1
  11. package/lib/typert.remote-client.js +481 -127
  12. package/lib/types/client/AwikiAvatar.d.ts +40 -0
  13. package/lib/types/client/AwikiAvatar.d.ts.map +1 -0
  14. package/lib/types/client/AwikiAvatar.js +201 -0
  15. package/lib/types/client/AwikiAvatar.js.map +1 -0
  16. package/lib/types/client/AwikiAvatarEditor.d.ts +11 -0
  17. package/lib/types/client/AwikiAvatarEditor.d.ts.map +1 -0
  18. package/lib/types/client/AwikiAvatarEditor.js +113 -0
  19. package/lib/types/client/AwikiAvatarEditor.js.map +1 -0
  20. package/lib/types/client/AwikiAvatarPreview.d.ts +8 -0
  21. package/lib/types/client/AwikiAvatarPreview.d.ts.map +1 -0
  22. package/lib/types/client/AwikiAvatarPreview.js +68 -0
  23. package/lib/types/client/AwikiAvatarPreview.js.map +1 -0
  24. package/lib/types/client/AwikiGroupDetails.js +2 -2
  25. package/lib/types/client/AwikiGroupDetails.js.map +1 -1
  26. package/lib/types/client/AwikiIdentityAccess.d.ts +1 -0
  27. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
  28. package/lib/types/client/AwikiIdentityAccess.js +10 -2
  29. package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
  30. package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
  31. package/lib/types/client/AwikiOverlay.js +92 -27
  32. package/lib/types/client/AwikiOverlay.js.map +1 -1
  33. package/lib/types/client/AwikiProfileCard.d.ts +1 -1
  34. package/lib/types/client/AwikiProfileCard.d.ts.map +1 -1
  35. package/lib/types/client/AwikiProfileCard.js +6 -2
  36. package/lib/types/client/AwikiProfileCard.js.map +1 -1
  37. package/lib/types/client/avatar-cache.d.ts +24 -0
  38. package/lib/types/client/avatar-cache.d.ts.map +1 -0
  39. package/lib/types/client/avatar-cache.js +336 -0
  40. package/lib/types/client/avatar-cache.js.map +1 -0
  41. package/lib/types/client/avatar-dialog.d.ts +3 -0
  42. package/lib/types/client/avatar-dialog.d.ts.map +1 -0
  43. package/lib/types/client/avatar-dialog.js +18 -0
  44. package/lib/types/client/avatar-dialog.js.map +1 -0
  45. package/lib/types/client/avatar-image.d.ts +26 -0
  46. package/lib/types/client/avatar-image.d.ts.map +1 -0
  47. package/lib/types/client/avatar-image.js +193 -0
  48. package/lib/types/client/avatar-image.js.map +1 -0
  49. package/lib/types/client/controller.d.ts +30 -4
  50. package/lib/types/client/controller.d.ts.map +1 -1
  51. package/lib/types/client/controller.js +267 -56
  52. package/lib/types/client/controller.js.map +1 -1
  53. package/lib/types/client/index.d.ts.map +1 -1
  54. package/lib/types/client/index.js +10 -2
  55. package/lib/types/client/index.js.map +1 -1
  56. package/lib/types/client/slots.d.ts +12 -3
  57. package/lib/types/client/slots.d.ts.map +1 -1
  58. package/lib/types/conversation-preferences.d.ts.map +1 -1
  59. package/lib/types/conversation-preferences.js +27 -0
  60. package/lib/types/conversation-preferences.js.map +1 -1
  61. package/lib/types/index.d.ts +8 -2
  62. package/lib/types/index.d.ts.map +1 -1
  63. package/lib/types/index.js +76 -11
  64. package/lib/types/index.js.map +1 -1
  65. package/lib/types/listener.d.ts.map +1 -1
  66. package/lib/types/listener.js +9 -8
  67. package/lib/types/listener.js.map +1 -1
  68. package/lib/types/package-version.generated.d.ts +2 -2
  69. package/lib/types/package-version.generated.js +2 -2
  70. package/lib/types/provider-api.d.ts +9 -3
  71. package/lib/types/provider-api.d.ts.map +1 -1
  72. package/lib/types/provider.d.ts.map +1 -1
  73. package/lib/types/provider.js +5 -2
  74. package/lib/types/provider.js.map +1 -1
  75. package/lib/types/sdk-adapter.d.ts +6 -3
  76. package/lib/types/sdk-adapter.d.ts.map +1 -1
  77. package/lib/types/sdk-adapter.js +58 -7
  78. package/lib/types/sdk-adapter.js.map +1 -1
  79. package/lib/types/tenant-registry.d.ts +2 -0
  80. package/lib/types/tenant-registry.d.ts.map +1 -1
  81. package/lib/types/tenant-registry.js +83 -16
  82. package/lib/types/tenant-registry.js.map +1 -1
  83. package/lib/types/types.d.ts +63 -7
  84. package/lib/types/types.d.ts.map +1 -1
  85. package/lib/types/types.js.map +1 -1
  86. package/lib/types/update-policy.d.ts +2 -2
  87. package/package.json +33 -34
@@ -1,3 +1,4 @@
1
+ import { avatarCache } from "./avatar-cache.js";
1
2
  import { AwikiDraftStore } from "./drafts.js";
2
3
  import { SHORT_HANDLE_INVITE_MESSAGE } from "../registration-policy.js";
3
4
  import { IndexedDbAwikiBrowserImageCache, } from "./image-cache.js";
@@ -12,6 +13,8 @@ function genericFailureMessage(failure) {
12
13
  return '无法连接 AWiki 服务,请检查网络后重试。';
13
14
  case 'rate-limited':
14
15
  return 'AWiki 请求过于频繁,请稍后重试。';
16
+ case 'group-member-encryption-not-ready':
17
+ return '尚未加入:对方尚未完成加密准备,请对方打开客户端后重试。';
15
18
  case 'not-registered':
16
19
  return '请先注册 AWiki 身份后重试。';
17
20
  case 'signed-out':
@@ -337,13 +340,17 @@ function canonicalMessagePage(incoming) {
337
340
  }
338
341
  /** Replace the newest loaded window while preserving older messages before it. */
339
342
  function mergeLatestMessages(current, incoming) {
340
- const page = canonicalMessagePage(incoming);
343
+ const previous = new Map(current.map(message => [message.id, message]));
344
+ const page = canonicalMessagePage(incoming).map(message => previous.get(message.id)?.encryptionRequired === true
345
+ ? { ...message, encryptionRequired: true } : message);
341
346
  const incomingIds = new Set(page.map(message => message.id));
342
347
  return [...current.filter(message => !incomingIds.has(message.id)), ...page];
343
348
  }
344
349
  /** Prepend one chronological continuation page while exact-updating any overlap. */
345
350
  function mergeOlderMessages(current, incoming) {
346
- const page = canonicalMessagePage(incoming);
351
+ const previous = new Map(current.map(message => [message.id, message]));
352
+ const page = canonicalMessagePage(incoming).map(message => previous.get(message.id)?.encryptionRequired === true
353
+ ? { ...message, encryptionRequired: true } : message);
347
354
  const incomingIds = new Set(page.map(message => message.id));
348
355
  return [...page, ...current.filter(message => !incomingIds.has(message.id))];
349
356
  }
@@ -352,7 +359,7 @@ function appendMessageById(current, incoming) {
352
359
  const index = current.findIndex(message => message.id === incoming.id);
353
360
  if (index < 0)
354
361
  return [...current, incoming];
355
- return current.map((message, currentIndex) => currentIndex === index ? incoming : message);
362
+ return current.map((message, currentIndex) => currentIndex === index ? { ...incoming, ...(message.encryptionRequired === true ? { encryptionRequired: true } : {}) } : message);
356
363
  }
357
364
  /** Explain a background failure without implying that visible local messages were lost. */
358
365
  function refreshFailureMessage(messages, error) {
@@ -524,6 +531,7 @@ export class AwikiController {
524
531
  remote;
525
532
  persistentImageCache;
526
533
  drafts = new AwikiDraftStore();
534
+ retiredMessageIds = new Set();
527
535
  view = INITIAL_VIEW;
528
536
  listeners = new Set();
529
537
  /** Runtime-only selected operations survive tenant round trips without reopening stale browser hints after reload. */
@@ -539,8 +547,12 @@ export class AwikiController {
539
547
  tenantTransition;
540
548
  generation = 0;
541
549
  selectionRevision = 0;
550
+ groupReadRevision = 0;
542
551
  disposed = false;
543
552
  polling = false;
553
+ lastAvatarProfileCheck = 0;
554
+ avatarProfileRefreshGeneration = -1;
555
+ avatarProfileRefresh;
544
556
  markReadInFlight = new Map();
545
557
  unreadAtOpen = new Map();
546
558
  summaryBaselines = new Map();
@@ -583,7 +595,7 @@ export class AwikiController {
583
595
  if (!preserveView)
584
596
  this.summaryBaselines.clear();
585
597
  const previous = this.view;
586
- const previousTenant = this.config?.tenantId;
598
+ const previousTenant = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
587
599
  const generation = this.generation;
588
600
  if (!preserveView)
589
601
  this.publish({ ...INITIAL_VIEW, status: 'loading' });
@@ -606,13 +618,14 @@ export class AwikiController {
606
618
  if (!session.ok)
607
619
  return failed(session.error);
608
620
  const identity = session.value.status === 'active' ? session.value.identity : null;
609
- const sameOwner = preserveView && previousTenant === config.value.tenantId
621
+ const sameOwner = preserveView && previousTenant === (config.value.tenantStorageScopeId ?? config.value.tenantId)
610
622
  && previous.sessionStatus === session.value.status && previous.identity?.did === identity?.did;
611
- this.drafts.setScope(config.value.tenantId ?? 'default', identity?.did ?? session.value.status);
623
+ this.drafts.setScope((config.value.tenantStorageScopeId ?? config.value.tenantId) ?? 'default', identity?.did ?? session.value.status);
624
+ this.restoreGroupCreationDraft();
612
625
  this.activatePresentationCache(identity);
613
- const selectedRecovery = previousTenant === config.value.tenantId
626
+ const selectedRecovery = previousTenant === (config.value.tenantStorageScopeId ?? config.value.tenantId)
614
627
  ? previous.recoveryOperationId
615
- : this.recoveryOperationsByTenant.get(recoveryTenantKey(config.value.tenantId)) ?? null;
628
+ : this.recoveryOperationsByTenant.get(recoveryTenantKey((config.value.tenantStorageScopeId ?? config.value.tenantId))) ?? null;
616
629
  this.publish({
617
630
  ...(sameOwner ? this.view : INITIAL_VIEW),
618
631
  status: 'ready',
@@ -622,7 +635,7 @@ export class AwikiController {
622
635
  attachmentMaxBytes: config.value.attachmentMaxBytes,
623
636
  handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled,
624
637
  recoveryOperationId: selectedRecovery,
625
- recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, config.value.tenantId),
638
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, (config.value.tenantStorageScopeId ?? config.value.tenantId)),
626
639
  });
627
640
  await this.refreshIdentityAccess();
628
641
  if (!this.current(generation))
@@ -634,7 +647,7 @@ export class AwikiController {
634
647
  await this.loadConversationPreferences(generation);
635
648
  const profile = await call(() => this.remote.getProfile());
636
649
  if (this.current(generation) && profile.ok)
637
- this.publish({ ...this.view, profile: profile.value });
650
+ this.applyProfile(profile.value);
638
651
  }
639
652
  return { ok: true, value: undefined };
640
653
  }
@@ -808,15 +821,15 @@ export class AwikiController {
808
821
  const selected = this.view.recoveryOperationId;
809
822
  this.publish({ ...this.view, identityAccess: result.value, recoveryOperationId: selected,
810
823
  recoveryProgress: selected === this.view.recoveryOperationId ? this.view.recoveryProgress : null,
811
- recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, this.config?.tenantId) });
824
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)) });
812
825
  if (selected !== null) {
813
826
  const status = await callWithFailureCode(() => this.remote.getRecoveryStatus({ operationId: selected }));
814
827
  if (!this.recoveryCurrent(generation, revision))
815
828
  return this.recoveryLeft();
816
829
  if (!status.ok) {
817
830
  if (status.failureCode === 'not-found' && candidates.length === 0) {
818
- clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
819
- this.rememberRecoveryOperation(null, this.config?.tenantId);
831
+ clearRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
832
+ this.rememberRecoveryOperation(null, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
820
833
  this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, recoveryOtpRetryAt: null });
821
834
  }
822
835
  else {
@@ -827,9 +840,9 @@ export class AwikiController {
827
840
  else {
828
841
  const complete = status.value.phase === 'applied';
829
842
  if (complete)
830
- clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
843
+ clearRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
831
844
  this.publish({ ...this.view, recoveryProgress: status.value,
832
- recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, this.config?.tenantId),
845
+ recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)),
833
846
  identityAccess: result.value });
834
847
  }
835
848
  }
@@ -844,9 +857,9 @@ export class AwikiController {
844
857
  this.leaveRecovery();
845
858
  else
846
859
  this.recoveryRevision++;
847
- this.rememberRecoveryOperation(operationId, this.config?.tenantId);
860
+ this.rememberRecoveryOperation(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
848
861
  this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null,
849
- recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, this.config?.tenantId) });
862
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)) });
850
863
  const result = await this.refreshRecoveryStatus();
851
864
  return result.ok ? { ok: true, value: undefined } : result;
852
865
  }
@@ -877,7 +890,7 @@ export class AwikiController {
877
890
  this.pendingRequest = undefined;
878
891
  this.pendingRecovery = false;
879
892
  this.drafts.clearFlow();
880
- this.rememberRecoveryOperation(null, this.config?.tenantId);
893
+ this.rememberRecoveryOperation(null, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
881
894
  this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, accessError: null,
882
895
  accessLoading: false, pending: wasPending ? null : this.view.pending, error: null });
883
896
  }
@@ -927,9 +940,12 @@ export class AwikiController {
927
940
  }
928
941
  if (result.value.status === 'registered') {
929
942
  this.drafts.clearScope();
930
- this.drafts.setScope(this.config?.tenantId ?? 'default', result.value.identity.did);
943
+ this.drafts.setScope((this.config?.tenantStorageScopeId ?? this.config?.tenantId) ?? 'default', result.value.identity.did);
931
944
  this.activatePresentationCache(result.value.identity);
932
945
  this.publish({ ...this.view, sessionStatus: 'active', identity: result.value.identity, error: null });
946
+ await this.loadConversationPreferences(generation);
947
+ if (!this.current(generation))
948
+ return result;
933
949
  await this.refreshConversations(generation);
934
950
  }
935
951
  return result;
@@ -970,7 +986,7 @@ export class AwikiController {
970
986
  /** Cancel one exact Join choice and send Recovery OTP only while its original browser scope remains current. */
971
987
  async beginRecoveryFromDeviceJoin(request) {
972
988
  const generation = this.generation;
973
- const tenantId = this.config?.tenantId;
989
+ const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
974
990
  const flowScope = this.drafts.getScope('recovery:retryDeadline');
975
991
  const flowEpoch = this.drafts.epoch(flowScope);
976
992
  const changed = () => ({
@@ -978,7 +994,7 @@ export class AwikiController {
978
994
  error: '恢复能力或身份环境已改变,请重新检查后再选择。',
979
995
  });
980
996
  const scopeCurrent = () => this.current(generation)
981
- && this.config?.tenantId === tenantId
997
+ && (this.config?.tenantStorageScopeId ?? this.config?.tenantId) === tenantId
982
998
  && this.drafts.getScope('recovery:retryDeadline') === flowScope
983
999
  && this.drafts.epoch(flowScope) === flowEpoch;
984
1000
  if (!scopeCurrent())
@@ -988,7 +1004,7 @@ export class AwikiController {
988
1004
  return changed();
989
1005
  if (!latest.ok)
990
1006
  return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
991
- if (latest.value.tenantId !== tenantId)
1007
+ if ((latest.value.tenantStorageScopeId ?? latest.value.tenantId) !== tenantId)
992
1008
  return changed();
993
1009
  this.config = latest.value;
994
1010
  this.publish({ ...this.view, handleRecoveryPhoneEnabled: latest.value.handleRecoveryPhoneEnabled });
@@ -1018,7 +1034,7 @@ export class AwikiController {
1018
1034
  if (result.value === null)
1019
1035
  return result;
1020
1036
  this.recordRecoveryOtp(result.value, tenantId);
1021
- const sameFlow = this.config?.tenantId === tenantId
1037
+ const sameFlow = (this.config?.tenantStorageScopeId ?? this.config?.tenantId) === tenantId
1022
1038
  && this.drafts.getScope('recovery:retryDeadline') === flowScope
1023
1039
  && this.drafts.epoch(flowScope) === flowEpoch;
1024
1040
  if (!sameFlow)
@@ -1090,6 +1106,65 @@ export class AwikiController {
1090
1106
  this.publish({ ...this.view, identity: result.value, error: null });
1091
1107
  return result;
1092
1108
  }
1109
+ applyProfile(profile, updateIdentity = false) {
1110
+ if (profile.did !== this.view.identity?.did)
1111
+ return;
1112
+ const current = this.view.profile?.profileVersion;
1113
+ const incoming = profile.profileVersion;
1114
+ if (current !== undefined && incoming !== undefined && /^(0|[1-9][0-9]*)$/u.test(current) && /^(0|[1-9][0-9]*)$/u.test(incoming) && BigInt(incoming) < BigInt(current))
1115
+ return;
1116
+ const identity = updateIdentity ? { ...this.view.identity, displayName: profile.displayName } : this.view.identity;
1117
+ this.publish({ ...this.view, identity, profile, error: null });
1118
+ }
1119
+ refreshAvatarProfile() {
1120
+ if (this.avatarProfileRefresh !== undefined && this.avatarProfileRefreshGeneration === this.generation)
1121
+ return this.avatarProfileRefresh;
1122
+ const generation = this.generation;
1123
+ this.lastAvatarProfileCheck = Date.now();
1124
+ const operation = (async () => {
1125
+ const result = await call(() => this.remote.getProfile());
1126
+ if (!this.current(generation))
1127
+ return { ok: false, error: '账号已切换,请重新打开头像设置' };
1128
+ if (!result.ok)
1129
+ return { ok: false, error: result.error };
1130
+ this.applyProfile(result.value);
1131
+ return { ok: true, value: this.view.profile ?? result.value };
1132
+ })();
1133
+ this.avatarProfileRefresh = operation;
1134
+ this.avatarProfileRefreshGeneration = generation;
1135
+ void operation.finally(() => { if (this.avatarProfileRefresh === operation)
1136
+ this.avatarProfileRefresh = undefined; });
1137
+ return operation;
1138
+ }
1139
+ async setAvatar(request) {
1140
+ return this.mutateAvatar(() => this.remote.setAvatar(request));
1141
+ }
1142
+ async clearAvatar(request) {
1143
+ return this.mutateAvatar(() => this.remote.clearAvatar(request));
1144
+ }
1145
+ async mutateAvatar(operation) {
1146
+ if (this.view.profile?.avatarUploadEnabled !== true)
1147
+ return { ok: false, error: '当前账号或服务暂不支持修改头像' };
1148
+ const generation = this.generation;
1149
+ const result = await this.withPending('保存头像', () => call(operation), { publishFailure: false });
1150
+ if (!this.current(generation))
1151
+ return { ok: false, error: '账号已切换,请重新打开头像设置' };
1152
+ if (result.ok)
1153
+ this.applyProfile(result.value);
1154
+ else
1155
+ await this.refreshAvatarProfile();
1156
+ return result;
1157
+ }
1158
+ async avatarDisplayProfiles(peers) {
1159
+ const generation = this.generation;
1160
+ const result = await call(() => this.remote.getDisplayProfiles(peers.slice(0, 100), true));
1161
+ return this.current(generation) && result.ok ? result.value : [];
1162
+ }
1163
+ async avatarGroup(groupDid) {
1164
+ const generation = this.generation;
1165
+ const result = await call(() => this.remote.getGroup({ groupDid }));
1166
+ return this.current(generation) && result.ok ? result.value : null;
1167
+ }
1093
1168
  /** Save all supported public profile fields and keep identity/profile projections aligned. */
1094
1169
  async updateProfile(request) {
1095
1170
  const displayName = request.displayName.trim();
@@ -1106,15 +1181,14 @@ export class AwikiController {
1106
1181
  const result = await this.withPending('保存资料', () => call(() => this.remote.updateProfile({ displayName, bio, tags })));
1107
1182
  if (!result.ok || !this.current(generation))
1108
1183
  return result;
1109
- const identity = this.view.identity === null ? null : { ...this.view.identity, displayName: result.value.displayName };
1110
- this.publish({ ...this.view, identity, profile: result.value, error: null });
1184
+ this.applyProfile(result.value, true);
1111
1185
  return result;
1112
1186
  }
1113
1187
  /** Request a dedicated OTP only within the selected account flow. */
1114
1188
  async sendRecoveryOtp(request) {
1115
1189
  const generation = this.generation;
1116
1190
  const revision = this.recoveryRevision;
1117
- const tenantId = this.config?.tenantId;
1191
+ const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
1118
1192
  const flowScope = this.drafts.getScope('recovery:retryDeadline');
1119
1193
  const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false, recovery: true });
1120
1194
  if (!result.ok) {
@@ -1125,7 +1199,7 @@ export class AwikiController {
1125
1199
  }
1126
1200
  this.recordRecoveryOtp(result.value, tenantId);
1127
1201
  if (!this.recoveryCurrent(generation, revision)
1128
- || this.config?.tenantId !== tenantId
1202
+ || (this.config?.tenantStorageScopeId ?? this.config?.tenantId) !== tenantId
1129
1203
  || this.drafts.getScope('recovery:retryDeadline') !== flowScope)
1130
1204
  return result;
1131
1205
  this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
@@ -1138,7 +1212,7 @@ export class AwikiController {
1138
1212
  storeRecoveryOtpRetryAt(result.operationId, recoveryOtpRetryAt(result), tenantId);
1139
1213
  }
1140
1214
  /** Publish results only into the exact selected flow; Core work survives navigation. */
1141
- async runRecovery(label, operation, enterWhenApplied = false) {
1215
+ async runRecovery(label, operation) {
1142
1216
  const generation = this.generation;
1143
1217
  const revision = this.recoveryRevision;
1144
1218
  const operationId = this.view.recoveryOperationId;
@@ -1158,9 +1232,7 @@ export class AwikiController {
1158
1232
  }
1159
1233
  this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1160
1234
  if (result.value.phase === 'applied')
1161
- clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1162
- if (enterWhenApplied && result.value.phase === 'applied')
1163
- return this.enterRecoveredSession();
1235
+ clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1164
1236
  return result;
1165
1237
  }
1166
1238
  async prepareRecovery(request) {
@@ -1172,7 +1244,7 @@ export class AwikiController {
1172
1244
  const progress = this.view.recoveryProgress;
1173
1245
  if (!progress?.allowedActions?.includes('activate'))
1174
1246
  return { ok: false, error: '请先完成恢复信息验证' };
1175
- return this.runRecovery('恢复身份', operationId => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage), true);
1247
+ return this.runRecovery('恢复身份', operationId => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage));
1176
1248
  }
1177
1249
  /** Read-only even for applied operations. Entering the identity is a separate action. */
1178
1250
  async refreshRecoveryStatus() {
@@ -1193,14 +1265,14 @@ export class AwikiController {
1193
1265
  }
1194
1266
  this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1195
1267
  if (result.value.phase === 'applied')
1196
- clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1268
+ clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1197
1269
  return result;
1198
1270
  }
1199
1271
  async resumeRecovery() {
1200
1272
  const progress = this.view.recoveryProgress;
1201
1273
  if (!progress?.allowedActions?.includes('resume'))
1202
1274
  return { ok: false, error: '当前恢复状态不能重试' };
1203
- return this.runRecovery('继续恢复身份', operationId => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage), true);
1275
+ return this.runRecovery('继续恢复身份', operationId => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage));
1204
1276
  }
1205
1277
  async enterRecoveredSession() {
1206
1278
  const generation = this.generation;
@@ -1214,7 +1286,7 @@ export class AwikiController {
1214
1286
  return this.recoveryLeft();
1215
1287
  if (!result.ok)
1216
1288
  return result;
1217
- clearRecoveryOtpRetryAt(progress.operationId, this.config?.tenantId);
1289
+ clearRecoveryOtpRetryAt(progress.operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1218
1290
  this.leaveRecovery();
1219
1291
  const opened = await this.open();
1220
1292
  return opened.ok ? result : opened;
@@ -1234,7 +1306,7 @@ export class AwikiController {
1234
1306
  return this.recoveryLeft();
1235
1307
  if (!result.ok)
1236
1308
  return result;
1237
- clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1309
+ clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1238
1310
  this.leaveRecovery();
1239
1311
  void this.refreshIdentityAccess();
1240
1312
  return { ok: true, value: undefined };
@@ -1434,7 +1506,7 @@ export class AwikiController {
1434
1506
  * @param members - Handle or DID values entered by the user.
1435
1507
  * @returns the created group and settled invitation outcomes.
1436
1508
  */
1437
- async createGroup(name, members) {
1509
+ async createGroup(name, members, encrypted = false, draftOperationId) {
1438
1510
  if (this.disposed)
1439
1511
  return { ok: false, error: 'AWiki 插件已卸载' };
1440
1512
  const normalizedName = name.trim();
@@ -1452,29 +1524,80 @@ export class AwikiController {
1452
1524
  return this.fail('群成员列表不需要包含自己');
1453
1525
  }
1454
1526
  const generation = this.generation;
1527
+ const resumeKey = this.groupCreationStorageKey();
1528
+ const operationId = draftOperationId ?? `group-${crypto.randomUUID()}`;
1529
+ const operationKey = `${resumeKey}:${operationId}`;
1530
+ try {
1531
+ const storage = globalThis.localStorage;
1532
+ storage?.setItem(operationKey, JSON.stringify({ name: normalizedName, members: normalizedMembers, encrypted, operationId }));
1533
+ const keys = JSON.parse(storage?.getItem(`${resumeKey}:index`) ?? '[]');
1534
+ storage?.setItem(`${resumeKey}:index`, JSON.stringify([...new Set([...keys, operationKey])]));
1535
+ }
1536
+ catch {
1537
+ return this.fail('无法保存建群恢复标识,请检查本机存储后重试');
1538
+ }
1455
1539
  const result = await this.withPending('创建群聊', () => call(() => this.remote.createGroup({
1456
1540
  name: normalizedName,
1457
1541
  members: normalizedMembers,
1542
+ encrypted,
1543
+ operationId,
1458
1544
  }), groupCreateFailureMessage));
1459
1545
  if (!result.ok || !this.current(generation))
1460
1546
  return result;
1547
+ try {
1548
+ globalThis.localStorage?.removeItem(operationKey);
1549
+ }
1550
+ catch { /* Preserve the resume ID when cleanup is unavailable. */ }
1551
+ this.drafts.write(this.drafts.getScope('compose:groupFrozen'), 'compose:groupFrozen', false, false, true);
1461
1552
  const conversation = this.cacheConversation(result.value.conversation);
1462
1553
  this.publish({
1463
1554
  ...this.view,
1464
1555
  conversations: appendUnique([conversation], this.view.conversations, value => value.id),
1465
1556
  error: null,
1466
1557
  });
1467
- const selected = await this.selectConversation(result.value.conversation.id);
1558
+ // Navigation belongs to the live dialog instance; a closed form cannot redirect it.
1559
+ const selected = draftOperationId === undefined ? await this.selectConversation(result.value.conversation.id) : { ok: true };
1468
1560
  if (!this.current(generation))
1469
1561
  return result;
1470
1562
  const failed = result.value.failedMembers.map(item => item.member);
1471
1563
  const warning = failed.length === 0
1472
1564
  ? selected.ok ? null : '群聊已创建,但暂时无法打开消息历史。'
1473
- : `群聊已创建,但以下成员未加入:${failed.join('、')}`;
1565
+ : `群聊已创建,以下成员的邀请或加密准备未完成,请在群成员中重试:${failed.join('、')}`;
1474
1566
  if (warning !== null)
1475
1567
  this.publish({ ...this.view, error: warning });
1476
1568
  return result;
1477
1569
  }
1570
+ groupCreationStorageKey() {
1571
+ return `awiki.group-create.v2:${this.drafts.getScope('compose:groupName')}`;
1572
+ }
1573
+ restoreGroupCreationDraft() {
1574
+ const scope = this.drafts.getScope('compose:groupName');
1575
+ this.drafts.write(scope, 'compose:groupName', '', '', true);
1576
+ this.drafts.write(scope, 'compose:groupMembers', '', '', true);
1577
+ this.drafts.write(scope, 'compose:encryptedGroup', false, false, true);
1578
+ this.drafts.write(scope, 'compose:groupFrozen', false, false, true);
1579
+ // Retain the operation independently of the new form, including legacy records.
1580
+ const generation = this.generation;
1581
+ const prefix = this.groupCreationStorageKey();
1582
+ try {
1583
+ const storage = globalThis.localStorage;
1584
+ if (storage === undefined)
1585
+ return;
1586
+ const keys = [prefix, ...JSON.parse(storage.getItem(`${prefix}:index`) ?? '[]')];
1587
+ for (const key of keys.slice(0, 8)) {
1588
+ const value = JSON.parse(storage.getItem(key) ?? 'null');
1589
+ if (value === null || typeof value.operationId !== 'string' || typeof value.name !== 'string' || !Array.isArray(value.members))
1590
+ continue;
1591
+ void call(() => this.remote.createGroup(value)).then(result => {
1592
+ if (!result.ok || !this.current(generation))
1593
+ return;
1594
+ storage.removeItem(key);
1595
+ void this.refreshConversations(generation);
1596
+ });
1597
+ }
1598
+ }
1599
+ catch { /* Keep unreadable legacy records; they must not freeze a new form. */ }
1600
+ }
1478
1601
  /** Join one open group by its canonical DID, then select the refreshed conversation. */
1479
1602
  async joinGroup(groupDidInput) {
1480
1603
  const groupDid = groupDidInput.trim();
@@ -1566,9 +1689,9 @@ export class AwikiController {
1566
1689
  groupDid: group.groupDid,
1567
1690
  member,
1568
1691
  }));
1692
+ const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
1569
1693
  if (!invited.ok)
1570
1694
  return invited;
1571
- const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
1572
1695
  if (!refreshed.ok) {
1573
1696
  return {
1574
1697
  ok: false,
@@ -1689,18 +1812,21 @@ export class AwikiController {
1689
1812
  async loadGroupState(conversation, reset, preserveAvailableOnNetworkFailure = false) {
1690
1813
  const generation = this.generation;
1691
1814
  const selectionRevision = this.selectionRevision;
1815
+ const readRevision = ++this.groupReadRevision;
1816
+ const isCurrent = () => this.currentSelection(generation, selectionRevision, conversation.id)
1817
+ && this.groupReadRevision === readRevision;
1692
1818
  const snapshot = await callGroupRead(() => this.remote.getGroup({ groupDid: conversation.groupDid }));
1693
1819
  if (!snapshot.ok) {
1694
1820
  const retainAvailable = preserveAvailableOnNetworkFailure
1695
1821
  && snapshot.reason === 'network-error'
1696
1822
  && this.view.groupAccess?.groupDid === conversation.groupDid
1697
1823
  && this.view.groupAccess.status === 'available';
1698
- if (!retainAvailable && this.currentSelection(generation, selectionRevision, conversation.id)) {
1824
+ if (!retainAvailable && isCurrent()) {
1699
1825
  this.publishGroupAccessFailure(conversation, snapshot.reason);
1700
1826
  }
1701
1827
  return snapshot;
1702
1828
  }
1703
- if (this.currentSelection(generation, selectionRevision, conversation.id)) {
1829
+ if (isCurrent()) {
1704
1830
  const access = this.view.groupAccess;
1705
1831
  this.publish({
1706
1832
  ...this.view,
@@ -1717,12 +1843,12 @@ export class AwikiController {
1717
1843
  && members.reason === 'network-error'
1718
1844
  && this.view.groupAccess?.groupDid === conversation.groupDid
1719
1845
  && this.view.groupAccess.status === 'available';
1720
- if (!retainAvailable && this.currentSelection(generation, selectionRevision, conversation.id)) {
1846
+ if (!retainAvailable && isCurrent()) {
1721
1847
  this.publishGroupAccessFailure(conversation, members.reason);
1722
1848
  }
1723
1849
  return members;
1724
1850
  }
1725
- if (!this.currentSelection(generation, selectionRevision, conversation.id))
1851
+ if (!isCurrent())
1726
1852
  return snapshot;
1727
1853
  if (members.value.pageGroup !== undefined && members.value.pageGroup !== conversation.groupDid) {
1728
1854
  return this.fail('群成员列表归属不一致,请刷新后重试');
@@ -1798,7 +1924,7 @@ export class AwikiController {
1798
1924
  messages: mergeLatestMessages(this.view.messages, committed.value.items),
1799
1925
  historyHasMore: remote.value.hasMore && remote.value.nextCursor !== undefined,
1800
1926
  refreshing: false,
1801
- error: existingError?.startsWith('群聊已创建,但以下成员未加入:') ? existingError : null,
1927
+ error: existingError?.startsWith('群聊已创建,以下成员的邀请或加密准备未完成,请在群成员中重试:') ? existingError : null,
1802
1928
  summaries: this.staleSummaries(conversationId, incoming),
1803
1929
  });
1804
1930
  }
@@ -1964,27 +2090,37 @@ export class AwikiController {
1964
2090
  * @param clientMessageId - optional logical identity shared with the optimistic row.
1965
2091
  * @returns successful delivery or one display-safe failure.
1966
2092
  */
1967
- async sendText(text, clientMessageId, mentions) {
2093
+ async sendText(text, clientMessageId, mentions, security) {
1968
2094
  const conversation = this.selectedConversation();
1969
2095
  if (conversation === undefined)
1970
2096
  return this.fail('请先选择会话');
2097
+ if (conversation.kind === 'direct' && this.view.messageSecurityReady !== true)
2098
+ return this.fail('消息模式尚未就绪,请刷新后重试');
2099
+ if (conversation.kind === 'group' && this.view.selectedGroup?.encrypted && !this.view.selectedGroup.canSendSecure)
2100
+ return this.fail('群加密尚未就绪,请等待群主完成准备或修复');
1971
2101
  if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
1972
2102
  return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
1973
2103
  }
1974
2104
  const conversationId = conversation.id;
1975
2105
  const generation = this.generation;
1976
2106
  const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
2107
+ security: security ?? 'plain',
1977
2108
  target: targetOf(conversation),
1978
2109
  text,
1979
2110
  idempotencyKey: clientMessageId ?? crypto.randomUUID(),
1980
2111
  ...mentions === undefined || mentions.length === 0 ? {} : { mentions },
1981
2112
  })));
1982
- if (!result.ok)
2113
+ if (!result.ok) {
2114
+ if (conversation.kind === 'group' && this.current(generation) && this.view.selectedGroup?.groupDid === conversation.groupDid) {
2115
+ this.publish({ ...this.view, selectedGroup: { ...this.view.selectedGroup, canSendSecure: false } });
2116
+ await this.loadGroupState(conversation, true, true);
2117
+ }
1983
2118
  return result;
2119
+ }
1984
2120
  if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
1985
2121
  return { ok: true, value: undefined };
1986
2122
  }
1987
- this.appendMessage(result.value);
2123
+ this.appendMessage(result.value, clientMessageId);
1988
2124
  return { ok: true, value: undefined };
1989
2125
  }
1990
2126
  /**
@@ -1996,12 +2132,17 @@ export class AwikiController {
1996
2132
  const conversation = this.selectedConversation();
1997
2133
  if (conversation === undefined)
1998
2134
  return this.fail('请先选择会话');
2135
+ if (conversation.kind === 'direct' && this.view.messageSecurityReady !== true)
2136
+ return this.fail('消息模式尚未就绪,请刷新后重试');
2137
+ if (conversation.kind === 'group' && this.view.selectedGroup?.encrypted && !this.view.selectedGroup.canSendSecure)
2138
+ return this.fail('群加密尚未就绪,请等待群主完成准备或修复');
1999
2139
  if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
2000
2140
  return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
2001
2141
  }
2002
2142
  const conversationId = conversation.id;
2003
2143
  const generation = this.generation;
2004
2144
  const request = {
2145
+ security: file.security ?? 'plain',
2005
2146
  target: targetOf(conversation),
2006
2147
  fileName: file.fileName,
2007
2148
  mimeType: file.mimeType,
@@ -2010,12 +2151,17 @@ export class AwikiController {
2010
2151
  idempotencyKey: file.clientMessageId ?? crypto.randomUUID(),
2011
2152
  };
2012
2153
  const result = await this.withPending('发送附件', () => call(() => this.remote.sendAttachment(request)));
2013
- if (!result.ok)
2154
+ if (!result.ok) {
2155
+ if (conversation.kind === 'group' && this.current(generation) && this.view.selectedGroup?.groupDid === conversation.groupDid) {
2156
+ this.publish({ ...this.view, selectedGroup: { ...this.view.selectedGroup, canSendSecure: false } });
2157
+ await this.loadGroupState(conversation, true, true);
2158
+ }
2014
2159
  return result;
2160
+ }
2015
2161
  if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
2016
2162
  return { ok: true, value: undefined };
2017
2163
  }
2018
- this.appendMessage(result.value);
2164
+ this.appendMessage(result.value, file.clientMessageId);
2019
2165
  return { ok: true, value: undefined };
2020
2166
  }
2021
2167
  /**
@@ -2062,13 +2208,14 @@ export class AwikiController {
2062
2208
  return { ok: false, error: 'AWiki 插件已卸载' };
2063
2209
  const generation = this.generation;
2064
2210
  const ownerDid = this.presentationCacheOwnerDid;
2065
- const tenantId = this.config?.tenantId;
2211
+ const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
2066
2212
  const result = await call(() => this.remote.clearLocalData(request));
2067
2213
  if (!result.ok)
2068
2214
  return result;
2069
2215
  const owners = [...(result.value.clearedIdentityDids ?? []), ...(ownerDid === null ? [] : [ownerDid])];
2070
2216
  for (const owner of new Set(owners)) {
2071
2217
  await this.persistentImageCache.clear(owner).catch(() => undefined);
2218
+ await avatarCache.clear(owner);
2072
2219
  try {
2073
2220
  clearMailBrowserCache(globalThis.localStorage, owner);
2074
2221
  }
@@ -2148,14 +2295,49 @@ export class AwikiController {
2148
2295
  this.close();
2149
2296
  this.listeners.clear();
2150
2297
  }
2298
+ async retryMessageSecurity() {
2299
+ await this.loadConversationPreferences(this.generation);
2300
+ }
2151
2301
  async loadConversationPreferences(generation) {
2302
+ this.publish({ ...this.view, messageSecurityReady: false, messageSecurityError: false });
2152
2303
  const result = await call(() => this.remote.getConversationPreferences(), conversationPreferenceFailureMessage);
2153
- if (!result.ok || !this.current(generation))
2304
+ if (!this.current(generation))
2305
+ return;
2306
+ if (!result.ok) {
2307
+ this.publish({ ...this.view, messageSecurityError: true });
2308
+ this.fail(result.error);
2154
2309
  return;
2310
+ }
2155
2311
  this.applyConversationPreferences(result.value);
2156
2312
  this.publish({ ...this.view, hiddenConversations: this.hiddenConversationsView() });
2157
2313
  }
2314
+ async setMessageSecurity(conversationId, encrypted) {
2315
+ if (this.view.messageSecurityReady !== true)
2316
+ return this.fail('消息模式尚未就绪,请重试读取');
2317
+ const generation = this.generation;
2318
+ const previous = this.view.encryptedConversationIds ?? [];
2319
+ this.publish({ ...this.view, encryptedConversationIds: encrypted ? [...new Set([...previous, conversationId])] : previous.filter(id => id !== conversationId) });
2320
+ const result = await this.withPending('保存消息模式', () => call(() => this.remote.updateConversationPreference({ action: 'security', conversationId, encrypted })));
2321
+ if (!result.ok) {
2322
+ if (this.current(generation))
2323
+ this.publish({ ...this.view, encryptedConversationIds: previous });
2324
+ return result;
2325
+ }
2326
+ if (this.current(generation))
2327
+ this.applyConversationPreferences(result.value);
2328
+ return { ok: true, value: undefined };
2329
+ }
2330
+ async repairSelectedGroupSecurity() {
2331
+ const group = this.view.selectedGroup;
2332
+ if (group === null)
2333
+ return this.fail('请先选择群聊');
2334
+ const result = await this.withPending('修复群加密', () => call(() => this.remote.repairGroupSecurity({ groupDid: group.groupDid })));
2335
+ if (!result.ok)
2336
+ return result;
2337
+ return this.reloadSelectedGroupAfterMutation(group.groupDid);
2338
+ }
2158
2339
  applyConversationPreferences(preferences) {
2340
+ this.publish({ ...this.view, messageSecurityReady: true, messageSecurityError: false, encryptedConversationIds: [...(preferences.encryptedConversationIds ?? [])] });
2159
2341
  this.hiddenConversationPreferences.clear();
2160
2342
  for (const hidden of preferences.hiddenConversations) {
2161
2343
  this.hiddenConversationPreferences.set(hidden.conversation.id, {
@@ -2256,6 +2438,9 @@ export class AwikiController {
2256
2438
  sessionStatus: status,
2257
2439
  conversations: Object.freeze([]),
2258
2440
  hiddenConversations: Object.freeze([]),
2441
+ messageSecurityReady: false,
2442
+ messageSecurityError: false,
2443
+ encryptedConversationIds: Object.freeze([]),
2259
2444
  conversationsHasMore: false,
2260
2445
  selectedConversationId: null,
2261
2446
  selectedGroup: null,
@@ -2313,14 +2498,30 @@ export class AwikiController {
2313
2498
  if (this.polling || !this.current(generation) || this.view.identity === null)
2314
2499
  return;
2315
2500
  this.polling = true;
2501
+ if (globalThis.document?.visibilityState !== 'hidden' && Date.now() - this.lastAvatarProfileCheck >= 60_000)
2502
+ void this.refreshAvatarProfile();
2316
2503
  try {
2317
2504
  await this.refreshConversations(generation, true);
2318
2505
  const selected = this.view.selectedConversationId;
2319
2506
  if (selected === null || !this.current(generation))
2320
2507
  return;
2321
2508
  const selectedConversation = this.selectedConversation();
2322
- if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available')
2323
- return;
2509
+ if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available') {
2510
+ const access = this.view.groupAccess?.status;
2511
+ if (access === 'blocked' || access === 'not-member')
2512
+ return;
2513
+ await this.loadGroupState(selectedConversation, false, true);
2514
+ if (!this.current(generation) || this.view.selectedConversationId !== selected
2515
+ || this.getSnapshot().groupAccess?.status !== 'available')
2516
+ return;
2517
+ }
2518
+ if (selectedConversation?.kind === 'group' && this.view.selectedGroup?.encrypted) {
2519
+ const status = await callGroupRead(() => this.remote.getGroup({ groupDid: selectedConversation.groupDid }));
2520
+ if (!this.current(generation) || this.view.selectedConversationId !== selected)
2521
+ return;
2522
+ if (status.ok)
2523
+ this.publish({ ...this.view, selectedGroup: status.value });
2524
+ }
2324
2525
  const result = await call(() => this.remote.getHistory({ conversationId: selected }));
2325
2526
  if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
2326
2527
  return;
@@ -2402,9 +2603,14 @@ export class AwikiController {
2402
2603
  }
2403
2604
  }
2404
2605
  }
2405
- appendMessage(message) {
2606
+ appendMessage(message, logicalId) {
2406
2607
  if (this.view.selectedConversationId !== message.conversationId)
2407
2608
  return;
2609
+ // Core resolves a queued logical ID to the accepted group event ID. Retire
2610
+ // only that explicit alias, including history reads started before acceptance.
2611
+ if (logicalId !== undefined && logicalId !== message.id) {
2612
+ this.retiredMessageIds.add(`${message.conversationId}/${logicalId}`);
2613
+ }
2408
2614
  const isNew = !this.view.messages.some(current => current.id === message.id);
2409
2615
  const messages = appendMessageById(this.view.messages, message);
2410
2616
  if ((this.unreadAtOpen.get(message.conversationId) ?? 0) > 0 && messages.length > this.view.messages.length) {
@@ -2463,6 +2669,7 @@ export class AwikiController {
2463
2669
  const ownerDid = identity?.did ?? null;
2464
2670
  if (ownerDid === this.presentationCacheOwnerDid)
2465
2671
  return;
2672
+ this.retiredMessageIds.clear();
2466
2673
  this.directProfiles.clear();
2467
2674
  this.groupTitles.clear();
2468
2675
  this.hiddenConversationPreferences.clear();
@@ -2471,6 +2678,7 @@ export class AwikiController {
2471
2678
  }
2472
2679
  /** Drop every browser projection without touching the Core-owned SQLite cache. */
2473
2680
  clearPresentationCache() {
2681
+ this.retiredMessageIds.clear();
2474
2682
  this.directProfiles.clear();
2475
2683
  this.groupTitles.clear();
2476
2684
  this.hiddenConversationPreferences.clear();
@@ -2574,7 +2782,10 @@ export class AwikiController {
2574
2782
  /* v8 ignore next -- every asynchronous and public mutation path checks disposal before publishing. */
2575
2783
  if (this.disposed)
2576
2784
  return;
2577
- this.view = Object.freeze(view);
2785
+ const hasRetired = view.messages.some(message => this.retiredMessageIds.has(`${message.conversationId}/${message.id}`));
2786
+ this.view = Object.freeze(hasRetired
2787
+ ? { ...view, messages: view.messages.filter(message => !this.retiredMessageIds.has(`${message.conversationId}/${message.id}`)) }
2788
+ : view);
2578
2789
  for (const listener of [...this.listeners])
2579
2790
  listener();
2580
2791
  }