From c240598a5b9623f73fe66c292cd0724999bbcf18 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:03:42 +0200 Subject: [PATCH 1/2] test(mobile): reproduce blank restored sidebar view --- tests/e2e/journey-mobile.test.ts | 48 +++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/e2e/journey-mobile.test.ts b/tests/e2e/journey-mobile.test.ts index 1d792603..f1993858 100644 --- a/tests/e2e/journey-mobile.test.ts +++ b/tests/e2e/journey-mobile.test.ts @@ -1,13 +1,14 @@ import { describe, expect, onTestFinished, test } from "vitest"; import { createPodNotesE2EHarness } from "./harness"; import { + asPhone, closePodNotes, closeSettings, enableAsNewInstall, reloadObsidian, setMobileEmulation, } from "./journey/actions"; -import { click, typeText } from "./journey/input"; +import { click, touchDrag, typeText } from "./journey/input"; import { ui } from "./journey/targets"; import { holdsFor, waitUntilFocused, waitUntilShown } from "./journey/wait"; import { type FixtureFeed, startFixtureServer } from "./journey/fixtures"; @@ -20,6 +21,51 @@ const getContext = createPodNotesE2EHarness("journey-mobile"); describe("mobile journeys", () => { // Obsidian's mobile emulation on desktop: the plugin runs its mobile code paths and // the mobile layout, but this is not an iOS or Android device. + test("a restored phone sidebar shows Library when selected through the workspace picker", async () => { + const context = getContext(); + const { obsidian } = context; + const show: FixtureFeed = { + title: "Sidebar Podcast", + slug: "sidebar-podcast", + artwork: false, + episodes: [{ title: "Sidebar Pilot", seconds: 6 }], + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [show] }); + await asPhone(obsidian); + await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getRightLeaf(false); + await leaf.setViewState({ type: "podcast_player_view", state: { page: { kind: "library" } }, active: false }); + app.workspace.rightSplit.collapse(); + await app.workspace.saveLayout(); + return true; + })()`); + await reloadObsidian(obsidian); + expect( + await obsidian.dev.evalJson( + 'app.workspace.getLeavesOfType("podcast_player_view").map(leaf => leaf.isDeferred)', + ), + ).toEqual([true]); + expect( + await obsidian.dev.evalJson('document.querySelector(".podcast-view") !== null'), + ).toBe(false); + + await touchDrag(obsidian, { x: 385, y: 400 }, { x: 85, y: 400 }); + await click(obsidian, { + name: "right sidebar view picker", + elements: + '[...document.querySelectorAll(".workspace-drawer.mod-right .workspace-drawer-tab-select")]', + }); + await click(obsidian, { + name: "Podcast Player sidebar option", + elements: + "[...document.querySelectorAll('.workspace-drawer.mod-right .workspace-drawer-tab-options-list [aria-label=\"Podcast Player\"]')]", + }); + await waitUntilShown(obsidian, ui.libraryHead); + await waitUntilShown(obsidian, ui.podcastCard(show.title)); + expect(await playerLeafCount(obsidian)).toBe(1); + }, 120_000); test("J-ONB-02 a new install on mobile opens PodNotes once, on First run, ready to type", async () => { const context = getContext(); const { obsidian, plugin } = context; From 76e93fa444a8386c73e45886816a80e90ee34c82 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:09:47 +0200 Subject: [PATCH 2/2] fix(mobile): mount the player when Obsidian opens its view --- src/commands.test.ts | 1 - src/getContextMenuHandler.test.ts | 1 - src/main.activateView.test.ts | 42 ------------------ src/main.fileRenames.test.ts | 1 - src/main.ts | 27 +----------- src/main.undo.test.ts | 1 - src/types/IPodNotes.ts | 6 --- src/ui/PodcastView/index.notes.test.ts | 2 - src/ui/PodcastView/index.test.ts | 61 ++++++-------------------- src/ui/PodcastView/index.ts | 13 ------ 10 files changed, 16 insertions(+), 139 deletions(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index aaaeefb5..eeaa505b 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -184,7 +184,6 @@ async function loadPlugin(data: Record) { closing: null, writeInFlight: null, storeUnsubscribers: [], - views: new Set(), manifest: { dir: ".obsidian/plugins/podnotes" }, app: { secretStorage: { diff --git a/src/getContextMenuHandler.test.ts b/src/getContextMenuHandler.test.ts index 06895d51..2fad2d34 100644 --- a/src/getContextMenuHandler.test.ts +++ b/src/getContextMenuHandler.test.ts @@ -115,7 +115,6 @@ function makeApp(workspace: unknown, file: TFile) { app, player, navigation: new Navigation(), - views: new Set(), }); plugin.set(podnotes); return app; diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index f066e7c0..0238f51b 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -41,10 +41,6 @@ function setupPlugin({ // Build a bare instance so we exercise activateView without running the full // onload() side effects (store wiring, command registration, etc.). const plugin = Object.create(PodNotes.prototype) as PodNotes; - Object.assign(plugin as unknown as Record, { - podcastViewMountEnabled: true, - views: new Set(), - }); (plugin as unknown as { app: { workspace: typeof workspace } }).app = { workspace, }; @@ -88,25 +84,6 @@ describe("PodNotes.activateView", () => { await expect(plugin.activateView()).resolves.toBeUndefined(); expect(workspace.revealLeaf).not.toHaveBeenCalled(); }); - - it("enables and mounts a dormant restored view before revealing it", async () => { - const existing = makeLeaf(); - const firstRestoredView = { mountPodcastView: vi.fn() }; - const secondRestoredView = { mountPodcastView: vi.fn() }; - const { plugin } = setupPlugin({ existingLeaves: [existing] }); - Object.assign(plugin as unknown as Record, { - podcastViewMountEnabled: false, - views: new Set([firstRestoredView, secondRestoredView]), - }); - - expect(plugin.shouldMountPodcastView()).toBe(false); - - await plugin.activateView(); - - expect(plugin.shouldMountPodcastView()).toBe(true); - expect(firstRestoredView.mountPodcastView).toHaveBeenCalledTimes(1); - expect(secondRestoredView.mountPodcastView).toHaveBeenCalledTimes(1); - }); }); describe("PodNotes.onLayoutReady", () => { @@ -150,9 +127,6 @@ describe("PodNotes.onLayoutReady", () => { isUnloaded: false, isReady: true, storeUnsubscribers: [], - views: new Set(), - // onload leaves the view dormant in the mobile app. - podcastViewMountEnabled: runtime === "desktop", settings: structuredClone(DEFAULT_SETTINGS), persistenceUnknownFields: {}, loadData: vi.fn(async () => structuredClone(disk) ?? null), @@ -250,21 +224,6 @@ describe("PodNotes.onLayoutReady", () => { expect(workspace.detachLeavesOfType).not.toHaveBeenCalled(); }); }); - - it("mounts the dormant view when the mobile app opens it on the first start", async () => { - const { plugin } = await start("mobile"); - - expect(plugin.shouldMountPodcastView()).toBe(true); - }); - - it("keeps the view dormant on a later mobile start", async () => { - const { plugin } = await start( - "mobile", - encodePodNotesData(structuredClone(DEFAULT_SETTINGS)), - ); - - expect(plugin.shouldMountPodcastView()).toBe(false); - }); }); // Locks the actual #55 wiring (not just activateView's internals): the @@ -328,7 +287,6 @@ describe("PodNotes onload wiring (#55)", () => { closing: null, writeInFlight: null, storeUnsubscribers: [], - views: new Set(), manifest: { dir: ".obsidian/plugins/podnotes" }, app: { loadLocalStorage: vi.fn(() => null), diff --git a/src/main.fileRenames.test.ts b/src/main.fileRenames.test.ts index fc593d1b..84c0d943 100644 --- a/src/main.fileRenames.test.ts +++ b/src/main.fileRenames.test.ts @@ -73,7 +73,6 @@ async function loadPlugin(data: Record): Promise { closing: null, writeInFlight: null, storeUnsubscribers: [], - views: new Set(), manifest: { dir: ".obsidian/plugins/podnotes" }, app: { loadLocalStorage: vi.fn(() => null), diff --git a/src/main.ts b/src/main.ts index 789c08bb..30e88773 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; import { registerCommands } from "src/commands"; -import { normalizePath, Notice, Platform, Plugin, type WorkspaceLeaf } from "obsidian"; +import { normalizePath, Notice, Plugin, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; import { VIEW_TYPE } from "src/constants"; @@ -96,8 +96,6 @@ export default class PodNotes extends Plugin implements IPodNotes { public navigation!: Navigation; public online!: Readable; - private views = new Set(); - // Store subscriptions tied to the plugin lifetime. Disposed together in onunload. private storeUnsubscribers: Unsubscriber[] = []; private transcriptionService?: TranscriptionService; @@ -106,7 +104,6 @@ export default class PodNotes extends Plugin implements IPodNotes { private isUnloaded = false; private freshInstall = false; - private podcastViewMountEnabled = true; private isReady = false; // Set once the window starts unloading or Obsidian quits: a write started then is // cut off after it truncates the file, which leaves data.json empty. @@ -123,8 +120,6 @@ export default class PodNotes extends Plugin implements IPodNotes { // Before registerView, where the views Obsidian restores mount and append their sheets. removePreviousStyles(this.app.workspace); this.isUnloaded = false; - // The mobile app keeps a restored view dormant until PodNotes is opened. - this.podcastViewMountEnabled = !Platform.isMobileApp && this.app.isMobile !== true; this.registerDomEvent(window, "pagehide", () => { this.closing = "window-unloading"; }); @@ -202,11 +197,7 @@ export default class PodNotes extends Plugin implements IPodNotes { this.addSettingTab(new PodNotesSettingsTab(this.app, this)); this.navigation = new Navigation(); - this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => { - const view = new MainView(leaf, this); - this.views.add(view); - return view; - }); + this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => new MainView(leaf, this)); // PodNotes is a single-instance view, and onunload deliberately does NOT // detach its leaves (detaching there would reset a leaf the user moved back @@ -265,11 +256,6 @@ export default class PodNotes extends Plugin implements IPodNotes { // makes "Show PodNotes" and the ribbon icon reliably surface the view even // when it is already open but hidden in a collapsed/overflowing sidebar (#55). async activateView(): Promise { - this.podcastViewMountEnabled = true; - for (const view of this.views) { - view.mountPodcastView(); - } - const { workspace } = this.app; const existing = workspace.getLeavesOfType(VIEW_TYPE); @@ -308,14 +294,6 @@ export default class PodNotes extends Plugin implements IPodNotes { void this.saveSettings(); } - shouldMountPodcastView(): boolean { - return this.podcastViewMountEnabled; - } - - unregisterPodcastView(view: MainView): void { - this.views.delete(view); - } - // Collapse the single-instance player view back to one leaf, keeping the // first (earliest) so a cold-restart-restored leaf is preserved while a // hot-reload's duplicate auto-open is dropped. Runs on layout-change; a no-op @@ -352,7 +330,6 @@ export default class PodNotes extends Plugin implements IPodNotes { this.localFilesMirrorUnsubscribe?.(); this.transcriptionService?.dispose(); this.transcriptionService = undefined; - this.views.clear(); // Intentionally do NOT detach the view's leaves here. Obsidian persists and // restores plugin leaves across reloads; detaching in onunload would reset a diff --git a/src/main.undo.test.ts b/src/main.undo.test.ts index 19537b6e..9856b87b 100644 --- a/src/main.undo.test.ts +++ b/src/main.undo.test.ts @@ -52,7 +52,6 @@ async function loadPlugin(secrets: obsidian.SecretStorage): Promise { closing: null, writeInFlight: null, storeUnsubscribers: [], - views: new Set(), manifest: { dir: ".obsidian/plugins/podnotes" }, app: { loadLocalStorage: vi.fn(() => null), diff --git a/src/types/IPodNotes.ts b/src/types/IPodNotes.ts index dbf18939..b3b28732 100644 --- a/src/types/IPodNotes.ts +++ b/src/types/IPodNotes.ts @@ -8,10 +8,6 @@ import type { Navigation } from "src/ui/PodcastView/Navigation"; import type { Readable } from "svelte/store"; import type { IPodNotesSettings } from "./IPodNotesSettings"; -interface PodNotesViewRegistration { - mountPodcastView(): void; -} - export interface IPodNotes { settings: IPodNotesSettings; api: IAPI; @@ -21,8 +17,6 @@ export interface IPodNotes { navigation: Navigation; capture: CaptureService; online: Readable; - shouldMountPodcastView(): boolean; - unregisterPodcastView(view: PodNotesViewRegistration): void; saveSettings(): Promise; showPlayer(): Promise; openSettingsTab(): void; diff --git a/src/ui/PodcastView/index.notes.test.ts b/src/ui/PodcastView/index.notes.test.ts index b939e098..c544ca50 100644 --- a/src/ui/PodcastView/index.notes.test.ts +++ b/src/ui/PodcastView/index.notes.test.ts @@ -64,8 +64,6 @@ async function openPodcastPage(notes: string[]) { const plugin = { ...services, feedUrls: new FeedUrlRepository(app.secretStorage), - shouldMountPodcastView: () => true, - unregisterPodcastView: () => {}, } as unknown as IPodNotes; const view = new MainView({} as WorkspaceLeaf, plugin); const contentEl = document.body.appendChild(document.createElement("div")); diff --git a/src/ui/PodcastView/index.test.ts b/src/ui/PodcastView/index.test.ts index 63a72c58..9268a03a 100644 --- a/src/ui/PodcastView/index.test.ts +++ b/src/ui/PodcastView/index.test.ts @@ -49,18 +49,8 @@ function createVault() { return vault; } -function createView({ - shouldMount, - layoutReady = true, -}: { - shouldMount: boolean; - layoutReady?: boolean; -}) { - const plugin = { - navigation: new Navigation(), - shouldMountPodcastView: vi.fn(() => shouldMount), - unregisterPodcastView: vi.fn(), - } as unknown as IPodNotes; +function createView({ layoutReady = true }: { layoutReady?: boolean } = {}) { + const plugin = { navigation: new Navigation() } as unknown as IPodNotes; const view = new MainView({} as WorkspaceLeaf, plugin); const contentEl = document.createElement("div"); const pending: Array<() => void> = []; @@ -96,22 +86,13 @@ async function closeView(view: MainView): Promise { await (view as unknown as { close(): Promise }).close(); } -describe("MainView mobile startup mounting", () => { +describe("MainView mounting", () => { afterEach(() => { vi.clearAllMocks(); }); - it("does not mount the Svelte UI when plugin startup keeps the view dormant", async () => { - const { plugin, view } = createView({ shouldMount: false }); - - await openView(view); - - expect(plugin.shouldMountPodcastView).toHaveBeenCalled(); - expect(svelte.mount).not.toHaveBeenCalled(); - }); - - it("mounts the Svelte UI when the plugin allows the view to wake", async () => { - const { contentEl, plugin, view } = createView({ shouldMount: true }); + it("mounts the Svelte UI when Obsidian opens the view", async () => { + const { contentEl, plugin, view } = createView(); await openView(view); @@ -123,7 +104,7 @@ describe("MainView mobile startup mounting", () => { }); it("gives its components the view's own key scope, the one Obsidian hands keys to", async () => { - const { view } = createView({ shouldMount: true }); + const { view } = createView(); await openView(view); @@ -132,7 +113,7 @@ describe("MainView mobile startup mounting", () => { }); it("Save as note asks for the feed's transcript without the paid fallback; Transcribe uses the audio", async () => { - const { plugin, view } = createView({ shouldMount: true }); + const { plugin, view } = createView(); const transcription = { transcribeCurrentEpisode: vi.fn(async () => {}), transcribeCurrentEpisodeFromAudio: vi.fn(), @@ -149,21 +130,8 @@ describe("MainView mobile startup mounting", () => { expect(transcription.transcribeCurrentEpisodeFromAudio).toHaveBeenCalledExactlyOnceWith(); }); - it("mountPodcastView wakes a dormant restored view exactly once", () => { - const { contentEl, plugin, view } = createView({ shouldMount: false }); - - view.mountPodcastView(); - view.mountPodcastView(); - - expect(svelte.mount).toHaveBeenCalledTimes(1); - expect(svelte.mount).toHaveBeenCalledWith(expect.anything(), { - target: contentEl, - props: { services: expect.objectContaining({ navigation: plugin.navigation }) }, - }); - }); - it("gives the view the plugin's settings, current through a toggle and an import", async () => { - const { plugin, view } = createView({ shouldMount: true }); + const { plugin, view } = createView(); const settings = (autoQueue: boolean): IPodNotesSettings => ({ ...structuredClone(DEFAULT_SETTINGS), autoQueue, @@ -184,13 +152,12 @@ describe("MainView mobile startup mounting", () => { }); it("unmounts a mounted PodcastView", async () => { - const { plugin, view } = createView({ shouldMount: true }); + const { view } = createView(); await openView(view); await closeView(view); expect(svelte.unmount).toHaveBeenCalledTimes(1); - expect(plugin.unregisterPodcastView).toHaveBeenCalledWith(view); }); }); @@ -199,7 +166,7 @@ describe("MainView episode notes", () => { const notePath = "podcasts/Episode One.md"; function createNotesView(options: { layoutReady?: boolean } = {}) { - const created = createView({ shouldMount: true, ...options }); + const created = createView(options); pluginStore.set({ app: created.app, settings: { note: { path: "podcasts/{{title}}" } }, @@ -317,12 +284,12 @@ describe("MainView episode notes", () => { describe("MainView workspace state", () => { it("saves the open page and restores it in a new session", async () => { - const first = createView({ shouldMount: true }); + const first = createView(); first.plugin.navigation.go({ kind: "feed", key: "Test Podcast" }); first.plugin.navigation.expand(); const saved = JSON.parse(JSON.stringify(first.view.getState())); - const next = createView({ shouldMount: true }); + const next = createView(); await next.view.setState(saved, { history: false }); expect(next.plugin.navigation.current).toEqual({ @@ -336,7 +303,7 @@ describe("MainView workspace state", () => { it.each([undefined, null, {}, { page: { kind: "feed" } }, { page: "queue" }])( "keeps the page for the saved state %j", async (state) => { - const { plugin, view } = createView({ shouldMount: true }); + const { plugin, view } = createView(); plugin.navigation.go({ kind: "latest" }); await view.setState(state, { history: false }); @@ -347,7 +314,7 @@ describe("MainView workspace state", () => { ); it("asks Obsidian to save the layout when the page changes, until the view closes", async () => { - const { plugin, view, workspace } = createView({ shouldMount: true }); + const { plugin, view, workspace } = createView(); await openView(view); plugin.navigation.expand(); diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index 012dd125..ed763fb0 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -66,18 +66,6 @@ export class MainView extends ItemView { this.app.workspace.requestSaveLayout(); }); - if (!this.plugin.shouldMountPodcastView()) { - return; - } - - this.mountPodcastView(); - } - - mountPodcastView(): void { - if (this.podcastView) { - return; - } - this.podcastView = mount(PodcastView, { target: this.contentEl, props: { services: this.services() }, @@ -159,6 +147,5 @@ export class MainView extends ItemView { } this.contentEl.empty(); - this.plugin.unregisterPodcastView(this); } }