Fix history for the 100th time. Fix role resolution in the invitation screen

This commit is contained in:
2026-05-04 11:36:09 +00:00
parent 2f2e515d72
commit dedfb69dff
4 changed files with 663 additions and 680 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -350,29 +350,32 @@ export function WalletStateScreen(): React.ReactElement {
const indicator = isFocused ? '▸ ' : ' '; const indicator = isFocused ? '▸ ' : ' ';
const groupingPrefix = row.isNested ? ' -> ' : ''; const groupingPrefix = row.isNested ? ' -> ' : '';
if (row.type === 'invitation') { if (row.type === 'history_item') {
const sats = row.valueSatoshis ?? 0n;
const fiatSuffix = getFiatSuffix(sats);
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}> <Text color={itemColor}>
{indicator}[Invitation] {row.label} {indicator}{formatSatoshis(sats)}{fiatSuffix}
</Text> </Text>
<Text color={colors.textMuted}> {row.label}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box> </Box>
); );
} }
if (row.type === 'invitation_input') { if (row.type === 'history_input') {
const inputSatoshis = row.utxo?.valueSatoshis; const sats = row.valueSatoshis ?? 0n;
const inputFiatSuffix = inputSatoshis !== undefined
? getFiatSuffix(inputSatoshis)
: '';
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box> <Box>
<Text color={itemColor}> <Text color={itemColor}>
{indicator}{groupingPrefix}[Input] {row.label} {indicator}{groupingPrefix}[Input] {formatSatoshis(sats)}
{inputFiatSuffix} {getFiatSuffix(sats)}
</Text> </Text>
<Text color={colors.textMuted}> {row.label}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -380,8 +383,9 @@ export function WalletStateScreen(): React.ReactElement {
); );
} }
if (row.type === 'invitation_output') { if (row.type === 'history_output') {
const sats = row.utxo?.valueSatoshis ?? 0n; const sats = row.valueSatoshis ?? 0n;
const reservedTag = row.reserved ? ' [Reserved]' : '';
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row"> <Box flexDirection="row">
@@ -389,6 +393,7 @@ export function WalletStateScreen(): React.ReactElement {
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
{getFiatSuffix(sats)} {getFiatSuffix(sats)}
</Text> </Text>
<Text color={colors.textMuted}> {row.label}{reservedTag}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -396,23 +401,6 @@ export function WalletStateScreen(): React.ReactElement {
); );
} }
if (row.type === 'utxo') {
const sats = row.utxo?.valueSatoshis ?? 0n;
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types // Fallback for other types
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
@@ -515,7 +503,7 @@ export function WalletStateScreen(): React.ReactElement {
height={14} height={14}
overflow="hidden" overflow="hidden"
> >
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text> <Text color={colors.primary} bold> Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''}</Text>
{isLoading ? ( {isLoading ? (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text> <Text color={colors.textMuted}>Loading...</Text>

View File

@@ -21,7 +21,7 @@ import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js'; import type { Invitation } from '../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types'; import type { XOInvitationCommit, XOTemplate } from '@xo-cash/types';
import { import {
getInvitationState, getInvitationState,
@@ -29,7 +29,6 @@ import {
getInvitationInputs, getInvitationInputs,
getInvitationOutputs, getInvitationOutputs,
getInvitationVariables, getInvitationVariables,
getUserRole,
formatInvitationListItem, formatInvitationListItem,
formatInvitationId, formatInvitationId,
} from '../../../utils/invitation-utils.js'; } from '../../../utils/invitation-utils.js';
@@ -80,6 +79,29 @@ const invitationListGroups: ListGroup[] = [
{ id: 'invitations', separator: true }, { id: 'invitations', separator: true },
]; ];
type OwnInvitationContext = {
entityIdentifier: string | null;
roleIdentifier: string | null;
};
function getRoleIdentifierFromCommits(commits: XOInvitationCommit[]): string | null {
for (const commit of commits) {
for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) return input.roleIdentifier;
}
for (const output of commit.data.outputs ?? []) {
if (output.roleIdentifier) return output.roleIdentifier;
}
for (const variable of commit.data.variables ?? []) {
if (variable.roleIdentifier) return variable.roleIdentifier;
}
}
return null;
}
/** /**
* Invitation Screen Component. * Invitation Screen Component.
*/ */
@@ -107,6 +129,10 @@ export function InvitationScreen(): React.ReactElement {
// ── Template cache ─────────────────────────────────────────────────────── // ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map()); const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
const [ownInvitationContext, setOwnInvitationContext] = useState<OwnInvitationContext>({
entityIdentifier: null,
roleIdentifier: null,
});
// Check if we should open import dialog on mount // Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined; const initialMode = navData.mode as string | undefined;
@@ -180,6 +206,43 @@ export function InvitationScreen(): React.ReactElement {
.then(template => setSelectedTemplate(template ?? null)); .then(template => setSelectedTemplate(template ?? null));
}, [selectedInvitation, appService]); }, [selectedInvitation, appService]);
/**
* Load the current engine entity's commits for the selected invitation.
*/
useEffect(() => {
if (!selectedInvitation || !appService) {
setOwnInvitationContext({
entityIdentifier: null,
roleIdentifier: null,
});
return;
}
let isCurrent = true;
appService.engine.getOwnCommits(selectedInvitation.data)
.then((ownCommits) => {
if (!isCurrent) return;
setOwnInvitationContext({
entityIdentifier: ownCommits[0]?.entityIdentifier ?? null,
roleIdentifier: getRoleIdentifierFromCommits(ownCommits),
});
})
.catch(() => {
if (!isCurrent) return;
setOwnInvitationContext({
entityIdentifier: null,
roleIdentifier: null,
});
});
return () => {
isCurrent = false;
};
}, [selectedInvitation, appService]);
// ── Import flow callbacks ────────────────────────────────────────────── // ── Import flow callbacks ──────────────────────────────────────────────
/** /**
@@ -512,9 +575,8 @@ export function InvitationScreen(): React.ReactElement {
const inputs = getInvitationInputs(selectedInvitation); const inputs = getInvitationInputs(selectedInvitation);
const outputs = getInvitationOutputs(selectedInvitation); const outputs = getInvitationOutputs(selectedInvitation);
const variables = getInvitationVariables(selectedInvitation); const variables = getInvitationVariables(selectedInvitation);
const userEntityId = ownInvitationContext.entityIdentifier;
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null; const userRole = ownInvitationContext.roleIdentifier;
const userRole = getUserRole(selectedInvitation, userEntityId);
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;

View File

@@ -1,7 +1,8 @@
import type { import type {
HistoryItem, HistoryItem,
HistoryInvitationItem, WalletHistoryInput,
HistoryUtxoItem, WalletHistoryItem,
WalletHistoryOutput,
} from "../services/history.js"; } from "../services/history.js";
export type HistoryColorName = export type HistoryColorName =
@@ -13,10 +14,9 @@ export type HistoryColorName =
| "text"; | "text";
export type HistoryRowType = export type HistoryRowType =
| "invitation" | "history_item"
| "invitation_input" | "history_input"
| "invitation_output" | "history_output";
| "utxo";
export interface HistoryDisplayRow { export interface HistoryDisplayRow {
id: string; id: string;
@@ -25,8 +25,11 @@ export interface HistoryDisplayRow {
description?: string; description?: string;
timestamp?: number; timestamp?: number;
isNested: boolean; isNested: boolean;
utxo?: HistoryUtxoItem; valueSatoshis?: bigint;
invitation?: HistoryInvitationItem; reserved?: boolean;
input?: WalletHistoryInput;
output?: WalletHistoryOutput;
item?: WalletHistoryItem;
} }
export function formatHistoryDate(timestamp?: number): string | undefined { export function formatHistoryDate(timestamp?: number): string | undefined {
@@ -40,61 +43,68 @@ export function buildHistoryDisplayRows(
const rows: HistoryDisplayRow[] = []; const rows: HistoryDisplayRow[] = [];
for (const item of items) { for (const item of items) {
if (item.kind === "invitation") { const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown";
if (item.source === "utxo") {
for (const output of item.outputs) {
rows.push({ rows.push({
id: item.id, id: `${item.id}-output-${output.id}`,
type: "invitation", type: "history_output",
label: item.description, label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output",
description: `${item.template} | ${roles} | ${output.description}`,
timestamp: item.createdAtTimestamp, timestamp: item.createdAtTimestamp,
isNested: false, isNested: false,
invitation: item, valueSatoshis: output.valueSatoshis,
reserved: output.reserved,
output,
item,
});
}
continue;
}
rows.push({
id: item.id,
type: "history_item",
label: `${item.template} | ${roles} | ${item.description}`,
description: item.action,
timestamp: item.createdAtTimestamp,
isNested: false,
valueSatoshis: item.valueSatoshis,
item,
}); });
if (item.source !== "invitation") continue;
for (const input of item.inputs) { for (const input of item.inputs) {
const satsPrefix =
input.valueSatoshis !== undefined
? `${input.valueSatoshis.toLocaleString()} sats `
: "";
rows.push({ rows.push({
id: `${item.id}-input-${input.id}`, id: `${item.id}-input-${input.id}`,
type: "invitation_input", type: "history_input",
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`, label: `${input.outpoint.txid}:${input.outpoint.index}`,
description: input.description, description: input.description,
isNested: true, isNested: true,
utxo: input, valueSatoshis: input.valueSatoshis,
invitation: item, input,
item,
}); });
} }
for (const output of item.outputs) { for (const output of item.outputs) {
rows.push({ rows.push({
id: `${item.id}-output-${output.id}`, id: `${item.id}-output-${output.id}`,
type: "invitation_output", type: "history_output",
label: label: output.outpoint
output.valueSatoshis !== undefined ? `${output.outpoint.txid}:${output.outpoint.index}`
? `${output.valueSatoshis.toLocaleString()} sats` : output.outputIdentifier ?? "Output",
: "Output",
description: output.description, description: output.description,
isNested: true, isNested: true,
utxo: output, valueSatoshis: output.valueSatoshis,
invitation: item, reserved: output.reserved,
output,
item,
}); });
} }
continue;
}
rows.push({
id: item.id,
type: "utxo",
label:
item.valueSatoshis !== undefined
? `${item.valueSatoshis.toLocaleString()} sats`
: "UTXO",
description: item.description,
isNested: false,
utxo: item,
});
} }
return rows; return rows;
@@ -106,14 +116,14 @@ export function getHistoryItemColorName(
): HistoryColorName { ): HistoryColorName {
if (isSelected) return "info"; if (isSelected) return "info";
switch (row.type) { switch (row.type) {
case "invitation": case "history_input":
return "text";
case "invitation_input":
return "error"; return "error";
case "invitation_output": case "history_output":
return "success"; return row.reserved ? "warning" : "success";
case "utxo": case "history_item":
return row.utxo?.reserved ? "warning" : "success"; if ((row.valueSatoshis ?? 0n) < 0n) return "error";
if ((row.valueSatoshis ?? 0n) > 0n) return "success";
return "text";
default: default:
return "text"; return "text";
} }