diff --git a/packages/extension/rspack.config.js b/packages/extension/rspack.config.js index aa410df3ced..ce7a748ea08 100644 --- a/packages/extension/rspack.config.js +++ b/packages/extension/rspack.config.js @@ -220,6 +220,10 @@ const mainConfig = { import: path.join(sourcePath, 'frame', 'index.ts'), runtime: false, }, + ping: { + import: path.join(sourcePath, 'ping', 'index.ts'), + runtime: false, + }, newtab: { import: path.join(sourcePath, 'newtab', 'index.tsx'), runtime: 'runtime', @@ -253,7 +257,9 @@ const mainConfig = { ...baseConfig.optimization, splitChunks: { chunks(chunk) { - return !['content', 'companion', 'manifest'].includes(chunk.name); + return !['content', 'companion', 'manifest', 'ping'].includes( + chunk.name, + ); }, maxSize: 244000, cacheGroups: { diff --git a/packages/extension/src/content/index.tsx b/packages/extension/src/content/index.tsx index f0da0fb1ae1..ab6e2742c8e 100644 --- a/packages/extension/src/content/index.tsx +++ b/packages/extension/src/content/index.tsx @@ -2,30 +2,34 @@ import browser from 'webextension-polyfill'; import { removeLinkTargetElement } from '@dailydotdev/shared/src/lib/strings'; import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension'; -const isRendered = !!document.querySelector('daily-companion-app'); +// Keep the companion out of embedded article/reader iframes so our injected +// host element and CSS can never alter the page being previewed. +if (window.top === window.self) { + const isRendered = !!document.querySelector('daily-companion-app'); -if (!isRendered) { - // Inject app div - const appContainer = document.createElement('daily-companion-app'); - document.body.appendChild(appContainer); + if (!isRendered) { + // Inject app div + const appContainer = document.createElement('daily-companion-app'); + document.body.appendChild(appContainer); - // Create shadow dom - const shadow = document - .querySelector('daily-companion-app') - .attachShadow({ mode: 'open' }); + // Create shadow dom + const shadow = appContainer.attachShadow({ mode: 'open' }); - const wrapper = document.createElement('div'); - wrapper.id = 'daily-companion-wrapper'; - shadow.appendChild(wrapper); + const wrapper = document.createElement('div'); + wrapper.id = 'daily-companion-wrapper'; + shadow.appendChild(wrapper); - browser.runtime.sendMessage({ type: ExtensionMessageType.ContentLoaded }); + browser.runtime.sendMessage({ type: ExtensionMessageType.ContentLoaded }); - let lastUrl = removeLinkTargetElement(window.location.href); - new MutationObserver(() => { - const current = removeLinkTargetElement(window.location.href); - if (current !== lastUrl) { - lastUrl = current; - browser.runtime.sendMessage({ type: ExtensionMessageType.ContentLoaded }); - } - }).observe(document, { subtree: true, childList: true }); + let lastUrl = removeLinkTargetElement(window.location.href); + new MutationObserver(() => { + const current = removeLinkTargetElement(window.location.href); + if (current !== lastUrl) { + lastUrl = current; + browser.runtime.sendMessage({ + type: ExtensionMessageType.ContentLoaded, + }); + } + }).observe(document, { subtree: true, childList: true }); + } } diff --git a/packages/extension/src/frame/controller.spec.ts b/packages/extension/src/frame/controller.spec.ts new file mode 100644 index 00000000000..11e05cc36cf --- /dev/null +++ b/packages/extension/src/frame/controller.spec.ts @@ -0,0 +1,90 @@ +import { extensionSiteEmbedFrameEvent } from '@dailydotdev/shared/src/features/extensionEmbed/common'; +import browser from 'webextension-polyfill'; +import { initializeFrame } from './controller'; +import { + enableFrameEmbeddingViaBackground, + hasFrameEmbeddingPermissions, + requestFrameEmbeddingPermissions, +} from '../lib/frameEmbedding'; +import { renderMessage, renderPermissionPrompt } from './render'; + +jest.mock('../lib/frameEmbedding', () => ({ + enableFrameEmbeddingViaBackground: jest.fn(), + hasFrameEmbeddingPermissions: jest.fn(), + requestFrameEmbeddingPermissions: jest.fn(), +})); + +jest.mock('webextension-polyfill', () => ({ + runtime: { + reload: jest.fn(), + }, +})); + +jest.mock('./render', () => ({ + renderMessage: jest.fn(), + renderPermissionPrompt: jest.fn(), +})); + +describe('initializeFrame', () => { + const root = document.createElement('div'); + const target = new URL('https://example.com/article'); + const sendParentMessage = jest.fn(); + const onEmbeddingEnabled = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('requests an extension reload after permission is granted', async () => { + (hasFrameEmbeddingPermissions as jest.Mock).mockResolvedValue(false); + (requestFrameEmbeddingPermissions as jest.Mock).mockResolvedValue(true); + + let requestPermission: + | (() => Promise<'granted' | 'dismissed' | 'failed'>) + | undefined; + (renderPermissionPrompt as jest.Mock).mockImplementation( + ({ + onRequestPermission, + }: { + onRequestPermission: () => Promise<'granted' | 'dismissed' | 'failed'>; + }) => { + requestPermission = onRequestPermission; + }, + ); + + await initializeFrame({ + root, + target, + sendParentMessage, + onEmbeddingEnabled, + }); + + expect(requestPermission).toBeDefined(); + await expect(requestPermission?.()).resolves.toBe('granted'); + + expect(sendParentMessage).toHaveBeenCalledWith( + extensionSiteEmbedFrameEvent.Error, + { + reason: 'missing-permission', + target: target.href, + }, + ); + expect(sendParentMessage).toHaveBeenCalledWith( + extensionSiteEmbedFrameEvent.ReloadRequested, + { + target: target.href, + }, + ); + expect(enableFrameEmbeddingViaBackground).not.toHaveBeenCalled(); + expect(onEmbeddingEnabled).not.toHaveBeenCalled(); + expect(renderMessage).not.toHaveBeenCalled(); + expect(browser.runtime.reload).toHaveBeenCalledTimes(0); + jest.runOnlyPendingTimers(); + expect(browser.runtime.reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/extension/src/frame/controller.ts b/packages/extension/src/frame/controller.ts index b5191d10e86..ac27034f46d 100644 --- a/packages/extension/src/frame/controller.ts +++ b/packages/extension/src/frame/controller.ts @@ -15,6 +15,50 @@ const errorLog = (...args: unknown[]): void => { type SendParentMessage = (type: string, detail: Record) => void; +type PermissionRequestOutcome = 'granted' | 'dismissed' | 'failed'; + +const prepareEmbedding = async ({ + root, + target, + sendParentMessage, + onEmbeddingEnabled, +}: { + root: HTMLDivElement; + target: URL; + sendParentMessage: SendParentMessage; + onEmbeddingEnabled: () => void; +}): Promise => { + sendParentMessage(extensionSiteEmbedFrameEvent.PermissionsReady, { + target: target.href, + }); + + // The visible site iframe only mounts after the tab-scoped rule is live. + // That avoids a flash of the browser's built-in frame-blocked error page. + renderMessage( + root, + 'Preparing embedded browsing', + 'Configuring this tab so the requested site can be embedded safely.', + ); + + const result = await enableFrameEmbeddingViaBackground(); + if (!result.enabled) { + sendParentMessage(extensionSiteEmbedFrameEvent.Error, { + reason: 'enable-frame-embedding-failed', + target: target.href, + error: result.error ?? 'Unknown frame embedding error', + }); + return false; + } + + // The webapp keeps this frame mounted invisibly after success so it can + // tear the tab-scoped rule down when the page unloads or a new target starts. + onEmbeddingEnabled(); + sendParentMessage(extensionSiteEmbedFrameEvent.EmbeddingReady, { + target: target.href, + }); + return true; +}; + export const createFrameCleanupController = () => { let shouldDisableEmbeddingOnCleanup = false; @@ -53,39 +97,41 @@ export const initializeFrame = async ({ const hasPermissions = await hasFrameEmbeddingPermissions(); if (!hasPermissions) { - renderPermissionPrompt({ - root, - onRequestPermission: async () => { - try { - const granted = await requestFrameEmbeddingPermissions(); - - if (!granted) { - sendParentMessage(extensionSiteEmbedFrameEvent.Error, { - reason: 'permission-denied', - target: target.href, - }); - return 'dismissed'; - } - - sendParentMessage(extensionSiteEmbedFrameEvent.ReloadRequested, { - target: target.href, - }); - - // After the optional permission prompt resolves, the fresh extension - // context is much more reliable for enabling the DNR session rule. - globalThis.setTimeout(() => { - browser.runtime.reload(); - }, 100); + const onRequestPermission = async (): Promise => { + try { + const granted = await requestFrameEmbeddingPermissions(); - return 'granted'; - } catch { + if (!granted) { sendParentMessage(extensionSiteEmbedFrameEvent.Error, { - reason: 'permission-request-failed', + reason: 'permission-denied', target: target.href, }); - return 'failed'; + return 'dismissed'; } - }, + + sendParentMessage(extensionSiteEmbedFrameEvent.ReloadRequested, { + target: target.href, + }); + + // After the optional permission prompt resolves, the fresh extension + // context is much more reliable for enabling the DNR session rule. + globalThis.setTimeout(() => { + browser.runtime.reload(); + }, 100); + + return 'granted'; + } catch { + sendParentMessage(extensionSiteEmbedFrameEvent.Error, { + reason: 'permission-request-failed', + target: target.href, + }); + return 'failed'; + } + }; + + renderPermissionPrompt({ + root, + onRequestPermission, }); sendParentMessage(extensionSiteEmbedFrameEvent.Error, { @@ -95,32 +141,10 @@ export const initializeFrame = async ({ return; } - sendParentMessage(extensionSiteEmbedFrameEvent.PermissionsReady, { - target: target.href, - }); - - // The visible site iframe only mounts after the tab-scoped rule is live. - // That avoids a flash of the browser's built-in frame-blocked error page. - renderMessage( + await prepareEmbedding({ root, - 'Preparing embedded browsing', - 'Configuring this tab so the requested site can be embedded safely.', - ); - - const result = await enableFrameEmbeddingViaBackground(); - if (!result.enabled) { - sendParentMessage(extensionSiteEmbedFrameEvent.Error, { - reason: 'enable-frame-embedding-failed', - target: target.href, - error: result.error ?? 'Unknown frame embedding error', - }); - return; - } - - // The webapp keeps this frame mounted invisibly after success so it can - // tear the tab-scoped rule down when the page unloads or a new target starts. - onEmbeddingEnabled(); - sendParentMessage(extensionSiteEmbedFrameEvent.EmbeddingReady, { - target: target.href, + target, + sendParentMessage, + onEmbeddingEnabled, }); }; diff --git a/packages/extension/src/frame/parentMessaging.ts b/packages/extension/src/frame/parentMessaging.ts index 172fbfa1cb6..23265435507 100644 --- a/packages/extension/src/frame/parentMessaging.ts +++ b/packages/extension/src/frame/parentMessaging.ts @@ -9,9 +9,26 @@ const isAllowedParentHost = (hostname: string): boolean => hostname.endsWith('.daily.dev') || hostname.endsWith('.local.fylla.dev'); +const isSelfExtensionOrigin = (origin: URL): boolean => { + if ( + origin.protocol !== 'chrome-extension:' && + origin.protocol !== 'moz-extension:' + ) { + return false; + } + // Only accept the frame's own extension origin — this allows the new tab + // page (same extension) to embed frame.html, but rejects other extensions. + return ( + typeof window !== 'undefined' && origin.origin === window.location.origin + ); +}; + const getAllowedOrigin = (value: string): string | null => { try { const origin = new URL(value); + if (isSelfExtensionOrigin(origin)) { + return origin.origin; + } return isAllowedParentHost(origin.hostname) ? origin.origin : null; } catch { return null; diff --git a/packages/extension/src/frame/render.ts b/packages/extension/src/frame/render.ts index f9f968b168c..4271b361a8d 100644 --- a/packages/extension/src/frame/render.ts +++ b/packages/extension/src/frame/render.ts @@ -1,15 +1,165 @@ -const createCardContainer = (): HTMLDivElement => { - const container = document.createElement('div'); - container.style.cssText = - 'display:flex;flex:1;align-items:center;justify-content:center;padding:24px'; - return container; +const STYLE_TAG_ID = 'embedded-browsing-styles'; + +const ensureStyles = (): void => { + if (document.getElementById(STYLE_TAG_ID)) { + return; + } + const style = document.createElement('style'); + style.id = STYLE_TAG_ID; + style.textContent = ` + .embedded-browsing-shell { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + min-height: 28rem; + overflow: visible; + } + .embedded-browsing-ambient { + pointer-events: none; + position: absolute; + inset: 0; + overflow: hidden; + opacity: 0.44; + background: + repeating-linear-gradient( + 120deg, + rgba(255, 255, 255, 0.06) 0, + rgba(255, 255, 255, 0.06) 0.75rem, + transparent 0.75rem, + transparent 1.5rem + ), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.02), + rgba(255, 255, 255, 0.04) + ); + background-size: 190% 190%, 100% 100%; + animation: embeddedBrowsingStripeShift 42s linear infinite; + will-change: background-position; + } + .embedded-browsing-ambient::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 120deg, + transparent 0, + transparent 1.75rem, + rgba(255, 255, 255, 0.04) 1.75rem, + rgba(255, 255, 255, 0.04) 2.5rem + ); + background-size: 220% 220%; + animation: embeddedBrowsingStripeShiftReverse 58s linear infinite; + opacity: 0.24; + } + @keyframes embeddedBrowsingStripeShift { + 0% { background-position: 0% 0%, 50% 50%; } + 100% { background-position: 220% 120%, 50% 50%; } + } + @keyframes embeddedBrowsingStripeShiftReverse { + 0% { background-position: 220% 140%; } + 100% { background-position: 0% 0%; } + } + @media (prefers-reduced-motion: reduce) { + .embedded-browsing-ambient, + .embedded-browsing-ambient::before { + animation: none; + } + .embedded-browsing-ambient { + background-position: 50% 50%, 50% 50%; + opacity: 0.42; + } + .embedded-browsing-ambient::before { + opacity: 0.18; + } + } + .embedded-browsing-card { + position: relative; + z-index: 10; + display: flex; + width: 100%; + max-width: 40rem; + flex-shrink: 0; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + text-align: center; + } + .embedded-browsing-heading { + margin: 0; + font-size: 1.25rem; + line-height: 1.625rem; + font-weight: 700; + color: #ffffff; + } + .embedded-browsing-body { + margin: 0; + font-size: 0.9375rem; + line-height: 1.25rem; + color: #cfd6e6; + } + .embedded-browsing-status { + margin: 0; + font-size: 0.8125rem; + line-height: 1.125rem; + color: #a8b3cf; + } + .embedded-browsing-actions { + margin-top: 0.25rem; + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + .embedded-browsing-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-width: 8.5rem; + height: 2.5rem; + padding: 0 1rem; + border: 0; + border-radius: 12px; + background: #c029f0; + color: #ffffff; + font: inherit; + font-size: 0.9375rem; + font-weight: 700; + cursor: pointer; + transition: opacity 150ms ease, transform 150ms ease; + } + .embedded-browsing-button:hover { + opacity: 0.92; + transform: translateY(-1px); + } + .embedded-browsing-button:disabled { opacity: 0.6; cursor: not-allowed; } + `; + document.head.appendChild(style); }; -const createCard = (): HTMLDivElement => { +type PromptShell = { + shell: HTMLDivElement; + card: HTMLDivElement; +}; + +const createPromptShell = (): PromptShell => { + ensureStyles(); + const shell = document.createElement('div'); + shell.className = 'embedded-browsing-shell'; + const ambient = document.createElement('div'); + ambient.className = 'embedded-browsing-ambient'; + ambient.setAttribute('aria-hidden', 'true'); const card = document.createElement('div'); - card.style.cssText = - 'max-width:640px;border:1px solid rgba(148,163,184,0.35);border-radius:16px;padding:24px;background:rgba(15,23,42,0.92);box-shadow:0 20px 45px rgba(15,23,42,0.35);color:#e2e8f0'; - return card; + card.className = 'embedded-browsing-card'; + shell.append(ambient, card); + return { shell, card }; }; export const renderMessage = ( @@ -18,22 +168,18 @@ export const renderMessage = ( body: string, ): void => { root.replaceChildren(); + const { shell, card } = createPromptShell(); - const container = createCardContainer(); - const card = createCard(); - - const heading = document.createElement('div'); - heading.style.cssText = 'font-size:18px;font-weight:700;line-height:1.4'; + const heading = document.createElement('h2'); + heading.className = 'embedded-browsing-heading'; heading.textContent = title; const paragraph = document.createElement('p'); - paragraph.style.cssText = - 'margin:12px 0 0;font-size:14px;line-height:1.6;color:#cbd5e1'; + paragraph.className = 'embedded-browsing-body'; paragraph.textContent = body; card.append(heading, paragraph); - container.append(card); - root.append(container); + root.append(shell); }; type PermissionRequestOutcome = 'granted' | 'dismissed' | 'failed'; @@ -46,40 +192,36 @@ export const renderPermissionPrompt = ({ onRequestPermission: () => Promise; }): void => { root.replaceChildren(); + const { shell, card } = createPromptShell(); - const container = createCardContainer(); - const card = createCard(); - - const heading = document.createElement('div'); - heading.style.cssText = 'font-size:18px;font-weight:700;line-height:1.4'; + const heading = document.createElement('h2'); + heading.className = 'embedded-browsing-heading'; heading.textContent = 'Enable embedded browsing'; const description = document.createElement('p'); - description.style.cssText = - 'margin:12px 0 0;font-size:14px;line-height:1.6;color:#cbd5e1'; + description.className = 'embedded-browsing-body'; description.textContent = 'To load websites inside daily.dev, allow this extension to modify response headers on embedded pages.'; const status = document.createElement('p'); - status.style.cssText = - 'margin:12px 0 0;font-size:13px;line-height:1.5;color:#cbd5e1'; + status.className = 'embedded-browsing-status'; status.textContent = 'This is optional and only applies to pages embedded by daily.dev.'; + const actions = document.createElement('div'); + actions.className = 'embedded-browsing-actions'; + const button = document.createElement('button'); button.type = 'button'; + button.className = 'embedded-browsing-button'; button.textContent = 'Enable for this browser'; - button.style.cssText = - 'margin-top:16px;padding:10px 14px;border:0;border-radius:12px;background:#22c55e;color:#0f172a;font-weight:700;cursor:pointer'; const resetButton = () => { button.disabled = false; - button.style.opacity = '1'; }; button.addEventListener('click', async () => { button.disabled = true; - button.style.opacity = '0.7'; status.textContent = 'Requesting permissions...'; const outcome = await onRequestPermission(); @@ -98,7 +240,7 @@ export const renderPermissionPrompt = ({ resetButton(); }); - card.append(heading, description, status, button); - container.append(card); - root.append(container); + actions.append(button); + card.append(heading, description, status, actions); + root.append(shell); }; diff --git a/packages/extension/src/lib/extensionScripts.spec.ts b/packages/extension/src/lib/extensionScripts.spec.ts new file mode 100644 index 00000000000..ee8be55ba10 --- /dev/null +++ b/packages/extension/src/lib/extensionScripts.spec.ts @@ -0,0 +1,39 @@ +import browser from 'webextension-polyfill'; +import { registerBrowserContentScripts } from './extensionScripts'; + +jest.mock('webextension-polyfill', () => ({ + contentScripts: { + register: jest.fn(), + }, + scripting: { + getRegisteredContentScripts: jest.fn().mockResolvedValue([]), + registerContentScripts: jest.fn().mockResolvedValue(undefined), + }, + permissions: { + contains: jest.fn(), + request: jest.fn(), + }, + runtime: { + id: 'test-extension', + }, +})); + +describe('registerBrowserContentScripts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('registers companion scripts only in the top frame', async () => { + await registerBrowserContentScripts(); + + expect(browser.scripting.registerContentScripts).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'daily-companion-app', + matches: ['*://*/*'], + allFrames: false, + css: ['css/daily-companion-app.css'], + js: ['js/content.bundle.js', 'js/companion.bundle.js'], + }), + ]); + }); +}); diff --git a/packages/extension/src/lib/extensionScripts.ts b/packages/extension/src/lib/extensionScripts.ts index b27ca962b2a..9117ef80eb2 100644 --- a/packages/extension/src/lib/extensionScripts.ts +++ b/packages/extension/src/lib/extensionScripts.ts @@ -30,6 +30,7 @@ export const registerBrowserContentScripts = async (): Promise => { // TODO: this needs to be switched to browser.scripting when bumping FF to V3 as well await browser.contentScripts.register({ matches: ['*://*/*'], + allFrames: false, css: [{ file: 'css/daily-companion-app.css' }], js: [ { file: 'js/content.bundle.js' }, @@ -43,20 +44,19 @@ export const registerBrowserContentScripts = async (): Promise => { }); if (registeredScripts.length) { - return null; + return; } await browser.scripting.registerContentScripts([ { id: companionScriptId, matches: ['*://*/*'], + allFrames: false, css: ['css/daily-companion-app.css'], js: ['js/content.bundle.js', 'js/companion.bundle.js'], }, ]); } - - return null; }; export const getContentScriptPermission = (): Promise => diff --git a/packages/extension/src/lib/frameEmbeddingApi.ts b/packages/extension/src/lib/frameEmbeddingApi.ts index eba5396ad29..1db88ae0caf 100644 --- a/packages/extension/src/lib/frameEmbeddingApi.ts +++ b/packages/extension/src/lib/frameEmbeddingApi.ts @@ -1,7 +1,9 @@ export const FRAME_EMBED_PERMISSION = 'declarativeNetRequestWithHostAccess'; export const FRAME_EMBED_ORIGIN = '*://*/*'; -const DNR_CALL_TIMEOUT_MS = 1200; +// Chromium can take a few seconds to apply DNR session-rule changes, +// especially right after the optional host-access permission is granted. +const DNR_CALL_TIMEOUT_MS = 4000; export type FrameEmbedRule = { id: number; @@ -61,12 +63,23 @@ export const wait = (ms: number): Promise => export const withDnrTimeout = async ( promise: Promise, label: string, -): Promise => - Promise.race([ - promise, - new Promise((_, reject) => { - globalThis.setTimeout(() => { - reject(new Error(`${label} timed out after ${DNR_CALL_TIMEOUT_MS}ms`)); - }, DNR_CALL_TIMEOUT_MS); - }), - ]); +): Promise => { + let timeoutId: ReturnType | undefined; + + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutId = globalThis.setTimeout(() => { + reject( + new Error(`${label} timed out after ${DNR_CALL_TIMEOUT_MS}ms`), + ); + }, DNR_CALL_TIMEOUT_MS); + }), + ]); + } finally { + if (timeoutId) { + globalThis.clearTimeout(timeoutId); + } + } +}; diff --git a/packages/extension/src/lib/frameEmbeddingRules.ts b/packages/extension/src/lib/frameEmbeddingRules.ts index c82d2fbd54b..9370d502695 100644 --- a/packages/extension/src/lib/frameEmbeddingRules.ts +++ b/packages/extension/src/lib/frameEmbeddingRules.ts @@ -1,12 +1,7 @@ import type { FrameEmbedRule } from './frameEmbeddingApi'; const FRAME_EMBED_RULE_ID_OFFSET = 1_000_000; -const FRAME_EMBED_HEADERS = [ - 'X-Frame-Options', - 'Frame-Options', - 'Content-Security-Policy', - 'Cross-Origin-Opener-Policy', -]; +const FRAME_EMBED_HEADERS = ['X-Frame-Options', 'Content-Security-Policy']; const FRAME_EMBED_RULE_REGEX = '^https?://'; export const getFrameEmbedRuleId = (tabId: number): number => diff --git a/packages/extension/src/lib/frameEmbeddingSession.ts b/packages/extension/src/lib/frameEmbeddingSession.ts index 89cd3045578..e5bc8e2a710 100644 --- a/packages/extension/src/lib/frameEmbeddingSession.ts +++ b/packages/extension/src/lib/frameEmbeddingSession.ts @@ -159,13 +159,17 @@ export const disableFrameEmbeddingForTab = async ( const ruleId = getFrameEmbedRuleId(tabId); if (!dnr) { - errorLog(`${FRAME_LOG_PREFIX} DNR API unavailable while disabling`); + // The DNR API is only present once the user grants the optional + // `declarativeNetRequestWithHostAccess` permission. If it's missing here, + // we also couldn't have enabled a rule in the first place, so disabling + // is a no-op. Return success silently instead of logging — callers like + // the frame cleanup routinely invoke this on pagehide regardless of + // permission state. return { enabled: false, tabId, ruleId, rulesCount: 0, - error: 'declarativeNetRequest API unavailable', }; } diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index d737fdbd3ca..e0ec7734f2c 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -58,6 +58,24 @@ "chrome_url_overrides": { "newtab": "index.html" }, + "content_scripts": [ + { + "matches": [ + "https://daily.dev/*", + "https://*.daily.dev/*" + ], + "__dev__matches": [ + "https://daily.dev/*", + "https://*.daily.dev/*", + "https://staging.daily.dev/*", + "https://*.staging.daily.dev/*", + "https://*.local.fylla.dev/*" + ], + "js": ["js/ping.bundle.js"], + "run_at": "document_start", + "all_frames": false + } + ], "web_accessible_resources": [ { "resources": ["index.html", "companion.html", "js/companion.bundle.js", "css/companion.css"], @@ -74,7 +92,8 @@ "__dev__matches": [ "https://staging.daily.dev/*", "https://*.staging.daily.dev/*", - "https://*.local.fylla.dev/*" + "https://*.local.fylla.dev/*", + "https://preview.app.daily.dev/*" ] } ], diff --git a/packages/extension/src/ping/index.ts b/packages/extension/src/ping/index.ts new file mode 100644 index 00000000000..e57fd92a68f --- /dev/null +++ b/packages/extension/src/ping/index.ts @@ -0,0 +1,50 @@ +// Lightweight content script that runs on daily.dev surfaces at +// document_start to tell the webapp the extension is installed in this +// browser — without the webapp needing to know (or guess) the extension id. +// +// We signal in two redundant ways: +// 1. Stamp the install state onto `` as dataset attributes. This is +// synchronous and survives React hydration. +// 2. Post a window message after the marker is set, so any listeners that +// mount after document_start still see the signal. +// +// The webapp reads the marker to decide install-prompt vs. embed flow. The +// frame.html iframe still owns the permission-granted check. + +const INSTALL_MARKER = 'dailyExtensionInstalled'; +const ID_MARKER = 'dailyExtensionId'; +const MESSAGE_SOURCE = 'daily-extension-ping'; + +const marker = document.documentElement; + +if (marker && !marker.dataset[INSTALL_MARKER]) { + marker.dataset[INSTALL_MARKER] = 'true'; + + let runtimeId: string | undefined; + try { + runtimeId = ( + globalThis as typeof globalThis & { + chrome?: { runtime?: { id?: string } }; + } + ).chrome?.runtime?.id; + if (runtimeId) { + marker.dataset[ID_MARKER] = runtimeId; + } + } catch { + // chrome.runtime can throw during an extension reload; the install marker + // alone is still enough for the webapp to skip the install prompt. + } + + try { + window.postMessage( + { source: MESSAGE_SOURCE, extensionId: runtimeId ?? null }, + window.location.origin, + ); + } catch { + // postMessage can fail if the target origin is exotic; the DOM marker is + // the primary signal and doesn't rely on this. + } + + // eslint-disable-next-line no-console + console.debug('[daily.dev extension] ping', runtimeId ?? '(no id)'); +} diff --git a/packages/extension/views/frame.html b/packages/extension/views/frame.html index 10440e4b4a6..688f12fe5f5 100644 --- a/packages/extension/views/frame.html +++ b/packages/extension/views/frame.html @@ -13,8 +13,8 @@ margin: 0; padding: 0; overflow: hidden; - background: #ffffff; - color: #111827; + background: #0e1217; + color: #ffffff; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 8be4db5e450..2fcfaa755cd 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -45,13 +45,15 @@ import { useFeedLayout, useFeedVotePost, useMutationSubscription, + useViewSize, + ViewSize, } from '../hooks'; import { useProfileCompletionCard } from '../hooks/profile/useProfileCompletionCard'; import type { AllFeedPages } from '../lib/query'; import { OtherFeedPage, RequestKey } from '../lib/query'; import { MarketingCtaVariant } from './marketingCta/common'; -import { isNullOrUndefined } from '../lib/func'; +import { isExtensionCapableBrowser, isNullOrUndefined } from '../lib/func'; import { useSearchResultsLayout } from '../hooks/search/useSearchResultsLayout'; import { SearchResultsLayout } from './search/SearchResults/SearchResultsLayout'; import { acquisitionKey } from './cards/AcquisitionForm/common/common'; @@ -65,14 +67,17 @@ import { briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, + featureReaderModal, } from '../lib/featureManagement'; import type { AwardProps } from '../graphql/njord'; import { getProductsQueryOptions } from '../graphql/njord'; import { useUpdateQuery } from '../hooks/useUpdateQuery'; +import { isDevelopment } from '../lib/constants'; import { BriefBannerFeed } from './cards/brief/BriefBanner/BriefBannerFeed'; import { ActionType } from '../graphql/actions'; import { TopHero } from './banners/HeroBottomBanner'; import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; +import { useLegacyPostLayoutOptOut } from './post/reader/hooks/useLegacyPostLayoutOptOut'; const FeedErrorScreen = dynamic( () => import(/* webpackChunkName: "feedErrorScreen" */ './FeedErrorScreen'), @@ -142,6 +147,13 @@ const SocialTwitterPostModal = dynamic( ), ); +const ReaderPostModal = dynamic( + () => + import( + /* webpackChunkName: "readerPostModal" */ './modals/ReaderPostModal' + ), +); + const BriefCardFeed = dynamic( () => import( @@ -319,6 +331,46 @@ export default function Feed({ canFetchMore, feedName, }); + // Only enroll users whose browser can install our extension into the + // reader-modal experiment — Firefox/Safari/Other can never complete the + // embedded-browsing flow, so pulling them into the test pollutes the stats. + const isExtensionBrowser = useMemo(() => isExtensionCapableBrowser(), []); + const { + value: readerModalFromGrowthBook, + isLoading: isReaderFeatureLoading, + } = useConditionalFeature({ + feature: featureReaderModal, + shouldEvaluate: isExtensionBrowser, + }); + const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); + const forceLegacyPostModalInDev = + isDevelopment && process.env.NEXT_PUBLIC_FORCE_LEGACY_POST_MODAL === 'true'; + const isReaderModalFromConfig = isDevelopment + ? !forceLegacyPostModalInDev + : readerModalFromGrowthBook; + const isTabletViewport = useViewSize(ViewSize.Tablet); + const isReaderModalOn = + isExtensionBrowser && + isReaderModalFromConfig && + !isLegacyLayoutOptedOut && + isTabletViewport; + const isReaderModalFeatureReady = isDevelopment || !isReaderFeatureLoading; + const readerEligiblePostTypes = useMemo( + () => + new Set([ + PostType.Article, + PostType.Digest, + PostType.VideoYouTube, + ]), + [], + ); + const isReaderEligiblePost = useCallback( + (post: Post): boolean => + isReaderModalFeatureReady && + isReaderModalOn && + readerEligiblePostTypes.has(post.type), + [isReaderModalFeatureReady, isReaderModalOn, readerEligiblePostTypes], + ); const { adjustedHeroInsertIndex, shouldShowTopHero, @@ -494,6 +546,25 @@ export default function Feed({ [openSharePost, virtualizedNumCards], ); + const PostModal = useMemo(() => { + if (!selectedPost) { + return undefined; + } + const readerEligibleTypes = new Set([ + PostType.Article, + PostType.Digest, + PostType.VideoYouTube, + ]); + if ( + isReaderModalFeatureReady && + isReaderModalOn && + readerEligibleTypes.has(selectedPost.type) + ) { + return ReaderPostModal; + } + return PostModalMap[selectedPost.type]; + }, [selectedPost, isReaderModalFeatureReady, isReaderModalOn]); + if (!loadedSettings || isFallback) { return <>; } @@ -526,11 +597,23 @@ export default function Feed({ row, column, isAuxClick, + event, ) => { + const isMiddleClick = event?.type === 'auxclick' || event?.button === 1; + const isModifierClick = !!(event && (event.ctrlKey || event.metaKey)); + const readerEligible = isReaderEligiblePost(post); + const shouldOpenModal = + !isAuxClick && + !isMiddleClick && + !isModifierClick && + (!shouldUseListFeedLayout || readerEligible); + if (shouldOpenModal && shouldUseListFeedLayout && event) { + event.preventDefault(); + } await onPostClick(post, index, row, column, { skipPostUpdate: true, }); - if (!isAuxClick && !shouldUseListFeedLayout) { + if (shouldOpenModal) { onPostModalOpen({ index, row, column }); } }; @@ -561,13 +644,11 @@ export default function Feed({ is_ad: isAd, }), ); - if (!shouldUseListFeedLayout) { + if (!shouldUseListFeedLayout || isReaderEligiblePost(post)) { onPostModalOpen({ index, row, column }); } }; - const PostModal = selectedPost ? PostModalMap[selectedPost.type] : undefined; - if (isError) { return ; } @@ -704,17 +785,20 @@ export default function Feed({ {!isFetching && !isInitialLoading && !isHorizontal && ( )} - {!shouldUseListFeedLayout && selectedPost && PostModal && ( - - )} + {selectedPost && + PostModal && + (!shouldUseListFeedLayout || + isReaderEligiblePost(selectedPost)) && ( + + )} )} diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 28d769c7f7e..0c2b87cfa97 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -421,7 +421,9 @@ function FeedItemComponent({ }, }); }} - onPostClick={(post: Post) => onPostClick(post, index, row, column)} + onPostClick={(post: Post, event) => + onPostClick(post, index, row, column, false, event) + } onPostAuxClick={(post: Post) => onPostClick(post, index, row, column, true) } diff --git a/packages/shared/src/components/ShareBar.tsx b/packages/shared/src/components/ShareBar.tsx index 77f9a7a99c2..ae4d342ae6c 100644 --- a/packages/shared/src/components/ShareBar.tsx +++ b/packages/shared/src/components/ShareBar.tsx @@ -90,7 +90,7 @@ export default function ShareBar({ post }: ShareBarProps): ReactElement { }; return ( - +

Would you recommend this post?

diff --git a/packages/shared/src/components/buttons/BookmarkButton.tsx b/packages/shared/src/components/buttons/BookmarkButton.tsx index c8a774ec4b7..7536a8fa8c8 100644 --- a/packages/shared/src/components/buttons/BookmarkButton.tsx +++ b/packages/shared/src/components/buttons/BookmarkButton.tsx @@ -18,6 +18,7 @@ import { DropdownMenuOptions, DropdownMenuTrigger, } from '../dropdown/DropdownMenu'; +import type { MenuItemProps } from '../dropdown/common'; interface BookmarkButtonProps { buttonProps?: Omit, 'icon'>; @@ -39,8 +40,11 @@ export function BookmarkButton({ const { openModal } = useLazyModal(); const { onRemoveReminder } = useBookmarkReminder({ post }); const Icon = hasReminder ? BookmarkReminderIcon : BookmarkIcon; + const buttonIconPosition = children + ? ButtonIconPosition.Top + : ButtonIconPosition.Left; - const dropdownOptions = [ + const dropdownOptions: MenuItemProps[] = [ { label: 'Edit reminder', action: () => @@ -52,8 +56,8 @@ export function BookmarkButton({ }, { label: 'Remove bookmark', - action: (e: React.MouseEvent) => - buttonProps.onClick(e), + action: (...args: unknown[]) => + buttonProps.onClick?.(args[0] as React.MouseEvent), }, ]; @@ -98,7 +102,7 @@ export function BookmarkButton({ {...buttonProps} type="button" pressed={post.bookmarked} - iconPosition={ButtonIconPosition.Top} + iconPosition={buttonIconPosition} onClick={(e: React.MouseEvent) => buttonProps.onClick?.(e) } diff --git a/packages/shared/src/components/cards/Freeform/FreeformList.tsx b/packages/shared/src/components/cards/Freeform/FreeformList.tsx index e6e0729622b..f979aef0a77 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformList.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformList.tsx @@ -47,8 +47,9 @@ export const FreeformList = forwardRef(function SharePostCard( const { interaction } = usePostActions({ post }); const { pinnedAt, type: postType } = post; const isMobile = useViewSize(ViewSize.MobileL); - const onPostCardClick = () => onPostClick(post); - const containerRef = useRef(); + const onPostCardClick = (event: React.MouseEvent) => + onPostClick?.(post, event); + const containerRef = useRef(null); const isFeedPreview = useFeedPreviewMode(); const image = usePostImage(post); const { title } = useSmartTitle(post); @@ -79,7 +80,7 @@ export const FreeformList = forwardRef(function SharePostCard( ); const metadata = useMemo(() => { - const authorName = post.author?.name ?? post.source.name; + const authorName = post.author?.name ?? post.source?.name; if (isUserSource) { return { @@ -88,11 +89,11 @@ export const FreeformList = forwardRef(function SharePostCard( } return { - topLabel: enableSourceHeader ? post.source.name : authorName, + topLabel: enableSourceHeader ? post.source?.name : authorName, bottomLabel: enableSourceHeader - ? post.author?.name ?? `@${post.source.handle ?? 'unknown'}` + ? post.author?.name ?? `@${post.source?.handle ?? 'unknown'}` : `@${ - post.source.handle ?? post.sharedPost?.source?.handle ?? 'unknown' + post.source?.handle ?? post.sharedPost?.source?.handle ?? 'unknown' }`, }; }, [ @@ -113,17 +114,19 @@ export const FreeformList = forwardRef(function SharePostCard( ref={ref} flagProps={{ pinnedAt, type: postType }} linkProps={ - !isFeedPreview && { - title: post.title, - onClick: onPostCardClick, - href: post.commentsPermalink, - } + !isFeedPreview + ? { + title: post.title, + onClick: onPostCardClick, + href: post.commentsPermalink, + } + : undefined } bookmarked={post.bookmarked} > - {!isUserSource && ( + {!isUserSource && post.source && ( onPostClick?.(post); + const onPostCardClick = (event: React.MouseEvent) => + onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); const { showFeedback } = usePostFeedback({ post }); const isFeedPreview = useFeedPreviewMode(); @@ -74,7 +75,7 @@ export const ArticleList = forwardRef(function ArticleList( ); const metadata = useMemo(() => { - const authorName = post.author?.name ?? post.source.name; + const authorName = post.author?.name ?? post.source?.name; if (isUserSource) { return { @@ -83,13 +84,13 @@ export const ArticleList = forwardRef(function ArticleList( } return { - topLabel: ( + topLabel: post.source?.permalink ? ( {post.source.name} - ), + ) : undefined, bottomLabel: ( {showFeedback ? ( onUpvoteClick(post, Origin.FeedbackCard)} - onDownvoteClick={() => onDownvoteClick(post, Origin.FeedbackCard)} + onUpvoteClick={() => onUpvoteClick?.(post, Origin.FeedbackCard)} + onDownvoteClick={() => onDownvoteClick?.(post, Origin.FeedbackCard)} isVideoType={isVideoType} /> ) : ( @@ -134,7 +137,7 @@ export const ArticleList = forwardRef(function ArticleList( onReadArticleClick={onReadArticleClick} metadata={metadata} > - {!isUserSource && ( + {!isUserSource && post.source && ( {truncatedTitle} diff --git a/packages/shared/src/components/cards/common/ClickbaitShield.tsx b/packages/shared/src/components/cards/common/ClickbaitShield.tsx index 24dc30ae704..68722783df5 100644 --- a/packages/shared/src/components/cards/common/ClickbaitShield.tsx +++ b/packages/shared/src/components/cards/common/ClickbaitShield.tsx @@ -31,7 +31,7 @@ export const ClickbaitShield = ({ post }: { post: Post }): ReactElement => { if (!isPlus) { return ( @@ -100,7 +100,7 @@ export const ClickbaitShield = ({ post }: { post: Post }): ReactElement => { return ( => classed(type, visibleOnGroupHover); export type Callback = (post: Post) => unknown; +export type PostCardClickCallback = ( + post: Post, + event?: React.MouseEvent, +) => unknown; export const Container = classed('div', 'relative flex flex-1 flex-col'); export interface PostCardProps extends CommonCardCoverProps { post: Post; - onPostClick?: Callback; + onPostClick?: PostCardClickCallback; onPostAuxClick?: Callback; onBookmarkClick?: Callback; onUpvoteClick?: (post: Post, origin?: Origin) => unknown; diff --git a/packages/shared/src/components/cards/common/list/SignalList.tsx b/packages/shared/src/components/cards/common/list/SignalList.tsx index 61db316e0e9..5f96cf85983 100644 --- a/packages/shared/src/components/cards/common/list/SignalList.tsx +++ b/packages/shared/src/components/cards/common/list/SignalList.tsx @@ -36,7 +36,8 @@ export const SignalList = forwardRef(function SignalList( ref: Ref, ): ReactElement { const isFeedPreview = useFeedPreviewMode(); - const onPostCardClick = () => onPostClick?.(post); + const onPostCardClick = (event: React.MouseEvent) => + onPostClick?.(post, event); const { title } = useSmartTitle(post); const resolvedTitle = title?.trim() || post.title?.trim() || ''; const isTweetPost = diff --git a/packages/shared/src/components/cards/entity/SourceEntityCard.tsx b/packages/shared/src/components/cards/entity/SourceEntityCard.tsx index bf9c9bf6399..e87e1320798 100644 --- a/packages/shared/src/components/cards/entity/SourceEntityCard.tsx +++ b/packages/shared/src/components/cards/entity/SourceEntityCard.tsx @@ -80,10 +80,11 @@ const SourceEntityCard = ({ source, className }: SourceEntityCardProps) => { {source.name} diff --git a/packages/shared/src/components/cards/poll/PollList.tsx b/packages/shared/src/components/cards/poll/PollList.tsx index 60dee572c6e..afbf936b1e7 100644 --- a/packages/shared/src/components/cards/poll/PollList.tsx +++ b/packages/shared/src/components/cards/poll/PollList.tsx @@ -43,7 +43,8 @@ export const PollList = forwardRef(function PollList( const { user } = useAuthContext(); const { handleVote, shouldAnimateResults } = usePollVote({ post }); - const onPostCardClick = () => onPostClick?.(post); + const onPostCardClick = (event: React.MouseEvent) => + onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); const isFeedPreview = useFeedPreviewMode(); const { title } = useSmartTitle(post); @@ -66,10 +67,10 @@ export const PollList = forwardRef(function PollList( ); const metadata = useMemo(() => { - const authorName = post.author?.name ?? post.source.name; + const authorName = post.author?.name ?? post.source?.name; return { - topLabel: isUserSource ? authorName : post.source.name, + topLabel: isUserSource ? authorName : post.source?.name, bottomLabel: ( @@ -109,7 +112,7 @@ export const PollList = forwardRef(function PollList( onReadArticleClick={onReadArticleClick} metadata={metadata} > - {!isUserSource && ( + {!isUserSource && post.source && ( {truncatedTitle} onPostClick(post); - const containerRef = useRef(); + const onPostCardClick = (event: React.MouseEvent) => + onPostClick?.(post, event); + const containerRef = useRef(null); const isFeedPreview = useFeedPreviewMode(); const { sharedPost } = post; const isVideoType = isVideoPost(post); @@ -79,7 +80,7 @@ export const ShareList = forwardRef(function ShareList( ); const metadata = useMemo(() => { - const authorName = post.author?.name ?? post.source.name; + const authorName = post.author?.name ?? post.source?.name; if (isUserSource) { return { @@ -88,15 +89,16 @@ export const ShareList = forwardRef(function ShareList( } return { - topLabel: enableSourceHeader ? ( - - - {post.source.name} - - - ) : ( - authorName - ), + topLabel: + enableSourceHeader && post.source?.permalink ? ( + + + {post.source.name} + + + ) : ( + authorName + ), bottomLabel: enableSourceHeader ? authorName : `@${sharedPost?.source?.handle}`, @@ -105,8 +107,8 @@ export const ShareList = forwardRef(function ShareList( enableSourceHeader, isUserSource, post?.author?.name, - post.source.name, - post.source.permalink, + post.source?.name, + post.source?.permalink, sharedPost?.source?.handle, ]); @@ -119,11 +121,13 @@ export const ShareList = forwardRef(function ShareList( ref={ref} flagProps={{ pinnedAt, trending, type }} linkProps={ - !isFeedPreview && { - title: post.title, - onClick: onPostCardClick, - href: post.commentsPermalink, - } + !isFeedPreview + ? { + title: post.title, + onClick: onPostCardClick, + href: post.commentsPermalink, + } + : undefined } bookmarked={post.bookmarked} > @@ -137,7 +141,7 @@ export const ShareList = forwardRef(function ShareList( metadata={metadata} postLink={sharedPost?.permalink} > - {!isUserSource && ( + {!isUserSource && post.source && ( {truncatedTitle} @@ -176,7 +180,7 @@ export const ShareList = forwardRef(function ShareList(
{truncatedTitle} diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx index 62c08cabfa8..42025b362ee 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -48,8 +48,9 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( ): ReactElement { const { pinnedAt, trending, type: postType } = post; const isMobile = useViewSize(ViewSize.MobileL); - const onPostCardClick = () => onPostClick(post); - const containerRef = useRef(); + const onPostCardClick = (event: React.MouseEvent) => + onPostClick?.(post, event); + const containerRef = useRef(null); const isFeedPreview = useFeedPreviewMode(); const { title } = useSmartTitle(post); const { normalizedContent, hasDailyDevMarkdown, socialTextDirectionProps } = @@ -123,11 +124,13 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( ref={ref} flagProps={{ pinnedAt, trending, type: postType }} linkProps={ - !isFeedPreview && { - title: cardLinkTitle, - onClick: onPostCardClick, - href: post.commentsPermalink, - } + !isFeedPreview + ? { + title: cardLinkTitle, + onClick: onPostCardClick, + href: post.commentsPermalink, + } + : undefined } bookmarked={post.bookmarked} > @@ -158,7 +161,7 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( {hasDailyDevMarkdown && ( {truncatedTitle} diff --git a/packages/shared/src/components/modals/ArticlePostModal.tsx b/packages/shared/src/components/modals/ArticlePostModal.tsx index 0b6c14b4a48..a5d04802af1 100644 --- a/packages/shared/src/components/modals/ArticlePostModal.tsx +++ b/packages/shared/src/components/modals/ArticlePostModal.tsx @@ -9,6 +9,10 @@ import type { Post } from '../../graphql/posts'; import { PostType } from '../../graphql/posts'; import type { PassedPostNavigationProps } from '../post/common'; import { Origin } from '../../lib/log'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureReaderModal } from '../../lib/featureManagement'; +import { ReaderLegacyLayoutToggleButton } from '../post/reader/ReaderHeaderActionButtons'; +import { useLegacyPostLayoutOptOut } from '../post/reader/hooks/useLegacyPostLayoutOptOut'; interface ArticlePostModalProps extends ModalProps, PassedPostNavigationProps { id: string; @@ -29,6 +33,12 @@ export default function ArticlePostModal({ isDisplayed: props.isOpen, offset: 0, }); + const { value: isReaderModalEnabled } = useConditionalFeature({ + feature: featureReaderModal, + shouldEvaluate: true, + }); + const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); + return ( + ) : null + } > { +}: Props & ModalProps): ReactElement | null => { const { logSubscriptionEvent, isPlus } = usePlusSubscription(); const { value: { full: plusCta }, @@ -83,6 +83,7 @@ const ClickbaitShieldModal = ({ - ) + ) : undefined } onClick={async (event: MouseEvent) => { if (hasUsedFreeTrial) { diff --git a/packages/shared/src/components/modals/ReaderPostModal.tsx b/packages/shared/src/components/modals/ReaderPostModal.tsx new file mode 100644 index 00000000000..85223f22e40 --- /dev/null +++ b/packages/shared/src/components/modals/ReaderPostModal.tsx @@ -0,0 +1,86 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import type { ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import styles from './BasePostModal.module.css'; +import type { Post } from '../../graphql/posts'; +import type { PassedPostNavigationProps } from '../post/common'; +import { ReaderPostLayout } from '../post/reader/ReaderPostLayout'; +import { usePostReferrerContext } from '../../contexts/PostReferrerContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +import { useEventListener } from '../../hooks'; +import useDebounceFn from '../../hooks/useDebounceFn'; + +interface ReaderPostModalProps extends ModalProps, PassedPostNavigationProps { + id: string; + post: Post; +} + +export default function ReaderPostModal({ + id: _id, + className, + onRequestClose, + onPreviousPost, + onNextPost, + postPosition, + post, + ...props +}: ReaderPostModalProps): ReactElement { + const usePostReferrer = + usePostReferrerContext()?.usePostReferrer ?? (() => {}); + const { logEvent } = useLogContext(); + const [scrollNode, setScrollNode] = useState(null); + + usePostReferrer({ post }); + + const onScroll = useCallback( + (event?: Event) => { + if (!post?.id || !event) { + return; + } + const targetElement = event.target as HTMLElement; + logEvent({ + event_name: LogEvent.PageScroll, + target_type: TargetType.Post, + target_id: post.id, + extra: JSON.stringify({ + scrollTop: targetElement.scrollTop, + }), + }); + }, + [logEvent, post?.id], + ); + + const [debouncedOnScroll] = useDebounceFn(onScroll, 100); + useEventListener(scrollNode, 'scroll', debouncedOnScroll); + + return ( + setScrollNode(node)} + onRequestClose={onRequestClose} + {...props} + overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" + className={classNames( + className, + 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(100vw-1rem,100rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', + '!overscroll-y-auto', + )} + > + { + onRequestClose?.({} as MouseEvent); + }} + /> + + ); +} diff --git a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx new file mode 100644 index 00000000000..ea6caba0e33 --- /dev/null +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -0,0 +1,448 @@ +import classNames from 'classnames'; +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { EmbeddedBrowsingWebPrompt } from '../../features/extensionEmbed/EmbeddedBrowsingWebPrompt'; +import { ExtensionSiteEmbed } from '../../features/extensionEmbed/ExtensionSiteEmbed'; +import { getBrowserExtensionInstallId } from '../../features/extensionEmbed/getBrowserExtensionInstallId'; +import type { UseExtensionSiteEmbedResult } from '../../features/extensionEmbed/useExtensionSiteEmbed'; +import { + detectBrowserExtensionInstalled, + useIsBrowserExtensionInstalled, +} from '../../features/extensionEmbed/useIsBrowserExtensionInstalled'; +import { checkIsExtension } from '../../lib/func'; +import { apiUrl } from '../../lib/config'; +import { Loader } from '../Loader'; +import { + Typography, + TypographyTag, + TypographyColor, + TypographyType, +} from '../typography/Typography'; + +const FRAME_LOAD_TIMEOUT_MS = 7000; +const PERMISSION_FRAME_CONNECT_TIMEOUT_MS = 7000; +const EXTENSION_INSTALL_POLL_INTERVAL_MS = 1500; +const EXTENSION_PING_MESSAGE_SOURCE = 'daily-extension-ping'; + +type PostArticlePreviewEmbedProps = { + targetUrl: string; + previewHost?: string; + className?: string; + leftHeaderActions?: ReactElement | null; + rightHeaderActions?: ReactElement | null; + onPreviewUnavailable?: () => void; + forceUnavailable?: boolean; + collapseOnUnavailable?: boolean; +}; + +export function PostArticlePreviewEmbed({ + targetUrl, + previewHost, + className, + leftHeaderActions, + rightHeaderActions, + onPreviewUnavailable, + forceUnavailable = false, + collapseOnUnavailable = true, +}: PostArticlePreviewEmbedProps): ReactElement | null { + const [extensionId, setExtensionId] = useState(() => + getBrowserExtensionInstallId(), + ); + const [isFrameLoaded, setIsFrameLoaded] = useState(false); + const [embedStatus, setEmbedStatus] = + useState('idle'); + const [isInstalledAfterPrompt, setIsInstalledAfterPrompt] = useState(false); + // Two distinct "things went wrong" states so we can map each to the + // right UX: a pre-connect timeout or a genuine post-ready failure. + const [timedOutBeforeConnect, setTimedOutBeforeConnect] = useState(false); + const [previewBroken, setPreviewBroken] = useState(false); + + const isInExtension = checkIsExtension(); + const { isInstalled } = useIsBrowserExtensionInstalled(); + const hasInstalledExtension = isInstalled || isInstalledAfterPrompt; + + const previewDomain = useMemo(() => { + if (previewHost) { + return previewHost; + } + try { + return new URL(targetUrl).hostname; + } catch { + return targetUrl; + } + }, [previewHost, targetUrl]); + + const faviconSrc = useMemo(() => { + const pixelRatio = globalThis?.window?.devicePixelRatio ?? 1; + const iconSize = Math.max(Math.round(16 * pixelRatio), 96); + return `${apiUrl}/icon?url=${encodeURIComponent( + previewDomain, + )}&size=${iconSize}`; + }, [previewDomain]); + + // Reset per target/extension change. + useEffect(() => { + setIsFrameLoaded(false); + setEmbedStatus('idle'); + setIsInstalledAfterPrompt(false); + setTimedOutBeforeConnect(false); + setPreviewBroken(false); + }, [extensionId, targetUrl]); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const onPingMessage = (event: MessageEvent) => { + if (event.source !== window || event.origin !== window.location.origin) { + return; + } + + if (event.data?.source !== EXTENSION_PING_MESSAGE_SOURCE) { + return; + } + + setIsInstalledAfterPrompt(true); + const nextExtensionId = + typeof event.data?.extensionId === 'string' + ? event.data.extensionId.trim() + : ''; + if (nextExtensionId) { + setExtensionId(nextExtensionId); + } + }; + + window.addEventListener('message', onPingMessage); + return () => window.removeEventListener('message', onPingMessage); + }, []); + + // Fast-path "not installed / not embeddable from this origin": probe a + // resource that shares `frame.html`'s `web_accessible_resources` matches. + // If the probe fails (extension missing OR origin not allowed to embed), + // short-circuit the 7s connect timeout and show the install prompt in + // ~100ms instead of leaving the user on Chrome's "page blocked" screen. + useEffect(() => { + if (isInExtension || !extensionId) { + return undefined; + } + let cancelled = false; + const resolvedExtensionId = getBrowserExtensionInstallId() ?? extensionId; + if (resolvedExtensionId !== extensionId) { + setExtensionId(resolvedExtensionId); + } + detectBrowserExtensionInstalled(resolvedExtensionId).then((installed) => { + if (cancelled) { + return; + } + setIsInstalledAfterPrompt(installed); + if (!cancelled && !installed) { + setTimedOutBeforeConnect(true); + } + }); + return () => { + cancelled = true; + }; + }, [extensionId, isInExtension]); + + const onCopyPreviewUrl = useCallback(() => { + navigator.clipboard?.writeText(targetUrl).catch(() => {}); + }, [targetUrl]); + + // "Extension not installed / origin not in frame.html's WAR matches": + // - No extensionId at all → can't even construct the iframe src. + // - OR ping hasn't stamped the install marker AND the iframe never + // reported any state within the connect timeout. + // Either way we stay inline and show the install prompt — we don't drop + // the user into the classic reader for a one-click fix. + const shouldPromptInstall = + !isInExtension && + (!extensionId || + timedOutBeforeConnect || + (!hasInstalledExtension && + embedStatus === 'idle' && + timedOutBeforeConnect)); + + useEffect(() => { + if ( + isInExtension || + !extensionId || + !shouldPromptInstall || + hasInstalledExtension + ) { + return undefined; + } + + let cancelled = false; + const interval = globalThis.setInterval(() => { + const resolvedExtensionId = getBrowserExtensionInstallId() ?? extensionId; + if (resolvedExtensionId !== extensionId) { + setExtensionId(resolvedExtensionId); + } + + if (!resolvedExtensionId) { + return; + } + + detectBrowserExtensionInstalled(resolvedExtensionId).then( + (installedNow) => { + if (cancelled || !installedNow) { + return; + } + + setIsInstalledAfterPrompt(true); + setIsFrameLoaded(false); + setEmbedStatus('idle'); + setTimedOutBeforeConnect(false); + setPreviewBroken(false); + }, + ); + }, EXTENSION_INSTALL_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + globalThis.clearInterval(interval); + }; + }, [extensionId, hasInstalledExtension, isInExtension, shouldPromptInstall]); + + // "Iframe connected but the embed ultimately failed": dropped target + // frame, DNR rule couldn't be set up, publisher blocks embedding, etc. + // These warrant the classic reader fallback via the parent. + const isGenuinelyUnavailable = forceUnavailable || previewBroken; + const shouldCollapseUnavailable = + collapseOnUnavailable && isGenuinelyUnavailable; + + const didNotifyRef = useRef(false); + useEffect(() => { + if ( + shouldCollapseUnavailable && + !didNotifyRef.current && + !forceUnavailable + ) { + didNotifyRef.current = true; + onPreviewUnavailable?.(); + } + }, [forceUnavailable, onPreviewUnavailable, shouldCollapseUnavailable]); + + // Pre-connect timeout: iframe mounted but the permission frame never posted + // anything back within the window. Treat this as "can't embed from here" + // and show the install prompt. Armed only while we haven't heard from the + // iframe yet. + const isAwaitingFirstConnect = + !!extensionId && + !isInExtension && + embedStatus === 'idle' && + !timedOutBeforeConnect && + !shouldPromptInstall && + !isGenuinelyUnavailable; + useEffect(() => { + if (!isAwaitingFirstConnect) { + return undefined; + } + const timeout = globalThis.setTimeout( + () => setTimedOutBeforeConnect(true), + PERMISSION_FRAME_CONNECT_TIMEOUT_MS, + ); + return () => globalThis.clearTimeout(timeout); + }, [isAwaitingFirstConnect]); + + // Post-ready timeout: iframe is in `ready` state but the target site never + // actually loaded (publisher blocked embedding even after our DNR rule was + // set). This is a genuine "preview unavailable". + const isAwaitingTargetLoad = + !!extensionId && + embedStatus === 'ready' && + !isFrameLoaded && + !isGenuinelyUnavailable; + useEffect(() => { + if (!isAwaitingTargetLoad) { + return undefined; + } + const timeout = globalThis.setTimeout( + () => setPreviewBroken(true), + FRAME_LOAD_TIMEOUT_MS, + ); + return () => globalThis.clearTimeout(timeout); + }, [isAwaitingTargetLoad]); + + const onEmbedStateChange = useCallback( + (state: UseExtensionSiteEmbedResult) => { + setEmbedStatus(state.status); + if (state.status !== 'ready') { + setIsFrameLoaded(false); + } + // Keep the iframe mounted for states the user can retry from within + // it: missing-permission (prompt hasn't been attempted), permission- + // denied (ESC / "Not now"), permission-request-failed (transient). + // The underlying hook flips `status` to `'error'` for the two latter + // ones, so gate on the reason before collapsing into the genuine + // failure path. + const isRecoverablePermissionReason = + state.errorReason === 'missing-permission' || + state.errorReason === 'permission-denied' || + state.errorReason === 'permission-request-failed'; + if (isRecoverablePermissionReason) { + return; + } + if ( + state.status === 'error' || + state.errorReason === 'preview-unavailable' || + state.errorReason === 'enable-frame-embedding-failed' + ) { + setPreviewBroken(true); + } + }, + [], + ); + + const renderEmbedChrome = useCallback( + (state: UseExtensionSiteEmbedResult): ReactElement | null => { + if (state.status === 'error' && state.error) { + return ( +
+ + {state.error} + +
+ ); + } + + if (state.status === 'reloading-extension') { + return ( +
+ + + Reloading extension… + +
+ ); + } + + const shouldShowLoading = + !previewBroken && + (state.status === 'idle' || + state.status === 'preparing-tab' || + (state.status === 'ready' && !isFrameLoaded)); + + if (!shouldShowLoading) { + return null; + } + + return ( +
+ + + Loading article preview… + +
+ ); + }, + [isFrameLoaded, previewBroken], + ); + + if (shouldCollapseUnavailable) { + return null; + } + + return ( + + ); +} diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index 09063b79bac..dfa0691187a 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -25,6 +25,7 @@ import { generateCommentsQueryKey } from '../../lib/query'; const threadCommentOrigins = new Set([ Origin.ArticleModal, + Origin.ReaderModal, Origin.ArticlePage, Origin.CollectionModal, Origin.BriefModal, @@ -45,6 +46,9 @@ interface PostCommentsProps { onCommented?: MainCommentProps['onCommented']; } +const noopShare = (): void => {}; +const noopShowUpvotes = (): void => {}; + export function PostComments({ post, origin, @@ -59,7 +63,7 @@ export function PostComments({ onCommented, }: PostCommentsProps): ReactElement { const { id } = post; - const container = useRef(); + const container = useRef(null); const isModalThread = threadCommentOrigins.has(origin); const { tokenRefreshed } = useContext(AuthContext); const { requestMethod } = useRequestProtocol(); @@ -68,7 +72,7 @@ export function PostComments({ useQuery({ queryKey, - queryFn: () => + queryFn: (): Promise => requestMethod( POST_COMMENTS_QUERY, { postId: id, [initialDataKey]: comments, first: 500, sortBy }, @@ -83,7 +87,7 @@ export function PostComments({ const { hash: commentHash } = globalThis?.window?.location || {}; const commentsCount = comments?.postComments?.edges?.length || 0; - const commentRef = useRef(null); + const commentRef = useRef(null); const { deleteComment } = useDeleteComment(); const [scrollToComment, setScrollToComment] = useState(!!commentHash); @@ -106,6 +110,9 @@ export function PostComments({ ); } + const getAppendTooltipParent = (): HTMLElement => + modalParentSelector?.() ?? container.current ?? document.body; + return (
- {comments.postComments.edges.map((e, index) => ( + {comments!.postComments.edges.map((e, index) => ( } comment={e.node} key={e.node.id} - onShare={onShare} + onShare={onShare ?? noopShare} onDelete={(comment, parentId) => - deleteComment(comment.id, parentId, post) + deleteComment(comment.id, parentId ?? null, post) } - onShowUpvotes={onClickUpvote} - postAuthorId={post.author?.id} - postScoutId={post.scout?.id} - appendTooltipTo={modalParentSelector ?? (() => container?.current)} + onShowUpvotes={onClickUpvote ?? noopShowUpvotes} + postAuthorId={post.author?.id ?? null} + postScoutId={post.scout?.id ?? null} + appendTooltipTo={getAppendTooltipParent} permissionNotificationCommentId={permissionNotificationCommentId} joinNotificationCommentId={joinNotificationCommentId} onCommented={onCommented} diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index f91331918a0..bf0333c769b 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -1,13 +1,19 @@ import classNames from 'classnames'; import type { ComponentProps, ReactElement } from 'react'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import type { Post } from '../../graphql/posts'; -import { isVideoPost } from '../../graphql/posts'; +import { isVideoPost, PostType } from '../../graphql/posts'; +import { isEmbeddableSiteTarget } from '../../features/extensionEmbed/common'; import PostMetadata from '../cards/common/PostMetadata'; import { PostWidgets } from './PostWidgets'; import PostToc from '../widgets/PostToc'; -import { ToastSubject, useToastNotification } from '../../hooks'; +import { + ToastSubject, + useToastNotification, + useViewSize, + ViewSize, +} from '../../hooks'; import PostContentContainer from './PostContentContainer'; import usePostContent from '../../hooks/usePostContent'; import { BasePostContent } from './BasePostContent'; @@ -19,7 +25,8 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { useViewPost } from '../../hooks/post'; import { TruncateText } from '../utilities'; import { useFeature } from '../GrowthBookProvider'; -import { feature } from '../../lib/featureManagement'; +import { feature, featureReaderModal } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { LazyImage } from '../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { withPostById } from './withPostById'; @@ -27,17 +34,57 @@ import { PostClickbaitShield } from './common/PostClickbaitShield'; import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; +import { PostArticlePreviewEmbed } from './PostArticlePreviewEmbed'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { EarthIcon } from '../icons'; +import { Drawer } from '../drawers/Drawer'; +import { useLegacyPostLayoutOptOut } from './reader/hooks/useLegacyPostLayoutOptOut'; type PostContentRawProps = Omit & { post: Post }; export const SCROLL_OFFSET = 80; +// Post content fixed column (matches grid-cols-[22rem_...] on laptop) +const POST_COLUMN_REM = 22; +// Widgets sidebar width (matches w-[21.25rem] on laptop) +const WIDGETS_COLUMN_REM = 21.25; + +const PREVIEW_MIN_WIDTH = 360; +const PREVIEW_RESTORE_WIDTH = 380; +const FLOATING_PREVIEW_ANIMATION_MS = 300; +const REM_IN_PX = 16; +const PREVIEW_LAYOUT_MIN_WIDTH = + (POST_COLUMN_REM + WIDGETS_COLUMN_REM) * REM_IN_PX + PREVIEW_MIN_WIDTH; + const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ './PostCodeSnippets').then( (mod) => mod.PostCodeSnippets, ), ); +const ArticleLink = ({ + href, + onClick, + children, + ...props +}: ComponentProps<'a'> & { href?: string; onClick?: () => void }) => { + const clickHandlers = onClick + ? combinedClicks(() => onClick()) + : undefined; + return ( + + {children} + + ); +}; + export function PostContentRaw({ post, className = {}, @@ -66,10 +113,13 @@ export function PostContentRaw({ const onSendViewPost = useViewPost(); const showCodeSnippets = useFeature(feature.showCodeSnippets); const { title } = useSmartTitle(post); + const isTablet = useViewSize(ViewSize.Tablet); + const isLaptop = useViewSize(ViewSize.Laptop); const hasNavigation = !!onPreviousPost || !!onNextPost; const isVideoType = isVideoPost(post); const hasToc = (post.toc?.length ?? 0) > 0; const isCompactModalSpacing = !isPostPage; + const [isPreviewHydrated, setIsPreviewHydrated] = useState(false); let metadataMarginClassName = 'mb-8'; if (isVideoType) { metadataMarginClassName = isCompactModalSpacing ? 'mb-3' : 'mb-4'; @@ -80,8 +130,243 @@ export function PostContentRaw({ isCompactModalSpacing ? 'mt-3 !typo-callout' : 'mt-4 !typo-callout', metadataMarginClassName, ); + const embedArticleTargetUrl = + post.permalink && isEmbeddableSiteTarget(post.permalink) + ? post.permalink + : null; + + const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); + const { value: isReaderModalEnabled } = useConditionalFeature({ + feature: featureReaderModal, + shouldEvaluate: true, + }); + const showArticlePreviewEmbed = + isPreviewHydrated && + isReaderModalEnabled && + !isLegacyLayoutOptedOut && + !isVideoType && + post.type === PostType.Article && + embedArticleTargetUrl !== null; + + const [isArticlePreviewDismissed, setArticlePreviewDismissed] = + useState(false); + const [isArticlePreviewUnavailable, setArticlePreviewUnavailable] = + useState(false); + const [isMobilePreviewOpen, setMobilePreviewOpen] = useState(false); + const [isPreviewNarrow, setIsPreviewNarrow] = useState(false); + const [isFloatingPreviewVisible, setFloatingPreviewVisible] = useState(false); + const [isFloatingPreviewClosing, setFloatingPreviewClosing] = useState(false); + const [isFloatingPreviewActive, setFloatingPreviewActive] = useState(false); + const [isTabletPreviewToggling, setTabletPreviewToggling] = useState(false); + const previewLayoutRef = useRef(null); + const previewColumnRef = useRef(null); + const ignorePreviewResizeRef = useRef(false); + const resizeObserverResetTimeoutRef = useRef< + ReturnType | undefined + >(); + const floatingPreviewCloseTimeoutRef = useRef< + ReturnType | undefined + >(); + const floatingPreviewEnterFrameRef = useRef(); + + useEffect(() => { + setIsPreviewHydrated(true); + }, []); + + const evaluatePreviewWidth = useCallback((width: number) => { + setIsPreviewNarrow((prev) => { + if (!prev && width < PREVIEW_MIN_WIDTH) { + return true; + } + + if (prev && width >= PREVIEW_RESTORE_WIDTH) { + return false; + } + + return prev; + }); + }, []); + + useEffect(() => { + return () => { + if (!resizeObserverResetTimeoutRef.current) { + return; + } + + globalThis.clearTimeout(resizeObserverResetTimeoutRef.current); + }; + }, []); + + useEffect(() => { + return () => { + if (!floatingPreviewCloseTimeoutRef.current) { + return; + } + + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + }; + }, []); + + useEffect(() => { + return () => { + if (!floatingPreviewEnterFrameRef.current) { + return; + } + + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + }; + }, []); + + useEffect(() => { + if (floatingPreviewCloseTimeoutRef.current) { + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + floatingPreviewCloseTimeoutRef.current = undefined; + } + if (floatingPreviewEnterFrameRef.current) { + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + floatingPreviewEnterFrameRef.current = undefined; + } + setArticlePreviewDismissed(false); + setArticlePreviewUnavailable(false); + setMobilePreviewOpen(false); + setIsPreviewNarrow(false); + setFloatingPreviewVisible(false); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + setTabletPreviewToggling(false); + }, [post.id]); + + const showArticlePreviewColumn = + showArticlePreviewEmbed && !isArticlePreviewDismissed; + const shouldShowArticlePreviewToggle = false; + const isPreviewFloating = + isLaptop && showArticlePreviewColumn && isPreviewNarrow; + const shouldRenderFloatingPreview = + isFloatingPreviewVisible || isPreviewFloating; + + useEffect(() => { + if (floatingPreviewCloseTimeoutRef.current) { + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + floatingPreviewCloseTimeoutRef.current = undefined; + } + if (floatingPreviewEnterFrameRef.current) { + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + floatingPreviewEnterFrameRef.current = undefined; + } + + if (isPreviewFloating) { + setFloatingPreviewVisible(true); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + floatingPreviewEnterFrameRef.current = globalThis.requestAnimationFrame( + () => { + setFloatingPreviewActive(true); + }, + ); + return; + } + + if (!isFloatingPreviewVisible) { + return; + } + + setFloatingPreviewActive(false); + setFloatingPreviewClosing(true); + floatingPreviewCloseTimeoutRef.current = globalThis.setTimeout(() => { + setFloatingPreviewVisible(false); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + }, FLOATING_PREVIEW_ANIMATION_MS); + }, [isFloatingPreviewVisible, isPreviewFloating]); + + useEffect(() => { + const node = previewColumnRef.current; + + if (!isLaptop || !showArticlePreviewColumn || !node) { + setIsPreviewNarrow(false); + return undefined; + } + + const observer = new ResizeObserver(([entry]) => { + if (ignorePreviewResizeRef.current) { + return; + } + + const { width } = entry.contentRect; + + if (width < 1) { + return; + } + + evaluatePreviewWidth(width); + }); + + observer.observe(node); + if (!ignorePreviewResizeRef.current) { + evaluatePreviewWidth(node.getBoundingClientRect().width); + } + + return () => observer.disconnect(); + }, [evaluatePreviewWidth, isLaptop, showArticlePreviewColumn]); + + const onToggleArticlePreview = useCallback(() => { + if (isTablet) { + const isOpeningPreview = isArticlePreviewDismissed; + let shouldForceFloatingOnOpen = false; + if (isOpeningPreview && isLaptop) { + const layoutWidth = + previewLayoutRef.current?.getBoundingClientRect().width; + if (layoutWidth && layoutWidth < PREVIEW_LAYOUT_MIN_WIDTH) { + setIsPreviewNarrow(true); + shouldForceFloatingOnOpen = true; + } + } + + if (isOpeningPreview) { + if (floatingPreviewCloseTimeoutRef.current) { + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + floatingPreviewCloseTimeoutRef.current = undefined; + } + if (floatingPreviewEnterFrameRef.current) { + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + floatingPreviewEnterFrameRef.current = undefined; + } + setFloatingPreviewVisible(false); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + } + ignorePreviewResizeRef.current = true; + if (resizeObserverResetTimeoutRef.current) { + globalThis.clearTimeout(resizeObserverResetTimeoutRef.current); + } + resizeObserverResetTimeoutRef.current = globalThis.setTimeout(() => { + ignorePreviewResizeRef.current = false; + setTabletPreviewToggling(false); + const width = previewColumnRef.current?.getBoundingClientRect().width; + if (!width || width < 1) { + setIsPreviewNarrow(false); + return; + } + evaluatePreviewWidth(width); + }, 350); + setArticlePreviewDismissed((currentState) => !currentState); + if (!shouldForceFloatingOnOpen) { + setIsPreviewNarrow(false); + } + setTabletPreviewToggling(true); + } else { + setMobilePreviewOpen((currentState) => !currentState); + } + }, [evaluatePreviewWidth, isArticlePreviewDismissed, isLaptop, isTablet]); + + const onPreviewUnavailable = useCallback(() => { + setArticlePreviewUnavailable(true); + setArticlePreviewDismissed(true); + setMobilePreviewOpen(false); + }, []); + const containerClass = classNames( - 'laptop:flex-row laptop:pb-0', + 'tablet:flex-row tablet:pb-0', className?.container, ); @@ -105,19 +390,181 @@ export function PostContentRaw({ onSendViewPost(post.id); }, [isVideoType, onSendViewPost, post.id, user?.id]); - const ArticleLink = ({ children, ...props }: ComponentProps<'a'>) => { - return ( - + - {children} - - ); +
+
+ + {!isTablet && + showArticlePreviewEmbed && + shouldShowArticlePreviewToggle && ( +
+

+ + {title} + +

+ {post.clickbaitTitleDetected && } +
+ {isVideoType && ( + + )} + {post.summary && ( +
+

+ {post.summary} +

+
+ )} + + 0 && ( + + From{' '} + + {post.domain} + + + ) + } + /> + {!isVideoType && ( + + + + )} + {hasToc && ( + + )} + {showCodeSnippets && ( + + )} + + + ); + + const postWidgetsColumn = ( + + ); + + const getPreviewGridColsClass = (): string => { + if (!showArticlePreviewColumn || !isTablet) { + return 'grid-cols-[1fr_0px_0fr]'; + } + + if (!isLaptop) { + return 'grid-cols-[1fr_0px_1fr]'; + } + + if (isPreviewFloating) { + return 'grid-cols-[1fr_0px_0fr]'; + } + + return 'grid-cols-[22rem_0px_1fr]'; }; return ( @@ -141,129 +588,86 @@ export function PostContentRaw({ : undefined } > - - -
- -

- {title} -

- {post.clickbaitTitleDetected && } -
- {isVideoType && ( - - )} - {post.summary && ( +
+
+ {postMainColumn} + {!isLaptop && postWidgetsColumn} +
+
-

- {post.summary} -

+ {!isPreviewFloating && !isTabletPreviewToggling && ( + + )}
- )} - - 0 && ( - - From{' '} - - {post.domain} - - - ) - } - /> - {!isVideoType && ( - + {isLaptop && postWidgetsColumn} + {shouldRenderFloatingPreview && ( +
- - - )} - {hasToc && ( - +
)} - {showCodeSnippets && ( - setMobilePreviewOpen(false)} + className={{ + wrapper: 'h-[88vh]', + drawer: 'flex-1 !p-0', + }} + displayCloseButton + appendOnRoot + > + - )} - - - + +
+ ) : ( + <> + {postMainColumn} + {postWidgetsColumn} + + )} ); } diff --git a/packages/shared/src/components/post/common/PostClickbaitShield.tsx b/packages/shared/src/components/post/common/PostClickbaitShield.tsx index 939bfbc1426..02b67abb71a 100644 --- a/packages/shared/src/components/post/common/PostClickbaitShield.tsx +++ b/packages/shared/src/components/post/common/PostClickbaitShield.tsx @@ -127,7 +127,7 @@ export const PostClickbaitShield = ({ post }: { post: Post }): ReactElement => { return ( { } >
+ )} +
+ {showNavigation && ( + <> + {onPreviousPost && ( + +
+
+
+ +
+
+
+ {source && source.type === SourceType.Squad && ( + + )} + {source && source.type !== SourceType.Squad && ( + + )} + +
+ + {displayTitle} + + {post.clickbaitTitleDetected && } + {post.summary && ( +
+ +
+ )} + + + + commentRef.current?.onShowInput(Origin.PostCommentButton) + } + className="mt-1" + /> +
+ +
+
+
+ {upvotes > 0 && ( + onShowUpvoted(post.id, upvotes)}> + {largeNumberFormat(upvotes)} Upvote{upvotes > 1 ? 's' : ''} + + )} + + {largeNumberFormat(comments)} Comment + {comments === 1 ? '' : 's'} + + {canSeeAnalytics && ( + + + + Analytics + + + )} +
+ +
+ } + shouldHandleCommentQuery + onComposerOpenChange={setIsComposerOpen} + size={ProfileImageSize.Medium} + CommentInputOrModal={CommentInputOrModal} + /> + openShareComment(comment, post)} + onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} + modalParentSelector={() => + document.getElementById('reader-post-modal-root') ?? document.body + } + /> + +
+ + + + {tokenRefreshed && ( +
+ +
+ )} +
+ + ); +} diff --git a/packages/shared/src/components/post/reader/PaneDivider.tsx b/packages/shared/src/components/post/reader/PaneDivider.tsx new file mode 100644 index 00000000000..e1e4278914b --- /dev/null +++ b/packages/shared/src/components/post/reader/PaneDivider.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useRef } from 'react'; +import classNames from 'classnames'; + +type PaneDividerProps = { + onResizeDelta: (deltaPx: number) => void; + className?: string; +}; + +export function PaneDivider({ + onResizeDelta, + className, +}: PaneDividerProps): ReactElement { + const startXRef = useRef(0); + + const onPointerDown = useCallback((event: React.PointerEvent) => { + event.preventDefault(); + startXRef.current = event.clientX; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) { + return; + } + const delta = event.clientX - startXRef.current; + startXRef.current = event.clientX; + onResizeDelta(delta); + }, + [onResizeDelta], + ); + + const onPointerUp = useCallback((event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, []); + + return ( +
+
+
+
+ ); +} diff --git a/packages/shared/src/components/post/reader/RailHeader.tsx b/packages/shared/src/components/post/reader/RailHeader.tsx new file mode 100644 index 00000000000..573bb24537f --- /dev/null +++ b/packages/shared/src/components/post/reader/RailHeader.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; + +type RailHeaderProps = { + title: string; + className?: string; +}; + +export function RailHeader({ + title, + className, +}: RailHeaderProps): ReactElement { + return ( + + {title} + + ); +} diff --git a/packages/shared/src/components/post/reader/ReaderChrome.tsx b/packages/shared/src/components/post/reader/ReaderChrome.tsx new file mode 100644 index 00000000000..7bce93e2494 --- /dev/null +++ b/packages/shared/src/components/post/reader/ReaderChrome.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ReaderHeaderActionGroup } from './ReaderHeaderActionButtons'; + +type ReaderChromeProps = { + onClose: () => void; + isPostPage?: boolean; +}; + +export function ReaderChrome({ + onClose, + isPostPage = false, +}: ReaderChromeProps): ReactElement { + return ( +
+
+ + {!isPostPage && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/shared/src/components/post/reader/ReaderContext.tsx b/packages/shared/src/components/post/reader/ReaderContext.tsx new file mode 100644 index 00000000000..269d7f16e54 --- /dev/null +++ b/packages/shared/src/components/post/reader/ReaderContext.tsx @@ -0,0 +1,42 @@ +import type { MutableRefObject, ReactElement, RefObject } from 'react'; +import React, { createContext, useContext } from 'react'; +import type { Post } from '../../../graphql/posts'; + +export type ReaderContextValue = { + post: Post; + isRailOpen: boolean; + setRailOpen: (open: boolean) => void; + toggleRail: () => void; + railWidthPx: number; + setRailWidthPx: (width: number) => void; + focusCommentRef: MutableRefObject<() => void>; + articleScrollRef: RefObject; +}; + +const ReaderContext = createContext(null); + +export function ReaderContextProvider({ + children, + value, +}: { + children: ReactElement; + value: ReaderContextValue; +}): ReactElement { + return ( + {children} + ); +} + +export function useReaderContext(): ReaderContextValue { + const ctx = useContext(ReaderContext); + if (!ctx) { + throw new Error( + 'useReaderContext must be used within ReaderContextProvider', + ); + } + return ctx; +} + +export function useOptionalReaderContext(): ReaderContextValue | null { + return useContext(ReaderContext); +} diff --git a/packages/shared/src/components/post/reader/ReaderFallback.tsx b/packages/shared/src/components/post/reader/ReaderFallback.tsx new file mode 100644 index 00000000000..18e21507519 --- /dev/null +++ b/packages/shared/src/components/post/reader/ReaderFallback.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef, useContext } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { LazyImage } from '../../LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import SettingsContext from '../../../contexts/SettingsContext'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; + +type ReaderFallbackProps = { + post: Post; + className?: string; + contentTopOffsetPx?: number; +}; + +export const ReaderFallback = forwardRef( + function ReaderFallback( + { post, className, contentTopOffsetPx = 0 }, + ref, + ): ReactElement { + const { openNewTab } = useContext(SettingsContext); + const { title } = useSmartTitle(post); + const hostname = + post.domain && post.domain.length > 0 + ? post.domain + : (() => { + if (!post.permalink) { + return ''; + } + try { + return new URL(post.permalink).hostname; + } catch { + return post.permalink; + } + })(); + + return ( +
+ + + {title} + + {post.summary && ( + + {post.summary} + + )} + +
+ ); + }, +); diff --git a/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx b/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx new file mode 100644 index 00000000000..44250e65692 --- /dev/null +++ b/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx @@ -0,0 +1,146 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { UserVote } from '../../../graphql/posts'; +import { + DiscussIcon as CommentIcon, + DownvoteIcon, + ShareIcon, + UpvoteIcon, +} from '../../icons'; +import { Button, ButtonVariant, ButtonColor } from '../../buttons/Button'; +import { BookmarkButton } from '../../buttons'; +import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; +import { useVotePost } from '../../../hooks/vote/useVotePost'; +import { useSharePost } from '../../../hooks/useSharePost'; +import { Origin } from '../../../lib/log'; +import { largeNumberFormat } from '../../../lib/numberFormat'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { IconSize } from '../../Icon'; + +type ReaderFloatingActionBarProps = { + post: Post; + onCommentClick: () => void; +}; + +const FLOATING_ICON_SIZE = IconSize.XSmall; +const countActionButtonClasses = + '!h-8 !min-w-0 !gap-1 !rounded-10 !px-2 !justify-center !font-normal'; +const iconActionButtonClasses = + '!h-8 !w-8 !min-w-8 !rounded-10 !p-0 !justify-center'; +const countClasses = 'text-text-tertiary typo-footnote tabular-nums'; + +export function ReaderFloatingActionBar({ + post, + onCommentClick, +}: ReaderFloatingActionBarProps): ReactElement { + const { toggleBookmark } = useBookmarkPost(); + const { toggleUpvote, toggleDownvote } = useVotePost(); + const { openSharePost } = useSharePost(Origin.ReaderModal); + + const isUpvoteActive = post?.userState?.vote === UserVote.Up; + const isDownvoteActive = post?.userState?.vote === UserVote.Down; + const upvotes = largeNumberFormat(post.numUpvotes ?? 0) ?? '0'; + const comments = largeNumberFormat(post.numComments ?? 0) ?? '0'; + + return ( +
+ + + + + + + { + toggleBookmark({ post, origin: Origin.ReaderModal }).catch( + () => {}, + ); + }, + className: '!h-8 !w-8 !shrink-0', + buttonClassName: classNames( + iconActionButtonClasses, + 'btn-tertiary-bun', + ), + }} + /> + +
+ ); +} diff --git a/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx b/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx new file mode 100644 index 00000000000..c55dc485bcd --- /dev/null +++ b/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + EyeIcon, + MiniCloseIcon as CloseIcon, + SidebarArrowRight, +} from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { useLegacyPostLayoutOptOut } from './hooks/useLegacyPostLayoutOptOut'; + +export const readerHeaderActionGroupClassName = + 'flex h-9 items-center gap-px rounded-12 border border-border-subtlest-tertiary bg-background-default p-px shadow-3'; + +const iconButtonClassName = '!h-8 !w-8 !min-w-8 !rounded-10 !p-0'; + +type ReaderDiscussionToggleButtonProps = { + onToggleRail: () => void; +}; + +export function ReaderDiscussionToggleButton({ + onToggleRail, +}: ReaderDiscussionToggleButtonProps): ReactElement { + return ( + +
+ ); +} diff --git a/packages/shared/src/components/post/reader/SourceStrip.tsx b/packages/shared/src/components/post/reader/SourceStrip.tsx new file mode 100644 index 00000000000..f91e4e5cc36 --- /dev/null +++ b/packages/shared/src/components/post/reader/SourceStrip.tsx @@ -0,0 +1,106 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { SourceTooltip } from '../../../graphql/sources'; +import { FollowButton } from '../../contentPreference/FollowButton'; +import { useContentPreferenceStatusQuery } from '../../../hooks/contentPreference/useContentPreferenceStatusQuery'; +import { ContentPreferenceType } from '../../../graphql/contentPreference'; +import useShowFollowAction from '../../../hooks/useShowFollowAction'; +import { ButtonVariant } from '../../buttons/Button'; +import Link from '../../utilities/Link'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import HoverCard from '../../cards/common/HoverCard'; +import SourceEntityCard from '../../cards/entity/SourceEntityCard'; + +type SourceStripProps = { + source: SourceTooltip; +}; + +export function SourceStrip({ source }: SourceStripProps): ReactElement | null { + const sourceId = source?.id ?? ''; + const sourceName = source?.name ?? ''; + const { showActionBtn } = useShowFollowAction({ + entityId: sourceId, + entityType: ContentPreferenceType.Source, + }); + const { data: contentPreference } = useContentPreferenceStatusQuery({ + id: sourceId, + entity: ContentPreferenceType.Source, + }); + if (!source?.id || !source.name || !source.image || !source.permalink) { + return null; + } + + const sourceHandle = source.handle ? `@${source.handle}` : null; + + return ( +
+ + + + + + +
+ + + {source.name} + + + {sourceHandle && ( + + {sourceHandle} + + )} +
+
+ } + > + + + {showActionBtn && ( + + )} +
+ ); +} diff --git a/packages/shared/src/components/post/reader/hooks/useIframeEmbed.ts b/packages/shared/src/components/post/reader/hooks/useIframeEmbed.ts new file mode 100644 index 00000000000..d08d9ecb2de --- /dev/null +++ b/packages/shared/src/components/post/reader/hooks/useIframeEmbed.ts @@ -0,0 +1,31 @@ +import { useMemo, useState } from 'react'; +import { isEmbeddableSiteTarget } from '../../../../features/extensionEmbed/common'; +import { getBrowserExtensionInstallId } from '../../../../features/extensionEmbed/getBrowserExtensionInstallId'; + +export type IframeEmbedState = { + extensionId: string | null; + targetUrl: string | null; + isEmbeddable: boolean; +}; + +export function useIframeEmbed( + permalink: string | undefined, +): IframeEmbedState { + const [extensionId] = useState(() => getBrowserExtensionInstallId()); + + return useMemo(() => { + if (!permalink) { + return { + extensionId, + targetUrl: null, + isEmbeddable: false, + }; + } + + return { + extensionId, + targetUrl: permalink, + isEmbeddable: isEmbeddableSiteTarget(permalink), + }; + }, [extensionId, permalink]); +} diff --git a/packages/shared/src/components/post/reader/hooks/useLegacyPostLayoutOptOut.ts b/packages/shared/src/components/post/reader/hooks/useLegacyPostLayoutOptOut.ts new file mode 100644 index 00000000000..5bd8a91f266 --- /dev/null +++ b/packages/shared/src/components/post/reader/hooks/useLegacyPostLayoutOptOut.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; +import { useSettingsContext } from '../../../../contexts/SettingsContext'; + +export function useLegacyPostLayoutOptOut(): { + isOptedOut: boolean; + optOut: () => void; + optIn: () => void; +} { + const { flags, updateFlag } = useSettingsContext(); + const isOptedOut = flags?.legacyPostLayoutOptOut ?? false; + + const optOut = useCallback(() => { + updateFlag('legacyPostLayoutOptOut', true); + }, [updateFlag]); + + const optIn = useCallback(() => { + updateFlag('legacyPostLayoutOptOut', false); + }, [updateFlag]); + + return { isOptedOut, optOut, optIn }; +} diff --git a/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts b/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts new file mode 100644 index 00000000000..0377e9006cf --- /dev/null +++ b/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useState } from 'react'; + +const STORAGE_KEY_RAIL_OPEN = 'readerModal.railOpen'; +const STORAGE_KEY_RAIL_WIDTH = 'readerModal.railWidthPx'; + +const DEFAULT_RAIL_WIDTH_PX = 356; +const MIN_RAIL_WIDTH_PX = 356; +const MAX_RAIL_WIDTH_PX = 720; + +function readRailOpen(): boolean { + if (typeof globalThis.window === 'undefined') { + return true; + } + + try { + const raw = globalThis.window.localStorage.getItem(STORAGE_KEY_RAIL_OPEN); + if (raw === null) { + return true; + } + return raw === '1'; + } catch { + return true; + } +} + +function readRailWidth(): number { + if (typeof globalThis.window === 'undefined') { + return DEFAULT_RAIL_WIDTH_PX; + } + + try { + const raw = globalThis.window.localStorage.getItem(STORAGE_KEY_RAIL_WIDTH); + if (!raw) { + return DEFAULT_RAIL_WIDTH_PX; + } + const n = Number.parseInt(raw, 10); + if (Number.isNaN(n)) { + return DEFAULT_RAIL_WIDTH_PX; + } + return Math.min(MAX_RAIL_WIDTH_PX, Math.max(MIN_RAIL_WIDTH_PX, n)); + } catch { + return DEFAULT_RAIL_WIDTH_PX; + } +} + +export function useReaderLayoutPrefs(): { + isRailOpen: boolean; + setRailOpen: (open: boolean) => void; + railWidthPx: number; + setRailWidthPx: (width: number) => void; + minRailWidthPx: number; + maxRailWidthPx: number; +} { + const [isRailOpen, setRailOpenState] = useState(readRailOpen); + const [railWidthPx, setRailWidthState] = useState(readRailWidth); + + useEffect(() => { + try { + globalThis.window?.localStorage.setItem( + STORAGE_KEY_RAIL_OPEN, + isRailOpen ? '1' : '0', + ); + } catch { + // ignore quota / private mode + } + }, [isRailOpen]); + + useEffect(() => { + try { + globalThis.window?.localStorage.setItem( + STORAGE_KEY_RAIL_WIDTH, + String(railWidthPx), + ); + } catch { + // ignore + } + }, [railWidthPx]); + + const setRailOpen = useCallback((open: boolean) => { + setRailOpenState(open); + }, []); + + const setRailWidthPx = useCallback((width: number) => { + setRailWidthState( + Math.min(MAX_RAIL_WIDTH_PX, Math.max(MIN_RAIL_WIDTH_PX, width)), + ); + }, []); + + return { + isRailOpen, + setRailOpen, + railWidthPx, + setRailWidthPx, + minRailWidthPx: MIN_RAIL_WIDTH_PX, + maxRailWidthPx: MAX_RAIL_WIDTH_PX, + }; +} diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css new file mode 100644 index 00000000000..de617f3705e --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css @@ -0,0 +1,118 @@ +.root { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + align-items: stretch; + align-self: stretch; + width: 100%; + height: 100%; + min-height: 28rem; + overflow: visible; +} + +@media (max-width: 655px) { + .root { + align-items: center; + justify-content: center; + } +} + +.stickyShell { + position: sticky; + top: 50vh; + transform: translateY(-50%); + z-index: 2; + display: flex; + width: 100%; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +@media (max-width: 655px) { + .stickyShell { + position: static; + transform: none; + } +} + +.ambient { + pointer-events: none; + position: absolute; + inset: 0; + overflow: hidden; + opacity: 0.44; + background: + repeating-linear-gradient( + 120deg, + color-mix(in srgb, var(--theme-border-subtlest-tertiary) 12%, transparent) + 0, + color-mix(in srgb, var(--theme-border-subtlest-tertiary) 12%, transparent) + 0.75rem, + transparent 0.75rem, + transparent 1.5rem + ), + linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-text-tertiary) 5%, transparent), + color-mix(in srgb, var(--theme-border-subtlest-tertiary) 6%, transparent) + ); + background-size: 190% 190%, 100% 100%; + animation: embeddedBrowsingStripeShift 42s linear infinite; + will-change: background-position; +} + +.ambient::before { + content: ''; + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 120deg, + transparent 0, + transparent 1.75rem, + color-mix(in srgb, var(--theme-text-tertiary) 4%, transparent) 1.75rem, + color-mix(in srgb, var(--theme-text-tertiary) 4%, transparent) 2.5rem + ); + background-size: 220% 220%; + animation: embeddedBrowsingStripeShiftReverse 58s linear infinite; + opacity: 0.24; +} + +@keyframes embeddedBrowsingStripeShift { + 0% { + background-position: 0% 0%, 50% 50%; + } + + 100% { + background-position: 220% 120%, 50% 50%; + } +} + +@keyframes embeddedBrowsingStripeShiftReverse { + 0% { + background-position: 220% 140%; + } + + 100% { + background-position: 0% 0%; + } +} + +@media (prefers-reduced-motion: reduce) { + .ambient, + .ambient::before { + animation: none; + } + + .ambient { + background-position: 50% 50%, 50% 50%; + opacity: 0.42; + } + + .ambient::before { + opacity: 0.18; + } +} diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx new file mode 100644 index 00000000000..2e5d1abe8ae --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx @@ -0,0 +1,71 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; +import { downloadBrowserExtension, isChrome } from '../../lib/constants'; +import { ChromeIcon, EdgeIcon } from '../../components/icons'; +import styles from './EmbeddedBrowsingWebPrompt.module.css'; + +/** + * Webapp prompt shown when the daily.dev extension isn't installed in this + * browser. The "extension installed but permission not granted" case is + * handled inside the iframe itself (`packages/extension/src/frame/render.ts`) + * so the Enable click carries the user gesture Chrome requires. + */ +export function EmbeddedBrowsingWebPrompt(): ReactElement { + const isChromeBrowser = isChrome(); + const BrowserIcon = isChromeBrowser ? ChromeIcon : EdgeIcon; + const installButtonLabel = isChromeBrowser + ? 'Install Chrome extension' + : 'Install Edge extension'; + + return ( +
+
+
+
+ + Enable embedded browsing + + + Preview and open sites directly inside daily.dev. To use this + feature, install the daily.dev browser extension. + +
+ +
+
+
+
+ ); +} diff --git a/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx b/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx index c9b26c9f1b3..760a6cee369 100644 --- a/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx +++ b/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx @@ -1,5 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; import { useExtensionSiteEmbed } from './useExtensionSiteEmbed'; import type { UseExtensionSiteEmbedOptions, @@ -11,22 +12,43 @@ interface ExtensionSiteEmbedProps extends UseExtensionSiteEmbedOptions { permissionFrameTitle?: string; targetFrameTitle?: string; renderState?: (state: UseExtensionSiteEmbedResult) => ReactNode; + onStateChange?: (state: UseExtensionSiteEmbedResult) => void; + onTargetFrameLoad?: () => void; } const hiddenPermissionFrameClassName = 'pointer-events-none absolute h-0 w-0 opacity-0'; const visibleFrameClassName = 'h-full w-full'; +const frameBaseLayerClassName = 'relative z-0'; +const frameContainerClassName = 'relative z-0 flex min-h-0 flex-1 flex-col'; +const permissionFrameVisibleReasons = new Set([ + 'missing-permission', + 'permission-denied', + 'permission-request-failed', +]); export const ExtensionSiteEmbed = ({ className = visibleFrameClassName, permissionFrameTitle = 'Extension permission frame', targetFrameTitle = 'Embedded site', renderState, + onStateChange, + onTargetFrameLoad, ...options }: ExtensionSiteEmbedProps): ReactElement | null => { const state = useExtensionSiteEmbed(options); + const stateRef = useRef(state); + stateRef.current = state; const view = renderState?.(state); const hasAnyFrame = !!state.permissionFrameSrc || !!state.targetFrameSrc; + const shouldShowPermissionFrame = + state.showPermissionFrame && + (state.status === 'permission-required' || + permissionFrameVisibleReasons.has(state.errorReason ?? '')); + + useEffect(() => { + onStateChange?.(stateRef.current); + }, [onStateChange, state.status, state.errorReason]); if (!hasAnyFrame && !view) { return null; @@ -36,7 +58,7 @@ export const ExtensionSiteEmbed = ({ <> {view} {hasAnyFrame ? ( -
+
{state.permissionFrameSrc ? (