Skip to content
Merged
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
59 changes: 56 additions & 3 deletions src/api/Action/Action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { isUrlSameOrigin } from '../../shared';
import { proxify, proxifyImage } from '../../utils/proxify.ts';
import type { ActionAdapter } from '../ActionConfig.ts';
import type {
ActionGetResponse,
ActionParameterType,
ExtendedActionGetResponse,
NextAction,
NextActionLink,
NextActionPostRequest,
Expand All @@ -26,6 +26,8 @@ import {

const MULTI_VALUE_TYPES: ActionParameterType[] = ['checkbox'];

const EXPERIMENTAL_DYNAMIC_DATA_DEFAULT_DELAY_MS = 1000;

interface ActionMetadata {
blockchainIds?: string[];
version?: string;
Expand All @@ -40,6 +42,15 @@ type ActionChainMetadata =
isChained: false;
};

interface DynamicData {
enabled: boolean;
delayMs?: number;
}

interface ExperimentalFeatures {
dynamicData?: DynamicData;
}

export class Action {
private readonly _actions: AbstractActionComponent[];

Expand All @@ -50,6 +61,7 @@ export class Action {
private readonly _supportStrategy: ActionSupportStrategy,
private _adapter?: ActionAdapter,
private readonly _chainMetadata: ActionChainMetadata = { isChained: false },
private readonly _experimental?: ExperimentalFeatures,
) {
// if no links present or completed, fallback to original solana pay spec (or just using the button as a placeholder)
if (_data.type === 'completed' || !_data.links?.actions) {
Expand All @@ -67,6 +79,25 @@ export class Action {
});
}

// this API MAY change in the future
public get dynamicData_experimental(): Required<DynamicData> | null {
const dynamicData = this._experimental?.dynamicData;

if (!dynamicData) {
return null;
}

return {
enabled: dynamicData.enabled,
delayMs: dynamicData.delayMs
? Math.max(
dynamicData.delayMs,
EXPERIMENTAL_DYNAMIC_DATA_DEFAULT_DELAY_MS,
)
: EXPERIMENTAL_DYNAMIC_DATA_DEFAULT_DELAY_MS,
};
}

public get isChained() {
return this._chainMetadata.isChained;
}
Expand Down Expand Up @@ -224,10 +255,11 @@ export class Action {
return new Action(url, data, metadata, supportStrategy, adapter);
}

static async fetch(
private static async _fetch(
apiUrl: string,
adapter?: ActionAdapter,
supportStrategy: ActionSupportStrategy = defaultActionSupportStrategy,
chainMetadata?: ActionChainMetadata,
) {
const proxyUrl = proxify(apiUrl);
const response = await fetch(proxyUrl, {
Expand All @@ -242,7 +274,7 @@ export class Action {
);
}

const data = (await response.json()) as ActionGetResponse;
const data = (await response.json()) as ExtendedActionGetResponse;
const metadata = getActionMetadata(response);

return new Action(
Expand All @@ -251,6 +283,27 @@ export class Action {
metadata,
supportStrategy,
adapter,
chainMetadata,
data.dialectExperimental,
);
}

static async fetch(
apiUrl: string,
adapter?: ActionAdapter,
supportStrategy: ActionSupportStrategy = defaultActionSupportStrategy,
) {
return Action._fetch(apiUrl, adapter, supportStrategy, {
isChained: false,
});
}

refresh() {
return Action._fetch(
this.url,
this.adapter,
this._supportStrategy,
this._chainMetadata,
);
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/api/actions-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,16 @@ export interface ActionError {
/** simple error message to be displayed to the user */
message: string;
}

// Dialect's extensions to the Actions API
export interface DialectExperimentalFeatures {
dialectExperimental?: {
dynamicData?: {
enabled: boolean;
delayMs?: number; // default 1000 (1s)
};
};
}

export type ExtendedActionGetResponse = ActionGetResponse &
DialectExperimentalFeatures;
39 changes: 28 additions & 11 deletions src/ext/twitter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function setupTwitterObserver(
// it's fast to iterate like this
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) {
Expand Down Expand Up @@ -196,15 +197,29 @@ async function handleNewNode(
return;
}

addMargin(container).replaceChildren(
createAction({
originalUrl: actionUrl,
action,
callbacks,
options,
isInterstitial: interstitialData.isInterstitial,
}),
);
const { container: actionContainer, reactRoot } = createAction({
originalUrl: actionUrl,
action,
callbacks,
options,
isInterstitial: interstitialData.isInterstitial,
});

addStyles(container).replaceChildren(actionContainer);

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

function createAction({
Expand Down Expand Up @@ -237,7 +252,7 @@ function createAction({
</div>,
);

return container;
return { container, reactRoot: actionRoot };
}

const resolveXStylePreset = (): StylePreset => {
Expand Down Expand Up @@ -318,11 +333,13 @@ function getContainerForLink(tweetText: Element) {
return root;
}

function addMargin(element: HTMLElement) {
function addStyles(element: HTMLElement) {
if (element && element.classList.contains('dialect-wrapper')) {
element.style.marginTop = '12px';
if (element.classList.contains('dialect-dm')) {
element.style.marginBottom = '8px';
element.style.width = '100%';
element.style.minWidth = '350px';
}
}
return element;
Expand Down
106 changes: 76 additions & 30 deletions src/ui/ActionContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ const DEFAULT_SECURITY_LEVEL: SecurityLevel = 'only-trusted';
type Source = 'websites' | 'interstitials' | 'actions';
type NormalizedSecurityLevel = Record<Source, SecurityLevel>;

// overall flow: check-supportability -> idle/block -> executing -> success/error or chain
export const ActionContainer = ({
action: initialAction,
websiteUrl,
Expand Down Expand Up @@ -273,49 +274,92 @@ export const ActionContainer = ({
);

const [executionState, dispatch] = useReducer(executionReducer, {
status:
overallState !== 'malicious' && isPassingSecurityCheck
? 'idle'
: 'blocked',
status: 'checking-supportability',
});

// in case, where action or websiteUrl changes, we need to reset the action state
// in case, where initialAction or websiteUrl changes, we need to reset the action state
useEffect(() => {
if (action === initialAction || action.isChained) {
return;
}

setAction(initialAction);
setActionState(getOverallActionState(initialAction, websiteUrl));
dispatch({ type: ExecutionType.RESET });
}, [action, initialAction, websiteUrl]);
dispatch({ type: ExecutionType.CHECK_SUPPORTABILITY });
// we want to run this one when initialAction or websiteUrl changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialAction, websiteUrl]);

useEffect(() => {
callbacks?.onActionMount?.(
action,
websiteUrl ?? action.url,
actionState.action,
);
// we ignore changes to `actionState.action` explicitly, since we want this to run once
// we ignore changes to `actionState.action` or callbacks explicitly, since we want this to run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [callbacks, action, websiteUrl]);
}, [action, websiteUrl]);

useEffect(() => {
const dynamicDataConfig = action.dynamicData_experimental;
if (
!dynamicDataConfig ||
!dynamicDataConfig.enabled ||
executionState.status !== 'idle' ||
action.isChained
) {
return;
}

let timeout: any; // NodeJS.Timeout
const fetcher = async () => {
try {
const newAction = await action.refresh();

// if after refresh user clicked started execution, we should not update the action
if (executionState.status === 'idle') {
setAction(newAction);
}
} catch (e) {
console.error(
`[@dialectlabs/blinks] Failed to fetch dynamic data for action ${action.url}`,
);
// if fetch failed, we retry after the same delay
timeout = setTimeout(fetcher, dynamicDataConfig.delayMs);
}
};

// since either way we're rebuilding the whole action, we'll update and restart this effect
timeout = setTimeout(fetcher, dynamicDataConfig.delayMs);

return () => {
clearTimeout(timeout);
};
}, [action, executionState.status]);

useEffect(() => {
const checkSupportability = async (action: Action) => {
if (action.isChained) {
if (
action.isChained ||
executionState.status !== 'checking-supportability'
) {
return;
}
try {
dispatch({ type: ExecutionType.CHECK_SUPPORTABILITY });
const supportability = await action.isSupported();
setSupportability(supportability);
} finally {
dispatch({ type: ExecutionType.RESET });
dispatch({
type:
overallState !== 'malicious' && isPassingSecurityCheck
? ExecutionType.RESET
: ExecutionType.BLOCK,
});
}
};

checkSupportability(action);
}, [action]);
}, [action, executionState.status, overallState, isPassingSecurityCheck]);

const buttons = useMemo(
() =>
Expand Down Expand Up @@ -473,22 +517,24 @@ export const ActionContainer = ({
}
};

const asButtonProps = (it: ButtonActionComponent) => ({
text: buttonLabelMap[executionState.status] ?? it.label,
loading:
executionState.status === 'executing' &&
it === executionState.executingAction,
disabled:
action.disabled ||
action.type === 'completed' ||
executionState.status !== 'idle',
variant:
buttonVariantMap[
action.type === 'completed' ? 'success' : executionState.status
],
onClick: (params?: Record<string, string | string[]>) =>
execute(it.parentComponent ?? it, params),
});
const asButtonProps = (it: ButtonActionComponent) => {
return {
text: buttonLabelMap[executionState.status] ?? it.label,
loading:
executionState.status === 'executing' &&
it === executionState.executingAction,
disabled:
action.disabled ||
action.type === 'completed' ||
executionState.status !== 'idle',
variant:
buttonVariantMap[
action.type === 'completed' ? 'success' : executionState.status
],
onClick: (params?: Record<string, string | string[]>) =>
execute(it.parentComponent ?? it, params),
};
};

const asInputProps = (
it: SingleValueActionComponent | MultiValueActionComponent,
Expand Down Expand Up @@ -571,7 +617,7 @@ export const ActionContainer = ({
: null
}
success={executionState.successMessage}
buttons={buttons.map(asButtonProps)}
buttons={buttons.map((button) => asButtonProps(button))}
inputs={inputs.map((input) => asInputProps(input))}
form={form ? asFormProps(form) : undefined}
disclaimer={disclaimer}
Expand Down