From 37c7db11c18ec80319bf647a6deec8ef77c63bb5 Mon Sep 17 00:00:00 2001 From: Howard Chan Date: Sat, 15 Aug 2026 01:15:07 +0800 Subject: [PATCH] refresh AWiki profiles and unread summaries --- typescript/ts_sdk/CHANGELOG.md | 2 + typescript/ts_sdk/README.md | 6 +- typescript/ts_sdk/src/im/client.ts | 31 +- typescript/ts_sdk/src/im/display-name.ts | 90 +++++ typescript/ts_sdk/src/im/identity.ts | 98 +++++- typescript/ts_sdk/src/im/messaging.ts | 401 ++++++++++++++++++++-- typescript/ts_sdk/src/im/protocol.ts | 2 + typescript/ts_sdk/src/im/types.ts | 34 ++ typescript/ts_sdk/tests/im-client.test.ts | 281 ++++++++++++++- 9 files changed, 896 insertions(+), 49 deletions(-) create mode 100644 typescript/ts_sdk/src/im/display-name.ts diff --git a/typescript/ts_sdk/CHANGELOG.md b/typescript/ts_sdk/CHANGELOG.md index 178b0a3..1c827b1 100644 --- a/typescript/ts_sdk/CHANGELOG.md +++ b/typescript/ts_sdk/CHANGELOG.md @@ -6,6 +6,8 @@ - Add durable, restart-safe idempotency state for text and attachment sends. - Add strict JSON-RPC, message acknowledgement, history membership, attachment ticket, digest, and size validation. - Add explicit User Service domain, public Message Service endpoint and DID, attachment origin allowlist, and attachment size limit configuration. +- Add public WNS display-name reads and updates, persist refreshed direct-peer profiles, and expose sender display names for group history. +- Add conversation unread counts and newest-message previews, including bounded Legacy group-history refresh and read acknowledgement. - Make client disposal abort network work and join every operation that already entered the client before settling. - Export `createAwikiImClient`, `AwikiImError`, and all AWiki IM public types from the package root. diff --git a/typescript/ts_sdk/README.md b/typescript/ts_sdk/README.md index 567741a..f7259ee 100644 --- a/typescript/ts_sdk/README.md +++ b/typescript/ts_sdk/README.md @@ -142,7 +142,9 @@ Low-level Rust-aligned names are still exported for compatibility. ### im - Register and restore one AWiki identity -- List persisted known direct conversations, current unread senders, and existing groups +- Read and update the identity's public WNS display name +- Resolve a direct peer's latest WNS display name and persist it with the known conversation; later history reads preserve that refreshed Profile instead of replacing it with an older per-message name snapshot +- List persisted known direct conversations, current unread senders, and existing groups with unread counts and newest-message previews - Read paginated history and send idempotent text messages - Upload, send, download, and verify one P7 attachment per message @@ -156,7 +158,7 @@ Attachments are limited by `attachmentMaxBytes`, use transport protection with ` Text and attachment idempotency keys are persisted with a request fingerprint, fixed wire IDs, fixed timestamps, progress stage, and completed public result. Reusing a key for a different request fails with `conflict`. After restart or response loss, attachment upload resumes from its durable stage; a committed-slot retry does not upload the object again. -`listConversations()` combines durable conversations already seen by this client with the current unread inbox and all current groups. A fresh Legacy installation cannot reconstruct every previously read direct conversation because the Legacy service does not expose a complete direct-conversation roster. +`listConversations()` combines durable conversations already seen by this client with the current unread inbox and all current groups. Each observed conversation can carry a display-only newest-message preview: text is preserved, while image and file messages use a type-and-filename label. Because the Legacy group roster omits message summaries, each list refresh reads the newest bounded Group history page, updates its preview and timestamp, and supplements unread state for newly observed incoming Group messages that the Legacy inbox omitted. Opening that Group clears the in-memory supplemental count; durable cross-restart Group read state remains outside the Legacy API. A fresh Legacy installation cannot reconstruct every previously read direct conversation because the Legacy service does not expose a complete direct-conversation roster. `getHistory()` returns each page in ascending timestamp order. A call without a cursor returns the newest service page; its opaque cursor is bound to the conversation and requests the next older page. The terminal page has `hasMore: false` and no high-water cursor. Legacy history uses offset pagination, so concurrent new deliveries can shift offsets; callers should deduplicate by message ID when merging pages. For refresh, call without a cursor and merge the newest page by message ID. diff --git a/typescript/ts_sdk/src/im/client.ts b/typescript/ts_sdk/src/im/client.ts index 861b3ac..30b189d 100644 --- a/typescript/ts_sdk/src/im/client.ts +++ b/typescript/ts_sdk/src/im/client.ts @@ -5,12 +5,16 @@ import { AwikiMessagingRuntime } from './messaging.js'; import { AwikiImTransport, validateServiceBaseUrl } from './protocol.js'; import { AwikiImStateStore } from './storage.js'; import type { + AwikiDid, + AwikiHandle, AwikiIdentity, AwikiImClient, + AwikiResolvedPeer, AwikiImClientOptions, AwikiPageRequest, AwikiPage, AwikiConversation, + AwikiConversationId, GetAwikiHistoryRequest, AwikiMessage, SendAwikiTextRequest, @@ -20,6 +24,7 @@ import type { RegisterIdentityRequest, SendRegistrationOtpRequest, SendRegistrationOtpResult, + UpdateAwikiDisplayNameRequest, } from './types.js'; /** Create one high-level Node.js AWiki IM client. */ @@ -98,7 +103,10 @@ class DefaultAwikiImClient implements AwikiImClient { } public async getIdentity(): Promise { - return this.run(() => Promise.resolve(structuredClone(this.identity.getIdentity()))); + return this.run(async () => { + await this.identity.hydrateDisplayName(); + return structuredClone(this.identity.getIdentity()); + }); } public async sendRegistrationOtp( @@ -111,6 +119,23 @@ class DefaultAwikiImClient implements AwikiImClient { return this.run(() => this.identity.registerIdentity(request)); } + public async updateDisplayName(request: UpdateAwikiDisplayNameRequest): Promise { + return this.run(() => this.identity.updateDisplayName(request.displayName)); + } + + public async resolvePeer(peer: string): Promise { + return this.run(async () => { + this.identity.requireSecrets(); + const resolved = await this.messaging.resolveTarget({ kind: 'direct', peer }); + return { + did: resolved.did as AwikiDid, + conversationId: resolved.conversationId, + ...(resolved.handle === undefined ? {} : { handle: resolved.handle as AwikiHandle }), + ...(resolved.displayName === undefined ? {} : { displayName: resolved.displayName }), + }; + }); + } + public async listConversations( request?: AwikiPageRequest ): Promise> { @@ -121,6 +146,10 @@ class DefaultAwikiImClient implements AwikiImClient { return this.run(() => this.messaging.getHistory(request)); } + public async markConversationRead(conversationId: AwikiConversationId): Promise { + return this.run(() => this.messaging.markConversationRead(conversationId)); + } + public async sendText(request: SendAwikiTextRequest): Promise { return this.run(() => this.messaging.sendText(request)); } diff --git a/typescript/ts_sdk/src/im/display-name.ts b/typescript/ts_sdk/src/im/display-name.ts new file mode 100644 index 0000000..a597dcb --- /dev/null +++ b/typescript/ts_sdk/src/im/display-name.ts @@ -0,0 +1,90 @@ +/** WNS display-name lookup used by identity and conversation projection. */ + +import type { AwikiImTransport } from './protocol.js'; + +/** Split a Handle into the WNS local part and domain. */ +export function parseHandle( + value: string +): { local: string; domain: string; handle: string } | undefined { + const trimmed = value + .trim() + .replace(/^@+/u, '') + .replace(/^wba:\/\//u, ''); + const separator = trimmed.indexOf('.'); + if (separator <= 0 || separator === trimmed.length - 1) { + return undefined; + } + const local = trimmed.slice(0, separator); + const domain = trimmed.slice(separator + 1); + if (!local || !domain) { + return undefined; + } + return { local, domain, handle: `${local}.${domain}` }; +} + +/** Infer a Handle from a `did:wba` path when inbox rows omit one. */ +export function handleCandidateFromDid(did: string): string | undefined { + if (!did.startsWith('did:wba:')) { + return undefined; + } + const parts = did.split(':'); + if (parts.length < 4) { + return undefined; + } + const domain = parts[2]; + const path = parts.slice(3); + const local = path[0] === 'user' ? path[1] : path[0]; + if (!domain || !local || /^(?:e1_|k1_)/u.test(local)) { + return undefined; + } + return `${local}.${domain}`; +} + +/** Read a public WNS profile and return its display-only name. */ +export async function lookupDisplayName( + transport: AwikiImTransport, + handle: string, + expectedDid?: string +): Promise { + const parsed = parseHandle(handle); + if (!parsed) { + return undefined; + } + try { + const document = await transport.getJson( + `https://${parsed.domain}/.well-known/handle/${encodeURIComponent(parsed.local)}` + ); + if (stringValue(document.handle)?.toLowerCase() !== parsed.handle.toLowerCase()) { + return undefined; + } + if (expectedDid && stringValue(document.did) !== expectedDid) { + return undefined; + } + const profile = isRecord(document.profile) ? document.profile : undefined; + const displayName = stringValue(document.display_name) ?? stringValue(profile?.display_name); + if (!displayName) { + return undefined; + } + if (profile) { + const subjectDid = stringValue(profile.subject_did); + const profileHandle = stringValue(profile.handle); + if (subjectDid && expectedDid && subjectDid !== expectedDid) { + return undefined; + } + if (profileHandle && profileHandle.toLowerCase() !== parsed.handle.toLowerCase()) { + return undefined; + } + } + return displayName; + } catch { + return undefined; + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/typescript/ts_sdk/src/im/identity.ts b/typescript/ts_sdk/src/im/identity.ts index 2c9bce4..0c844ec 100644 --- a/typescript/ts_sdk/src/im/identity.ts +++ b/typescript/ts_sdk/src/im/identity.ts @@ -10,10 +10,12 @@ import { AwikiImError, normalizeAwikiImError } from './errors.js'; import { type PendingRegistrationState, type PersistedIdentitySecrets } from './internal.js'; import { DID_AUTH_RPC_PATH, + DID_PROFILE_RPC_PATH, HANDLE_RPC_PATH, randomChallenge, type AwikiImTransport, } from './protocol.js'; +import { lookupDisplayName } from './display-name.js'; import type { AwikiImStateStore } from './storage.js'; import type { AwikiDid, @@ -36,6 +38,8 @@ interface IdentityRuntimeOptions { /** Identity registration and token refresh operations for the high-level client. */ export class AwikiIdentityRuntime { private registrationTail: Promise = Promise.resolve(); + private profileTail: Promise = Promise.resolve(); + private displayNameResolved = false; public constructor(private readonly options: IdentityRuntimeOptions) {} @@ -44,6 +48,31 @@ export class AwikiIdentityRuntime { return this.options.store.snapshot().identity?.public ?? null; } + /** Fill a missing WNS display name once; failures leave the identity unchanged. */ + public async hydrateDisplayName(): Promise { + const current = this.getIdentity(); + if (!current || current.displayName !== undefined || this.displayNameResolved) { + return; + } + this.displayNameResolved = true; + const displayName = await lookupDisplayName( + this.options.transport, + current.handle, + current.did + ); + if (!displayName) { + return; + } + await this.options.store.mutate((state) => { + if (state.identity) { + state.identity = { + ...state.identity, + public: { ...state.identity.public, displayName }, + }; + } + }); + } + /** Reject persisted identity material that belongs to a different configured deployment. */ public validateConfiguredIdentity(): void { const snapshot = this.options.store.snapshot(); @@ -149,6 +178,58 @@ export class AwikiIdentityRuntime { }); } + /** Update the public WNS display name and keep the local projection in sync. */ + public async updateDisplayName(value: string): Promise { + return this.exclusiveProfileUpdate(async () => { + const displayName = value.trim(); + const length = [...displayName].length; + if (length === 0 || length > 50) { + throw new AwikiImError( + 'invalid-request', + 'AWiki display name must contain between 1 and 50 characters' + ); + } + let identity = this.requireSecrets(); + let result; + try { + result = await this.options.transport.rpc( + this.options.userServiceUrl, + DID_PROFILE_RPC_PATH, + 'update_me', + { nick_name: displayName }, + identity.accessToken + ); + } catch (error) { + const normalized = normalizeAwikiImError(error); + if (normalized.code !== 'forbidden') throw normalized; + const accessToken = await this.refreshAccessToken(); + identity = this.requireSecrets(); + result = await this.options.transport.rpc( + this.options.userServiceUrl, + DID_PROFILE_RPC_PATH, + 'update_me', + { nick_name: displayName }, + accessToken + ); + } + const returnedName = requiredWireString(result.value.display_name, 'display_name'); + if (returnedName !== displayName) { + throw new AwikiImError('remote', 'AWiki service returned an invalid response'); + } + await this.options.store.mutate((state) => { + if (state.identity) { + state.identity = { + ...state.identity, + ...(result.accessToken === undefined ? {} : { accessToken: result.accessToken }), + public: { ...state.identity.public, displayName: returnedName }, + }; + } + }); + this.displayNameResolved = true; + return this.getIdentity() ?? identity.public; + }); + } + /** Require secret identity material for a message operation. */ public requireSecrets(): PersistedIdentitySecrets { const identity = this.options.store.snapshot().identity; @@ -257,7 +338,8 @@ export class AwikiIdentityRuntime { delete state.registrationOtp; delete state.pendingRegistration; }); - return publicIdentity; + await this.hydrateDisplayName(); + return this.getIdentity() ?? publicIdentity; } private async exclusiveRegistration(operation: () => Promise): Promise { @@ -273,6 +355,20 @@ export class AwikiIdentityRuntime { release(); } } + + private async exclusiveProfileUpdate(operation: () => Promise): Promise { + let release: () => void = () => undefined; + const previous = this.profileTail; + this.profileTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } } function createPendingRegistration( diff --git a/typescript/ts_sdk/src/im/messaging.ts b/typescript/ts_sdk/src/im/messaging.ts index f9e247c..dd27f88 100644 --- a/typescript/ts_sdk/src/im/messaging.ts +++ b/typescript/ts_sdk/src/im/messaging.ts @@ -17,6 +17,7 @@ import { MESSAGE_RPC_PATH, operationId, } from './protocol.js'; +import { handleCandidateFromDid, lookupDisplayName } from './display-name.js'; import type { AwikiIdentityRuntime } from './identity.js'; import type { AwikiImStateStore } from './storage.js'; import type { AwikiImTransport } from './protocol.js'; @@ -53,12 +54,17 @@ export interface ResolvedMessageTarget { readonly kind: 'direct' | 'group'; readonly did: string; readonly handle?: string; + readonly displayName?: string; readonly conversationId: AwikiConversationId; } /** Direct/group conversation, history, and plain-message operations. */ export class AwikiMessagingRuntime { private sendTail: Promise = Promise.resolve(); + private inboxRefreshed = false; + private unreadMessageIds = new Map(); + private groupUnreadMessageIds = new Map(); + private groupMessageWindows = new Map>(); public constructor(private readonly options: MessagingRuntimeOptions) {} @@ -69,8 +75,17 @@ export class AwikiMessagingRuntime { this.options.identity.requireSecrets(); const limit = pageLimit(request.limit); await this.refreshConversations(); + await this.hydrateGroupMessagePreviews(); + await this.hydrateDirectDisplayNames(); const conversations = Object.values(this.options.store.snapshot().conversations) - .map((record) => record.conversation) + .map((record) => { + const key = conversationKey(record.conversation.id); + const unreadCount = new Set([ + ...(this.unreadMessageIds.get(key) ?? []), + ...(this.groupUnreadMessageIds.get(key) ?? []), + ]).size; + return { ...record.conversation, unreadCount }; + }) .sort(compareConversationRecency); const offset = decodeOffsetCursor(request.cursor); const items = conversations.slice(offset, offset + limit); @@ -124,8 +139,9 @@ export class AwikiMessagingRuntime { : null ) .filter((message): message is MappedMessage => message !== null); - await this.persistMappedMessages(mapped); - const items = mapped.map((entry) => entry.message).sort(compareMessageTime); + const hydrated = await this.hydrateGroupSenderDisplayNames(mapped); + await this.persistMappedMessages(hydrated); + const items = hydrated.map((entry) => entry.message).sort(compareMessageTime); const consumed = wires.length; if (result.has_more === true && consumed === 0) { throw new AwikiImError('remote', 'AWiki history pagination did not advance'); @@ -140,6 +156,41 @@ export class AwikiMessagingRuntime { }; } + /** Mark every currently unread inbox message in one conversation as read. */ + public async markConversationRead(conversationId: AwikiConversationId): Promise { + const key = conversationKey(conversationId); + if (!this.options.store.snapshot().conversations[key]) { + throw new AwikiImError('not-found', 'AWiki conversation was not found'); + } + if (!this.inboxRefreshed) { + await this.refreshInbox(); + } + const messageIds = this.unreadMessageIds.get(key) ?? []; + const localGroupMessageIds = this.groupUnreadMessageIds.get(key) ?? []; + if (messageIds.length === 0 && localGroupMessageIds.length === 0) { + return 0; + } + let updatedCount = 0; + if (messageIds.length > 0) { + const identity = this.options.identity.requireSecrets(); + const result = await this.authenticatedRpc('inbox.mark_read', { + meta: localMeta(identity.public.did as string, 'anp.inbox.local.v1'), + body: { + user_did: identity.public.did as string, + message_ids: [...messageIds], + }, + }); + updatedCount = integerValue(result.updated_count) ?? -1; + if (updatedCount < 0 || updatedCount > messageIds.length) { + throw new AwikiImError('remote', 'AWiki mark-read acknowledgement is invalid'); + } + } + this.unreadMessageIds.delete(key); + this.groupUnreadMessageIds.delete(key); + const locallyCleared = localGroupMessageIds.filter((id) => !messageIds.includes(id)).length; + return updatedCount + locallyCleared; + } + /** Resolve and send one idempotent text message. */ public async sendText(request: SendAwikiTextRequest): Promise { return this.exclusiveSend(async () => { @@ -207,21 +258,30 @@ export class AwikiMessagingRuntime { const resolved = peer.startsWith('did:') ? { did: peer } : await this.resolveHandle(peer.replace(/^wba:\/\//, '')); + const handle = resolved.handle ?? handleCandidateFromDid(resolved.did); + const displayName = + resolved.displayName ?? + (handle + ? await lookupDisplayName(this.options.transport, handle, resolved.did) + : undefined); const conversationId = directConversationId(resolved.did); - await this.upsertConversation({ - conversation: { - kind: 'direct', - id: conversationId, - peerDid: resolved.did as AwikiDid, - ...(resolved.handle ? { peerHandle: resolved.handle as AwikiHandle } : {}), - title: resolved.handle ?? resolved.did, + await this.upsertConversation( + { + conversation: directConversation({ + id: conversationId, + peerDid: resolved.did, + peerHandle: handle, + displayName, + }), + peerDid: resolved.did, }, - peerDid: resolved.did, - }); + true + ); return { kind: 'direct', did: resolved.did, - ...(resolved.handle ? { handle: resolved.handle } : {}), + ...(handle ? { handle } : {}), + ...(displayName ? { displayName } : {}), conversationId, }; } @@ -293,7 +353,11 @@ export class AwikiMessagingRuntime { if (conversation) { state.conversations[conversationKey(target.conversationId)] = { ...conversation, - conversation: { ...conversation.conversation, lastMessageAt: sentAt }, + conversation: { + ...conversation.conversation, + lastMessageAt: sentAt, + lastMessagePreview: messagePreview(content), + }, }; } if (attachmentReference) { @@ -369,7 +433,13 @@ export class AwikiMessagingRuntime { private async refreshConversations(): Promise { await this.refreshGroups(); + await this.refreshInbox(); + } + + private async refreshInbox(): Promise { const identity = this.options.identity.requireSecrets(); + const unread = new Map(); + const seen = new Set(); let skip = 0; for (let page = 0; page < MAX_REFRESH_PAGES; page += 1) { const result = await this.authenticatedRpc('inbox.get', { @@ -388,8 +458,17 @@ export class AwikiMessagingRuntime { : null ) .filter((message): message is MappedMessage => message !== null); + for (const entry of mapped) { + const message = entry.message; + if (message.outgoing || seen.has(message.id as string)) continue; + seen.add(message.id as string); + const key = conversationKey(message.conversationId); + unread.set(key, [...(unread.get(key) ?? []), message.id as string]); + } await this.persistMappedMessages(mapped); if (result.has_more !== true) { + this.unreadMessageIds = unread; + this.inboxRefreshed = true; return; } if (wires.length === 0) { @@ -436,7 +515,9 @@ export class AwikiMessagingRuntime { throw new AwikiImError('remote', 'AWiki group pagination exceeded the safety limit'); } - private async resolveHandle(peer: string): Promise<{ did: string; handle?: string }> { + private async resolveHandle( + peer: string + ): Promise<{ did: string; handle?: string; displayName?: string }> { const result = await this.options.transport.rpc( this.options.userServiceUrl, HANDLE_RPC_PATH, @@ -445,7 +526,165 @@ export class AwikiMessagingRuntime { ); const did = requiredWireString(result.value.did, 'resolved DID'); const handle = stringValue(result.value.full_handle) ?? stringValue(result.value.handle); - return { did, ...(handle ? { handle } : {}) }; + const profile = isRecord(result.value.profile) ? result.value.profile : undefined; + const displayName = + stringValue(result.value.display_name) ?? + stringValue(profile?.display_name) ?? + (handle ? await lookupDisplayName(this.options.transport, handle, did) : undefined); + return { + did, + ...(handle ? { handle } : {}), + ...(displayName ? { displayName } : {}), + }; + } + + /** Fill missing direct `displayName` values from WNS without blocking on failure. */ + private async hydrateDirectDisplayNames(): Promise { + const pending = Object.values(this.options.store.snapshot().conversations).filter( + (record) => + record.conversation.kind === 'direct' && record.conversation.displayName === undefined + ); + if (pending.length === 0) { + return; + } + const resolved = await Promise.all( + pending.map(async (record) => { + if (record.conversation.kind !== 'direct') { + return null; + } + const handle = + record.conversation.peerHandle ?? handleCandidateFromDid(record.conversation.peerDid); + if (!handle) { + return null; + } + const displayName = await lookupDisplayName( + this.options.transport, + handle, + record.conversation.peerDid + ); + if (!displayName) { + return null; + } + return { + conversation: directConversation({ + id: record.conversation.id, + peerDid: record.conversation.peerDid, + peerHandle: handle, + displayName, + lastMessageAt: record.conversation.lastMessageAt, + lastMessagePreview: record.conversation.lastMessagePreview, + }), + peerDid: record.peerDid ?? record.conversation.peerDid, + }; + }) + ); + const updates = resolved.filter((value): value is NonNullable => value !== null); + if (updates.length === 0) { + return; + } + await this.options.store.mutate((state) => { + for (const update of updates) { + const key = conversationKey(update.conversation.id); + state.conversations[key] = mergeConversation(state.conversations[key], update); + } + }); + } + + /** Refresh Group previews and supplement unread state when Legacy inbox omits Group messages. */ + private async hydrateGroupMessagePreviews(): Promise { + const identity = this.options.identity.requireSecrets(); + const pending = Object.values(this.options.store.snapshot().conversations).filter( + (record) => record.conversation.kind === 'group' + ); + await Promise.all( + pending.map(async (record) => { + const key = conversationKey(record.conversation.id); + const previousWindow = this.groupMessageWindows.get(key); + const previousLastMessageAt = record.conversation.lastMessageAt; + const result = await this.authenticatedRpc('group.list_messages', { + meta: groupLocalMeta( + identity.public.did as string, + requiredConversationValue(record.groupDid) + ), + body: { + group_did: requiredConversationValue(record.groupDid), + limit: MAX_PAGE_LIMIT, + }, + }); + const wires = arrayValue(result.messages); + validateHistoryWires(wires, record, identity.public.did as string); + const mapped = wires + .map((wire) => + isRecord(wire) + ? this.mapWireMessage(wire, record.conversation, identity.public.did as string) + : null + ) + .filter((message): message is MappedMessage => message !== null); + const currentWindow = new Set(mapped.map((entry) => entry.message.id as string)); + const locallyUnread = new Set(this.groupUnreadMessageIds.get(key) ?? []); + for (const entry of mapped) { + const message = entry.message; + const observedAfterPersistedSummary = + previousLastMessageAt !== undefined && message.sentAt > previousLastMessageAt; + const observedAfterEqualTimestamp = + previousWindow !== undefined && + previousLastMessageAt !== undefined && + message.sentAt === previousLastMessageAt && + !previousWindow.has(message.id as string); + if (!message.outgoing && (observedAfterPersistedSummary || observedAfterEqualTimestamp)) { + locallyUnread.add(message.id as string); + } + } + this.groupMessageWindows.set(key, currentWindow); + if (locallyUnread.size > 0) { + this.groupUnreadMessageIds.set(key, [...locallyUnread]); + } + await this.persistMappedMessages(mapped); + }) + ); + } + + /** Fill missing incoming group sender names from WNS without blocking history on failure. */ + private async hydrateGroupSenderDisplayNames( + mapped: readonly MappedMessage[] + ): Promise { + const pending = new Map(); + for (const entry of mapped) { + const message = entry.message; + if ( + message.conversationKind !== 'group' || + message.outgoing || + message.senderDisplayName !== undefined + ) { + continue; + } + const handle = message.senderHandle ?? handleCandidateFromDid(message.senderDid); + if (handle) { + pending.set(message.senderDid, handle); + } + } + if (pending.size === 0) { + return mapped; + } + const displayNames = new Map( + ( + await Promise.all( + [...pending].map(async ([did, handle]) => { + const displayName = await lookupDisplayName(this.options.transport, handle, did); + return displayName ? ([did, displayName] as const) : null; + }) + ) + ).filter((value): value is readonly [string, string] => value !== null) + ); + if (displayNames.size === 0) { + return mapped; + } + return mapped.map((entry) => { + const senderDisplayName = displayNames.get(entry.message.senderDid); + return senderDisplayName + ? { ...entry, message: { ...entry.message, senderDisplayName } } + : entry; + }); } private mapWireMessage( @@ -499,6 +738,14 @@ export class AwikiMessagingRuntime { const peerHandle = stringValue(wire.peer_full_handle) ?? (senderDid !== ownerDid ? stringValue(wire.sender_handle) : undefined); + const senderDisplayName = + stringValue(wire.sender_display_name) ?? stringValue(wire.display_name); + const peerDisplayName = + kind === 'direct' + ? (stringValue(wire.peer_display_name) ?? + (senderDid !== ownerDid ? senderDisplayName : undefined) ?? + (fallbackConversation?.kind === 'direct' ? fallbackConversation.displayName : undefined)) + : undefined; const conversation: AwikiConversation = kind === 'group' ? { @@ -511,19 +758,18 @@ export class AwikiMessagingRuntime { ? fallbackConversation.title : (groupDid as string)), ...(sentAt ? { lastMessageAt: sentAt } : {}), + ...(sentAt ? { lastMessagePreview: messagePreview(content) } : {}), } - : { - kind: 'direct', + : directConversation({ id: conversationId, - peerDid: peerDid as AwikiDid, - ...(peerHandle ? { peerHandle: peerHandle as AwikiHandle } : {}), - title: - peerHandle ?? - (fallbackConversation?.kind === 'direct' - ? fallbackConversation.title - : (peerDid as string)), - ...(sentAt ? { lastMessageAt: sentAt } : {}), - }; + peerDid: peerDid as string, + peerHandle, + displayName: peerDisplayName, + lastMessageAt: sentAt || undefined, + lastMessagePreview: sentAt ? messagePreview(content) : undefined, + fallbackTitle: + fallbackConversation?.kind === 'direct' ? fallbackConversation.title : undefined, + }); const message: AwikiMessage = { id: messageId as AwikiMessageId, conversationId, @@ -532,6 +778,7 @@ export class AwikiMessagingRuntime { ...(stringValue(wire.sender_handle) ? { senderHandle: stringValue(wire.sender_handle) as AwikiHandle } : {}), + ...(senderDisplayName ? { senderDisplayName } : {}), sentAt, outgoing: senderDid === ownerDid, content, @@ -573,10 +820,17 @@ export class AwikiMessagingRuntime { }); } - private async upsertConversation(record: PersistedConversation): Promise { + private async upsertConversation( + record: PersistedConversation, + replaceDirectProfile = false + ): Promise { await this.options.store.mutate((state) => { const key = conversationKey(record.conversation.id); - state.conversations[key] = mergeConversation(state.conversations[key], record); + state.conversations[key] = mergeConversation( + state.conversations[key], + record, + replaceDirectProfile + ); }); } @@ -688,6 +942,7 @@ function groupConversationFromWire(value: unknown): PersistedConversation | null stringValue(profile?.display_name) ?? groupDid; const lastMessageAt = timestampValue(value.last_message_at); + const lastMessagePreview = stringValue(value.last_message_preview); return { conversation: { kind: 'group', @@ -695,6 +950,7 @@ function groupConversationFromWire(value: unknown): PersistedConversation | null groupDid: groupDid as AwikiDid, title, ...(lastMessageAt ? { lastMessageAt } : {}), + ...(lastMessagePreview ? { lastMessagePreview } : {}), }, groupDid, }; @@ -774,6 +1030,14 @@ function textContent(wire: MessageWireValue): string { return ''; } +function messagePreview(content: AwikiMessageContent): string { + if (content.kind === 'text') { + return content.text.trim() || '消息'; + } + const kind = content.attachment.mimeType.startsWith('image/') ? '图片' : '附件'; + return `[${kind}] ${content.attachment.fileName}`; +} + function stableIdentifiers(idempotencyKey: string): { readonly operationId: string; readonly messageId: string; @@ -825,26 +1089,93 @@ function groupConversationId(groupDid: string): AwikiConversationId { function mergeConversation( current: PersistedConversation | undefined, - next: PersistedConversation + next: PersistedConversation, + replaceDirectProfile = false ): PersistedConversation { if (!current) { return next; } const currentTime = current.conversation.lastMessageAt ?? 0; const nextTime = next.conversation.lastMessageAt ?? 0; + const lastMessageAt = Math.max(currentTime, nextTime); + const lastMessagePreview = + nextTime > currentTime + ? next.conversation.lastMessagePreview + : currentTime > nextTime + ? current.conversation.lastMessagePreview + : (next.conversation.lastMessagePreview ?? current.conversation.lastMessagePreview); + if (current.conversation.kind === 'direct' && next.conversation.kind === 'direct') { + const peerHandle = replaceDirectProfile + ? (next.conversation.peerHandle ?? current.conversation.peerHandle) + : (current.conversation.peerHandle ?? next.conversation.peerHandle); + const displayName = replaceDirectProfile + ? (next.conversation.displayName ?? current.conversation.displayName) + : (current.conversation.displayName ?? next.conversation.displayName); + return { + ...current, + ...next, + conversation: directConversation({ + id: next.conversation.id, + peerDid: next.conversation.peerDid, + peerHandle, + displayName, + lastMessageAt: lastMessageAt || undefined, + lastMessagePreview, + fallbackTitle: preferredLabel( + displayName, + peerHandle, + current.conversation.title, + next.conversation.title, + next.conversation.peerDid + ), + }), + }; + } + const { lastMessagePreview: _currentPreview, ...currentConversation } = current.conversation; + const { lastMessagePreview: _nextPreview, ...nextConversation } = next.conversation; + void _currentPreview; + void _nextPreview; return { ...current, ...next, conversation: { - ...current.conversation, - ...next.conversation, - ...(Math.max(currentTime, nextTime) > 0 - ? { lastMessageAt: Math.max(currentTime, nextTime) } - : {}), + ...currentConversation, + ...nextConversation, + ...(lastMessageAt > 0 ? { lastMessageAt } : {}), + ...(lastMessagePreview !== undefined ? { lastMessagePreview } : {}), } as AwikiConversation, }; } +function directConversation(args: { + readonly id: AwikiConversationId; + readonly peerDid: string; + readonly peerHandle?: string; + readonly displayName?: string; + readonly lastMessageAt?: number; + readonly lastMessagePreview?: string; + readonly fallbackTitle?: string; +}): AwikiConversation { + const title = preferredLabel(args.displayName, args.peerHandle, args.fallbackTitle, args.peerDid); + return { + kind: 'direct', + id: args.id, + peerDid: args.peerDid as AwikiDid, + title, + ...(args.peerHandle ? { peerHandle: args.peerHandle as AwikiHandle } : {}), + ...(args.displayName ? { displayName: args.displayName } : {}), + ...(args.lastMessageAt ? { lastMessageAt: args.lastMessageAt } : {}), + ...(args.lastMessagePreview !== undefined + ? { lastMessagePreview: args.lastMessagePreview } + : {}), + }; +} + +function preferredLabel(...candidates: readonly (string | undefined)[]): string { + const values = candidates.filter((value): value is string => !!value && value.trim() !== ''); + return values.find((value) => !value.startsWith('did:')) ?? values[0] ?? ''; +} + function compareConversationRecency(left: AwikiConversation, right: AwikiConversation): number { return ( (right.lastMessageAt ?? 0) - (left.lastMessageAt ?? 0) || left.title.localeCompare(right.title) diff --git a/typescript/ts_sdk/src/im/protocol.ts b/typescript/ts_sdk/src/im/protocol.ts index 8c6d311..e5e5b9a 100644 --- a/typescript/ts_sdk/src/im/protocol.ts +++ b/typescript/ts_sdk/src/im/protocol.ts @@ -12,6 +12,8 @@ import type { JsonRpcErrorValue } from './internal.js'; export const HANDLE_RPC_PATH = '/user-service/v1/handle/rpc'; /** User Service DID authentication RPC path used by the AWiki Core client. */ export const DID_AUTH_RPC_PATH = '/user-service/v1/did-auth/rpc'; +/** User Service DID profile RPC path used for authenticated profile changes. */ +export const DID_PROFILE_RPC_PATH = '/user-service/v1/did/profile/rpc'; /** Local Message Service RPC path used by the AWiki Core client. */ export const MESSAGE_RPC_PATH = '/im/rpc'; diff --git a/typescript/ts_sdk/src/im/types.ts b/typescript/ts_sdk/src/im/types.ts index 438bbd4..2446677 100644 --- a/typescript/ts_sdk/src/im/types.ts +++ b/typescript/ts_sdk/src/im/types.ts @@ -29,9 +29,20 @@ export type AwikiCursor = AwikiImId<'cursor'>; export interface AwikiIdentity { readonly handle: AwikiHandle; readonly did: AwikiDid; + /** WNS `profile.display_name`. Display-only; never used for routing. */ + readonly displayName?: string; readonly registeredAt: number; } +/** Public peer produced by Handle lookup or a DID target. */ +export interface AwikiResolvedPeer { + readonly did: AwikiDid; + readonly handle?: AwikiHandle; + /** WNS `profile.display_name`. Display-only; never used for routing. */ + readonly displayName?: string; + readonly conversationId: AwikiConversationId; +} + /** Request for one registration verification code. */ export interface SendRegistrationOtpRequest { readonly handle: string; @@ -51,14 +62,25 @@ export interface RegisterIdentityRequest { readonly otp: string; } +/** Replace the registered identity's public WNS display name. */ +export interface UpdateAwikiDisplayNameRequest { + readonly displayName: string; +} + /** Existing direct-message conversation. */ export interface AwikiDirectConversation { readonly kind: 'direct'; readonly id: AwikiConversationId; readonly peerDid: AwikiDid; readonly peerHandle?: AwikiHandle; + /** WNS `profile.display_name`. Display-only; never used for routing. */ + readonly displayName?: string; readonly title: string; + /** Current unread inbox messages for this conversation. */ + readonly unreadCount?: number; readonly lastMessageAt?: number; + /** Display-only summary of the newest observed message. */ + readonly lastMessagePreview?: string; } /** Existing group conversation. */ @@ -67,7 +89,11 @@ export interface AwikiGroupConversation { readonly id: AwikiConversationId; readonly groupDid: AwikiDid; readonly title: string; + /** Current unread inbox messages for this conversation. */ + readonly unreadCount?: number; readonly lastMessageAt?: number; + /** Display-only summary of the newest observed message. */ + readonly lastMessagePreview?: string; } /** Conversation visible to the registered identity. */ @@ -120,6 +146,8 @@ export interface AwikiMessage { readonly conversationKind: AwikiConversation['kind']; readonly senderDid: AwikiDid; readonly senderHandle?: AwikiHandle; + /** WNS `profile.display_name` for the sender. Display-only; never used for routing. */ + readonly senderDisplayName?: string; readonly sentAt: number; readonly outgoing: boolean; readonly content: AwikiMessageContent; @@ -224,10 +252,16 @@ export interface AwikiImClient { sendRegistrationOtp(request: SendRegistrationOtpRequest): Promise; /** Register and persist the deployment's only identity. */ registerIdentity(request: RegisterIdentityRequest): Promise; + /** Update and persist the registered identity's public WNS display name. */ + updateDisplayName(request: UpdateAwikiDisplayNameRequest): Promise; + /** Resolve one Handle or DID to a public peer and persist the direct conversation row. */ + resolvePeer(peer: string): Promise; /** List direct and existing group conversations. */ listConversations(request?: AwikiPageRequest): Promise>; /** Read one conversation's paginated history. */ getHistory(request: GetAwikiHistoryRequest): Promise>; + /** Mark every currently unread inbox message in one conversation as read. */ + markConversationRead(conversationId: AwikiConversationId): Promise; /** Send one idempotent text message. */ sendText(request: SendAwikiTextRequest): Promise; /** Upload and send one idempotent attachment message. */ diff --git a/typescript/ts_sdk/tests/im-client.test.ts b/typescript/ts_sdk/tests/im-client.test.ts index af99ec1..9194683 100644 --- a/typescript/ts_sdk/tests/im-client.test.ts +++ b/typescript/ts_sdk/tests/im-client.test.ts @@ -44,6 +44,7 @@ describe('AWiki IM client', () => { otp: '123456', }); expect(identity.handle).toBe('alice.awiki.test'); + expect(identity.displayName).toBe('Alice'); expect(identity.did).toMatch(/^did:wba:awiki\.test:alice:e1_/); const registration = service.calls.find((call) => call.method === 'register'); @@ -84,16 +85,75 @@ describe('AWiki IM client', () => { await restored.dispose(); }); + test('updates, authenticates, and persists the public display name', async () => { + const service = new FakeAwikiService(); + const statePath = await temporaryStatePath(); + const client = await registeredClient(service, statePath); + + await expect(client.updateDisplayName({ displayName: ' 新昵称 ' })).resolves.toMatchObject({ + handle: 'alice.awiki.test', + displayName: '新昵称', + }); + const updateCall = service.calls.find((call) => call.method === 'update_me'); + expect(updateCall).toMatchObject({ + path: '/user-service/v1/did/profile/rpc', + method: 'update_me', + params: { nick_name: '新昵称' }, + }); + expect(updateCall?.headers.authorization).toBe('Bearer test-access-token'); + + service.calls.length = 0; + service.rpcErrorOnce = { code: 1403 }; + await expect(client.updateDisplayName({ displayName: '刷新后昵称' })).resolves.toMatchObject({ + displayName: '刷新后昵称', + }); + expect(service.methods()).toEqual(['update_me', 'get_me', 'update_me']); + expect(service.calls.at(-1)?.headers.authorization).toBe('Bearer refreshed'); + + await client.dispose(); + const restored = createClient(service, statePath); + await expect(restored.getIdentity()).resolves.toMatchObject({ displayName: '刷新后昵称' }); + await restored.dispose(); + }); + + test('rejects empty and overlong display names before contacting the service', async () => { + const service = new FakeAwikiService(); + const client = await registeredClient(service); + + await expect(client.updateDisplayName({ displayName: ' ' })).rejects.toMatchObject({ + code: 'invalid-request', + }); + await expect(client.updateDisplayName({ displayName: '名'.repeat(51) })).rejects.toMatchObject({ + code: 'invalid-request', + }); + expect(service.calls).toEqual([]); + await client.dispose(); + }); + test('lists direct and group conversations, pages history, and sends text', async () => { const service = new FakeAwikiService(); const client = await registeredClient(service); const conversations = await client.listConversations({ limit: 10 }); - expect(conversations.items.map((item) => item.kind)).toEqual(['direct', 'group']); + expect(conversations.items.map((item) => item.kind)).toEqual(['group', 'direct']); const direct = conversations.items.find((item) => item.kind === 'direct'); const group = conversations.items.find((item) => item.kind === 'group'); - expect(direct?.title).toBe('bob.awiki.test'); - expect(group?.title).toBe('Harness Team'); + expect(direct).toMatchObject({ + title: 'Bob', + displayName: 'Bob', + peerHandle: 'bob.awiki.test', + unreadCount: 1, + lastMessagePreview: 'hello from bob', + }); + expect(group).toMatchObject({ + title: 'Harness Team', + lastMessagePreview: '[附件] incoming.txt', + }); + + expect(await client.markConversationRead(direct?.id as AwikiConversationId)).toBe(1); + expect( + (await client.listConversations()).items.find((item) => item.id === direct?.id) + ).toMatchObject({ unreadCount: 0 }); const history = await client.getHistory({ conversationId: direct?.id as AwikiConversationId, @@ -129,6 +189,48 @@ describe('AWiki IM client', () => { }); }); + test('refreshes group preview, timestamp, and supplemental unread state from group history', async () => { + const service = new FakeAwikiService(); + const client = await registeredClient(service); + + const initial = await client.listConversations(); + const initialGroup = initial.items.find((item) => item.kind === 'group'); + expect(initialGroup).toMatchObject({ + lastMessagePreview: '[附件] incoming.txt', + unreadCount: 1, + }); + expect(await client.markConversationRead(initialGroup?.id as AwikiConversationId)).toBe(1); + + service.groupHistoryMessages = [ + { + id: `${FakeAwikiService.GROUP_DID}:8`, + message_id: 'group-new-message', + group_did: FakeAwikiService.GROUP_DID, + sender_did: FakeAwikiService.BOB_DID, + content: 'group refresh', + content_type: 'text/plain', + sent_at: '2026-08-14T00:04:00Z', + }, + ]; + const refreshed = await client.listConversations(); + const refreshedGroup = refreshed.items.find((item) => item.kind === 'group'); + expect(refreshedGroup).toMatchObject({ + lastMessageAt: Date.parse('2026-08-14T00:04:00Z'), + lastMessagePreview: 'group refresh', + unreadCount: 1, + }); + + expect(await client.markConversationRead(refreshedGroup?.id as AwikiConversationId)).toBe(1); + const afterRead = await client.listConversations(); + expect(afterRead.items.find((item) => item.id === refreshedGroup?.id)).toMatchObject({ + lastMessageAt: Date.parse('2026-08-14T00:04:00Z'), + lastMessagePreview: 'group refresh', + unreadCount: 0, + }); + expect(service.calls.filter((call) => call.method === 'inbox.mark_read')).toHaveLength(0); + await client.dispose(); + }); + test('uploads, commits, sends, downloads, and verifies one attachment', async () => { const service = new FakeAwikiService(); const client = await registeredClient(service); @@ -225,6 +327,7 @@ describe('AWiki IM client', () => { }); expect(history.items[0]?.id).toBe('group-wire-message'); + expect(history.items[0]?.senderDisplayName).toBe('Bob'); if (history.items[0]?.content.kind !== 'attachment') throw new Error('attachment expected'); await client.downloadAttachment({ attachmentId: history.items[0].content.attachment.id, @@ -460,11 +563,11 @@ describe('AWiki IM client', () => { ).rejects.toMatchObject({ code: 'remote' }); const groupService = new FakeAwikiService(); - groupService.groupHistoryWrongGroup = true; const groupClient = await registeredClient(groupService); const group = (await groupClient.listConversations()).items.find( (conversation) => conversation.kind === 'group' ); + groupService.groupHistoryWrongGroup = true; await expect( groupClient.getHistory({ conversationId: group?.id as AwikiConversationId }) ).rejects.toMatchObject({ code: 'remote' }); @@ -535,10 +638,111 @@ describe('AWiki IM client', () => { ).rejects.toMatchObject({ code: value.expected }); } }); + + test('resolves a Handle through User Service lookup and rejects a missing peer', async () => { + const unregistered = createClient(new FakeAwikiService(), await temporaryStatePath()); + await expect(unregistered.resolvePeer('bob.awiki.test')).rejects.toMatchObject({ + code: 'not-registered', + }); + await unregistered.dispose(); + + const service = new FakeAwikiService(); + const client = await registeredClient(service); + const resolved = await client.resolvePeer('bob.awiki.test'); + expect(resolved).toMatchObject({ + did: FakeAwikiService.BOB_DID, + handle: 'bob.awiki.test', + displayName: 'Bob', + }); + expect(resolved.conversationId).toBeTruthy(); + expect(service.calls.some((call) => call.method === 'lookup')).toBe(true); + await expect(client.resolvePeer(' ')).rejects.toMatchObject({ code: 'invalid-request' }); + await expect(client.resolvePeer('missing.awiki.test')).rejects.toMatchObject({ + code: 'not-found', + }); + await client.dispose(); + }); + + test('refreshes and persists the latest peer display name when resolving an existing DID', async () => { + const service = new FakeAwikiService(); + const statePath = await temporaryStatePath(); + const client = await registeredClient(service, statePath); + await client.resolvePeer('bob.awiki.test'); + + service.bobDisplayName = 'Robert'; + service.calls.length = 0; + const refreshed = await client.resolvePeer(FakeAwikiService.BOB_DID); + expect(refreshed).toMatchObject({ + did: FakeAwikiService.BOB_DID, + handle: 'bob.awiki.test', + displayName: 'Robert', + }); + expect( + service.calls.some((call) => call.method === 'GET' && call.path === '/.well-known/handle/bob') + ).toBe(true); + + await client.getHistory({ conversationId: refreshed.conversationId }); + const afterHistory = await client.listConversations(); + expect(afterHistory.items.find((item) => item.kind === 'direct')).toMatchObject({ + kind: 'direct', + displayName: 'Robert', + title: 'Robert', + }); + + const persisted = JSON.parse(await readFile(statePath, 'utf8')) as { + conversations: Record; + }; + expect( + Object.values(persisted.conversations).some( + ({ conversation }) => + conversation.displayName === 'Robert' && conversation.title === 'Robert' + ) + ).toBe(true); + + await client.dispose(); + const restored = createClient(service, statePath); + const restoredConversations = await restored.listConversations(); + expect(restoredConversations.items.find((item) => item.kind === 'direct')).toMatchObject({ + kind: 'direct', + displayName: 'Robert', + title: 'Robert', + }); + await restored.dispose(); + }); + + test('titles a handle-less inbox direct chat from the WNS display name', async () => { + const service = new FakeAwikiService(); + service.omitDirectHandle = true; + const client = await registeredClient(service); + const conversations = await client.listConversations(); + const direct = conversations.items.find((item) => item.kind === 'direct'); + expect(direct).toMatchObject({ + kind: 'direct', + displayName: 'Bob', + title: 'Bob', + peerHandle: 'bob.awiki.test', + }); + expect(direct?.title).not.toMatch(/^did:/); + const wellKnown = service.calls.filter( + (call) => call.method === 'GET' && call.path.startsWith('/.well-known/handle/') + ); + expect(wellKnown).toHaveLength(1); + service.calls.length = 0; + await client.listConversations(); + expect( + service.calls.filter( + (call) => call.method === 'GET' && call.path.startsWith('/.well-known/handle/') + ) + ).toHaveLength(0); + await client.dispose(); + }); }); -async function registeredClient(service: FakeAwikiService): Promise { - const client = createClient(service, await temporaryStatePath()); +async function registeredClient( + service: FakeAwikiService, + statePath?: string +): Promise { + const client = createClient(service, statePath ?? (await temporaryStatePath())); await client.sendRegistrationOtp({ handle: 'alice.awiki.test', phone: '+8613800138000', @@ -632,6 +836,11 @@ class FakeAwikiService { public commitObjectUri = 'https://objects.awiki.test/objects/object-1'; public envelopeFaultOnce?: 'wrong-id' | 'missing-version' | 'both' | 'error-scalar'; public rpcErrorOnce?: { readonly code: number; readonly serviceCode?: string }; + public omitDirectHandle = false; + public bobDisplayName = 'Bob'; + public bobMessageDisplayName = 'Bob'; + public groupHistoryMessages?: readonly Record[]; + private readonly readMessageIds = new Set(); public waitForMethod?: { readonly method: string; readonly started: () => void; @@ -657,6 +866,40 @@ class FakeAwikiService { } if (init?.method === 'GET') { this.calls.push({ path: url.pathname, method: 'GET', params: {}, headers }); + if (url.pathname.startsWith('/.well-known/handle/')) { + const local = url.pathname.slice('/.well-known/handle/'.length); + if (local === 'alice' && this.aliceDid) { + return Response.json({ + handle: 'alice.awiki.test', + did: this.aliceDid, + status: 'active', + binding_generation: '1', + profile: { + type: 'DIDSubjectProfile', + subject_did: this.aliceDid, + subject_type: 'person', + handle: 'alice.awiki.test', + display_name: 'Alice', + }, + }); + } + if (local !== 'bob') { + return new Response('not found', { status: 404 }); + } + return Response.json({ + handle: 'bob.awiki.test', + did: FakeAwikiService.BOB_DID, + status: 'active', + binding_generation: '1', + profile: { + type: 'DIDSubjectProfile', + subject_did: FakeAwikiService.BOB_DID, + subject_type: 'person', + handle: 'bob.awiki.test', + display_name: this.bobDisplayName, + }, + }); + } if (url.pathname.endsWith('/did.json')) { return Response.json( this.returnInvalidDidDocument @@ -740,12 +983,19 @@ class FakeAwikiService { binding_generation: '1', }); } - case 'lookup': + case 'update_me': + return rpcResult({ display_name: String(params.nick_name) }); + case 'lookup': { + const handle = String(params.handle ?? '').toLowerCase(); + if (handle.includes('missing')) { + return rpcError(1404, 'handle not found', { anp_code: 'anp.target_not_found' }); + } return rpcResult({ did: FakeAwikiService.BOB_DID, handle: 'bob', full_handle: 'bob.awiki.test', }); + } case 'group.list': return rpcResult({ groups: [ @@ -762,7 +1012,7 @@ class FakeAwikiService { { id: 'inbox-1', sender_did: FakeAwikiService.BOB_DID, - sender_handle: 'bob.awiki.test', + ...(this.omitDirectHandle ? {} : { sender_handle: 'bob.awiki.test' }), receiver_did: this.aliceDid, content: 'hello from bob', content_type: 'text/plain', @@ -776,14 +1026,25 @@ class FakeAwikiService { : []), ] : []), - ], + ].filter( + (message) => typeof message.id !== 'string' || !this.readMessageIds.has(message.id) + ), }); + case 'inbox.mark_read': { + const body = params.body as { message_ids?: unknown }; + const messageIds = Array.isArray(body.message_ids) + ? body.message_ids.filter((value): value is string => typeof value === 'string') + : []; + for (const messageId of messageIds) this.readMessageIds.add(messageId); + return rpcResult({ updated_count: messageIds.length }); + } case 'direct.get_history': return rpcResult({ messages: [ { id: 'history-1', sender_did: FakeAwikiService.BOB_DID, + sender_display_name: this.bobMessageDisplayName, receiver_did: this.directHistoryWrongPeer ? 'did:wba:awiki.test:mallory' : this.aliceDid, @@ -797,7 +1058,7 @@ class FakeAwikiService { }); case 'group.list_messages': return rpcResult({ - messages: [ + messages: this.groupHistoryMessages ?? [ { ...this.incomingAttachmentMessage('group-wire-message'), id: `${FakeAwikiService.GROUP_DID}:7`,