Skip to content

Commit

Permalink
Feat: Youtube Integration
Browse files Browse the repository at this point in the history
  • Loading branch information
scriptscrypt committed Oct 21, 2024
1 parent 9ef02ef commit 5157eb2
Show file tree
Hide file tree
Showing 9 changed files with 399 additions and 11 deletions.
19 changes: 12 additions & 7 deletions packages/blinks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,24 @@
"types": "dist/index.d.ts",
"exports": {
"./ext/twitter": {
"types": "./dist/ext/twitter.d.ts",
"import": "./dist/ext/twitter.js",
"require": "./dist/ext/twitter.cjs",
"types": "./dist/ext/twitter.d.ts"
"require": "./dist/ext/twitter.cjs"
},
"./ext/youtube": {
"types": "./dist/ext/youtube.d.ts",
"import": "./dist/ext/youtube.js",
"require": "./dist/ext/youtube.cjs"
},
"./hooks/solana": {
"types": "./dist/hooks/solana/index.d.ts",
"import": "./dist/hooks/solana/index.js",
"require": "./dist/hooks/solana/index.cjs",
"types": "./dist/hooks/solana/index.d.ts"
"require": "./dist/hooks/solana/index.cjs"
},
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
"require": "./dist/index.cjs"
},
"./index.css": "./dist/index.css"
},
Expand Down Expand Up @@ -61,7 +66,7 @@
"bs58": "^5.0.0",
"@solana/wallet-adapter-react": "^0.15.0",
"@solana/wallet-adapter-react-ui": "^0.9.0",
"@solana/web3.js": "^1.95.1",
"@solana/web3.js": "^1.95.4",
"react": ">=18",
"react-dom": ">=18"
},
Expand Down
1 change: 1 addition & 0 deletions packages/blinks/src/ext/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './twitter';
export * from './youtube';
261 changes: 261 additions & 0 deletions packages/blinks/src/ext/youtube.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import {
Action,
type ActionAdapter,
type ActionCallbacksConfig,
type ActionsJsonConfig,
ActionsRegistry,
type ActionSupportStrategy,
ActionsURLMapper,
checkSecurity,
defaultActionSupportStrategy,
getExtendedActionState,
getExtendedInterstitialState,
getExtendedWebsiteState,
isInterstitial,
proxify,
type SecurityLevel,
} from '@dialectlabs/blinks-core';
import { createRoot } from 'react-dom/client';
import { Blink, type StylePreset } from '../ui';

type ObserverSecurityLevel = SecurityLevel;

const noop = () => {};

export interface ObserverOptionsYT {
securityLevel:
| ObserverSecurityLevel
| Record<'websites' | 'interstitials' | 'actions', ObserverSecurityLevel>;
supportStrategy: ActionSupportStrategy;
}

interface NormalizedObserverOptionsYT {
securityLevel: Record<
'websites' | 'interstitials' | 'actions',
ObserverSecurityLevel
>;
supportStrategy: ActionSupportStrategy;
}

const DEFAULT_OPTIONS: ObserverOptionsYT = {
securityLevel: 'only-trusted',
supportStrategy: defaultActionSupportStrategy,
};

const normalizeOptions = (
options: Partial<ObserverOptionsYT>,
): NormalizedObserverOptionsYT => {
return {
...DEFAULT_OPTIONS,
...options,
securityLevel: (() => {
if (!options.securityLevel) {
return {
websites: DEFAULT_OPTIONS.securityLevel as ObserverSecurityLevel,
interstitials: DEFAULT_OPTIONS.securityLevel as ObserverSecurityLevel,
actions: DEFAULT_OPTIONS.securityLevel as ObserverSecurityLevel,
};
}

if (typeof options.securityLevel === 'string') {
return {
websites: options.securityLevel,
interstitials: options.securityLevel,
actions: options.securityLevel,
};
}

return options.securityLevel;
})(),
};
};

export function setupYouTubeObserver(
config: ActionAdapter,
callbacks: Partial<ActionCallbacksConfig> = {},
options: Partial<ObserverOptionsYT> = DEFAULT_OPTIONS,
) {
const mergedOptions = normalizeOptions(options);
const youtubeCommentsSection = document.querySelector('ytd-comments#comments');

const refreshRegistry = async () => {
return ActionsRegistry.getInstance().init();
};

refreshRegistry().then(() => {
const observer = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i++) {
const mutation = mutations[i];
for (let j = 0; j < mutation.addedNodes.length; j++) {
const node = mutation.addedNodes[j];
if (node.nodeType === Node.ELEMENT_NODE) {
handleNewYouTubeNode(
node as Element,
config,
callbacks,
mergedOptions,
).catch(noop);
}
}
}
});

if (youtubeCommentsSection) {
observer.observe(youtubeCommentsSection, { childList: true, subtree: true });
}
});
}

async function handleNewYouTubeNode(
node: Element,
config: ActionAdapter,
callbacks: Partial<ActionCallbacksConfig>,
options: NormalizedObserverOptionsYT,
) {
if (!node || node.nodeName !== 'YTD-COMMENT-THREAD-RENDERER') {
return;
}

const contentElement = node.querySelector('#content-text');
if (!contentElement) {
return;
}

const anchorTags = Array.from(contentElement.querySelectorAll('a.yt-core-attributed-string__link'));
for (const anchor of anchorTags) {
const linkText = anchor.textContent?.trim();
if (linkText && isValidUrl(linkText)) {
await processYouTubeLink(new URL(linkText), node, config, callbacks, options);
}
}
}

async function processYouTubeLink(
originalUrl: URL,
commentNode: Element,
config: ActionAdapter,
callbacks: Partial<ActionCallbacksConfig>,
options: NormalizedObserverOptionsYT,
) {
const interstitialData = isInterstitial(originalUrl);

let actionApiUrl: string | null;

if (interstitialData.isInterstitial) {
const interstitialState = getExtendedInterstitialState(originalUrl.toString());
if (!checkSecurity(interstitialState, options.securityLevel.interstitials)) {
return;
}
actionApiUrl = interstitialData.decodedActionUrl;

} else {
const websiteState = getExtendedWebsiteState(originalUrl.toString());
if (!checkSecurity(websiteState, options.securityLevel.websites)) {
return;
}

const actionsJsonUrl = originalUrl.origin + '/actions.json';
const actionsJson = await fetch(proxify(actionsJsonUrl)).then(
(res) => res.json() as Promise<ActionsJsonConfig>,
);

const actionsUrlMapper = new ActionsURLMapper(actionsJson);
actionApiUrl = actionsUrlMapper.mapUrl(originalUrl);
}

const state = actionApiUrl ? getExtendedActionState(actionApiUrl) : null;
if (
!actionApiUrl ||
!state ||
!checkSecurity(state, options.securityLevel.actions)
) {
return;
}

const action = await Action.fetch(
actionApiUrl,
config,
options.supportStrategy,
).catch(noop);

if (!action) {
return;
}

const { container: actionContainer, reactRoot } = createYouTubeAction({
originalUrl,
action,
callbacks,
options,
isInterstitial: interstitialData.isInterstitial,
});

const containerWrapper = document.createElement('div');
containerWrapper.className = 'dialect-wrapper';
containerWrapper.style.marginTop = '12px';
containerWrapper.appendChild(actionContainer);

commentNode.appendChild(containerWrapper);

new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
for (const removedNode of Array.from(mutation.removedNodes)) {
if (
removedNode === containerWrapper ||
!document.body.contains(containerWrapper)
) {
reactRoot.unmount();
observer.disconnect();
}
}
}
}).observe(document.body, { childList: true, subtree: true });
}

function createYouTubeAction({
originalUrl,
action,
callbacks,
options,
}: {
originalUrl: URL;
action: Action;
callbacks: Partial<ActionCallbacksConfig>;
options: NormalizedObserverOptionsYT;
isInterstitial: boolean;
}) {

const container = document.createElement('div');
container.className = 'dialect-action-root-container';

const actionRoot = createRoot(container);

actionRoot.render(
<div onClick={(e) => e.stopPropagation()}>
<Blink
stylePreset={resolveYouTubeStylePreset()}
action={action}
websiteUrl={originalUrl.toString()}
websiteText={originalUrl.hostname}
callbacks={callbacks}
securityLevel={options.securityLevel}
/>
</div>,
);

return { container, reactRoot: actionRoot };
}

const resolveYouTubeStylePreset = (): StylePreset => {
const darkTheme = document.documentElement.getAttribute('dark') === 'true';
return darkTheme ? 'youtube-dark' : 'youtube-light';
};

function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ export function useActionSolanaWalletAdapter(
},
signTransaction: async (txData: string) => {
try {
// Fixes the Buffer error on the base64 conversion
const tx = await wallet.sendTransaction(
VersionedTransaction.deserialize(Buffer.from(txData, 'base64')),
VersionedTransaction.deserialize(new Uint8Array(Buffer.from(txData, 'base64'))),
finalConnection,
);
return { signature: tx };
Expand Down
Loading

0 comments on commit 5157eb2

Please sign in to comment.