@awiki/dsh-plugin 0.3.17 → 0.3.19

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 (107) hide show
  1. package/README.md +4 -0
  2. package/README.zh.md +11 -0
  3. package/THIRD_PARTY_NOTICES.md +6 -0
  4. package/lib/client.js +4012 -671
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +603 -61
  7. package/lib/{sdk-adapter-DGcTiEZz.mjs → package-version.generated-nZcAVnm8.mjs} +103 -10
  8. package/lib/provider.js +5 -3
  9. package/lib/typert.host.js +553 -133
  10. package/lib/typert.remote-client.d.ts +11 -3
  11. package/lib/typert.remote-client.d.ts.map +1 -1
  12. package/lib/typert.remote-client.js +553 -133
  13. package/lib/types/backend-capabilities.d.ts +12 -0
  14. package/lib/types/backend-capabilities.d.ts.map +1 -0
  15. package/lib/types/backend-capabilities.js +48 -0
  16. package/lib/types/backend-capabilities.js.map +1 -0
  17. package/lib/types/client/AwikiAvatar.d.ts +40 -0
  18. package/lib/types/client/AwikiAvatar.d.ts.map +1 -0
  19. package/lib/types/client/AwikiAvatar.js +201 -0
  20. package/lib/types/client/AwikiAvatar.js.map +1 -0
  21. package/lib/types/client/AwikiAvatarEditor.d.ts +11 -0
  22. package/lib/types/client/AwikiAvatarEditor.d.ts.map +1 -0
  23. package/lib/types/client/AwikiAvatarEditor.js +113 -0
  24. package/lib/types/client/AwikiAvatarEditor.js.map +1 -0
  25. package/lib/types/client/AwikiAvatarPreview.d.ts +8 -0
  26. package/lib/types/client/AwikiAvatarPreview.d.ts.map +1 -0
  27. package/lib/types/client/AwikiAvatarPreview.js +68 -0
  28. package/lib/types/client/AwikiAvatarPreview.js.map +1 -0
  29. package/lib/types/client/AwikiGroupDetails.js +2 -2
  30. package/lib/types/client/AwikiGroupDetails.js.map +1 -1
  31. package/lib/types/client/AwikiIdentityAccess.d.ts +1 -0
  32. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
  33. package/lib/types/client/AwikiIdentityAccess.js +21 -4
  34. package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
  35. package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
  36. package/lib/types/client/AwikiOverlay.js +97 -29
  37. package/lib/types/client/AwikiOverlay.js.map +1 -1
  38. package/lib/types/client/AwikiProfileCard.d.ts +1 -1
  39. package/lib/types/client/AwikiProfileCard.d.ts.map +1 -1
  40. package/lib/types/client/AwikiProfileCard.js +6 -2
  41. package/lib/types/client/AwikiProfileCard.js.map +1 -1
  42. package/lib/types/client/AwikiSettingsSection.d.ts +1 -0
  43. package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
  44. package/lib/types/client/AwikiSettingsSection.js +5 -4
  45. package/lib/types/client/AwikiSettingsSection.js.map +1 -1
  46. package/lib/types/client/avatar-cache.d.ts +24 -0
  47. package/lib/types/client/avatar-cache.d.ts.map +1 -0
  48. package/lib/types/client/avatar-cache.js +336 -0
  49. package/lib/types/client/avatar-cache.js.map +1 -0
  50. package/lib/types/client/avatar-dialog.d.ts +3 -0
  51. package/lib/types/client/avatar-dialog.d.ts.map +1 -0
  52. package/lib/types/client/avatar-dialog.js +18 -0
  53. package/lib/types/client/avatar-dialog.js.map +1 -0
  54. package/lib/types/client/avatar-image.d.ts +26 -0
  55. package/lib/types/client/avatar-image.d.ts.map +1 -0
  56. package/lib/types/client/avatar-image.js +193 -0
  57. package/lib/types/client/avatar-image.js.map +1 -0
  58. package/lib/types/client/controller.d.ts +37 -4
  59. package/lib/types/client/controller.d.ts.map +1 -1
  60. package/lib/types/client/controller.js +306 -59
  61. package/lib/types/client/controller.js.map +1 -1
  62. package/lib/types/client/index.d.ts.map +1 -1
  63. package/lib/types/client/index.js +11 -2
  64. package/lib/types/client/index.js.map +1 -1
  65. package/lib/types/client/settings-locales.d.ts +1 -1
  66. package/lib/types/client/settings-locales.d.ts.map +1 -1
  67. package/lib/types/client/settings-locales.js +26 -2
  68. package/lib/types/client/settings-locales.js.map +1 -1
  69. package/lib/types/client/slots.d.ts +12 -3
  70. package/lib/types/client/slots.d.ts.map +1 -1
  71. package/lib/types/conversation-preferences.d.ts.map +1 -1
  72. package/lib/types/conversation-preferences.js +27 -0
  73. package/lib/types/conversation-preferences.js.map +1 -1
  74. package/lib/types/index.d.ts +26 -3
  75. package/lib/types/index.d.ts.map +1 -1
  76. package/lib/types/index.js +321 -33
  77. package/lib/types/index.js.map +1 -1
  78. package/lib/types/listener.d.ts.map +1 -1
  79. package/lib/types/listener.js +9 -8
  80. package/lib/types/listener.js.map +1 -1
  81. package/lib/types/package-version.generated.d.ts +2 -2
  82. package/lib/types/package-version.generated.js +2 -2
  83. package/lib/types/provider-api.d.ts +28 -3
  84. package/lib/types/provider-api.d.ts.map +1 -1
  85. package/lib/types/provider.d.ts.map +1 -1
  86. package/lib/types/provider.js +5 -2
  87. package/lib/types/provider.js.map +1 -1
  88. package/lib/types/sdk-adapter.d.ts +10 -3
  89. package/lib/types/sdk-adapter.d.ts.map +1 -1
  90. package/lib/types/sdk-adapter.js +78 -7
  91. package/lib/types/sdk-adapter.js.map +1 -1
  92. package/lib/types/tenant-capability-cache.d.ts +15 -0
  93. package/lib/types/tenant-capability-cache.d.ts.map +1 -0
  94. package/lib/types/tenant-capability-cache.js +45 -0
  95. package/lib/types/tenant-capability-cache.js.map +1 -0
  96. package/lib/types/tenant-registry.d.ts +2 -0
  97. package/lib/types/tenant-registry.d.ts.map +1 -1
  98. package/lib/types/tenant-registry.js +83 -16
  99. package/lib/types/tenant-registry.js.map +1 -1
  100. package/lib/types/types.d.ts +70 -7
  101. package/lib/types/types.d.ts.map +1 -1
  102. package/lib/types/types.js.map +1 -1
  103. package/lib/types/update-policy.d.ts +2 -2
  104. package/lib/types/update-policy.d.ts.map +1 -1
  105. package/lib/types/update-policy.js +10 -1
  106. package/lib/types/update-policy.js.map +1 -1
  107. 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. */
@@ -532,6 +540,7 @@ export class AwikiController {
532
540
  conversationsCursor;
533
541
  historyCursor;
534
542
  groupMembersCursor;
543
+ lastCapabilityRefresh = 0;
535
544
  timer;
536
545
  opening;
537
546
  drawerOpen = false;
@@ -539,8 +548,12 @@ export class AwikiController {
539
548
  tenantTransition;
540
549
  generation = 0;
541
550
  selectionRevision = 0;
551
+ groupReadRevision = 0;
542
552
  disposed = false;
543
553
  polling = false;
554
+ lastAvatarProfileCheck = 0;
555
+ avatarProfileRefreshGeneration = -1;
556
+ avatarProfileRefresh;
544
557
  markReadInFlight = new Map();
545
558
  unreadAtOpen = new Map();
546
559
  summaryBaselines = new Map();
@@ -583,7 +596,7 @@ export class AwikiController {
583
596
  if (!preserveView)
584
597
  this.summaryBaselines.clear();
585
598
  const previous = this.view;
586
- const previousTenant = this.config?.tenantId;
599
+ const previousTenant = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
587
600
  const generation = this.generation;
588
601
  if (!preserveView)
589
602
  this.publish({ ...INITIAL_VIEW, status: 'loading' });
@@ -600,19 +613,21 @@ export class AwikiController {
600
613
  if (!config.ok)
601
614
  return failed(config.error);
602
615
  this.config = config.value;
616
+ this.lastCapabilityRefresh = Date.now();
603
617
  const session = await call(() => this.remote.getSession());
604
618
  if (!this.current(generation))
605
619
  return { ok: true, value: undefined };
606
620
  if (!session.ok)
607
621
  return failed(session.error);
608
622
  const identity = session.value.status === 'active' ? session.value.identity : null;
609
- const sameOwner = preserveView && previousTenant === config.value.tenantId
623
+ const sameOwner = preserveView && previousTenant === (config.value.tenantStorageScopeId ?? config.value.tenantId)
610
624
  && previous.sessionStatus === session.value.status && previous.identity?.did === identity?.did;
611
- this.drafts.setScope(config.value.tenantId ?? 'default', identity?.did ?? session.value.status);
625
+ this.drafts.setScope((config.value.tenantStorageScopeId ?? config.value.tenantId) ?? 'default', identity?.did ?? session.value.status);
626
+ this.restoreGroupCreationDraft();
612
627
  this.activatePresentationCache(identity);
613
- const selectedRecovery = previousTenant === config.value.tenantId
628
+ const selectedRecovery = previousTenant === (config.value.tenantStorageScopeId ?? config.value.tenantId)
614
629
  ? previous.recoveryOperationId
615
- : this.recoveryOperationsByTenant.get(recoveryTenantKey(config.value.tenantId)) ?? null;
630
+ : this.recoveryOperationsByTenant.get(recoveryTenantKey((config.value.tenantStorageScopeId ?? config.value.tenantId))) ?? null;
616
631
  this.publish({
617
632
  ...(sameOwner ? this.view : INITIAL_VIEW),
618
633
  status: 'ready',
@@ -620,9 +635,11 @@ export class AwikiController {
620
635
  identity: session.value.status === 'active' ? session.value.identity : null,
621
636
  error: null,
622
637
  attachmentMaxBytes: config.value.attachmentMaxBytes,
638
+ serverFeatures: config.value.serverFeatures,
639
+ serverFeatureDiagnostics: config.value.serverFeatureDiagnostics,
623
640
  handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled,
624
641
  recoveryOperationId: selectedRecovery,
625
- recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, config.value.tenantId),
642
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, (config.value.tenantStorageScopeId ?? config.value.tenantId)),
626
643
  });
627
644
  await this.refreshIdentityAccess();
628
645
  if (!this.current(generation))
@@ -634,7 +651,7 @@ export class AwikiController {
634
651
  await this.loadConversationPreferences(generation);
635
652
  const profile = await call(() => this.remote.getProfile());
636
653
  if (this.current(generation) && profile.ok)
637
- this.publish({ ...this.view, profile: profile.value });
654
+ this.applyProfile(profile.value);
638
655
  }
639
656
  return { ok: true, value: undefined };
640
657
  }
@@ -698,6 +715,8 @@ export class AwikiController {
698
715
  status: 'ready',
699
716
  sessionStatus: 'signed-out',
700
717
  attachmentMaxBytes: this.config?.attachmentMaxBytes ?? 0,
718
+ serverFeatures: this.config?.serverFeatures,
719
+ serverFeatureDiagnostics: this.config?.serverFeatureDiagnostics,
701
720
  handleRecoveryPhoneEnabled: this.config?.handleRecoveryPhoneEnabled ?? false,
702
721
  });
703
722
  await this.loadSession();
@@ -806,17 +825,17 @@ export class AwikiController {
806
825
  }
807
826
  const candidates = result.value.recoveries;
808
827
  const selected = this.view.recoveryOperationId;
809
- this.publish({ ...this.view, identityAccess: result.value, recoveryOperationId: selected,
828
+ this.publish({ ...this.view, identityAccess: this.projectRegistrationAccess(result.value), recoveryOperationId: selected,
810
829
  recoveryProgress: selected === this.view.recoveryOperationId ? this.view.recoveryProgress : null,
811
- recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, this.config?.tenantId) });
830
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)) });
812
831
  if (selected !== null) {
813
832
  const status = await callWithFailureCode(() => this.remote.getRecoveryStatus({ operationId: selected }));
814
833
  if (!this.recoveryCurrent(generation, revision))
815
834
  return this.recoveryLeft();
816
835
  if (!status.ok) {
817
836
  if (status.failureCode === 'not-found' && candidates.length === 0) {
818
- clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
819
- this.rememberRecoveryOperation(null, this.config?.tenantId);
837
+ clearRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
838
+ this.rememberRecoveryOperation(null, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
820
839
  this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, recoveryOtpRetryAt: null });
821
840
  }
822
841
  else {
@@ -827,10 +846,10 @@ export class AwikiController {
827
846
  else {
828
847
  const complete = status.value.phase === 'applied';
829
848
  if (complete)
830
- clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
849
+ clearRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
831
850
  this.publish({ ...this.view, recoveryProgress: status.value,
832
- recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, this.config?.tenantId),
833
- identityAccess: result.value });
851
+ recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)),
852
+ identityAccess: this.projectRegistrationAccess(result.value) });
834
853
  }
835
854
  }
836
855
  this.publish({ ...this.view, accessLoading: false, accessError: null });
@@ -844,9 +863,9 @@ export class AwikiController {
844
863
  this.leaveRecovery();
845
864
  else
846
865
  this.recoveryRevision++;
847
- this.rememberRecoveryOperation(operationId, this.config?.tenantId);
866
+ this.rememberRecoveryOperation(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
848
867
  this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null,
849
- recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, this.config?.tenantId) });
868
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)) });
850
869
  const result = await this.refreshRecoveryStatus();
851
870
  return result.ok ? { ok: true, value: undefined } : result;
852
871
  }
@@ -877,7 +896,7 @@ export class AwikiController {
877
896
  this.pendingRequest = undefined;
878
897
  this.pendingRecovery = false;
879
898
  this.drafts.clearFlow();
880
- this.rememberRecoveryOperation(null, this.config?.tenantId);
899
+ this.rememberRecoveryOperation(null, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
881
900
  this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, accessError: null,
882
901
  accessLoading: false, pending: wasPending ? null : this.view.pending, error: null });
883
902
  }
@@ -927,9 +946,15 @@ export class AwikiController {
927
946
  }
928
947
  if (result.value.status === 'registered') {
929
948
  this.drafts.clearScope();
930
- this.drafts.setScope(this.config?.tenantId ?? 'default', result.value.identity.did);
949
+ this.drafts.setScope((this.config?.tenantStorageScopeId ?? this.config?.tenantId) ?? 'default', result.value.identity.did);
931
950
  this.activatePresentationCache(result.value.identity);
932
951
  this.publish({ ...this.view, sessionStatus: 'active', identity: result.value.identity, error: null });
952
+ await this.refreshServerFeatures(generation);
953
+ if (!this.current(generation))
954
+ return result;
955
+ await this.loadConversationPreferences(generation);
956
+ if (!this.current(generation))
957
+ return result;
933
958
  await this.refreshConversations(generation);
934
959
  }
935
960
  return result;
@@ -970,7 +995,7 @@ export class AwikiController {
970
995
  /** Cancel one exact Join choice and send Recovery OTP only while its original browser scope remains current. */
971
996
  async beginRecoveryFromDeviceJoin(request) {
972
997
  const generation = this.generation;
973
- const tenantId = this.config?.tenantId;
998
+ const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
974
999
  const flowScope = this.drafts.getScope('recovery:retryDeadline');
975
1000
  const flowEpoch = this.drafts.epoch(flowScope);
976
1001
  const changed = () => ({
@@ -978,7 +1003,7 @@ export class AwikiController {
978
1003
  error: '恢复能力或身份环境已改变,请重新检查后再选择。',
979
1004
  });
980
1005
  const scopeCurrent = () => this.current(generation)
981
- && this.config?.tenantId === tenantId
1006
+ && (this.config?.tenantStorageScopeId ?? this.config?.tenantId) === tenantId
982
1007
  && this.drafts.getScope('recovery:retryDeadline') === flowScope
983
1008
  && this.drafts.epoch(flowScope) === flowEpoch;
984
1009
  if (!scopeCurrent())
@@ -988,7 +1013,7 @@ export class AwikiController {
988
1013
  return changed();
989
1014
  if (!latest.ok)
990
1015
  return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
991
- if (latest.value.tenantId !== tenantId)
1016
+ if ((latest.value.tenantStorageScopeId ?? latest.value.tenantId) !== tenantId)
992
1017
  return changed();
993
1018
  this.config = latest.value;
994
1019
  this.publish({ ...this.view, handleRecoveryPhoneEnabled: latest.value.handleRecoveryPhoneEnabled });
@@ -1018,7 +1043,7 @@ export class AwikiController {
1018
1043
  if (result.value === null)
1019
1044
  return result;
1020
1045
  this.recordRecoveryOtp(result.value, tenantId);
1021
- const sameFlow = this.config?.tenantId === tenantId
1046
+ const sameFlow = (this.config?.tenantStorageScopeId ?? this.config?.tenantId) === tenantId
1022
1047
  && this.drafts.getScope('recovery:retryDeadline') === flowScope
1023
1048
  && this.drafts.epoch(flowScope) === flowEpoch;
1024
1049
  if (!sameFlow)
@@ -1090,6 +1115,65 @@ export class AwikiController {
1090
1115
  this.publish({ ...this.view, identity: result.value, error: null });
1091
1116
  return result;
1092
1117
  }
1118
+ applyProfile(profile, updateIdentity = false) {
1119
+ if (profile.did !== this.view.identity?.did)
1120
+ return;
1121
+ const current = this.view.profile?.profileVersion;
1122
+ const incoming = profile.profileVersion;
1123
+ 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))
1124
+ return;
1125
+ const identity = updateIdentity ? { ...this.view.identity, displayName: profile.displayName } : this.view.identity;
1126
+ this.publish({ ...this.view, identity, profile, error: null });
1127
+ }
1128
+ refreshAvatarProfile() {
1129
+ if (this.avatarProfileRefresh !== undefined && this.avatarProfileRefreshGeneration === this.generation)
1130
+ return this.avatarProfileRefresh;
1131
+ const generation = this.generation;
1132
+ this.lastAvatarProfileCheck = Date.now();
1133
+ const operation = (async () => {
1134
+ const result = await call(() => this.remote.getProfile());
1135
+ if (!this.current(generation))
1136
+ return { ok: false, error: '账号已切换,请重新打开头像设置' };
1137
+ if (!result.ok)
1138
+ return { ok: false, error: result.error };
1139
+ this.applyProfile(result.value);
1140
+ return { ok: true, value: this.view.profile ?? result.value };
1141
+ })();
1142
+ this.avatarProfileRefresh = operation;
1143
+ this.avatarProfileRefreshGeneration = generation;
1144
+ void operation.finally(() => { if (this.avatarProfileRefresh === operation)
1145
+ this.avatarProfileRefresh = undefined; });
1146
+ return operation;
1147
+ }
1148
+ async setAvatar(request) {
1149
+ return this.mutateAvatar(() => this.remote.setAvatar(request));
1150
+ }
1151
+ async clearAvatar(request) {
1152
+ return this.mutateAvatar(() => this.remote.clearAvatar(request));
1153
+ }
1154
+ async mutateAvatar(operation) {
1155
+ if (this.view.profile?.avatarUploadEnabled !== true)
1156
+ return { ok: false, error: '当前账号或服务暂不支持修改头像' };
1157
+ const generation = this.generation;
1158
+ const result = await this.withPending('保存头像', () => call(operation), { publishFailure: false });
1159
+ if (!this.current(generation))
1160
+ return { ok: false, error: '账号已切换,请重新打开头像设置' };
1161
+ if (result.ok)
1162
+ this.applyProfile(result.value);
1163
+ else
1164
+ await this.refreshAvatarProfile();
1165
+ return result;
1166
+ }
1167
+ async avatarDisplayProfiles(peers) {
1168
+ const generation = this.generation;
1169
+ const result = await call(() => this.remote.getDisplayProfiles(peers.slice(0, 100), true));
1170
+ return this.current(generation) && result.ok ? result.value : [];
1171
+ }
1172
+ async avatarGroup(groupDid) {
1173
+ const generation = this.generation;
1174
+ const result = await call(() => this.remote.getGroup({ groupDid }));
1175
+ return this.current(generation) && result.ok ? result.value : null;
1176
+ }
1093
1177
  /** Save all supported public profile fields and keep identity/profile projections aligned. */
1094
1178
  async updateProfile(request) {
1095
1179
  const displayName = request.displayName.trim();
@@ -1106,15 +1190,14 @@ export class AwikiController {
1106
1190
  const result = await this.withPending('保存资料', () => call(() => this.remote.updateProfile({ displayName, bio, tags })));
1107
1191
  if (!result.ok || !this.current(generation))
1108
1192
  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 });
1193
+ this.applyProfile(result.value, true);
1111
1194
  return result;
1112
1195
  }
1113
1196
  /** Request a dedicated OTP only within the selected account flow. */
1114
1197
  async sendRecoveryOtp(request) {
1115
1198
  const generation = this.generation;
1116
1199
  const revision = this.recoveryRevision;
1117
- const tenantId = this.config?.tenantId;
1200
+ const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
1118
1201
  const flowScope = this.drafts.getScope('recovery:retryDeadline');
1119
1202
  const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false, recovery: true });
1120
1203
  if (!result.ok) {
@@ -1125,7 +1208,7 @@ export class AwikiController {
1125
1208
  }
1126
1209
  this.recordRecoveryOtp(result.value, tenantId);
1127
1210
  if (!this.recoveryCurrent(generation, revision)
1128
- || this.config?.tenantId !== tenantId
1211
+ || (this.config?.tenantStorageScopeId ?? this.config?.tenantId) !== tenantId
1129
1212
  || this.drafts.getScope('recovery:retryDeadline') !== flowScope)
1130
1213
  return result;
1131
1214
  this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
@@ -1138,7 +1221,7 @@ export class AwikiController {
1138
1221
  storeRecoveryOtpRetryAt(result.operationId, recoveryOtpRetryAt(result), tenantId);
1139
1222
  }
1140
1223
  /** Publish results only into the exact selected flow; Core work survives navigation. */
1141
- async runRecovery(label, operation, enterWhenApplied = false) {
1224
+ async runRecovery(label, operation) {
1142
1225
  const generation = this.generation;
1143
1226
  const revision = this.recoveryRevision;
1144
1227
  const operationId = this.view.recoveryOperationId;
@@ -1158,9 +1241,7 @@ export class AwikiController {
1158
1241
  }
1159
1242
  this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1160
1243
  if (result.value.phase === 'applied')
1161
- clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1162
- if (enterWhenApplied && result.value.phase === 'applied')
1163
- return this.enterRecoveredSession();
1244
+ clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1164
1245
  return result;
1165
1246
  }
1166
1247
  async prepareRecovery(request) {
@@ -1172,7 +1253,7 @@ export class AwikiController {
1172
1253
  const progress = this.view.recoveryProgress;
1173
1254
  if (!progress?.allowedActions?.includes('activate'))
1174
1255
  return { ok: false, error: '请先完成恢复信息验证' };
1175
- return this.runRecovery('恢复身份', operationId => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage), true);
1256
+ return this.runRecovery('恢复身份', operationId => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage));
1176
1257
  }
1177
1258
  /** Read-only even for applied operations. Entering the identity is a separate action. */
1178
1259
  async refreshRecoveryStatus() {
@@ -1193,14 +1274,14 @@ export class AwikiController {
1193
1274
  }
1194
1275
  this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1195
1276
  if (result.value.phase === 'applied')
1196
- clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1277
+ clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1197
1278
  return result;
1198
1279
  }
1199
1280
  async resumeRecovery() {
1200
1281
  const progress = this.view.recoveryProgress;
1201
1282
  if (!progress?.allowedActions?.includes('resume'))
1202
1283
  return { ok: false, error: '当前恢复状态不能重试' };
1203
- return this.runRecovery('继续恢复身份', operationId => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage), true);
1284
+ return this.runRecovery('继续恢复身份', operationId => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage));
1204
1285
  }
1205
1286
  async enterRecoveredSession() {
1206
1287
  const generation = this.generation;
@@ -1214,7 +1295,7 @@ export class AwikiController {
1214
1295
  return this.recoveryLeft();
1215
1296
  if (!result.ok)
1216
1297
  return result;
1217
- clearRecoveryOtpRetryAt(progress.operationId, this.config?.tenantId);
1298
+ clearRecoveryOtpRetryAt(progress.operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1218
1299
  this.leaveRecovery();
1219
1300
  const opened = await this.open();
1220
1301
  return opened.ok ? result : opened;
@@ -1234,7 +1315,7 @@ export class AwikiController {
1234
1315
  return this.recoveryLeft();
1235
1316
  if (!result.ok)
1236
1317
  return result;
1237
- clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1318
+ clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
1238
1319
  this.leaveRecovery();
1239
1320
  void this.refreshIdentityAccess();
1240
1321
  return { ok: true, value: undefined };
@@ -1434,7 +1515,7 @@ export class AwikiController {
1434
1515
  * @param members - Handle or DID values entered by the user.
1435
1516
  * @returns the created group and settled invitation outcomes.
1436
1517
  */
1437
- async createGroup(name, members) {
1518
+ async createGroup(name, members, encrypted = false, draftOperationId) {
1438
1519
  if (this.disposed)
1439
1520
  return { ok: false, error: 'AWiki 插件已卸载' };
1440
1521
  const normalizedName = name.trim();
@@ -1452,29 +1533,80 @@ export class AwikiController {
1452
1533
  return this.fail('群成员列表不需要包含自己');
1453
1534
  }
1454
1535
  const generation = this.generation;
1536
+ const resumeKey = this.groupCreationStorageKey();
1537
+ const operationId = draftOperationId ?? `group-${crypto.randomUUID()}`;
1538
+ const operationKey = `${resumeKey}:${operationId}`;
1539
+ try {
1540
+ const storage = globalThis.localStorage;
1541
+ storage?.setItem(operationKey, JSON.stringify({ name: normalizedName, members: normalizedMembers, encrypted, operationId }));
1542
+ const keys = JSON.parse(storage?.getItem(`${resumeKey}:index`) ?? '[]');
1543
+ storage?.setItem(`${resumeKey}:index`, JSON.stringify([...new Set([...keys, operationKey])]));
1544
+ }
1545
+ catch {
1546
+ return this.fail('无法保存建群恢复标识,请检查本机存储后重试');
1547
+ }
1455
1548
  const result = await this.withPending('创建群聊', () => call(() => this.remote.createGroup({
1456
1549
  name: normalizedName,
1457
1550
  members: normalizedMembers,
1551
+ encrypted,
1552
+ operationId,
1458
1553
  }), groupCreateFailureMessage));
1459
1554
  if (!result.ok || !this.current(generation))
1460
1555
  return result;
1556
+ try {
1557
+ globalThis.localStorage?.removeItem(operationKey);
1558
+ }
1559
+ catch { /* Preserve the resume ID when cleanup is unavailable. */ }
1560
+ this.drafts.write(this.drafts.getScope('compose:groupFrozen'), 'compose:groupFrozen', false, false, true);
1461
1561
  const conversation = this.cacheConversation(result.value.conversation);
1462
1562
  this.publish({
1463
1563
  ...this.view,
1464
1564
  conversations: appendUnique([conversation], this.view.conversations, value => value.id),
1465
1565
  error: null,
1466
1566
  });
1467
- const selected = await this.selectConversation(result.value.conversation.id);
1567
+ // Navigation belongs to the live dialog instance; a closed form cannot redirect it.
1568
+ const selected = draftOperationId === undefined ? await this.selectConversation(result.value.conversation.id) : { ok: true };
1468
1569
  if (!this.current(generation))
1469
1570
  return result;
1470
1571
  const failed = result.value.failedMembers.map(item => item.member);
1471
1572
  const warning = failed.length === 0
1472
1573
  ? selected.ok ? null : '群聊已创建,但暂时无法打开消息历史。'
1473
- : `群聊已创建,但以下成员未加入:${failed.join('、')}`;
1574
+ : `群聊已创建,以下成员的邀请或加密准备未完成,请在群成员中重试:${failed.join('、')}`;
1474
1575
  if (warning !== null)
1475
1576
  this.publish({ ...this.view, error: warning });
1476
1577
  return result;
1477
1578
  }
1579
+ groupCreationStorageKey() {
1580
+ return `awiki.group-create.v2:${this.drafts.getScope('compose:groupName')}`;
1581
+ }
1582
+ restoreGroupCreationDraft() {
1583
+ const scope = this.drafts.getScope('compose:groupName');
1584
+ this.drafts.write(scope, 'compose:groupName', '', '', true);
1585
+ this.drafts.write(scope, 'compose:groupMembers', '', '', true);
1586
+ this.drafts.write(scope, 'compose:encryptedGroup', false, false, true);
1587
+ this.drafts.write(scope, 'compose:groupFrozen', false, false, true);
1588
+ // Retain the operation independently of the new form, including legacy records.
1589
+ const generation = this.generation;
1590
+ const prefix = this.groupCreationStorageKey();
1591
+ try {
1592
+ const storage = globalThis.localStorage;
1593
+ if (storage === undefined)
1594
+ return;
1595
+ const keys = [prefix, ...JSON.parse(storage.getItem(`${prefix}:index`) ?? '[]')];
1596
+ for (const key of keys.slice(0, 8)) {
1597
+ const value = JSON.parse(storage.getItem(key) ?? 'null');
1598
+ if (value === null || typeof value.operationId !== 'string' || typeof value.name !== 'string' || !Array.isArray(value.members))
1599
+ continue;
1600
+ void call(() => this.remote.createGroup(value)).then(result => {
1601
+ if (!result.ok || !this.current(generation))
1602
+ return;
1603
+ storage.removeItem(key);
1604
+ void this.refreshConversations(generation);
1605
+ });
1606
+ }
1607
+ }
1608
+ catch { /* Keep unreadable legacy records; they must not freeze a new form. */ }
1609
+ }
1478
1610
  /** Join one open group by its canonical DID, then select the refreshed conversation. */
1479
1611
  async joinGroup(groupDidInput) {
1480
1612
  const groupDid = groupDidInput.trim();
@@ -1566,9 +1698,9 @@ export class AwikiController {
1566
1698
  groupDid: group.groupDid,
1567
1699
  member,
1568
1700
  }));
1701
+ const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
1569
1702
  if (!invited.ok)
1570
1703
  return invited;
1571
- const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
1572
1704
  if (!refreshed.ok) {
1573
1705
  return {
1574
1706
  ok: false,
@@ -1689,18 +1821,21 @@ export class AwikiController {
1689
1821
  async loadGroupState(conversation, reset, preserveAvailableOnNetworkFailure = false) {
1690
1822
  const generation = this.generation;
1691
1823
  const selectionRevision = this.selectionRevision;
1824
+ const readRevision = ++this.groupReadRevision;
1825
+ const isCurrent = () => this.currentSelection(generation, selectionRevision, conversation.id)
1826
+ && this.groupReadRevision === readRevision;
1692
1827
  const snapshot = await callGroupRead(() => this.remote.getGroup({ groupDid: conversation.groupDid }));
1693
1828
  if (!snapshot.ok) {
1694
1829
  const retainAvailable = preserveAvailableOnNetworkFailure
1695
1830
  && snapshot.reason === 'network-error'
1696
1831
  && this.view.groupAccess?.groupDid === conversation.groupDid
1697
1832
  && this.view.groupAccess.status === 'available';
1698
- if (!retainAvailable && this.currentSelection(generation, selectionRevision, conversation.id)) {
1833
+ if (!retainAvailable && isCurrent()) {
1699
1834
  this.publishGroupAccessFailure(conversation, snapshot.reason);
1700
1835
  }
1701
1836
  return snapshot;
1702
1837
  }
1703
- if (this.currentSelection(generation, selectionRevision, conversation.id)) {
1838
+ if (isCurrent()) {
1704
1839
  const access = this.view.groupAccess;
1705
1840
  this.publish({
1706
1841
  ...this.view,
@@ -1717,12 +1852,12 @@ export class AwikiController {
1717
1852
  && members.reason === 'network-error'
1718
1853
  && this.view.groupAccess?.groupDid === conversation.groupDid
1719
1854
  && this.view.groupAccess.status === 'available';
1720
- if (!retainAvailable && this.currentSelection(generation, selectionRevision, conversation.id)) {
1855
+ if (!retainAvailable && isCurrent()) {
1721
1856
  this.publishGroupAccessFailure(conversation, members.reason);
1722
1857
  }
1723
1858
  return members;
1724
1859
  }
1725
- if (!this.currentSelection(generation, selectionRevision, conversation.id))
1860
+ if (!isCurrent())
1726
1861
  return snapshot;
1727
1862
  if (members.value.pageGroup !== undefined && members.value.pageGroup !== conversation.groupDid) {
1728
1863
  return this.fail('群成员列表归属不一致,请刷新后重试');
@@ -1798,7 +1933,7 @@ export class AwikiController {
1798
1933
  messages: mergeLatestMessages(this.view.messages, committed.value.items),
1799
1934
  historyHasMore: remote.value.hasMore && remote.value.nextCursor !== undefined,
1800
1935
  refreshing: false,
1801
- error: existingError?.startsWith('群聊已创建,但以下成员未加入:') ? existingError : null,
1936
+ error: existingError?.startsWith('群聊已创建,以下成员的邀请或加密准备未完成,请在群成员中重试:') ? existingError : null,
1802
1937
  summaries: this.staleSummaries(conversationId, incoming),
1803
1938
  });
1804
1939
  }
@@ -1964,27 +2099,37 @@ export class AwikiController {
1964
2099
  * @param clientMessageId - optional logical identity shared with the optimistic row.
1965
2100
  * @returns successful delivery or one display-safe failure.
1966
2101
  */
1967
- async sendText(text, clientMessageId, mentions) {
2102
+ async sendText(text, clientMessageId, mentions, security) {
1968
2103
  const conversation = this.selectedConversation();
1969
2104
  if (conversation === undefined)
1970
2105
  return this.fail('请先选择会话');
2106
+ if (conversation.kind === 'direct' && this.view.messageSecurityReady !== true)
2107
+ return this.fail('消息模式尚未就绪,请刷新后重试');
2108
+ if (conversation.kind === 'group' && this.view.selectedGroup?.encrypted && !this.view.selectedGroup.canSendSecure)
2109
+ return this.fail('群加密尚未就绪,请等待群主完成准备或修复');
1971
2110
  if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
1972
2111
  return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
1973
2112
  }
1974
2113
  const conversationId = conversation.id;
1975
2114
  const generation = this.generation;
1976
2115
  const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
2116
+ security: security ?? 'plain',
1977
2117
  target: targetOf(conversation),
1978
2118
  text,
1979
2119
  idempotencyKey: clientMessageId ?? crypto.randomUUID(),
1980
2120
  ...mentions === undefined || mentions.length === 0 ? {} : { mentions },
1981
2121
  })));
1982
- if (!result.ok)
2122
+ if (!result.ok) {
2123
+ if (conversation.kind === 'group' && this.current(generation) && this.view.selectedGroup?.groupDid === conversation.groupDid) {
2124
+ this.publish({ ...this.view, selectedGroup: { ...this.view.selectedGroup, canSendSecure: false } });
2125
+ await this.loadGroupState(conversation, true, true);
2126
+ }
1983
2127
  return result;
2128
+ }
1984
2129
  if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
1985
2130
  return { ok: true, value: undefined };
1986
2131
  }
1987
- this.appendMessage(result.value);
2132
+ this.appendMessage(result.value, clientMessageId);
1988
2133
  return { ok: true, value: undefined };
1989
2134
  }
1990
2135
  /**
@@ -1996,12 +2141,17 @@ export class AwikiController {
1996
2141
  const conversation = this.selectedConversation();
1997
2142
  if (conversation === undefined)
1998
2143
  return this.fail('请先选择会话');
2144
+ if (conversation.kind === 'direct' && this.view.messageSecurityReady !== true)
2145
+ return this.fail('消息模式尚未就绪,请刷新后重试');
2146
+ if (conversation.kind === 'group' && this.view.selectedGroup?.encrypted && !this.view.selectedGroup.canSendSecure)
2147
+ return this.fail('群加密尚未就绪,请等待群主完成准备或修复');
1999
2148
  if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
2000
2149
  return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
2001
2150
  }
2002
2151
  const conversationId = conversation.id;
2003
2152
  const generation = this.generation;
2004
2153
  const request = {
2154
+ security: file.security ?? 'plain',
2005
2155
  target: targetOf(conversation),
2006
2156
  fileName: file.fileName,
2007
2157
  mimeType: file.mimeType,
@@ -2010,12 +2160,17 @@ export class AwikiController {
2010
2160
  idempotencyKey: file.clientMessageId ?? crypto.randomUUID(),
2011
2161
  };
2012
2162
  const result = await this.withPending('发送附件', () => call(() => this.remote.sendAttachment(request)));
2013
- if (!result.ok)
2163
+ if (!result.ok) {
2164
+ if (conversation.kind === 'group' && this.current(generation) && this.view.selectedGroup?.groupDid === conversation.groupDid) {
2165
+ this.publish({ ...this.view, selectedGroup: { ...this.view.selectedGroup, canSendSecure: false } });
2166
+ await this.loadGroupState(conversation, true, true);
2167
+ }
2014
2168
  return result;
2169
+ }
2015
2170
  if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
2016
2171
  return { ok: true, value: undefined };
2017
2172
  }
2018
- this.appendMessage(result.value);
2173
+ this.appendMessage(result.value, file.clientMessageId);
2019
2174
  return { ok: true, value: undefined };
2020
2175
  }
2021
2176
  /**
@@ -2062,13 +2217,14 @@ export class AwikiController {
2062
2217
  return { ok: false, error: 'AWiki 插件已卸载' };
2063
2218
  const generation = this.generation;
2064
2219
  const ownerDid = this.presentationCacheOwnerDid;
2065
- const tenantId = this.config?.tenantId;
2220
+ const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
2066
2221
  const result = await call(() => this.remote.clearLocalData(request));
2067
2222
  if (!result.ok)
2068
2223
  return result;
2069
2224
  const owners = [...(result.value.clearedIdentityDids ?? []), ...(ownerDid === null ? [] : [ownerDid])];
2070
2225
  for (const owner of new Set(owners)) {
2071
2226
  await this.persistentImageCache.clear(owner).catch(() => undefined);
2227
+ await avatarCache.clear(owner);
2072
2228
  try {
2073
2229
  clearMailBrowserCache(globalThis.localStorage, owner);
2074
2230
  }
@@ -2088,6 +2244,8 @@ export class AwikiController {
2088
2244
  this.clearPresentationCache();
2089
2245
  this.publish({ ...INITIAL_VIEW, status: 'ready', accessLoading: false,
2090
2246
  attachmentMaxBytes: this.config?.attachmentMaxBytes ?? INITIAL_VIEW.attachmentMaxBytes,
2247
+ serverFeatures: this.config?.serverFeatures,
2248
+ serverFeatureDiagnostics: this.config?.serverFeatureDiagnostics,
2091
2249
  handleRecoveryPhoneEnabled: this.config?.handleRecoveryPhoneEnabled ?? false,
2092
2250
  identityAccess: { choice: null, joining: false, recoveries: [] } });
2093
2251
  return result;
@@ -2148,14 +2306,49 @@ export class AwikiController {
2148
2306
  this.close();
2149
2307
  this.listeners.clear();
2150
2308
  }
2309
+ async retryMessageSecurity() {
2310
+ await this.loadConversationPreferences(this.generation);
2311
+ }
2151
2312
  async loadConversationPreferences(generation) {
2313
+ this.publish({ ...this.view, messageSecurityReady: false, messageSecurityError: false });
2152
2314
  const result = await call(() => this.remote.getConversationPreferences(), conversationPreferenceFailureMessage);
2153
- if (!result.ok || !this.current(generation))
2315
+ if (!this.current(generation))
2154
2316
  return;
2317
+ if (!result.ok) {
2318
+ this.publish({ ...this.view, messageSecurityError: true });
2319
+ this.fail(result.error);
2320
+ return;
2321
+ }
2155
2322
  this.applyConversationPreferences(result.value);
2156
2323
  this.publish({ ...this.view, hiddenConversations: this.hiddenConversationsView() });
2157
2324
  }
2325
+ async setMessageSecurity(conversationId, encrypted) {
2326
+ if (this.view.messageSecurityReady !== true)
2327
+ return this.fail('消息模式尚未就绪,请重试读取');
2328
+ const generation = this.generation;
2329
+ const previous = this.view.encryptedConversationIds ?? [];
2330
+ this.publish({ ...this.view, encryptedConversationIds: encrypted ? [...new Set([...previous, conversationId])] : previous.filter(id => id !== conversationId) });
2331
+ const result = await this.withPending('保存消息模式', () => call(() => this.remote.updateConversationPreference({ action: 'security', conversationId, encrypted })));
2332
+ if (!result.ok) {
2333
+ if (this.current(generation))
2334
+ this.publish({ ...this.view, encryptedConversationIds: previous });
2335
+ return result;
2336
+ }
2337
+ if (this.current(generation))
2338
+ this.applyConversationPreferences(result.value);
2339
+ return { ok: true, value: undefined };
2340
+ }
2341
+ async repairSelectedGroupSecurity() {
2342
+ const group = this.view.selectedGroup;
2343
+ if (group === null)
2344
+ return this.fail('请先选择群聊');
2345
+ const result = await this.withPending('修复群加密', () => call(() => this.remote.repairGroupSecurity({ groupDid: group.groupDid })));
2346
+ if (!result.ok)
2347
+ return result;
2348
+ return this.reloadSelectedGroupAfterMutation(group.groupDid);
2349
+ }
2158
2350
  applyConversationPreferences(preferences) {
2351
+ this.publish({ ...this.view, messageSecurityReady: true, messageSecurityError: false, encryptedConversationIds: [...(preferences.encryptedConversationIds ?? [])] });
2159
2352
  this.hiddenConversationPreferences.clear();
2160
2353
  for (const hidden of preferences.hiddenConversations) {
2161
2354
  this.hiddenConversationPreferences.set(hidden.conversation.id, {
@@ -2256,6 +2449,9 @@ export class AwikiController {
2256
2449
  sessionStatus: status,
2257
2450
  conversations: Object.freeze([]),
2258
2451
  hiddenConversations: Object.freeze([]),
2452
+ messageSecurityReady: false,
2453
+ messageSecurityError: false,
2454
+ encryptedConversationIds: Object.freeze([]),
2259
2455
  conversationsHasMore: false,
2260
2456
  selectedConversationId: null,
2261
2457
  selectedGroup: null,
@@ -2309,18 +2505,59 @@ export class AwikiController {
2309
2505
  });
2310
2506
  return { ok: true, value: undefined };
2311
2507
  }
2508
+ async refreshBackendCapabilities() {
2509
+ await this.refreshServerFeatures(this.generation, true);
2510
+ }
2511
+ projectRegistrationAccess(access) {
2512
+ const support = this.config?.serverFeatures?.['registration.handle_only'];
2513
+ return access !== null && (support === 'supported' || support === 'unsupported')
2514
+ ? { ...access, registrationHandleOnly: support === 'supported' }
2515
+ : access;
2516
+ }
2517
+ async refreshServerFeatures(generation, force = false) {
2518
+ this.lastCapabilityRefresh = Date.now();
2519
+ const config = await call(() => force ? this.remote.refreshBackendCapabilities() : this.remote.getConfig());
2520
+ if (!this.current(generation))
2521
+ return;
2522
+ if (config.ok && config.value.tenantId === this.config?.tenantId && config.value.tenantGeneration === this.config?.tenantGeneration) {
2523
+ this.config = config.value;
2524
+ this.publish({ ...this.view, attachmentMaxBytes: config.value.attachmentMaxBytes, identityAccess: this.projectRegistrationAccess(this.view.identityAccess), serverFeatures: config.value.serverFeatures, serverFeatureDiagnostics: config.value.serverFeatureDiagnostics, handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled });
2525
+ }
2526
+ }
2312
2527
  async poll(generation) {
2313
- if (this.polling || !this.current(generation) || this.view.identity === null)
2528
+ if (!this.current(generation))
2529
+ return;
2530
+ // Public discovery also completes while onboarding has no identity.
2531
+ // Its Host projection must never hold up local sessions or history.
2532
+ if (Date.now() - this.lastCapabilityRefresh >= 5_000)
2533
+ void this.refreshServerFeatures(generation);
2534
+ if (this.polling || this.view.identity === null)
2314
2535
  return;
2315
2536
  this.polling = true;
2537
+ if (globalThis.document?.visibilityState !== 'hidden' && Date.now() - this.lastAvatarProfileCheck >= 60_000)
2538
+ void this.refreshAvatarProfile();
2316
2539
  try {
2317
2540
  await this.refreshConversations(generation, true);
2318
2541
  const selected = this.view.selectedConversationId;
2319
2542
  if (selected === null || !this.current(generation))
2320
2543
  return;
2321
2544
  const selectedConversation = this.selectedConversation();
2322
- if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available')
2323
- return;
2545
+ if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available') {
2546
+ const access = this.view.groupAccess?.status;
2547
+ if (access === 'blocked' || access === 'not-member')
2548
+ return;
2549
+ await this.loadGroupState(selectedConversation, false, true);
2550
+ if (!this.current(generation) || this.view.selectedConversationId !== selected
2551
+ || this.getSnapshot().groupAccess?.status !== 'available')
2552
+ return;
2553
+ }
2554
+ if (selectedConversation?.kind === 'group' && this.view.selectedGroup?.encrypted) {
2555
+ const status = await callGroupRead(() => this.remote.getGroup({ groupDid: selectedConversation.groupDid }));
2556
+ if (!this.current(generation) || this.view.selectedConversationId !== selected)
2557
+ return;
2558
+ if (status.ok)
2559
+ this.publish({ ...this.view, selectedGroup: status.value });
2560
+ }
2324
2561
  const result = await call(() => this.remote.getHistory({ conversationId: selected }));
2325
2562
  if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
2326
2563
  return;
@@ -2402,9 +2639,14 @@ export class AwikiController {
2402
2639
  }
2403
2640
  }
2404
2641
  }
2405
- appendMessage(message) {
2642
+ appendMessage(message, logicalId) {
2406
2643
  if (this.view.selectedConversationId !== message.conversationId)
2407
2644
  return;
2645
+ // Core resolves a queued logical ID to the accepted group event ID. Retire
2646
+ // only that explicit alias, including history reads started before acceptance.
2647
+ if (logicalId !== undefined && logicalId !== message.id) {
2648
+ this.retiredMessageIds.add(`${message.conversationId}/${logicalId}`);
2649
+ }
2408
2650
  const isNew = !this.view.messages.some(current => current.id === message.id);
2409
2651
  const messages = appendMessageById(this.view.messages, message);
2410
2652
  if ((this.unreadAtOpen.get(message.conversationId) ?? 0) > 0 && messages.length > this.view.messages.length) {
@@ -2463,6 +2705,7 @@ export class AwikiController {
2463
2705
  const ownerDid = identity?.did ?? null;
2464
2706
  if (ownerDid === this.presentationCacheOwnerDid)
2465
2707
  return;
2708
+ this.retiredMessageIds.clear();
2466
2709
  this.directProfiles.clear();
2467
2710
  this.groupTitles.clear();
2468
2711
  this.hiddenConversationPreferences.clear();
@@ -2471,6 +2714,7 @@ export class AwikiController {
2471
2714
  }
2472
2715
  /** Drop every browser projection without touching the Core-owned SQLite cache. */
2473
2716
  clearPresentationCache() {
2717
+ this.retiredMessageIds.clear();
2474
2718
  this.directProfiles.clear();
2475
2719
  this.groupTitles.clear();
2476
2720
  this.hiddenConversationPreferences.clear();
@@ -2574,7 +2818,10 @@ export class AwikiController {
2574
2818
  /* v8 ignore next -- every asynchronous and public mutation path checks disposal before publishing. */
2575
2819
  if (this.disposed)
2576
2820
  return;
2577
- this.view = Object.freeze(view);
2821
+ const hasRetired = view.messages.some(message => this.retiredMessageIds.has(`${message.conversationId}/${message.id}`));
2822
+ this.view = Object.freeze(hasRetired
2823
+ ? { ...view, messages: view.messages.filter(message => !this.retiredMessageIds.has(`${message.conversationId}/${message.id}`)) }
2824
+ : view);
2578
2825
  for (const listener of [...this.listeners])
2579
2826
  listener();
2580
2827
  }