Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/smoke-mobile-density.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const bootstrap = `
'GET_ACCOUNT_GROUPS', 'GET_ACCOUNT_GROUP_JOIN_REQUESTS', 'GET_ACTIVE_CHATS',
'GET_ADMIN_GROUP_JOIN_REQUESTS', 'GET_MINTING_STATUS', 'GET_PRIVATE_DIRECT_ACTIVE_CHATS',
'GET_SELECTED_ACCOUNT', 'RESOLVE_IDENTITIES', 'SEARCH_CHAT_MESSAGES',
'SEARCH_PRIVATE_DIRECT_CHAT_MESSAGES', 'SEND_CHAT_MESSAGE'
'SEARCH_PRIVATE_DIRECT_CHAT_MESSAGES', 'SEND_CHAT_MESSAGE', 'SEND_DIRECT_CHAT_MESSAGE'
];
case 'WHICH_UI': return 'QORTIUM_HOME_ELECTRON';
case 'IS_USING_PUBLIC_NODE': return false;
Expand Down
13 changes: 12 additions & 1 deletion scripts/smoke-new-user.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ try {
catalogueCalls,
directExpanded: directSections.map((section) => section.expanded),
empty: empty.textContent.trim(),
generalChips: Array.from(row.querySelectorAll('.group-row__id, .group-row__protocol'))
.map((chip) => chip.textContent.trim()),
generalMetadata: row.querySelector('.group-row__footer').textContent.trim(),
notice: notice.textContent.trim(),
networks,
Expand Down Expand Up @@ -328,6 +330,8 @@ try {
? {
catalogueCalls,
messageProbeIds,
chips: Array.from(rows[0].querySelectorAll('.group-row__id, .group-row__protocol'))
.map((chip) => chip.textContent.trim()),
metadata: rows[0].querySelector('.group-row__footer')?.textContent.trim(),
preview: rows[0].querySelector('.group-row__preview')?.textContent.trim(),
title: rows[0].querySelector('.group-row__name')?.textContent.trim()
Expand All @@ -340,6 +344,10 @@ try {
await waitUntil('public preview conversation', 5_000, async () =>
evaluate(client, `document.querySelector('.chat-pane__title')?.textContent.trim() === 'Public Lounge'`),
);
const publicGroupHeaderMetadata = await evaluate(
client,
`Array.from(document.querySelectorAll('.chat-pane__context span')).map((chip) => chip.textContent.trim()).join(' ')`,
);
await delay(150);
const discoveryScreenshot = await client.send('Page.captureScreenshot', {
captureBeyondViewport: false,
Expand Down Expand Up @@ -385,12 +393,14 @@ try {
desktop.shellTitle !== 'Chat' ||
!desktop.empty.includes('Say hello') ||
desktop.generalMetadata.includes('id:0') ||
JSON.stringify(desktop.generalChips) !== JSON.stringify(['#0', 'CHAT']) ||
!desktop.notice.includes('Share the selected account') ||
discovery.catalogueCalls !== 1 ||
JSON.stringify(discovery.messageProbeIds) !== JSON.stringify([7, 8]) ||
discovery.title !== 'Public Lounge' ||
discovery.preview !== 'A public preview' ||
!discovery.metadata.includes('CHAT') ||
JSON.stringify(discovery.chips) !== JSON.stringify(['#7', 'CHAT']) ||
!publicGroupHeaderMetadata.startsWith('#7 Qortium CHAT') ||
!mobile.notice.includes('Share the selected account') ||
mobile.title !== 'General Chat'
) {
Expand All @@ -402,6 +412,7 @@ try {
desktopScreenshotPath,
discovery,
discoveryScreenshotPath,
publicGroupHeaderMetadata,
mobile,
mobileScreenshotPath,
}, null, 2));
Expand Down
169 changes: 150 additions & 19 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
isPrivateAttachmentDescriptor,
isPublishSourceTokenError,
isQortalPrivateGroupChatState,
isQortiumPrivateGroupChatState,
leaveGroup,
joinGroup,
publishChatAttachment,
Expand Down Expand Up @@ -137,7 +138,13 @@ import { resolveGroupPreviewRevision, type GroupPreviewRevision } from './groupP
import {
getReactionPendingKey,
} from './messageReactions';
import { getBridgeState, hasAction, hasHomeBridge, qdnRequest } from './qdnRequest';
import {
canUseNodeWebSockets,
getBridgeState,
hasAction,
hasHomeBridge,
qdnRequest,
} from './qdnRequest';
import { isHomeV2AppTab } from './hostContext';
import { createTranslator, normalizeLanguage, type TranslateFunction } from './i18n';
import { applyDisplaySettings, getDisplaySettingsUpdateFromMessage, getInitialDisplaySettings } from './displaySettings';
Expand Down Expand Up @@ -221,7 +228,11 @@ import {
type ShowChatNotificationResult,
} from './notifications';
import { LatestRequestGuard } from './latestRequest';
import { canUseQortalAccountForHost, loadQortalAccountSnapshot } from './qortalAccountSession';
import {
canUseQortalAccountForHost,
loadQortalAccountSnapshot,
shouldRecoverQortiumAccountFromSharedHomeIdentity,
} from './qortalAccountSession';
import { getLegacyQortiumMigrationHint } from './qortalUiMigration';
import { StartupAccountRefreshCoordinator } from './startupAccountRefresh';
import { getDirectSectionDefaultCollapse, getPrivateChatCapabilityStatus } from './sidebarSections';
Expand Down Expand Up @@ -1449,6 +1460,7 @@ export default function App() {
const qortalGatewayGroupRequestRef = useRef(0);
const qortalGroupCatalogueRef = useRef<{ actionsKey: string; groups: GroupData[] } | null>(null);
const startupAccountRefreshCoordinatorRef = useRef<StartupAccountRefreshCoordinator | null>(null);
const qortiumSharedIdentityRecoveryAttemptRef = useRef<string | null>(null);
// Home 2 versions before the unlock ordering fix can notify this QDN view
// that its account became unlocked, then reject UNLOCK_SELECTED_ACCOUNT
// because their permission resolver raced the main-process state update.
Expand Down Expand Up @@ -1634,6 +1646,7 @@ export default function App() {
const [now, setNow] = useState(() => Date.now());
const actions = bridge.value.actions;
const actionsKey = actions.join('\n');
const nodeWebSocketsAvailable = canUseNodeWebSockets();
const qortalActionsKey = qortalBridge.value.actions.join('\n');
const avatarActionsByNetwork = useMemo(
() => ({ qortal: qortalBridge.value.actions, qortium: actions }),
Expand Down Expand Up @@ -2771,6 +2784,11 @@ export default function App() {
? privateGroupChatStateByKey.get(selectedPrivateGroupChatStateKey)
: undefined;
const selectedPrivateGroupChatState = selectedPrivateGroupChatStateAsync?.value ?? null;
const qortiumKeySetupJournalRevision = journalEntries
.filter((entry) => entry.stage === 'key-announcement')
.map((entry) => entry.signature)
.sort()
.join('\n');
// No separate `selectedQortiumPrivateGroupChatState` narrowing is kept here
// (unlike the Qortal one below): the one Qortium consumer,
// getPrivateGroupComposerMaxPlaintextBytes, takes the union type directly
Expand Down Expand Up @@ -5389,6 +5407,22 @@ export default function App() {
return getPrivateGroupComposerMaxPlaintextBytes(network, state);
}

function markQpgcKeyAvailableFor(target: PendingSendTarget) {
if (target.kind !== 'group' || !target.isPrivate || (target.network ?? 'qortium') !== 'qortium') return;
const key = getPrivateGroupChatStateKey('qortium', target.groupId);

setPrivateGroupChatStateByKey((current) => {
const entry = current.get(key);
if (!entry?.value || !isQortiumPrivateGroupChatState(entry.value) || entry.value.keyAvailable === true) {
return current;
}
const next = new Map(current);

next.set(key, { ...entry, value: { ...entry.value, keyAvailable: true } });
return next;
});
}

function setNetworkJournalEntries(network: ChatNetwork, entries: PendingBridgeTransactionEntry[]) {
if (network === 'qortal') {
setQortalJournalEntries(entries);
Expand Down Expand Up @@ -5445,10 +5479,14 @@ export default function App() {
// rather than surfacing a banner for a housekeeping call.
}

setNetworkJournalEntries(
network,
getNetworkJournalEntries(network).filter((entry) => entry.signature !== signature),
);
const removeEntry = (entries: PendingBridgeTransactionEntry[]) =>
entries.filter((entry) => entry.signature !== signature);

if (network === 'qortal') {
setQortalJournalEntries(removeEntry);
} else {
setJournalEntries(removeEntry);
}
}

// Reconciles a network's journal against a freshly loaded/refreshed message
Expand Down Expand Up @@ -5593,27 +5631,31 @@ export default function App() {
candidate.localId === localId &&
candidate.delivery.phase === 'pending' &&
candidate.delivery.updatedAt === attemptUpdatedAt
? result.outcome === 'ambiguous'
? resolvePendingSendAmbiguously(
? result.outcome === 'not-submitted'
? failPendingSend(candidate, result.error ?? t('status.loadingError.sendMessage'))
: result.outcome === 'ambiguous'
? resolvePendingSendAmbiguously(
candidate,
result,
result.error ?? t('message.delivery.ambiguous'),
)
: resolvePendingSend(candidate, result)
: resolvePendingSend(candidate, result)
: candidate,
),
);

if (entry.kind === 'reaction' && result.outcome === 'ambiguous') {
setWriteError(t('message.delivery.ambiguous'));
if (entry.kind === 'reaction' && result.outcome) {
setWriteError(result.error ?? t('message.delivery.ambiguous'));
}

// Item D: an ambiguous outcome is exactly the moment Home records a new
// pending-journal entry (a signed mutation with an unknown broadcast
// result) — refresh the journal so the conversation notice appears
// without waiting for the next unrelated bridge/account-ready trigger.
if (result.outcome === 'ambiguous') {
if (result.outcome) {
void fetchPendingJournal(entry.target.network ?? 'qortium');
} else {
markQpgcKeyAvailableFor(entry.target);
}

if (chat.kind === 'direct' && isCurrentWritablePendingTarget(entry.target, entry.accountAddress)) {
Expand Down Expand Up @@ -5704,21 +5746,25 @@ export default function App() {
candidate.localId === localId &&
candidate.delivery.phase === 'pending' &&
candidate.delivery.updatedAt === attemptUpdatedAt
? result.outcome === 'ambiguous'
? resolvePendingRevisionAmbiguously(
? result.outcome === 'not-submitted'
? failPendingRevision(candidate, result.error ?? t('status.loadingError.sendMessage'))
: result.outcome === 'ambiguous'
? resolvePendingRevisionAmbiguously(
candidate,
result,
result.error ?? t('message.delivery.ambiguous'),
)
: resolvePendingRevision(candidate, result)
: resolvePendingRevision(candidate, result)
: candidate,
),
);

// Item D: same as runPendingSend — an ambiguous revision outcome is
// exactly when Home records a new journal entry.
if (result.outcome === 'ambiguous') {
if (result.outcome) {
void fetchPendingJournal(entry.target.network ?? 'qortium');
} else {
markQpgcKeyAvailableFor(entry.target);
}

if (chat.kind === 'direct' && isCurrentWritablePendingTarget(entry.target, entry.accountAddress)) {
Expand Down Expand Up @@ -7337,6 +7383,42 @@ export default function App() {
}
}

useEffect(() => {
if (account) {
qortiumSharedIdentityRecoveryAttemptRef.current = null;
return;
}

if (!shouldRecoverQortiumAccountFromSharedHomeIdentity(
bridge.value.host,
bridge.phase === 'ready',
qortalAccount?.address ?? null,
null,
accountRefreshPending,
)) {
return;
}

const recoveryKey = `${qortalAccount?.address ?? ''}\n${actionsKey}`;

if (qortiumSharedIdentityRecoveryAttemptRef.current === recoveryKey) {
return;
}

// Dashboard unlocks can expose the shared Qortal identity before Chat's
// Qortium-side notification arrives. Recover that missing half once for
// this account/action catalogue without reopening a denied prompt loop.
qortiumSharedIdentityRecoveryAttemptRef.current = recoveryKey;
void connectSelectedAccount(actions);
}, [
account?.address,
accountRefreshPending,
actionsKey,
bridge.phase,
bridge.value.host,
qortalAccount?.address,
]);

// A live foreground SHOW_NOTIFICATION call is best-effort and never throws
// (see notifications.ts), but a `revoked`/`disabled` result is a signal
// worth reflecting: Home is telling Chat its one durable app permission is
Expand Down Expand Up @@ -8954,7 +9036,7 @@ export default function App() {
// timestamps for known addresses but never adds new decrypted entries, so the
// list itself must be re-fetched periodically to discover new conversations.
useEffect(() => {
if (!account || !isAccountUnlocked || !canReadPrivateDirectChat) {
if (!account || !nodeWebSocketsAvailable || !isAccountUnlocked || !canReadPrivateDirectChat) {
return undefined;
}

Expand All @@ -8969,7 +9051,7 @@ export default function App() {
}, 30000);

return () => window.clearInterval(interval);
}, [account?.address, actionsKey, canReadPrivateDirectChat, isAccountUnlocked]);
}, [account?.address, actionsKey, canReadPrivateDirectChat, isAccountUnlocked, nodeWebSocketsAvailable]);

// Qortal does not have a protocol-specific websocket route in the current
// bridge, so refresh its active group snapshot while visible. This drives
Expand Down Expand Up @@ -9274,6 +9356,7 @@ export default function App() {

if (
bridge.value.transport === 'gateway' ||
!nodeWebSocketsAvailable ||
selectedChat.kind !== 'group' ||
selectedChat.group.isOpen === false ||
selectedChat.network === 'qortal'
Expand Down Expand Up @@ -9434,6 +9517,7 @@ export default function App() {
isAccountUnlocked,
selectedClosedGroupReadKey,
bridge.value.transport,
nodeWebSocketsAvailable,
]);

// P3 item 2: GET_PRIVATE_GROUP_CHAT_STATE for the selected closed group.
Expand Down Expand Up @@ -9519,14 +9603,60 @@ export default function App() {
qortalAccount?.address,
actionsKey,
qortalBridge.value.actions.join('\n'),
qortiumKeySetupJournalRevision,
]);

// An uncertain automatic key announcement is journaled separately from the
// user message, which Home proves was never submitted. Once this account can
// resolve any current-epoch key, the setup goal is reconciled and its control
// signature no longer needs to keep a conversation-level journal notice.
useEffect(() => {
if (
selectedChat?.kind !== 'group' ||
selectedChat.network === 'qortal' ||
selectedChat.group.isOpen !== false ||
!selectedPrivateGroupChatState ||
!isQortiumPrivateGroupChatState(selectedPrivateGroupChatState) ||
selectedPrivateGroupChatState.keyAvailable !== true
) {
return;
}

const groupId = selectedChat.group.groupId;
for (const entry of journalEntries) {
if (
entry.stage === 'key-announcement' &&
entry.target.kind === 'group' &&
entry.target.groupId === groupId
) {
void forgetJournalEntry('qortium', entry.signature);
}
}
}, [journalEntries, selectedChatKey, selectedPrivateGroupChatState]);

useEffect(() => {
if (!account) {
return undefined;
}

const address = account.address;

if (!nodeWebSocketsAvailable) {
const selectedAccount = account;

void loadActiveChats(selectedAccount, actions, { quiet: true });

const interval = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}

void loadActiveChats(selectedAccount, actions, { quiet: true });
}, 15000);

return () => window.clearInterval(interval);
}

let socket: WebSocket | null = null;
let reconnectTimeout = 0;
let reconnectDelay = WS_RECONNECT_BASE_MS;
Expand Down Expand Up @@ -9668,7 +9798,7 @@ export default function App() {

socket?.close();
};
}, [account?.address]);
}, [account?.address, actionsKey, nodeWebSocketsAvailable]);

useEffect(() => {
if (!account) {
Expand Down Expand Up @@ -10553,6 +10683,7 @@ export default function App() {
closedLabel={t('label.group.closed')}
contextLabel={selectedChatContextLabel}
description={selectedChatDescription}
groupId={selectedChat?.kind === 'group' ? selectedChat.group.groupId : null}
isClosed={
selectedChat?.kind === 'group' &&
!isGeneralChatGroup(selectedChat.group) &&
Expand Down
Loading