mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 08:52:25 +00:00
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:
commit
f2e35f619b
14 files changed
+447
-61
No files matched your search
+74
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user