Invitations screen changes. Scrollable list. Details. And role selection on import

This commit is contained in:
2026-02-09 08:14:52 +00:00
parent df57f1b9ad
commit ef169e76db
10 changed files with 2237 additions and 557 deletions

View File

@@ -1,4 +1,5 @@
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
import { hasInvitationExpired } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
@@ -86,6 +87,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/
private storage: Storage;
/**
* True after we have successfully called sign() on this invitation (session-only, not persisted).
*/
private _weHaveSigned = false;
/**
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
*/
private _broadcasted = false;
/**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
*/
public status: string = 'unknown';
/**
* Create an invitation and start the SSE Session required for it.
*/
@@ -108,28 +124,25 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
async start(): Promise<void> {
// Connect to the sync server and get the invitation (in parallel)
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
const [_, invitation] = await Promise.all([
this.syncServer.connect(),
this.syncServer.getInvitation(this.data.invitationIdentifier),
]);
console.timeEnd(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
const sseCommits = this.data.commits;
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
// Merge the commits
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
console.timeEnd(`mergeCommits-${this.data.invitationIdentifier}`);
console.time(`setInvitationData-${this.data.invitationIdentifier}`);
// Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`);
// Compute and emit initial status
await this.updateStatus();
}
/**
@@ -155,8 +168,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Set the new commits
this.data = { ...this.data, commits: newCommits };
// Calculate the new status of the invitation
this.updateStatus();
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
this.updateStatus().catch(() => {});
// Emit the updated event
this.emit('invitation-updated', this.data);
@@ -185,12 +198,64 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Return the merged commits
return Array.from(initialMap.values());
}
/**
* Update the status of the invitation based on the filled in information
* Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown.
*/
private updateStatus(): void {
// Calculate the status of the invitation
this.emit('invitation-status-changed', 'pending - this is temporary');
private async computeStatus(): Promise<string> {
try {
return await this.computeStatusInternal();
} catch (err) {
return `error (${err instanceof Error ? err.message : String(err)})`;
}
}
/**
* Internal status computation: returns a single word.
* - expired: any commit has expired
* - complete: we have broadcast this invitation
* - ready: no missing requirements and we have signed (ready to broadcast)
* - signed: we have signed but there are still missing parts (waiting for others)
* - actionable: you can provide data (missing requirements and/or you can sign)
* - unknown: template/action not found or error
*/
private async computeStatusInternal(): Promise<string> {
if (hasInvitationExpired(this.data)) {
return 'expired';
}
if (this._broadcasted) {
return 'complete';
}
let missingReqs;
try {
missingReqs = await this.engine.listMissingRequirements(this.data);
} catch {
return 'unknown';
}
const hasMissing =
(missingReqs.variables?.length ?? 0) > 0 ||
(missingReqs.inputs?.length ?? 0) > 0 ||
(missingReqs.outputs?.length ?? 0) > 0 ||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
if (!hasMissing && this._weHaveSigned) {
return 'ready';
}
if (hasMissing && this._weHaveSigned) {
return 'signed';
}
return 'actionable';
}
/**
* Update the status of the invitation and emit the new single-word status.
*/
private async updateStatus(): Promise<void> {
const status = await this.computeStatus();
this.status = status;
this.emit('invitation-status-changed', status);
}
/**
@@ -204,7 +269,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.syncServer.publishInvitation(this.data);
// Update the status of the invitation
this.updateStatus();
await this.updateStatus();
}
/**
@@ -220,26 +285,26 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
this.data = signedInvitation;
this._weHaveSigned = true;
// Update the status of the invitation
this.updateStatus();
await this.updateStatus();
}
/**
* Broadcast the invitation
*/
async broadcast(): Promise<void> {
// Broadcast the invitation
const broadcastedInvitation = await this.engine.executeAction(this.data, {
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
await this.engine.executeAction(this.data, {
broadcastTransaction: true,
});
// Store the broadcasted invitation in the storage
await this.storage.set(this.data.invitationIdentifier, broadcastedInvitation);
// TODO: Am I supposed to do something here?
this._broadcasted = true;
// Update the status of the invitation
this.updateStatus();
await this.updateStatus();
}
// ============================================================================
@@ -260,7 +325,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.storage.set(this.data.invitationIdentifier, this.data);
// Update the status of the invitation
this.updateStatus();
await this.updateStatus();
}
/**
@@ -295,7 +360,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
// Update the status of the invitation
this.updateStatus();
await this.updateStatus();
// Return the suitable resources
return unspentOutputs;