b4d3c4833c
Amber users on Android who manually approve events must switch from Ditto to Amber to approve, then switch back. Backgrounding Ditto can freeze its WebSocket, causing the NIP-46 response to be silently dropped — leaving the operation hanging with no feedback and no way out. Users with Amber notifications working correctly are unaffected, as approving via notification does not background Ditto. Even with auto-approve enabled, kinds outside Amber's default whitelist require manual approval. Ditto uses several of these regularly: kind 1059 (NIP-17 gift-wrap DMs), 1111 (comments), 1311 (live chat), 31925 (RSVPs), 24242 (Blossom file upload auth), and 30078 (app settings). This introduces signerWithNudge, a NostrSigner wrapper with the following behaviour: Nudge toast after 4 seconds If a signing or encryption op is still pending after 4 s, a persistent toast appears naming what is being approved (e.g. 'Approve file upload auth'), with a human-readable label derived from the event kind. Android 'Approve in signer' button On Android the nudge toast includes an 'Approve in signer' button that opens Amber via the nostrsigner: URI scheme, keeping the WebSocket alive. After tapping, the button becomes a spinner and a Cancel button appears. Automatic retry on foreground resume When Ditto returns to the foreground after being backgrounded, it automatically retries the pending NIP-46 request (up to 2 times) and shows a brief 'Checking for signer response' toast. Hard 45-second timeout Operations with no response within 45 s are rejected with a clear error. Cancel / Skip The nudge toast has a Skip link throughout. After tapping 'Approve in signer' it becomes a full Cancel button. Multi-phase encrypt-then-sign Saving app settings (kind 30078) and mute lists (kind 10000) require a nip44 encrypt followed immediately by a signEvent. When the encrypt nudge was shown, a phase-transition toast tells the user a second approval is coming. The check is kind-specific to avoid false positives. Success feedback A brief 'Approved' toast confirms the outcome when the nudge was shown. Relay connectivity check At nudge time, if the bunker relay WebSocket is not OPEN the toast warns 'Signer relay unreachable' instead of prompting for an approval that cannot be delivered. Accessibility Toast buttons meet the 44 px touch target minimum. Text size and contrast were increased for readability on small screens.
60 lines
2.0 KiB
TypeScript
60 lines
2.0 KiB
TypeScript
/**
|
|
* Detects when a web app returns to the foreground after being backgrounded,
|
|
* primarily to work around Android's WebSocket zombie connection problem.
|
|
*
|
|
* Android aggressively throttles backgrounded tabs, causing WebSocket connections
|
|
* to silently miss events without triggering close/error handlers. This utility
|
|
* detects the resume and reports how long the app was in the background, so
|
|
* callers can force reconnection or re-query missed data.
|
|
*
|
|
* Framework-agnostic — no React dependency. Can be used in libraries.
|
|
*/
|
|
|
|
export interface AndroidResumeOptions {
|
|
/** Minimum background duration (ms) before triggering. Default: 0 */
|
|
threshold?: number;
|
|
/** Called when the app returns to foreground after exceeding the threshold. */
|
|
onResume?: (backgroundDurationMs: number) => void;
|
|
/**
|
|
* If true, only activates on Android user agents.
|
|
* Set to false to test on desktop. Default: true
|
|
*/
|
|
androidOnly?: boolean;
|
|
}
|
|
|
|
function isAndroid(): boolean {
|
|
return typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent);
|
|
}
|
|
|
|
export function androidResume(options: AndroidResumeOptions = {}): { destroy: () => void } {
|
|
const { threshold = 0, onResume, androidOnly = true } = options;
|
|
const noop = { destroy: () => {} };
|
|
|
|
// No-op in non-browser environments (e.g. Node.js, Deno without DOM).
|
|
if (typeof document === 'undefined') return noop;
|
|
|
|
if (androidOnly && !isAndroid()) return noop;
|
|
|
|
let hiddenAt: number | null = null;
|
|
|
|
const handler = () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
hiddenAt = Date.now();
|
|
} else if (document.visibilityState === 'visible') {
|
|
if (hiddenAt === null) return;
|
|
const duration = Date.now() - hiddenAt;
|
|
hiddenAt = null;
|
|
if (duration >= threshold) {
|
|
onResume?.(duration);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handler);
|
|
return {
|
|
destroy: () => {
|
|
document.removeEventListener('visibilitychange', handler);
|
|
},
|
|
};
|
|
}
|