Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { install, uninstall } from '@dailydotdev/shared/src/lib/constants';
import { BOOT_LOCAL_KEY } from '@dailydotdev/shared/src/contexts/common';
import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension';
import { storageWrapper as storage } from '@dailydotdev/shared/src/lib/storageWrapper';
import { frameEmbeddingPermissionBridgeTiming } from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge';
import type { PermissionGrantResponse } from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge';
import {
getContentScriptPermissionAndRegister,
registerEmbedTargetReadyContentScript,
Expand All @@ -20,6 +22,7 @@ import {
disableFrameEmbeddingForTab,
enableFrameEmbeddingForTab,
} from '../lib/frameEmbedding';
import { requestFrameEmbeddingPermissions } from '../lib/frameEmbeddingPermissions';

type ChromeRuntimeMessageSender = Runtime.MessageSender;
type ChromeSendResponse = (response?: unknown) => void;
Expand Down Expand Up @@ -185,6 +188,48 @@ async function handleMessages(
return disableFrameEmbeddingForTab(tabId);
}

if (message.type === ExtensionMessageType.PingFrameEmbeddingReady) {
// Lightweight liveness check used by the content script to detect when
// the service worker is back online after the post-grant runtime.reload.
return { ready: true };
}

if (message.type === ExtensionMessageType.RequestFrameEmbeddingPermissions) {
// Relay path so the daily.dev page can drive chrome.permissions.request
// without losing the user gesture. chrome.permissions.request is only
// callable from extension pages, so the content script forwards here and
// the user gesture from the originating click is preserved via the
// Runtime.MessageSender.userGesture flag.
try {
const granted = await requestFrameEmbeddingPermissions();
if (granted) {
// Chromium needs a fresh extension context to pick up the just-granted
// optional host permission for declarativeNetRequest — without it the
// DNR session-rule call from frame.html hangs. Schedule the reload
// on the next tick so the response can be delivered first; the
// content script then polls until the new worker is up before
// resolving the grant Promise on the page.
globalThis.setTimeout(() => {
browser.runtime.reload();
}, frameEmbeddingPermissionBridgeTiming.reloadDelayMs);
}
const response: PermissionGrantResponse = {
granted,
willReload: granted,
};
return response;
} catch (error) {
const response: PermissionGrantResponse = {
granted: false,
error:
error instanceof Error
? error.message
: 'Failed to request frame embedding permissions',
};
return response;
}
}

await getContentScriptPermissionAndRegister();

if (message.type === ExtensionMessageType.ContentLoaded) {
Expand Down
85 changes: 85 additions & 0 deletions packages/extension/src/ping/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,95 @@
// The webapp reads the marker to decide install-prompt vs. embed flow. The
// frame.html iframe still owns the permission-granted check.

import browser from 'webextension-polyfill';
import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension';
import {
frameEmbeddingPermissionBridgeTiming,
pagePermissionBridgeRequestEvent,
pagePermissionBridgeResultEvent,
} from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge';
import type {
PagePermissionBridgeResult,
PermissionGrantResponse,
} from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge';

const INSTALL_MARKER = 'dailyExtensionInstalled';
const ID_MARKER = 'dailyExtensionId';
const MESSAGE_SOURCE = 'daily-extension-ping';

// chrome.permissions.request is only callable from extension pages. We relay
// the page's click — while the user gesture is still active in the same task
// — to the background service worker, which calls request() on the page's
// behalf. The synchronous dispatchEvent on the page side is what keeps the
// gesture alive across the message boundary.
//
// After the grant the background schedules runtime.reload() so the new
// optional host permission is picked up for declarativeNetRequest. We hold
// the result event until the new service worker responds to a ping, so the
// page only mounts the reader iframe once DNR rules can be installed.
const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, ms);
});

const waitForExtensionReady = async (): Promise<void> => {
const { pingDelayBeforeFirstAttemptMs, pingRetryDelayMs, pingMaxAttempts } =
frameEmbeddingPermissionBridgeTiming;

await sleep(pingDelayBeforeFirstAttemptMs);

for (let attempt = 0; attempt < pingMaxAttempts; attempt += 1) {
try {
// eslint-disable-next-line no-await-in-loop -- sequential polling is the point
const response = (await browser.runtime.sendMessage({
type: ExtensionMessageType.PingFrameEmbeddingReady,
})) as { ready?: boolean } | undefined;
if (response?.ready) {
return;
}
} catch {
// sendMessage rejects while the service worker is mid-reload; retry.
}
// eslint-disable-next-line no-await-in-loop -- sequential backoff between pings
await sleep(pingRetryDelayMs);
}
};

window.addEventListener(pagePermissionBridgeRequestEvent, () => {
const dispatchResult = (result: PagePermissionBridgeResult): void => {
window.dispatchEvent(
new CustomEvent<PagePermissionBridgeResult>(
pagePermissionBridgeResultEvent,
{ detail: result },
),
);
};

browser.runtime
.sendMessage({
type: ExtensionMessageType.RequestFrameEmbeddingPermissions,
})
.then(async (response) => {
const typed = response as PermissionGrantResponse | undefined;
if (typed?.granted && typed?.willReload) {
await waitForExtensionReady();
}
dispatchResult({
granted: !!typed?.granted,
error: typed?.error,
});
})
.catch((error: unknown) => {
dispatchResult({
granted: false,
error:
error instanceof Error
? error.message
: 'Failed to request frame embedding permissions',
});
});
});

const marker = document.documentElement;

if (marker && !marker.dataset[INSTALL_MARKER]) {
Expand Down
21 changes: 1 addition & 20 deletions packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,6 @@ const SocialTwitterPostModal = dynamic(
),
);

const ReaderPostModal = dynamic(
() =>
import(
/* webpackChunkName: "readerPostModal" */ './modals/ReaderPostModal'
),
);

const BriefCardFeed = dynamic(
() =>
import(
Expand Down Expand Up @@ -544,20 +537,8 @@ export default function Feed<T>({
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]);
}, [selectedPost]);

if (!loadedSettings || isFallback) {
return <></>;
Expand Down
36 changes: 34 additions & 2 deletions packages/shared/src/components/cards/common/PostCardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import { isSourceUserSource } from '../../../graphql/sources';
import { ReadArticleButton, getReadPostButtonIcon } from './ReadArticleButton';
import { getGroupedHoverContainer } from './common';
import { useBookmarkProvider, useFeedPreviewMode } from '../../../hooks';
import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate';
import { useLegacyPostLayoutOptOut } from '../../post/reader/hooks/useLegacyPostLayoutOptOut';
import type { Post } from '../../../graphql/posts';
import {
getReadPostButtonText,
isInternalReadType,
isSharedPostSquadPost,
PostType,
} from '../../../graphql/posts';
import { ButtonVariant } from '../../buttons/Button';
import { useReaderModalEligibility } from '../../post/reader/hooks/useReaderModalEligibility';
import { EarthIcon } from '../../icons';
import type { FlagProps } from './FeedItemContainer';
import {
BookmakProviderHeader,
Expand Down Expand Up @@ -68,6 +73,33 @@ export const PostCardHeader = ({
const { highlightBookmarkedPost } = useBookmarkProvider({
bookmarked: (post.bookmarked && !showFeedback) ?? false,
});
const { isEligible: isReaderEligible, isReaderModalEnabled } =
useReaderModalEligibility();
const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut();
// Variant: clicking Read post on the card opens the reader preview inside
// daily.dev (globe-icon CTA) instead of navigating to the external article.
// Once a user opts out (via the install-prompt overlay) the card reverts
// to the classic external Read post button.
const isReaderVariant =
isReaderEligible &&
isReaderModalEnabled &&
post.type === PostType.Article &&
!isLegacyLayoutOptedOut;
const { onReadClick: onReaderInstallGateClick } =
useReaderInstallPromptGate(post);

const handleReadArticleClick = (event: React.MouseEvent) => {
if (onReaderInstallGateClick(event)) {
return;
}
onReadArticleClick?.(event);
};

const readPostIcon = isReaderVariant ? (
<EarthIcon />
) : (
getReadPostButtonIcon(post)
);

const articleLink = useMemo(() => {
if (post.sharedPost) {
Expand Down Expand Up @@ -121,9 +153,9 @@ export const PostCardHeader = ({
className="mr-2"
variant={ButtonVariant.Primary}
href={articleLink ?? ''}
onClick={onReadArticleClick}
onClick={handleReadArticleClick}
openNewTab={openNewTab}
icon={getReadPostButtonIcon(post)}
icon={readPostIcon}
/>
))}
<PostOptionButton post={post} />
Expand Down
Loading
Loading