@awiki/dsh-plugin 0.2.4 → 0.2.5
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.
- package/README.md +80 -11
- package/README.zh.md +67 -12
- package/THIRD_PARTY_NOTICES.md +8 -1
- package/cordis.patch.yml +13 -0
- package/lib/client.js +8223 -1939
- package/lib/client.js.map +1 -1
- package/lib/index.js +1486 -59
- package/lib/model-proxy-contract.js +89 -0
- package/lib/model-proxy.js +383 -0
- package/lib/provider.js +2 -1
- package/lib/sdk-adapter-DKgkyApB.mjs +669 -0
- package/lib/typert.host.js +470 -482
- package/lib/typert.remote-client.d.ts +15 -1
- package/lib/typert.remote-client.d.ts.map +1 -1
- package/lib/typert.remote-client.js +469 -103
- package/lib/types/attachment-cache.d.ts +28 -0
- package/lib/types/attachment-cache.d.ts.map +1 -0
- package/lib/types/attachment-cache.js +194 -0
- package/lib/types/attachment-cache.js.map +1 -0
- package/lib/types/client/AwikiMail.d.ts +14 -0
- package/lib/types/client/AwikiMail.d.ts.map +1 -0
- package/lib/types/client/AwikiMail.js +260 -0
- package/lib/types/client/AwikiMail.js.map +1 -0
- package/lib/types/client/AwikiOnboarding.d.ts +22 -0
- package/lib/types/client/AwikiOnboarding.d.ts.map +1 -0
- package/lib/types/client/AwikiOnboarding.js +87 -0
- package/lib/types/client/AwikiOnboarding.js.map +1 -0
- package/lib/types/client/AwikiOverlay.d.ts +15 -0
- package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
- package/lib/types/client/AwikiOverlay.js +251 -20
- package/lib/types/client/AwikiOverlay.js.map +1 -1
- package/lib/types/client/AwikiSettingsSection.d.ts +20 -4
- package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
- package/lib/types/client/AwikiSettingsSection.js +279 -23
- package/lib/types/client/AwikiSettingsSection.js.map +1 -1
- package/lib/types/client/RechargeComingSoonDialog.d.ts +11 -0
- package/lib/types/client/RechargeComingSoonDialog.d.ts.map +1 -0
- package/lib/types/client/RechargeComingSoonDialog.js +8 -0
- package/lib/types/client/RechargeComingSoonDialog.js.map +1 -0
- package/lib/types/client/controller.d.ts +82 -4
- package/lib/types/client/controller.d.ts.map +1 -1
- package/lib/types/client/controller.js +517 -74
- package/lib/types/client/controller.js.map +1 -1
- package/lib/types/client/image-cache.d.ts +20 -0
- package/lib/types/client/image-cache.d.ts.map +1 -0
- package/lib/types/client/image-cache.js +177 -0
- package/lib/types/client/image-cache.js.map +1 -0
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +84 -41
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/model-account-settings-availability.d.ts +4 -0
- package/lib/types/client/model-account-settings-availability.d.ts.map +1 -0
- package/lib/types/client/model-account-settings-availability.js +4 -0
- package/lib/types/client/model-account-settings-availability.js.map +1 -0
- package/lib/types/client/model-availability-controller.d.ts +27 -0
- package/lib/types/client/model-availability-controller.d.ts.map +1 -0
- package/lib/types/client/model-availability-controller.js +111 -0
- package/lib/types/client/model-availability-controller.js.map +1 -0
- package/lib/types/client/model-proxy-controller.d.ts +41 -0
- package/lib/types/client/model-proxy-controller.d.ts.map +1 -0
- package/lib/types/client/model-proxy-controller.js +241 -0
- package/lib/types/client/model-proxy-controller.js.map +1 -0
- package/lib/types/client/recharge-availability.d.ts +6 -0
- package/lib/types/client/recharge-availability.d.ts.map +1 -0
- package/lib/types/client/recharge-availability.js +6 -0
- package/lib/types/client/recharge-availability.js.map +1 -0
- package/lib/types/client/settings-locales.d.ts +1 -1
- package/lib/types/client/settings-locales.d.ts.map +1 -1
- package/lib/types/client/settings-locales.js +180 -2
- package/lib/types/client/settings-locales.js.map +1 -1
- package/lib/types/client/slots.d.ts +15 -2
- package/lib/types/client/slots.d.ts.map +1 -1
- package/lib/types/index.d.ts +56 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +462 -31
- package/lib/types/index.js.map +1 -1
- package/lib/types/listener-state.d.ts +23 -0
- package/lib/types/listener-state.d.ts.map +1 -0
- package/lib/types/listener-state.js +124 -0
- package/lib/types/listener-state.js.map +1 -0
- package/lib/types/listener.d.ts +96 -0
- package/lib/types/listener.d.ts.map +1 -0
- package/lib/types/listener.js +575 -0
- package/lib/types/listener.js.map +1 -0
- package/lib/types/mail.d.ts +8 -0
- package/lib/types/mail.d.ts.map +1 -0
- package/lib/types/mail.js +75 -0
- package/lib/types/mail.js.map +1 -0
- package/lib/types/model-proxy-contract.d.ts +61 -0
- package/lib/types/model-proxy-contract.d.ts.map +1 -0
- package/lib/types/model-proxy-contract.js +129 -0
- package/lib/types/model-proxy-contract.js.map +1 -0
- package/lib/types/model-proxy.d.ts +14 -0
- package/lib/types/model-proxy.d.ts.map +1 -0
- package/lib/types/model-proxy.js +371 -0
- package/lib/types/model-proxy.js.map +1 -0
- package/lib/types/provider-api.d.ts +78 -2
- package/lib/types/provider-api.d.ts.map +1 -1
- package/lib/types/provider.d.ts.map +1 -1
- package/lib/types/provider.js +1 -0
- package/lib/types/provider.js.map +1 -1
- package/lib/types/sdk-adapter.d.ts +26 -2
- package/lib/types/sdk-adapter.d.ts.map +1 -1
- package/lib/types/sdk-adapter.js +380 -24
- package/lib/types/sdk-adapter.js.map +1 -1
- package/lib/types/tools.d.ts +10 -0
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/tools.js +98 -0
- package/lib/types/tools.js.map +1 -1
- package/lib/types/types.d.ts +103 -1
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js.map +1 -1
- package/package.json +30 -5
- package/lib/sdk-adapter-DK6RqV-c.mjs +0 -355
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** React-free browser controller for the deployment's one AWiki identity. */
|
|
2
|
+
import { IndexedDbAwikiBrowserImageCache, } from "./image-cache.js";
|
|
2
3
|
/** Turn a registration rejection into an actionable message without exposing remote response text. */
|
|
3
4
|
function registrationFailureMessage(failure) {
|
|
4
5
|
switch (failure.code) {
|
|
@@ -52,6 +53,8 @@ const INITIAL_VIEW = Object.freeze({
|
|
|
52
53
|
selectedConversationId: null,
|
|
53
54
|
messages: Object.freeze([]),
|
|
54
55
|
historyHasMore: false,
|
|
56
|
+
localPending: false,
|
|
57
|
+
refreshing: false,
|
|
55
58
|
pending: null,
|
|
56
59
|
error: null,
|
|
57
60
|
attachmentMaxBytes: 0,
|
|
@@ -68,6 +71,20 @@ function summaryFailureMessage(failure) {
|
|
|
68
71
|
default: return `${failure.code}:${failure.message}`;
|
|
69
72
|
}
|
|
70
73
|
}
|
|
74
|
+
/** Turn closed mail failures into safe, actionable browser messages. */
|
|
75
|
+
function mailFailureMessage(failure) {
|
|
76
|
+
switch (failure.code) {
|
|
77
|
+
case 'invalid-request': return '邮件信息不完整或格式不正确,请检查后重试。';
|
|
78
|
+
case 'not-registered': return '请先注册 AWiki 身份后使用邮箱。';
|
|
79
|
+
case 'signed-out': return '当前 AWiki 身份已退出,请重新进入后使用邮箱。';
|
|
80
|
+
case 'forbidden': return '当前 AWiki 身份没有执行此邮件操作的权限。';
|
|
81
|
+
case 'not-found': return '该邮件不存在或已经不可访问。';
|
|
82
|
+
case 'rate-limited': return '邮件请求过于频繁,请稍后重试。';
|
|
83
|
+
case 'delivery-unknown': return '发送结果未知,请先检查已发送邮件再决定是否重试。';
|
|
84
|
+
case 'network': return '无法连接 AWiki 邮件服务,请检查网络后重试。';
|
|
85
|
+
default: return 'AWiki 邮件服务暂时不可用,请稍后重试。';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
71
88
|
/** Flatten the carrier and business result once for every controller caller. */
|
|
72
89
|
async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`) {
|
|
73
90
|
try {
|
|
@@ -99,18 +116,75 @@ function appendUnique(current, incoming, id) {
|
|
|
99
116
|
}
|
|
100
117
|
return [...current, ...appended];
|
|
101
118
|
}
|
|
102
|
-
/**
|
|
103
|
-
function
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
/** Keep one last-wins value per canonical id without changing the page's order. */
|
|
120
|
+
function canonicalMessagePage(incoming) {
|
|
121
|
+
const byId = new Map();
|
|
122
|
+
for (const message of incoming)
|
|
123
|
+
byId.set(message.id, message);
|
|
124
|
+
return [...byId.values()];
|
|
125
|
+
}
|
|
126
|
+
/** Replace the newest loaded window while preserving older messages before it. */
|
|
127
|
+
function mergeLatestMessages(current, incoming) {
|
|
128
|
+
const page = canonicalMessagePage(incoming);
|
|
129
|
+
const incomingIds = new Set(page.map(message => message.id));
|
|
130
|
+
return [...current.filter(message => !incomingIds.has(message.id)), ...page];
|
|
131
|
+
}
|
|
132
|
+
/** Prepend one chronological continuation page while exact-updating any overlap. */
|
|
133
|
+
function mergeOlderMessages(current, incoming) {
|
|
134
|
+
const page = canonicalMessagePage(incoming);
|
|
135
|
+
const incomingIds = new Set(page.map(message => message.id));
|
|
136
|
+
return [...page, ...current.filter(message => !incomingIds.has(message.id))];
|
|
137
|
+
}
|
|
138
|
+
/** Append a newly committed message, or enrich its existing canonical row in place. */
|
|
139
|
+
function appendMessageById(current, incoming) {
|
|
140
|
+
const index = current.findIndex(message => message.id === incoming.id);
|
|
141
|
+
if (index < 0)
|
|
142
|
+
return [...current, incoming];
|
|
143
|
+
return current.map((message, currentIndex) => currentIndex === index ? incoming : message);
|
|
144
|
+
}
|
|
145
|
+
/** Explain a background failure without implying that visible local messages were lost. */
|
|
146
|
+
function refreshFailureMessage(messages, error) {
|
|
147
|
+
return messages.length > 0 ? `刷新失败,当前显示本地数据。${error}` : error;
|
|
148
|
+
}
|
|
149
|
+
/** Reject a page that attempts to cross the selected canonical conversation boundary. */
|
|
150
|
+
function pageBelongsToConversation(conversationId, messages) {
|
|
151
|
+
return messages.every(message => message.conversationId === conversationId);
|
|
152
|
+
}
|
|
153
|
+
function timingStart() {
|
|
154
|
+
return globalThis.performance?.now() ?? Date.now();
|
|
155
|
+
}
|
|
156
|
+
function clearConversationTimings() {
|
|
157
|
+
const performance = globalThis.performance;
|
|
158
|
+
if (performance === undefined)
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
for (const name of [
|
|
162
|
+
'conversation.select.local_timeline_ms',
|
|
163
|
+
'conversation.select.first_paint_ms',
|
|
164
|
+
'conversation.select.remote_history_ms',
|
|
165
|
+
])
|
|
166
|
+
performance.clearMeasures(name);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Performance measurement is optional in restricted browser/test runtimes.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Keep only one secret-free development measure for each selected-conversation phase. */
|
|
173
|
+
function recordTiming(name, startedAt, success) {
|
|
174
|
+
const performance = globalThis.performance;
|
|
175
|
+
if (performance === undefined)
|
|
176
|
+
return;
|
|
177
|
+
try {
|
|
178
|
+
performance.clearMeasures(name);
|
|
179
|
+
performance.measure(name, {
|
|
180
|
+
start: startedAt,
|
|
181
|
+
end: performance.now(),
|
|
182
|
+
detail: { success, count: 1 },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Performance measurement is optional in restricted browser/test runtimes.
|
|
112
187
|
}
|
|
113
|
-
return [...prepended, ...current];
|
|
114
188
|
}
|
|
115
189
|
/** Strip surrounding space and a leading @ from a Handle the user typed. */
|
|
116
190
|
function normalizeHandle(value) {
|
|
@@ -122,6 +196,14 @@ function sameIdentity(identity, peer) {
|
|
|
122
196
|
const target = peer.toLowerCase();
|
|
123
197
|
return own === target || own.startsWith(`${target}.`) || target.startsWith(`${own}.`);
|
|
124
198
|
}
|
|
199
|
+
/** Bounded readiness window for a newly projected group conversation. */
|
|
200
|
+
const GROUP_HISTORY_RETRY_DELAYS_MS = [250, 750, 1_500, 2_500];
|
|
201
|
+
/** Runtime-only decoded-byte budget that prevents repeat Host calls while browsing. */
|
|
202
|
+
const BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
203
|
+
/** Wait between group-history readiness probes without retaining controller state. */
|
|
204
|
+
function delay(milliseconds) {
|
|
205
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
206
|
+
}
|
|
125
207
|
/** Keys that can identify one direct peer in the current roster. */
|
|
126
208
|
function directPeerKeys(conversation) {
|
|
127
209
|
const keys = [conversation.peerDid, conversation.title];
|
|
@@ -136,18 +218,20 @@ function findDirect(conversations, peer) {
|
|
|
136
218
|
const key = peer.toLowerCase();
|
|
137
219
|
return conversations.find((conversation) => (conversation.kind === 'direct' && directPeerKeys(conversation).includes(key)));
|
|
138
220
|
}
|
|
139
|
-
/**
|
|
140
|
-
function
|
|
141
|
-
if (
|
|
142
|
-
return
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
221
|
+
/** True when a group title contains presentation data instead of a protocol fallback. */
|
|
222
|
+
function hasDisplayableGroupTitle(conversation) {
|
|
223
|
+
if (conversation.kind !== 'group')
|
|
224
|
+
return false;
|
|
225
|
+
const title = conversation.title.trim();
|
|
226
|
+
return title !== '' && title !== conversation.groupDid && title !== conversation.id;
|
|
227
|
+
}
|
|
228
|
+
/** True when a direct title is richer than its routing identifiers. */
|
|
229
|
+
function hasDisplayableDirectTitle(conversation) {
|
|
230
|
+
const title = conversation.title.trim().replace(/^@/u, '');
|
|
231
|
+
if (title === '')
|
|
232
|
+
return false;
|
|
233
|
+
return ![conversation.id, conversation.peerDid, conversation.peerHandle]
|
|
234
|
+
.some(value => value !== undefined && value.trim().replace(/^@/u, '') === title);
|
|
151
235
|
}
|
|
152
236
|
/** Resolve one listed conversation into the send target accepted by AWiki. */
|
|
153
237
|
function targetOf(conversation) {
|
|
@@ -158,6 +242,7 @@ function targetOf(conversation) {
|
|
|
158
242
|
/** Browser object layer for identity, conversations, history, and polling. */
|
|
159
243
|
export class AwikiController {
|
|
160
244
|
remote;
|
|
245
|
+
persistentImageCache;
|
|
161
246
|
view = INITIAL_VIEW;
|
|
162
247
|
listeners = new Set();
|
|
163
248
|
config = null;
|
|
@@ -165,14 +250,27 @@ export class AwikiController {
|
|
|
165
250
|
historyCursor;
|
|
166
251
|
timer;
|
|
167
252
|
generation = 0;
|
|
253
|
+
selectionRevision = 0;
|
|
168
254
|
disposed = false;
|
|
169
255
|
polling = false;
|
|
170
256
|
markReadInFlight = new Map();
|
|
171
257
|
unreadAtOpen = new Map();
|
|
172
258
|
summaryBaselines = new Map();
|
|
173
|
-
/**
|
|
174
|
-
|
|
259
|
+
/** Last trustworthy direct profile for the active identity, keyed by canonical peer DID. */
|
|
260
|
+
directProfiles = new Map();
|
|
261
|
+
/** Last trustworthy group title for the active identity, keyed by canonical Group DID. */
|
|
262
|
+
groupTitles = new Map();
|
|
263
|
+
/** Verified image payloads retained outside observable state for instant remounts. */
|
|
264
|
+
imageAttachments = new Map();
|
|
265
|
+
imageAttachmentCacheBytes = 0;
|
|
266
|
+
presentationCacheOwnerDid = null;
|
|
267
|
+
/**
|
|
268
|
+
* @param remote - generated Host Remote namespace.
|
|
269
|
+
* @param persistentImageCache - browser-origin verified preview cache.
|
|
270
|
+
*/
|
|
271
|
+
constructor(remote, persistentImageCache = new IndexedDbAwikiBrowserImageCache()) {
|
|
175
272
|
this.remote = remote;
|
|
273
|
+
this.persistentImageCache = persistentImageCache;
|
|
176
274
|
}
|
|
177
275
|
/** Return the cached immutable view. */
|
|
178
276
|
getSnapshot = () => this.view;
|
|
@@ -181,11 +279,8 @@ export class AwikiController {
|
|
|
181
279
|
this.listeners.add(listener);
|
|
182
280
|
return () => { this.listeners.delete(listener); };
|
|
183
281
|
};
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
* @returns successful readiness or one display-safe Host failure.
|
|
187
|
-
*/
|
|
188
|
-
async open() {
|
|
282
|
+
/** Load Host policy and the shared identity state without starting drawer polling. */
|
|
283
|
+
async loadSession() {
|
|
189
284
|
if (this.disposed)
|
|
190
285
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
191
286
|
this.close();
|
|
@@ -204,21 +299,33 @@ export class AwikiController {
|
|
|
204
299
|
if (!session.ok)
|
|
205
300
|
return this.fail(session.error);
|
|
206
301
|
const identity = session.value.status === 'active' ? session.value.identity : null;
|
|
302
|
+
this.activatePresentationCache(identity);
|
|
207
303
|
this.publish({
|
|
208
304
|
...this.view,
|
|
209
305
|
status: 'ready',
|
|
210
306
|
sessionStatus: session.value.status,
|
|
211
|
-
identity,
|
|
307
|
+
identity: session.value.status === 'active' ? session.value.identity : null,
|
|
212
308
|
error: null,
|
|
213
309
|
attachmentMaxBytes: config.value.attachmentMaxBytes,
|
|
214
310
|
});
|
|
215
|
-
|
|
311
|
+
return { ok: true, value: undefined };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Load Host policy and identity, then start polling while the drawer remains open.
|
|
315
|
+
* @returns successful readiness or one display-safe Host failure.
|
|
316
|
+
*/
|
|
317
|
+
async open() {
|
|
318
|
+
const loaded = await this.loadSession();
|
|
319
|
+
if (!loaded.ok)
|
|
320
|
+
return loaded;
|
|
321
|
+
const generation = this.generation;
|
|
322
|
+
if (this.view.identity !== null) {
|
|
216
323
|
const listed = await this.refreshConversations(generation);
|
|
217
324
|
if (!listed.ok)
|
|
218
325
|
return listed;
|
|
219
326
|
}
|
|
220
327
|
if (this.current(generation)) {
|
|
221
|
-
this.timer = setInterval(() => { void this.poll(generation); }, this.config
|
|
328
|
+
this.timer = setInterval(() => { void this.poll(generation); }, this.config?.pollIntervalMs ?? 3_000);
|
|
222
329
|
}
|
|
223
330
|
return { ok: true, value: undefined };
|
|
224
331
|
}
|
|
@@ -233,6 +340,7 @@ export class AwikiController {
|
|
|
233
340
|
this.conversationsCursor = undefined;
|
|
234
341
|
this.historyCursor = undefined;
|
|
235
342
|
this.summaryBaselines.clear();
|
|
343
|
+
this.clearPresentationCache();
|
|
236
344
|
this.publish({
|
|
237
345
|
...INITIAL_VIEW,
|
|
238
346
|
status: 'ready',
|
|
@@ -256,6 +364,7 @@ export class AwikiController {
|
|
|
256
364
|
/** Stop polling and invalidate all in-flight drawer work. */
|
|
257
365
|
close() {
|
|
258
366
|
this.generation += 1;
|
|
367
|
+
this.selectionRevision += 1;
|
|
259
368
|
if (this.timer !== undefined)
|
|
260
369
|
clearInterval(this.timer);
|
|
261
370
|
this.timer = undefined;
|
|
@@ -282,6 +391,7 @@ export class AwikiController {
|
|
|
282
391
|
return result;
|
|
283
392
|
if (!this.current(generation))
|
|
284
393
|
return result;
|
|
394
|
+
this.activatePresentationCache(result.value);
|
|
285
395
|
this.publish({ ...this.view, sessionStatus: 'active', identity: result.value, error: null });
|
|
286
396
|
await this.refreshConversations(generation);
|
|
287
397
|
return result;
|
|
@@ -305,6 +415,26 @@ export class AwikiController {
|
|
|
305
415
|
this.publish({ ...this.view, identity: result.value, error: null });
|
|
306
416
|
return result;
|
|
307
417
|
}
|
|
418
|
+
/** Read the active deployment identity's public mailbox state. */
|
|
419
|
+
getMailAccount() {
|
|
420
|
+
return call(() => this.remote.getMailAccount(), mailFailureMessage);
|
|
421
|
+
}
|
|
422
|
+
/** List one browser-requested mailbox page without background polling. */
|
|
423
|
+
listMailInbox(request = {}) {
|
|
424
|
+
return call(() => this.remote.listMailInbox(request), mailFailureMessage);
|
|
425
|
+
}
|
|
426
|
+
/** Read one selected plain-text mail message without marking it read. */
|
|
427
|
+
readMail(request) {
|
|
428
|
+
return call(() => this.remote.readMail(request), mailFailureMessage);
|
|
429
|
+
}
|
|
430
|
+
/** Mark mail read only after the browser supplied an explicit selected id. */
|
|
431
|
+
markMailRead(request) {
|
|
432
|
+
return call(() => this.remote.markMailRead(request), mailFailureMessage);
|
|
433
|
+
}
|
|
434
|
+
/** Send one user-confirmed plain-text mail without retrying. */
|
|
435
|
+
sendMail(request) {
|
|
436
|
+
return call(() => this.remote.sendMail(request), mailFailureMessage);
|
|
437
|
+
}
|
|
308
438
|
/**
|
|
309
439
|
* Load another page of the conversation roster.
|
|
310
440
|
* @returns successful pagination or one display-safe failure.
|
|
@@ -317,9 +447,10 @@ export class AwikiController {
|
|
|
317
447
|
if (!this.current(generation))
|
|
318
448
|
return { ok: true, value: undefined };
|
|
319
449
|
this.conversationsCursor = result.value.nextCursor;
|
|
450
|
+
const conversations = result.value.items.map(incoming => this.cacheConversation(incoming, this.view.conversations.find(current => current.id === incoming.id)));
|
|
320
451
|
this.publish({
|
|
321
452
|
...this.view,
|
|
322
|
-
conversations: appendUnique(this.view.conversations,
|
|
453
|
+
conversations: appendUnique(this.view.conversations, conversations, value => value.id),
|
|
323
454
|
conversationsHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
324
455
|
});
|
|
325
456
|
return { ok: true, value: undefined };
|
|
@@ -360,14 +491,14 @@ export class AwikiController {
|
|
|
360
491
|
?? findDirect(this.view.conversations, resolved.value.did);
|
|
361
492
|
if (listed !== undefined)
|
|
362
493
|
return this.selectConversation(listed.id);
|
|
363
|
-
const conversation = {
|
|
494
|
+
const conversation = this.cacheConversation({
|
|
364
495
|
kind: 'direct',
|
|
365
496
|
id: resolved.value.conversationId,
|
|
366
497
|
peerDid: resolved.value.did,
|
|
367
498
|
title: resolved.value.displayName ?? resolved.value.handle ?? resolved.value.did,
|
|
368
499
|
...resolved.value.handle === undefined ? {} : { peerHandle: resolved.value.handle },
|
|
369
500
|
...resolved.value.displayName === undefined ? {} : { displayName: resolved.value.displayName },
|
|
370
|
-
};
|
|
501
|
+
});
|
|
371
502
|
this.publish({
|
|
372
503
|
...this.view,
|
|
373
504
|
conversations: [conversation, ...this.view.conversations],
|
|
@@ -375,52 +506,219 @@ export class AwikiController {
|
|
|
375
506
|
});
|
|
376
507
|
return this.selectConversation(conversation.id);
|
|
377
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* Create one group, add its initial members, and open the new canonical conversation.
|
|
511
|
+
* Group selection owns a bounded readiness retry so a fresh empty group can settle before
|
|
512
|
+
* its first history failure becomes visible.
|
|
513
|
+
* @param name - user-visible group name.
|
|
514
|
+
* @param members - Handle or DID values entered by the user.
|
|
515
|
+
* @returns the created group and settled invitation outcomes.
|
|
516
|
+
*/
|
|
517
|
+
async createGroup(name, members) {
|
|
518
|
+
if (this.disposed)
|
|
519
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
520
|
+
const normalizedName = name.trim();
|
|
521
|
+
if (normalizedName === '')
|
|
522
|
+
return this.fail('请输入群聊名称');
|
|
523
|
+
if (Array.from(normalizedName).length > 100)
|
|
524
|
+
return this.fail('群聊名称不能超过 100 个字符');
|
|
525
|
+
const normalizedMembers = [...new Set(members.map(normalizeHandle).filter(member => member !== ''))];
|
|
526
|
+
if (normalizedMembers.length === 0)
|
|
527
|
+
return this.fail('请至少添加一位群成员');
|
|
528
|
+
if (normalizedMembers.length > 50)
|
|
529
|
+
return this.fail('首批群成员不能超过 50 位');
|
|
530
|
+
const identity = this.view.identity;
|
|
531
|
+
if (identity === null)
|
|
532
|
+
return this.fail('请先注册 AWiki 身份');
|
|
533
|
+
if (normalizedMembers.some(member => member === identity.did || sameIdentity(identity, member))) {
|
|
534
|
+
return this.fail('群成员列表不需要包含自己');
|
|
535
|
+
}
|
|
536
|
+
const generation = this.generation;
|
|
537
|
+
const result = await this.withPending('创建群聊', () => call(() => this.remote.createGroup({
|
|
538
|
+
name: normalizedName,
|
|
539
|
+
members: normalizedMembers,
|
|
540
|
+
})));
|
|
541
|
+
if (!result.ok || !this.current(generation))
|
|
542
|
+
return result;
|
|
543
|
+
const conversation = this.cacheConversation(result.value.conversation);
|
|
544
|
+
this.publish({
|
|
545
|
+
...this.view,
|
|
546
|
+
conversations: appendUnique([conversation], this.view.conversations, value => value.id),
|
|
547
|
+
error: null,
|
|
548
|
+
});
|
|
549
|
+
const selected = await this.selectConversation(result.value.conversation.id);
|
|
550
|
+
if (!this.current(generation))
|
|
551
|
+
return result;
|
|
552
|
+
const failed = result.value.failedMembers.map(item => item.member);
|
|
553
|
+
const warning = failed.length === 0
|
|
554
|
+
? selected.ok ? null : '群聊已创建,但暂时无法打开消息历史。'
|
|
555
|
+
: `群聊已创建,但以下成员未加入:${failed.join('、')}`;
|
|
556
|
+
if (warning !== null)
|
|
557
|
+
this.publish({ ...this.view, error: warning });
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
378
560
|
/**
|
|
379
561
|
* Select a conversation and load its newest history page.
|
|
380
562
|
* @param conversationId - selected conversation, or `null` to return to the roster.
|
|
381
563
|
* @returns successful selection or one display-safe history failure.
|
|
382
564
|
*/
|
|
383
565
|
async selectConversation(conversationId) {
|
|
566
|
+
clearConversationTimings();
|
|
567
|
+
const selectStartedAt = timingStart();
|
|
568
|
+
const previousConversationId = this.view.selectedConversationId;
|
|
569
|
+
const sameConversation = conversationId !== null && previousConversationId === conversationId;
|
|
570
|
+
const selectionRevision = ++this.selectionRevision;
|
|
384
571
|
this.historyCursor = undefined;
|
|
385
572
|
const selected = conversationId === null
|
|
386
573
|
? undefined
|
|
387
574
|
: this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
388
575
|
if (selected !== undefined)
|
|
389
576
|
this.unreadAtOpen.set(selected.id, selected.unreadCount ?? 0);
|
|
390
|
-
this.publish({
|
|
577
|
+
this.publish({
|
|
578
|
+
...this.view,
|
|
579
|
+
selectedConversationId: conversationId,
|
|
580
|
+
messages: sameConversation ? this.view.messages : [],
|
|
581
|
+
historyHasMore: false,
|
|
582
|
+
localPending: conversationId !== null,
|
|
583
|
+
refreshing: false,
|
|
584
|
+
error: null,
|
|
585
|
+
});
|
|
391
586
|
if (conversationId === null)
|
|
392
587
|
return { ok: true, value: undefined };
|
|
393
588
|
const generation = this.generation;
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
&& selected?.kind === 'direct'
|
|
402
|
-
&& refreshed.value.did === selected.peerDid
|
|
403
|
-
&& refreshed.value.conversationId === selected.id) {
|
|
589
|
+
const localStartedAt = timingStart();
|
|
590
|
+
const local = await call(() => this.remote.getLocalHistory({ conversationId }));
|
|
591
|
+
recordTiming('conversation.select.local_timeline_ms', localStartedAt, local.ok);
|
|
592
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId)) {
|
|
593
|
+
return local.ok ? { ok: true, value: undefined } : local;
|
|
594
|
+
}
|
|
595
|
+
if (!local.ok) {
|
|
404
596
|
this.publish({
|
|
405
597
|
...this.view,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const displayName = refreshed.value.displayName ?? conversation.displayName;
|
|
410
|
-
const peerHandle = refreshed.value.handle ?? conversation.peerHandle;
|
|
411
|
-
return {
|
|
412
|
-
...conversation,
|
|
413
|
-
title: displayName ?? peerHandle ?? conversation.title,
|
|
414
|
-
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
415
|
-
...(displayName === undefined ? {} : { displayName }),
|
|
416
|
-
};
|
|
417
|
-
}),
|
|
598
|
+
localPending: false,
|
|
599
|
+
refreshing: true,
|
|
600
|
+
error: local.error,
|
|
418
601
|
});
|
|
602
|
+
void this.reconcileSelectedConversation(conversationId, generation, selectionRevision);
|
|
603
|
+
void this.refreshSelectedDirectProfile(selected, generation, selectionRevision);
|
|
604
|
+
return local;
|
|
419
605
|
}
|
|
420
|
-
if (!
|
|
421
|
-
return
|
|
606
|
+
if (!pageBelongsToConversation(conversationId, local.value.items)) {
|
|
607
|
+
return this.failSelectedConversation(generation, selectionRevision, conversationId, 'AWiki 本地消息归属不一致,请重新打开会话。');
|
|
608
|
+
}
|
|
609
|
+
this.publish({
|
|
610
|
+
...this.view,
|
|
611
|
+
messages: mergeLatestMessages(this.view.messages, local.value.items),
|
|
612
|
+
localPending: false,
|
|
613
|
+
refreshing: true,
|
|
614
|
+
error: null,
|
|
615
|
+
});
|
|
616
|
+
recordTiming('conversation.select.first_paint_ms', selectStartedAt, true);
|
|
617
|
+
void this.reconcileSelectedConversation(conversationId, generation, selectionRevision);
|
|
618
|
+
void this.refreshSelectedDirectProfile(selected, generation, selectionRevision);
|
|
422
619
|
return { ok: true, value: undefined };
|
|
423
620
|
}
|
|
621
|
+
async reconcileSelectedConversation(conversationId, generation, selectionRevision) {
|
|
622
|
+
const selected = this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
623
|
+
const remoteStartedAt = timingStart();
|
|
624
|
+
const remote = await this.readRemoteHistoryWithGroupReadiness(selected, conversationId, generation, selectionRevision);
|
|
625
|
+
recordTiming('conversation.select.remote_history_ms', remoteStartedAt, remote.ok);
|
|
626
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
627
|
+
return;
|
|
628
|
+
if (!remote.ok) {
|
|
629
|
+
this.publish({
|
|
630
|
+
...this.view,
|
|
631
|
+
refreshing: false,
|
|
632
|
+
error: refreshFailureMessage(this.view.messages, remote.error),
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (!pageBelongsToConversation(conversationId, remote.value.items)) {
|
|
637
|
+
this.failSelectedConversation(generation, selectionRevision, conversationId, 'AWiki 远端消息归属不一致,请重新打开会话。');
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
this.historyCursor = remote.value.nextCursor;
|
|
641
|
+
const committed = await call(() => this.remote.getLocalHistory({ conversationId }));
|
|
642
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
643
|
+
return;
|
|
644
|
+
if (!committed.ok) {
|
|
645
|
+
this.publish({
|
|
646
|
+
...this.view,
|
|
647
|
+
refreshing: false,
|
|
648
|
+
error: refreshFailureMessage(this.view.messages, committed.error),
|
|
649
|
+
});
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (!pageBelongsToConversation(conversationId, committed.value.items)) {
|
|
653
|
+
this.failSelectedConversation(generation, selectionRevision, conversationId, 'AWiki 本地消息归属不一致,请重新打开会话。');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const existingIds = new Set(this.view.messages.map(message => message.id));
|
|
657
|
+
const incoming = committed.value.items.filter(message => !existingIds.has(message.id));
|
|
658
|
+
const existingError = this.view.error;
|
|
659
|
+
this.publish({
|
|
660
|
+
...this.view,
|
|
661
|
+
messages: mergeLatestMessages(this.view.messages, committed.value.items),
|
|
662
|
+
historyHasMore: remote.value.hasMore && remote.value.nextCursor !== undefined,
|
|
663
|
+
refreshing: false,
|
|
664
|
+
error: existingError?.startsWith('群聊已创建,但以下成员未加入:') ? existingError : null,
|
|
665
|
+
summaries: this.staleSummaries(conversationId, incoming),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
async readRemoteHistoryWithGroupReadiness(conversation, conversationId, generation, selectionRevision) {
|
|
669
|
+
if (conversation?.kind !== 'group') {
|
|
670
|
+
return call(() => this.remote.getHistory({ conversationId }));
|
|
671
|
+
}
|
|
672
|
+
for (const retryDelay of GROUP_HISTORY_RETRY_DELAYS_MS) {
|
|
673
|
+
const remote = await call(() => this.remote.getHistory({ conversationId }));
|
|
674
|
+
if (remote.ok || !this.currentSelection(generation, selectionRevision, conversationId))
|
|
675
|
+
return remote;
|
|
676
|
+
await delay(retryDelay);
|
|
677
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
678
|
+
return remote;
|
|
679
|
+
}
|
|
680
|
+
return call(() => this.remote.getHistory({ conversationId }));
|
|
681
|
+
}
|
|
682
|
+
async refreshSelectedDirectProfile(selected, generation, selectionRevision) {
|
|
683
|
+
if (selected?.kind !== 'direct')
|
|
684
|
+
return;
|
|
685
|
+
const refreshed = await call(() => this.remote.resolvePeer({ peer: selected.peerDid }));
|
|
686
|
+
if (!refreshed.ok
|
|
687
|
+
|| !this.currentSelection(generation, selectionRevision, selected.id)
|
|
688
|
+
|| refreshed.value.did !== selected.peerDid
|
|
689
|
+
|| refreshed.value.conversationId !== selected.id)
|
|
690
|
+
return;
|
|
691
|
+
this.publish({
|
|
692
|
+
...this.view,
|
|
693
|
+
conversations: this.view.conversations.map((conversation) => {
|
|
694
|
+
if (conversation.id !== selected.id || conversation.kind !== 'direct')
|
|
695
|
+
return conversation;
|
|
696
|
+
const displayName = refreshed.value.displayName ?? conversation.displayName;
|
|
697
|
+
const peerHandle = refreshed.value.handle ?? conversation.peerHandle;
|
|
698
|
+
this.directProfiles.set(conversation.peerDid, {
|
|
699
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
700
|
+
...(displayName === undefined ? {} : { displayName, title: displayName }),
|
|
701
|
+
});
|
|
702
|
+
return this.cacheConversation({
|
|
703
|
+
...conversation,
|
|
704
|
+
title: displayName ?? peerHandle ?? conversation.title,
|
|
705
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
706
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
707
|
+
});
|
|
708
|
+
}),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
failSelectedConversation(generation, selectionRevision, conversationId, error) {
|
|
712
|
+
if (this.currentSelection(generation, selectionRevision, conversationId)) {
|
|
713
|
+
this.publish({
|
|
714
|
+
...this.view,
|
|
715
|
+
localPending: false,
|
|
716
|
+
refreshing: false,
|
|
717
|
+
error,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return { ok: false, error };
|
|
721
|
+
}
|
|
424
722
|
/**
|
|
425
723
|
* Mark the selected conversation read after the UI proves its newest message is visible.
|
|
426
724
|
* Repeated scroll and layout notifications share one Host request, while a failed
|
|
@@ -518,16 +816,19 @@ export class AwikiController {
|
|
|
518
816
|
/**
|
|
519
817
|
* Send one text message to the selected direct or group conversation.
|
|
520
818
|
* @param text - non-empty text prepared by the composer.
|
|
819
|
+
* @param clientMessageId - optional logical identity shared with the optimistic row.
|
|
521
820
|
* @returns successful delivery or one display-safe failure.
|
|
522
821
|
*/
|
|
523
|
-
async sendText(text) {
|
|
822
|
+
async sendText(text, clientMessageId) {
|
|
524
823
|
const conversation = this.selectedConversation();
|
|
525
824
|
if (conversation === undefined)
|
|
526
825
|
return this.fail('请先选择会话');
|
|
527
826
|
const conversationId = conversation.id;
|
|
528
827
|
const generation = this.generation;
|
|
529
828
|
const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
|
|
530
|
-
target: targetOf(conversation),
|
|
829
|
+
target: targetOf(conversation),
|
|
830
|
+
text,
|
|
831
|
+
idempotencyKey: clientMessageId ?? crypto.randomUUID(),
|
|
531
832
|
})));
|
|
532
833
|
if (!result.ok)
|
|
533
834
|
return result;
|
|
@@ -554,7 +855,7 @@ export class AwikiController {
|
|
|
554
855
|
mimeType: file.mimeType,
|
|
555
856
|
bytesBase64: file.bytesBase64,
|
|
556
857
|
...(file.caption === undefined ? {} : { caption: file.caption }),
|
|
557
|
-
idempotencyKey: crypto.randomUUID(),
|
|
858
|
+
idempotencyKey: file.clientMessageId ?? crypto.randomUUID(),
|
|
558
859
|
};
|
|
559
860
|
const result = await this.withPending('发送附件', () => call(() => this.remote.sendAttachment(request)));
|
|
560
861
|
if (!result.ok)
|
|
@@ -574,9 +875,34 @@ export class AwikiController {
|
|
|
574
875
|
async downloadAttachment(messageId, attachmentId) {
|
|
575
876
|
if (this.disposed)
|
|
576
877
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
878
|
+
const cacheKey = `${String(messageId)}\u0000${String(attachmentId)}`;
|
|
879
|
+
const cached = this.imageAttachments.get(cacheKey);
|
|
880
|
+
if (cached !== undefined) {
|
|
881
|
+
this.imageAttachments.delete(cacheKey);
|
|
882
|
+
this.imageAttachments.set(cacheKey, cached);
|
|
883
|
+
return { ok: true, value: cached };
|
|
884
|
+
}
|
|
577
885
|
const generation = this.generation;
|
|
886
|
+
const ownerDid = this.presentationCacheOwnerDid;
|
|
887
|
+
if (ownerDid !== null) {
|
|
888
|
+
const persisted = await this.persistentImageCache.read(ownerDid, messageId, attachmentId).catch(() => undefined);
|
|
889
|
+
if (!this.current(generation))
|
|
890
|
+
return { ok: false, error: 'AWiki 已关闭' };
|
|
891
|
+
if (persisted !== undefined) {
|
|
892
|
+
this.cacheImageAttachment(cacheKey, persisted);
|
|
893
|
+
return { ok: true, value: persisted };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
578
896
|
const result = await call(() => this.remote.downloadAttachment({ attachmentId, messageId }));
|
|
579
|
-
|
|
897
|
+
if (!this.current(generation))
|
|
898
|
+
return { ok: false, error: 'AWiki 已关闭' };
|
|
899
|
+
if (result.ok && result.value.attachment.mimeType.startsWith('image/')) {
|
|
900
|
+
this.cacheImageAttachment(cacheKey, result.value);
|
|
901
|
+
if (ownerDid !== null) {
|
|
902
|
+
void this.persistentImageCache.write(ownerDid, messageId, result.value).catch(() => undefined);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return result;
|
|
580
906
|
}
|
|
581
907
|
/** Clear Host-owned local data and immediately remove every cached browser projection. */
|
|
582
908
|
async clearLocalData(request) {
|
|
@@ -585,12 +911,14 @@ export class AwikiController {
|
|
|
585
911
|
const result = await call(() => this.remote.clearLocalData(request));
|
|
586
912
|
if (!result.ok)
|
|
587
913
|
return result;
|
|
914
|
+
await this.persistentImageCache.clear().catch(() => undefined);
|
|
588
915
|
this.close();
|
|
589
916
|
this.config = null;
|
|
590
917
|
this.conversationsCursor = undefined;
|
|
591
918
|
this.historyCursor = undefined;
|
|
592
919
|
this.unreadAtOpen.clear();
|
|
593
920
|
this.summaryBaselines.clear();
|
|
921
|
+
this.clearPresentationCache();
|
|
594
922
|
this.publish({ ...INITIAL_VIEW, status: 'ready' });
|
|
595
923
|
return result;
|
|
596
924
|
}
|
|
@@ -600,16 +928,19 @@ export class AwikiController {
|
|
|
600
928
|
this.close();
|
|
601
929
|
this.listeners.clear();
|
|
602
930
|
}
|
|
603
|
-
async refreshConversations(generation) {
|
|
931
|
+
async refreshConversations(generation, background = false) {
|
|
604
932
|
const result = await call(() => this.remote.listConversations({}));
|
|
605
933
|
if (!this.current(generation))
|
|
606
934
|
return { ok: true, value: undefined };
|
|
607
935
|
if (!result.ok)
|
|
608
|
-
return this.fail(result.error);
|
|
936
|
+
return background ? result : this.fail(result.error);
|
|
609
937
|
const firstPage = this.view.conversations.length === 0;
|
|
610
938
|
if (firstPage)
|
|
611
939
|
this.conversationsCursor = result.value.nextCursor;
|
|
612
|
-
const refreshed = result.value.items.map(
|
|
940
|
+
const refreshed = result.value.items.map((incoming) => {
|
|
941
|
+
const current = this.view.conversations.find(value => value.id === incoming.id);
|
|
942
|
+
return this.cacheConversation(incoming, current);
|
|
943
|
+
});
|
|
613
944
|
this.publish({
|
|
614
945
|
...this.view,
|
|
615
946
|
conversations: firstPage
|
|
@@ -618,7 +949,7 @@ export class AwikiController {
|
|
|
618
949
|
conversationsHasMore: firstPage
|
|
619
950
|
? result.value.hasMore && result.value.nextCursor !== undefined
|
|
620
951
|
: this.view.conversationsHasMore,
|
|
621
|
-
error: null,
|
|
952
|
+
error: background ? this.view.error : null,
|
|
622
953
|
});
|
|
623
954
|
return { ok: true, value: undefined };
|
|
624
955
|
}
|
|
@@ -638,10 +969,13 @@ export class AwikiController {
|
|
|
638
969
|
return { ok: true, value: undefined };
|
|
639
970
|
if (this.view.selectedConversationId !== conversationId)
|
|
640
971
|
return { ok: true, value: undefined };
|
|
972
|
+
if (!pageBelongsToConversation(conversationId, result.value.items)) {
|
|
973
|
+
return this.fail('AWiki 远端消息归属不一致,请重新打开会话。');
|
|
974
|
+
}
|
|
641
975
|
this.historyCursor = result.value.nextCursor;
|
|
642
976
|
const messages = older
|
|
643
|
-
?
|
|
644
|
-
: result.value.items;
|
|
977
|
+
? mergeOlderMessages(this.view.messages, result.value.items)
|
|
978
|
+
: mergeLatestMessages(this.view.messages, result.value.items);
|
|
645
979
|
this.publish({
|
|
646
980
|
...this.view,
|
|
647
981
|
// SDK pages are chronological. A continuation page contains older
|
|
@@ -657,16 +991,20 @@ export class AwikiController {
|
|
|
657
991
|
return;
|
|
658
992
|
this.polling = true;
|
|
659
993
|
try {
|
|
660
|
-
await this.refreshConversations(generation);
|
|
994
|
+
await this.refreshConversations(generation, true);
|
|
661
995
|
const selected = this.view.selectedConversationId;
|
|
662
996
|
if (selected === null || !this.current(generation))
|
|
663
997
|
return;
|
|
664
998
|
const result = await call(() => this.remote.getHistory({ conversationId: selected }));
|
|
665
999
|
if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
|
|
666
1000
|
return;
|
|
1001
|
+
if (!pageBelongsToConversation(selected, result.value.items)) {
|
|
1002
|
+
this.publish({ ...this.view, error: 'AWiki 远端消息归属不一致,请重新打开会话。' });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
667
1005
|
const existingIds = new Set(this.view.messages.map(message => message.id));
|
|
668
1006
|
const incoming = result.value.items.filter(message => !existingIds.has(message.id));
|
|
669
|
-
const messages =
|
|
1007
|
+
const messages = mergeLatestMessages(this.view.messages, result.value.items);
|
|
670
1008
|
const added = messages.length - this.view.messages.length;
|
|
671
1009
|
if (added > 0 && (this.unreadAtOpen.get(selected) ?? 0) > 0) {
|
|
672
1010
|
this.unreadAtOpen.set(selected, (this.unreadAtOpen.get(selected) ?? 0) + added);
|
|
@@ -693,8 +1031,10 @@ export class AwikiController {
|
|
|
693
1031
|
return result;
|
|
694
1032
|
}
|
|
695
1033
|
appendMessage(message) {
|
|
1034
|
+
if (this.view.selectedConversationId !== message.conversationId)
|
|
1035
|
+
return;
|
|
696
1036
|
const isNew = !this.view.messages.some(current => current.id === message.id);
|
|
697
|
-
const messages =
|
|
1037
|
+
const messages = appendMessageById(this.view.messages, message);
|
|
698
1038
|
if ((this.unreadAtOpen.get(message.conversationId) ?? 0) > 0 && messages.length > this.view.messages.length) {
|
|
699
1039
|
this.unreadAtOpen.set(message.conversationId, (this.unreadAtOpen.get(message.conversationId) ?? 0) + 1);
|
|
700
1040
|
}
|
|
@@ -738,6 +1078,104 @@ export class AwikiController {
|
|
|
738
1078
|
const selected = this.view.selectedConversationId;
|
|
739
1079
|
return selected === null ? undefined : this.view.conversations.find(value => value.id === selected);
|
|
740
1080
|
}
|
|
1081
|
+
/** Keep presentation-only cache entries isolated to one authenticated identity. */
|
|
1082
|
+
activatePresentationCache(identity) {
|
|
1083
|
+
const ownerDid = identity?.did ?? null;
|
|
1084
|
+
if (ownerDid === this.presentationCacheOwnerDid)
|
|
1085
|
+
return;
|
|
1086
|
+
this.directProfiles.clear();
|
|
1087
|
+
this.groupTitles.clear();
|
|
1088
|
+
this.clearImageAttachments();
|
|
1089
|
+
this.presentationCacheOwnerDid = ownerDid;
|
|
1090
|
+
}
|
|
1091
|
+
/** Drop every browser projection without touching the Core-owned SQLite cache. */
|
|
1092
|
+
clearPresentationCache() {
|
|
1093
|
+
this.directProfiles.clear();
|
|
1094
|
+
this.groupTitles.clear();
|
|
1095
|
+
this.clearImageAttachments();
|
|
1096
|
+
this.presentationCacheOwnerDid = null;
|
|
1097
|
+
}
|
|
1098
|
+
/** Retain recently used verified image bytes without exposing them in AwikiView. */
|
|
1099
|
+
cacheImageAttachment(key, value) {
|
|
1100
|
+
if (value.attachment.size > BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES)
|
|
1101
|
+
return;
|
|
1102
|
+
const previous = this.imageAttachments.get(key);
|
|
1103
|
+
if (previous !== undefined)
|
|
1104
|
+
this.imageAttachmentCacheBytes -= previous.attachment.size;
|
|
1105
|
+
this.imageAttachments.delete(key);
|
|
1106
|
+
this.imageAttachments.set(key, Object.freeze({
|
|
1107
|
+
attachment: Object.freeze({ ...value.attachment }),
|
|
1108
|
+
bytesBase64: value.bytesBase64,
|
|
1109
|
+
}));
|
|
1110
|
+
this.imageAttachmentCacheBytes += value.attachment.size;
|
|
1111
|
+
while (this.imageAttachmentCacheBytes > BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES) {
|
|
1112
|
+
const oldestKey = this.imageAttachments.keys().next().value;
|
|
1113
|
+
if (oldestKey === undefined)
|
|
1114
|
+
break;
|
|
1115
|
+
const oldest = this.imageAttachments.get(oldestKey);
|
|
1116
|
+
this.imageAttachments.delete(oldestKey);
|
|
1117
|
+
if (oldest !== undefined)
|
|
1118
|
+
this.imageAttachmentCacheBytes -= oldest.attachment.size;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
clearImageAttachments() {
|
|
1122
|
+
this.imageAttachments.clear();
|
|
1123
|
+
this.imageAttachmentCacheBytes = 0;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Reconcile direct identity and group title projections with their last trustworthy values.
|
|
1127
|
+
* Core remains authoritative; this browser cache only prevents sparse refreshes from
|
|
1128
|
+
* replacing already resolved presentation data with protocol identifiers.
|
|
1129
|
+
*/
|
|
1130
|
+
cacheConversation(incoming, current) {
|
|
1131
|
+
if (incoming.kind === 'direct') {
|
|
1132
|
+
const active = current?.kind === 'direct' && current.peerDid === incoming.peerDid ? current : undefined;
|
|
1133
|
+
const cached = this.directProfiles.get(incoming.peerDid);
|
|
1134
|
+
const incomingDisplayName = incoming.displayName?.trim();
|
|
1135
|
+
const displayName = active?.displayName
|
|
1136
|
+
?? cached?.displayName
|
|
1137
|
+
?? (incomingDisplayName === undefined || incomingDisplayName === '' ? undefined : incomingDisplayName);
|
|
1138
|
+
const peerHandle = active?.peerHandle ?? cached?.peerHandle ?? incoming.peerHandle;
|
|
1139
|
+
const title = displayName
|
|
1140
|
+
?? (active !== undefined && hasDisplayableDirectTitle(active) ? active.title : undefined)
|
|
1141
|
+
?? cached?.title
|
|
1142
|
+
?? (hasDisplayableDirectTitle(incoming) ? incoming.title : undefined)
|
|
1143
|
+
?? peerHandle
|
|
1144
|
+
?? incoming.title;
|
|
1145
|
+
if (displayName !== undefined || peerHandle !== undefined || hasDisplayableDirectTitle(incoming)) {
|
|
1146
|
+
this.directProfiles.set(incoming.peerDid, {
|
|
1147
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
1148
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
1149
|
+
...hasDisplayableDirectTitle({ ...incoming, title }) ? { title } : {},
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
...incoming,
|
|
1154
|
+
title,
|
|
1155
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
1156
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
return this.cacheGroupTitle(incoming, current);
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Reconcile one group roster row with the last trustworthy local presentation.
|
|
1163
|
+
* A real remote/Core title may update the cache; a temporary Group DID fallback may not.
|
|
1164
|
+
*/
|
|
1165
|
+
cacheGroupTitle(incoming, current) {
|
|
1166
|
+
if (incoming.kind !== 'group')
|
|
1167
|
+
return incoming;
|
|
1168
|
+
if (hasDisplayableGroupTitle(incoming)) {
|
|
1169
|
+
this.groupTitles.set(incoming.groupDid, incoming.title);
|
|
1170
|
+
return incoming;
|
|
1171
|
+
}
|
|
1172
|
+
if (current?.kind === 'group' && current.groupDid === incoming.groupDid && hasDisplayableGroupTitle(current)) {
|
|
1173
|
+
this.groupTitles.set(incoming.groupDid, current.title);
|
|
1174
|
+
return { ...incoming, title: current.title };
|
|
1175
|
+
}
|
|
1176
|
+
const cached = this.groupTitles.get(incoming.groupDid);
|
|
1177
|
+
return cached === undefined ? incoming : { ...incoming, title: cached };
|
|
1178
|
+
}
|
|
741
1179
|
fail(error) {
|
|
742
1180
|
this.publish({ ...this.view, status: this.view.status === 'loading' ? 'error' : this.view.status, pending: null, error });
|
|
743
1181
|
return { ok: false, error };
|
|
@@ -745,6 +1183,11 @@ export class AwikiController {
|
|
|
745
1183
|
current(generation) {
|
|
746
1184
|
return !this.disposed && generation === this.generation;
|
|
747
1185
|
}
|
|
1186
|
+
currentSelection(generation, selectionRevision, conversationId) {
|
|
1187
|
+
return this.current(generation)
|
|
1188
|
+
&& selectionRevision === this.selectionRevision
|
|
1189
|
+
&& this.view.selectedConversationId === conversationId;
|
|
1190
|
+
}
|
|
748
1191
|
publish(view) {
|
|
749
1192
|
/* v8 ignore next -- every asynchronous and public mutation path checks disposal before publishing. */
|
|
750
1193
|
if (this.disposed)
|