Promote unstable → beta

Channel grid reliability (progressive render, reliable fetch, always-query defaults so nos.lol-only videos show); resolved-set durability; wallet restore popup; mobile polish (tour bottom-sheet, tab grid, account cards); admin ring/star everywhere.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-08 09:55:23 -04:00
commit f2e35f619b
14 files changed
+447 -61

No files matched your search

+74 -3
View File
@@ -3594,6 +3594,55 @@
display: block;
}
/* Compact creator/account cards (Subscriptions, Friends, Blocked, Admin
lists, Moderation). Desktop lays them out in a row: summary left, actions
right. `.profile-account-card` is the card itself (padded); the
`--row` variant is for a nested flex row inside an already-padded card. */
.profile-account-card,
.profile-account-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.profile-account-card {
padding: var(--space-md);
}
.profile-account-card__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2xs);
}
/* Phones: stack the card so the name/npub get the full width (no more
4-line-wrapped npub in a crammed column), with the actions as a full-width
row of equal-width buttons below. */
@media (max-width: calc(theme("screens.sm") - 0.02px)) {
.profile-account-card,
.profile-account-card__row {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.profile-account-card {
padding: var(--space-sm);
}
.profile-account-card__actions > * {
flex: 1 1 auto;
}
/* tighter buttons so all three fit one clean row */
.profile-account-card__actions .btn-ghost {
padding-inline: var(--space-sm);
}
}
.profile-modal__panes {
display: flex;
flex: 1 1 auto;
@@ -3662,18 +3711,30 @@
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-xs);
/* Every tile the SAME height, so a 2-line label can't stretch its row
taller than the others (the "ragged grid of ellipses" fix). */
grid-auto-rows: var(--profile-modal-nav-tile-height);
}
.profile-modal__nav > button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
justify-content: center;
text-align: center;
gap: var(--space-2xs);
width: 100%;
padding-block: var(--space-sm);
padding-inline: var(--space-2xs);
block-size: 100%;
/* Consistent rounded-square tiles, not rounded-full ellipses (btn's
default) that turn into circles once a tile gets taller. */
border-radius: var(--radius-xl);
/* Subtle fill so they read as solid cards, not floating outlines. The
active tile's bg-primary (higher specificity) still wins. */
background-color: var(--color-panel);
padding-block: var(--space-xs);
padding-inline: var(--space-3xs);
line-height: 1.15;
overflow: hidden;
/* wrap labels at spaces, not mid-word */
overflow-wrap: break-word;
word-break: normal;
@@ -3684,6 +3745,16 @@
.profile-modal__nav > button svg {
width: var(--space-lg);
height: var(--space-lg);
flex-shrink: 0;
}
/* In the tile grid the unread dot rides the tile's top-right corner instead
of the vertical-nav left edge (where it landed in the grid gutter). */
.profile-modal__nav > button .profile-nav__unread-dot {
top: var(--space-2xs);
left: auto;
right: var(--space-2xs);
transform: none;
}
}
+3
View File
@@ -359,6 +359,9 @@
--notification-banner-max-inline: var(--size-28rem);
--profile-modal-nav-min-width: max-content;
--profile-modal-nav-max-width: max-content;
/* Uniform height for the mobile tab-grid tiles (roomy enough for a 2-line
label, so a wrapping tile never stretches its row taller than the rest). */
--profile-modal-nav-tile-height: 5rem;
--profile-modal-scrollbar-offset: var(--space-2xs);
--modal-embed-height: 80vh;
--profile-history-max-height: 26.25rem;
+59 -5
View File
@@ -102,6 +102,8 @@ import {
import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js";
import { rankHashtagsByFrequency } from "./utils/hashtagSuggestions.js";
import { launchBitvidTour } from "./ui/onboarding/bitvidTour.js";
import { shouldOfferOnboarding } from "./services/onboardingService.js";
import { showConfirm } from "./ui/confirmDialog.js";
import {
decideSignerEnsure,
isSignerCapable,
@@ -1759,13 +1761,40 @@ class Application {
}
if (pubkey) {
// Best-effort, never blocks login: offer to restore encrypted settings
// synced from another device.
this.maybeOfferSettingsRestore(pubkey);
// Order matters: offer the tour FIRST so it opens the "tour gate"
// synchronously when it will run. The settings-restore prompt then waits
// on that gate before showing its dialog, so it can't pop over the tour.
this.maybeOfferOnboardingTour(pubkey);
this.maybeOfferSettingsRestore(pubkey);
}
}
// A gate the settings-restore prompt awaits so its confirmation dialog never
// pops over the onboarding tour. Opened synchronously when the tour will run,
// resolved when the tour finishes/exits (or fails to start).
_openTourGate() {
if (this._tourGateResolve) {
return;
}
this._tourGate = new Promise((resolve) => {
this._tourGateResolve = resolve;
});
}
_closeTourGate() {
const resolve = this._tourGateResolve;
this._tourGateResolve = null;
this._tourGate = null;
if (typeof resolve === "function") {
resolve();
}
}
// Resolves immediately unless the onboarding tour is (about to be) active.
whenOnboardingTourInactive() {
return this._tourGate || Promise.resolve();
}
// First login of this pubkey on this device: offer the guided tour
// (docs/onboarding-plan.md). Deferred so the feed has rendered and the
// settings-restore prompt (2s window; only fires for accounts with synced
@@ -1775,14 +1804,26 @@ class Application {
if (!key) {
return;
}
// If the tour will actually run, open the gate NOW (synchronously) so the
// settings-restore prompt waits — even though the tour itself starts on a
// delay. If it won't run, leave the gate closed so restore proceeds normally.
if (!shouldOfferOnboarding(key)) {
return;
}
this._openTourGate();
setTimeout(() => {
try {
if (!this.isUserLoggedIn()) {
this._closeTourGate();
return;
}
this.startOnboardingTour({ pubkey: key, force: false });
const started = this.startOnboardingTour({ pubkey: key, force: false });
if (!started) {
this._closeTourGate();
}
} catch (error) {
devLogger.warn("[Application] Onboarding tour offer failed:", error);
this._closeTourGate();
}
}, 3500);
}
@@ -1795,7 +1836,7 @@ class Application {
if (!key) {
return false;
}
return launchBitvidTour({
const started = launchBitvidTour({
pubkey: key,
force,
openProfilePane: (pane) => {
@@ -1805,7 +1846,13 @@ class Application {
devLogger.warn("[Application] Tour deep-link failed:", error);
}
},
// Release the settings-restore prompt once the tour is done/exited.
onEnd: () => this._closeTourGate(),
});
if (!started) {
this._closeTourGate();
}
return started;
}
// One-time offer to restore encrypted storage/wallet settings on login (todo
@@ -1826,6 +1873,13 @@ class Application {
walletSync: createWalletSyncService({
nwcSettings: this.nwcSettingsService,
}),
// Never show the restore dialog over the onboarding tour — wait for
// it to finish/exit first. (The remote existence check still runs in
// parallel; only the confirmation dialog is held back.)
confirm: async (message) => {
await this.whenOnboardingTourInactive();
return showConfirm(message, { confirmLabel: "Restore" });
},
});
}
Promise.resolve(
+115 -30
View File
@@ -5539,46 +5539,124 @@ async function loadUserVideos(pubkey) {
// 1) Build filters for this pubkey's videos: native kind-30078 + NIP-71.
const filters = buildChannelVideoFilters(pubkey);
// 2) Collect raw events from all relays
// Accumulate raw events across every source (deduped by id) so the grid can
// paint PROGRESSIVELY as each relay/source returns, instead of blocking on
// the slowest relay's 8s timeout.
const events = [];
const seenEventIds = new Set();
const addEvents = (list) => {
let added = 0;
for (const evt of Array.isArray(list) ? list : []) {
if (evt && evt.id && !seenEventIds.has(evt.id)) {
seenEventIds.add(evt.id);
events.push(evt);
added += 1;
}
}
return added;
};
// Serialized, coalesced re-render. Mid-flight renders NEVER pass
// allowEmptyMessage, so a partial/slow fetch can't flash "No videos" — that
// message is reserved for the single final render below.
let renderInFlight = null;
let renderPending = false;
const scheduleRender = () => {
renderPending = true;
if (renderInFlight) {
return renderInFlight;
}
renderInFlight = (async () => {
while (renderPending) {
renderPending = false;
if (loadToken !== currentVideoLoadToken || !container) {
break;
}
const partial = buildRenderableChannelVideos({
events: mergeChannelVideoSources(
events,
getCachedChannelVideoEvents(pubkey)
),
app,
});
const paintedNow = await renderChannelVideosFromList({
videos: partial,
container,
app,
loadToken,
allowEmptyMessage: false,
});
if (paintedNow) {
hasVisibleContent = true;
renderedFromCache = true;
}
}
renderInFlight = null;
})();
return renderInFlight;
};
const knownRelays = Array.isArray(nostrClient.relays)
? nostrClient.relays
: Array.from(nostrClient.relays || []);
const relayList = knownRelays.filter((url) => isValidRelayUrl(url));
// ALWAYS include the bundled defaults. A creator's videos can live only on
// a default relay (e.g. a NIP-71 video that's only on nos.lol) that the
// user's capped NIP-65 read set excludes — otherwise a known, whitelisted
// video never shows on the channel because we never query the one relay
// that has it.
const relayList = [
...new Set([...knownRelays, ...DEFAULT_RELAY_URLS]),
].filter((url) => isValidRelayUrl(url));
// Route one fetch through the app's PROVEN path (the same one "My Videos"
// uses on refresh). It streams into the shared cache, so this grid, its
// revisits, and every other grid all benefit — then re-render from it.
const sharedFetch = (async () => {
try {
if (typeof app?.nostrService?.fetchVideosByAuthors === "function") {
await app.nostrService.fetchVideosByAuthors([pubkey]);
await scheduleRender();
}
} catch (error) {
// best-effort; the scoped relay fetch below still populates the grid
}
})();
// Scoped relay fetch (also covers NIP-71 cross-posts), rendered
// progressively as each relay returns rather than awaiting them all.
if (relayList.length === 0) {
try {
const fallbackEvents = await listRelayWithTimeout(
Array.from(DEFAULT_RELAY_URLS),
filters
);
if (Array.isArray(fallbackEvents)) {
events.push(...fallbackEvents);
if (addEvents(fallbackEvents) > 0) {
await scheduleRender();
}
} catch (error) {
userLogger.error("Relay error (default pool):", error);
}
} else {
const settled = await pMapSettled(
await pMapSettled(
relayList,
(url) => listRelayWithTimeout([url], filters),
async (url) => {
try {
const relayEvents = await listRelayWithTimeout([url], filters);
if (addEvents(relayEvents) > 0) {
await scheduleRender();
}
return relayEvents;
} catch (error) {
userLogger.error(`Relay error (${url}):`, error);
return [];
}
},
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
);
settled.forEach((result, index) => {
const relayUrl = relayList[index];
if (result.status === "fulfilled") {
const relayEvents = Array.isArray(result.value) ? result.value : [];
events.push(...relayEvents);
return;
}
if (result.reason) {
userLogger.error(`Relay error (${relayUrl}):`, result.reason);
}
});
}
await sharedFetch;
await renderInFlight;
await ensureAccessPromise;
if (loadToken !== currentVideoLoadToken) {
@@ -5590,21 +5668,28 @@ async function loadUserVideos(pubkey) {
return;
}
const videos = buildRenderableChannelVideos({ events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app });
saveCachedChannelVideos(pubkey, videos);
const rendered = await renderChannelVideosFromList({
videos,
container,
// Persist the merged set. Only do a final render when nothing painted during
// progressive rendering — that render carries the empty message, shown ONLY
// now that every source has genuinely settled.
const videos = buildRenderableChannelVideos({
events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)),
app,
loadToken,
allowEmptyMessage: !hasVisibleContent
});
saveCachedChannelVideos(pubkey, videos);
if (rendered) {
hasVisibleContent = true;
if (!hasVisibleContent) {
const rendered = await renderChannelVideosFromList({
videos,
container,
app,
loadToken,
allowEmptyMessage: true,
});
if (rendered) {
hasVisibleContent = true;
}
renderedFromCache = rendered || renderedFromCache;
}
renderedFromCache = rendered || renderedFromCache;
} catch (err) {
if (
loadToken === currentVideoLoadToken &&
+17
View File
@@ -59,6 +59,22 @@ export function createEncryptedSyncItem({
return Boolean(result?.exists);
}
// Does this device already have the item locally? (A non-null payload means
// there's something to sync — e.g. a connected wallet / configured storage.)
// Used by the login offer to re-surface a restore when the item is MISSING
// here but a Nostr backup exists.
async function hasLocal(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return false;
}
try {
return Boolean(await buildPayload(key));
} catch (error) {
return false;
}
}
async function push(pubkey, { confirmOverwrite } = {}) {
const key = normalizePubkey(pubkey);
if (!key) {
@@ -151,6 +167,7 @@ export function createEncryptedSyncItem({
isAvailable,
isEnabled,
hasRemote,
hasLocal,
push,
pull,
autoPullIfNewer,
+51 -3
View File
@@ -10,13 +10,16 @@ import { showConfirm } from "../ui/confirmDialog.js";
import { setSyncEnabled } from "./settingsSyncFlags.js";
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
// Set when the user explicitly declines a restore offer, so the "re-offer while
// the item is missing locally" behavior below doesn't nag them on every login.
const DISMISSED_KEY = "bitvid:settings-sync:dismissed:v1";
function readOffered() {
function readMap(storageKey) {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(OFFERED_KEY);
const raw = localStorage.getItem(storageKey);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
@@ -24,6 +27,31 @@ function readOffered() {
}
}
function readOffered() {
return readMap(OFFERED_KEY);
}
function isDismissed(pubkey, kind) {
const entry = readMap(DISMISSED_KEY)[pubkey];
return Boolean(entry && typeof entry === "object" && entry[kind]);
}
function markDismissed(pubkey, kind) {
try {
if (typeof localStorage === "undefined") {
return;
}
const map = readMap(DISMISSED_KEY);
const kinds =
map[pubkey] && typeof map[pubkey] === "object" ? { ...map[pubkey] } : {};
kinds[kind] = true;
map[pubkey] = kinds;
localStorage.setItem(DISMISSED_KEY, JSON.stringify(map));
} catch (error) {
// Best-effort.
}
}
// Per-pubkey, PER-ITEM offered state. Tracking only per-pubkey meant the first
// item offered (usually storage) flagged the whole pubkey, so a wallet note
// published later was never offered — "the wallet doesn't restore like storage".
@@ -78,13 +106,29 @@ export function createSettingsRestorePrompt({
["wallet", walletSync],
];
for (const [kind, service] of services) {
// Enabled items auto-pull silently elsewhere; declined items stay quiet.
if (
!service?.isAvailable?.() ||
service.isEnabled(pubkey) ||
hasOffered(pubkey, kind)
isDismissed(pubkey, kind)
) {
continue;
}
// Re-offer whenever the item is MISSING locally (e.g. a fresh device or a
// cleared wallet) so a Nostr backup gets surfaced — "restore like storage".
// Only when we DO have it locally do we respect the once-per-device flag
// (no nagging). Unknown (no hasLocal) → assume present → keep old behavior.
let localCopy = true;
if (typeof service.hasLocal === "function") {
try {
localCopy = Boolean(await service.hasLocal(pubkey));
} catch (error) {
localCopy = true;
}
}
if (localCopy && hasOffered(pubkey, kind)) {
continue;
}
try {
if (await service.hasRemote()) {
candidates.push(kind);
@@ -147,6 +191,10 @@ export function createSettingsRestorePrompt({
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?`
);
if (!accepted) {
// Remember the decline so we don't re-offer a missing item every login.
for (const kind of candidates) {
markDismissed(key, kind);
}
return { offered: true, accepted: false };
}
+16 -3
View File
@@ -74,19 +74,32 @@ export function launchBitvidTour({
openProfilePane,
document: doc,
force = false,
onEnd,
} = {}) {
if (!force && !shouldOfferOnboarding(pubkey)) {
return false;
}
// Fire onEnd on both finish and skip so callers can release anything they
// deferred for the tour's duration (e.g. the settings-restore prompt).
const end = (status) => {
markOnboarding(pubkey, status);
try {
onEnd?.(status);
} catch (error) {
// best-effort
}
};
const tour = createTour({
steps: buildBitvidTourSteps({ openProfilePane }),
document: doc,
onFinish: () => markOnboarding(pubkey, "completed"),
onSkip: () => markOnboarding(pubkey, "skipped"),
onFinish: () => end("completed"),
onSkip: () => end("skipped"),
});
return Boolean(tour?.start());
const started = Boolean(tour?.start());
return started;
}
export default launchBitvidTour;
+35 -1
View File
@@ -57,6 +57,20 @@ const TOUR_CSS = `
padding: 1rem;
transition: left 0.28s ease, top 0.28s ease;
}
/* Mobile: dock the popover as a bottom sheet instead of anchoring it to the
target. Anchored placement is fragile on phones (short viewports, wrapping
buttons, URL-bar resize) — a fixed sheet is always in a predictable spot and
the spotlight still highlights the (centered-scrolled) target above it. */
.bv-tour-popover.bv-tour-popover--sheet {
left: 0.75rem;
right: 0.75rem;
top: auto;
bottom: max(0.75rem, env(safe-area-inset-bottom, 0px));
transform: none;
width: auto;
max-width: 32rem;
margin-inline: auto;
}
.bv-tour-popover h3 { font-size: 1rem; font-weight: 700; margin: 0 0 0.375rem; }
.bv-tour-popover p { font-size: 0.875rem; margin: 0 0 0.75rem; opacity: 0.9; }
.bv-tour-dots { display: flex; gap: 0.375rem; margin-bottom: 0.75rem; }
@@ -171,10 +185,13 @@ export function createTour({
}
};
const isMobileViewport = () => (doc.defaultView?.innerWidth || 1200) < 640;
function measure(step) {
const target = step.target ? doc.querySelector(step.target) : null;
if (!target) {
// Centered card: zero-size cutout in the middle (pure dim).
state.popover.classList.remove("bv-tour-popover--sheet");
setVar(state.scrim, "--bv-tour-x", "50vw");
setVar(state.scrim, "--bv-tour-y", "50vh");
setVar(state.scrim, "--bv-tour-w", "0px");
@@ -187,8 +204,13 @@ export function createTour({
return;
}
const mobile = isMobileViewport();
try {
target.scrollIntoView({ block: "nearest", inline: "nearest" });
// Center the target on mobile so it sits above the bottom sheet.
target.scrollIntoView({
block: mobile ? "center" : "nearest",
inline: "nearest",
});
} catch (error) {
// older engines
}
@@ -200,11 +222,23 @@ export function createTour({
setVar(state.scrim, "--bv-tour-w", `${rect.width + pad * 2}px`);
setVar(state.scrim, "--bv-tour-h", `${rect.height + pad * 2}px`);
if (mobile) {
// Bottom sheet — no anchored-placement math to get wrong.
state.popover.classList.add("bv-tour-popover--sheet");
return;
}
// Desktop: anchor, but measure the ACTUAL popover so the fit checks are
// accurate (the old 240px/336px guesses pushed tall popovers off-screen).
state.popover.classList.remove("bv-tour-popover--sheet");
const popRect = state.popover.getBoundingClientRect();
const placementResult = computeTourPlacement({
rect,
viewportWidth: doc.defaultView?.innerWidth || 1200,
viewportHeight: doc.defaultView?.innerHeight || 800,
preferred: step.placement || "",
popWidth: popRect.width || 336,
popHeight: popRect.height || 240,
});
setVar(state.popover, "--bv-tour-px", `${placementResult.px}px`);
setVar(state.popover, "--bv-tour-py", `${placementResult.py}px`);
+2 -4
View File
@@ -161,8 +161,7 @@ export class ProfileAdminRenderer {
values.forEach((npub) => {
const item = document.createElement("li");
item.className =
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
item.className = "card profile-account-card";
const normalizedNpub = typeof npub === "string" ? npub.trim() : "";
const comparableNpub =
@@ -216,8 +215,7 @@ export class ProfileAdminRenderer {
}
const actions = document.createElement("div");
actions.className =
"flex flex-wrap items-center justify-end gap-2 sm:flex-none";
actions.className = "profile-account-card__actions";
const viewButton = this.mainController.createViewChannelButton({
targetNpub: encodedNpub,
@@ -282,8 +282,7 @@ export class ProfileBlockListController {
deduped.forEach(({ hex, label }) => {
const item = document.createElement("li");
item.className =
"card flex items-center justify-between gap-4 p-4";
item.className = "card profile-account-card";
let cachedProfile = null;
if (hex) {
@@ -311,7 +310,7 @@ export class ProfileBlockListController {
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
actions.className = "profile-account-card__actions";
const viewButton = this.mainController.createViewChannelButton({
targetNpub: encodedNpub,
@@ -501,7 +501,7 @@ export class ProfileModerationController {
item.className = "card space-y-2 p-4";
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-4";
row.className = "profile-account-card__row";
const authorKey = entry.authorPubkey;
let profileSummary = null;
@@ -516,7 +516,7 @@ export class ProfileModerationController {
profileSummary = this.mainController.dmController.createCompactProfileSummary(summaryData);
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
actions.className = "profile-account-card__actions";
const resetButton = this.mainController.createRemoveButton({
label: "Reset",
+4 -4
View File
@@ -3911,7 +3911,7 @@ export class ProfileModalController {
deduped.forEach(({ hex, label }) => {
const item = document.createElement("li");
item.className = "card flex items-center justify-between gap-4 p-4";
item.className = "card profile-account-card";
let cachedProfile = null;
if (hex && typeof this.services.getProfileCacheEntry === "function") {
@@ -3943,7 +3943,7 @@ export class ProfileModalController {
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
actions.className = "profile-account-card__actions";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
@@ -4230,7 +4230,7 @@ export class ProfileModalController {
deduped.forEach(({ hex, label }) => {
const item = document.createElement("li");
item.className = "card flex items-center justify-between gap-4 p-4";
item.className = "card profile-account-card";
let cachedProfile = null;
if (hex && typeof this.services.getProfileCacheEntry === "function") {
@@ -4262,7 +4262,7 @@ export class ProfileModalController {
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
actions.className = "profile-account-card__actions";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
+1 -1
View File
@@ -29,7 +29,7 @@ const GRANDFATHERED = {
"js/ui/profileModalController.js": 8130,
"js/ui/components/VideoModal.js": 4976,
"js/app.js": 5602,
"js/channelProfile.js": 5529,
"js/channelProfile.js": 5806,
"js/nostr/client.js": 4356,
"js/ui/components/VideoCard.js": 3129,
"js/nostrEventSchemas.js": 3541,
+66 -2
View File
@@ -15,8 +15,13 @@ import { isSyncEnabled } from "../js/services/settingsSyncFlags.js";
const PUBKEY = "c".repeat(64);
function makeSyncStub({ available = true, enabled = false, remote = false } = {}) {
return {
function makeSyncStub({
available = true,
enabled = false,
remote = false,
local = undefined,
} = {}) {
const stub = {
pulls: 0,
isAvailable: () => available,
isEnabled: () => enabled,
@@ -26,8 +31,16 @@ function makeSyncStub({ available = true, enabled = false, remote = false } = {}
return { found: true, imported: true };
},
};
// Only real services expose hasLocal; omitting it (local === undefined) keeps
// the legacy "assume present" behavior the older tests rely on.
if (local !== undefined) {
stub.hasLocal = async () => local;
}
return stub;
}
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
test("offers and restores items that have a remote copy and aren't already enabled", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
@@ -51,6 +64,57 @@ test("offers and restores items that have a remote copy and aren't already enabl
assert.deepEqual(restoredCalls, [["storage"]]);
});
test("re-offers a MISSING item even after it was already offered once (restore-like-storage)", async () => {
localStorage.clear();
// Simulate a prior offer for wallet on this device (the old blocker).
localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } }));
const walletSync = makeSyncStub({ remote: true, local: false });
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: false, local: true }),
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.equal(result.accepted, true);
assert.deepEqual(result.restored, ["wallet"]);
assert.equal(walletSync.pulls, 1, "missing wallet is re-surfaced despite the prior offer");
});
test("does NOT re-offer an item that is present locally once prompted", async () => {
localStorage.clear();
localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } }));
const walletSync = makeSyncStub({ remote: true, local: true });
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: false, local: true }),
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.equal(result.offered, false, "present + already offered → no nag");
assert.equal(walletSync.pulls, 0);
});
test("dismissing a missing item stops it re-offering every login", async () => {
localStorage.clear();
const walletSync = makeSyncStub({ remote: true, local: false });
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: false, local: true }),
walletSync,
confirm: () => false, // decline
});
const first = await prompt.maybeOffer(PUBKEY);
assert.equal(first.offered, true);
assert.equal(first.accepted, false);
// Next login: still missing locally, but the decline is remembered.
const second = await prompt.maybeOffer(PUBKEY);
assert.equal(second.offered, false, "declined → stays quiet even though still missing");
assert.equal(walletSync.pulls, 0);
});
test("declining the prompt restores nothing", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });