Skip to content

Commit 5ed9aa6

Browse files
authored
Feature/experimental-dynamic-data (#19)
* feat: implement action refresh with dynamic data config * chore: reset action properly * chore: fix action polling * chore: handle race conditions * chore: remove console log * fix: container unmounting when container removed from dom * chore: do not poll chained actions * chore: remove dynamic data types for chained actions * feat: do not allow polling faster than every second
1 parent 0284856 commit 5ed9aa6

File tree

4 files changed

+173
-44
lines changed

4 files changed

+173
-44
lines changed

src/api/Action/Action.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { isUrlSameOrigin } from '../../shared';
22
import { proxify, proxifyImage } from '../../utils/proxify.ts';
33
import type { ActionAdapter } from '../ActionConfig.ts';
44
import type {
5-
ActionGetResponse,
65
ActionParameterType,
6+
ExtendedActionGetResponse,
77
NextAction,
88
NextActionLink,
99
NextActionPostRequest,
@@ -26,6 +26,8 @@ import {
2626

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

29+
const EXPERIMENTAL_DYNAMIC_DATA_DEFAULT_DELAY_MS = 1000;
30+
2931
interface ActionMetadata {
3032
blockchainIds?: string[];
3133
version?: string;
@@ -40,6 +42,15 @@ type ActionChainMetadata =
4042
isChained: false;
4143
};
4244

45+
interface DynamicData {
46+
enabled: boolean;
47+
delayMs?: number;
48+
}
49+
50+
interface ExperimentalFeatures {
51+
dynamicData?: DynamicData;
52+
}
53+
4354
export class Action {
4455
private readonly _actions: AbstractActionComponent[];
4556

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

82+
// this API MAY change in the future
83+
public get dynamicData_experimental(): Required<DynamicData> | null {
84+
const dynamicData = this._experimental?.dynamicData;
85+
86+
if (!dynamicData) {
87+
return null;
88+
}
89+
90+
return {
91+
enabled: dynamicData.enabled,
92+
delayMs: dynamicData.delayMs
93+
? Math.max(
94+
dynamicData.delayMs,
95+
EXPERIMENTAL_DYNAMIC_DATA_DEFAULT_DELAY_MS,
96+
)
97+
: EXPERIMENTAL_DYNAMIC_DATA_DEFAULT_DELAY_MS,
98+
};
99+
}
100+
70101
public get isChained() {
71102
return this._chainMetadata.isChained;
72103
}
@@ -224,10 +255,11 @@ export class Action {
224255
return new Action(url, data, metadata, supportStrategy, adapter);
225256
}
226257

227-
static async fetch(
258+
private static async _fetch(
228259
apiUrl: string,
229260
adapter?: ActionAdapter,
230261
supportStrategy: ActionSupportStrategy = defaultActionSupportStrategy,
262+
chainMetadata?: ActionChainMetadata,
231263
) {
232264
const proxyUrl = proxify(apiUrl);
233265
const response = await fetch(proxyUrl, {
@@ -242,7 +274,7 @@ export class Action {
242274
);
243275
}
244276

245-
const data = (await response.json()) as ActionGetResponse;
277+
const data = (await response.json()) as ExtendedActionGetResponse;
246278
const metadata = getActionMetadata(response);
247279

248280
return new Action(
@@ -251,6 +283,27 @@ export class Action {
251283
metadata,
252284
supportStrategy,
253285
adapter,
286+
chainMetadata,
287+
data.dialectExperimental,
288+
);
289+
}
290+
291+
static async fetch(
292+
apiUrl: string,
293+
adapter?: ActionAdapter,
294+
supportStrategy: ActionSupportStrategy = defaultActionSupportStrategy,
295+
) {
296+
return Action._fetch(apiUrl, adapter, supportStrategy, {
297+
isChained: false,
298+
});
299+
}
300+
301+
refresh() {
302+
return Action._fetch(
303+
this.url,
304+
this.adapter,
305+
this._supportStrategy,
306+
this._chainMetadata,
254307
);
255308
}
256309
}

src/api/actions-spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,16 @@ export interface ActionError {
256256
/** simple error message to be displayed to the user */
257257
message: string;
258258
}
259+
260+
// Dialect's extensions to the Actions API
261+
export interface DialectExperimentalFeatures {
262+
dialectExperimental?: {
263+
dynamicData?: {
264+
enabled: boolean;
265+
delayMs?: number; // default 1000 (1s)
266+
};
267+
};
268+
}
269+
270+
export type ExtendedActionGetResponse = ActionGetResponse &
271+
DialectExperimentalFeatures;

src/ext/twitter.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function setupTwitterObserver(
8787
// it's fast to iterate like this
8888
for (let i = 0; i < mutations.length; i++) {
8989
const mutation = mutations[i];
90+
9091
for (let j = 0; j < mutation.addedNodes.length; j++) {
9192
const node = mutation.addedNodes[j];
9293
if (node.nodeType !== Node.ELEMENT_NODE) {
@@ -196,15 +197,29 @@ async function handleNewNode(
196197
return;
197198
}
198199

199-
addMargin(container).replaceChildren(
200-
createAction({
201-
originalUrl: actionUrl,
202-
action,
203-
callbacks,
204-
options,
205-
isInterstitial: interstitialData.isInterstitial,
206-
}),
207-
);
200+
const { container: actionContainer, reactRoot } = createAction({
201+
originalUrl: actionUrl,
202+
action,
203+
callbacks,
204+
options,
205+
isInterstitial: interstitialData.isInterstitial,
206+
});
207+
208+
addStyles(container).replaceChildren(actionContainer);
209+
210+
new MutationObserver((mutations, observer) => {
211+
for (const mutation of mutations) {
212+
for (const removedNode of Array.from(mutation.removedNodes)) {
213+
if (
214+
removedNode === actionContainer ||
215+
!document.body.contains(actionContainer)
216+
) {
217+
reactRoot.unmount();
218+
observer.disconnect();
219+
}
220+
}
221+
}
222+
}).observe(document.body, { childList: true, subtree: true });
208223
}
209224

210225
function createAction({
@@ -237,7 +252,7 @@ function createAction({
237252
</div>,
238253
);
239254

240-
return container;
255+
return { container, reactRoot: actionRoot };
241256
}
242257

243258
const resolveXStylePreset = (): StylePreset => {
@@ -318,11 +333,13 @@ function getContainerForLink(tweetText: Element) {
318333
return root;
319334
}
320335

321-
function addMargin(element: HTMLElement) {
336+
function addStyles(element: HTMLElement) {
322337
if (element && element.classList.contains('dialect-wrapper')) {
323338
element.style.marginTop = '12px';
324339
if (element.classList.contains('dialect-dm')) {
325340
element.style.marginBottom = '8px';
341+
element.style.width = '100%';
342+
element.style.minWidth = '350px';
326343
}
327344
}
328345
return element;

src/ui/ActionContainer.tsx

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ const DEFAULT_SECURITY_LEVEL: SecurityLevel = 'only-trusted';
217217
type Source = 'websites' | 'interstitials' | 'actions';
218218
type NormalizedSecurityLevel = Record<Source, SecurityLevel>;
219219

220+
// overall flow: check-supportability -> idle/block -> executing -> success/error or chain
220221
export const ActionContainer = ({
221222
action: initialAction,
222223
websiteUrl,
@@ -273,49 +274,92 @@ export const ActionContainer = ({
273274
);
274275

275276
const [executionState, dispatch] = useReducer(executionReducer, {
276-
status:
277-
overallState !== 'malicious' && isPassingSecurityCheck
278-
? 'idle'
279-
: 'blocked',
277+
status: 'checking-supportability',
280278
});
281279

282-
// in case, where action or websiteUrl changes, we need to reset the action state
280+
// in case, where initialAction or websiteUrl changes, we need to reset the action state
283281
useEffect(() => {
284282
if (action === initialAction || action.isChained) {
285283
return;
286284
}
287285

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

293293
useEffect(() => {
294294
callbacks?.onActionMount?.(
295295
action,
296296
websiteUrl ?? action.url,
297297
actionState.action,
298298
);
299-
// we ignore changes to `actionState.action` explicitly, since we want this to run once
299+
// we ignore changes to `actionState.action` or callbacks explicitly, since we want this to run once
300300
// eslint-disable-next-line react-hooks/exhaustive-deps
301-
}, [callbacks, action, websiteUrl]);
301+
}, [action, websiteUrl]);
302+
303+
useEffect(() => {
304+
const dynamicDataConfig = action.dynamicData_experimental;
305+
if (
306+
!dynamicDataConfig ||
307+
!dynamicDataConfig.enabled ||
308+
executionState.status !== 'idle' ||
309+
action.isChained
310+
) {
311+
return;
312+
}
313+
314+
let timeout: any; // NodeJS.Timeout
315+
const fetcher = async () => {
316+
try {
317+
const newAction = await action.refresh();
318+
319+
// if after refresh user clicked started execution, we should not update the action
320+
if (executionState.status === 'idle') {
321+
setAction(newAction);
322+
}
323+
} catch (e) {
324+
console.error(
325+
`[@dialectlabs/blinks] Failed to fetch dynamic data for action ${action.url}`,
326+
);
327+
// if fetch failed, we retry after the same delay
328+
timeout = setTimeout(fetcher, dynamicDataConfig.delayMs);
329+
}
330+
};
331+
332+
// since either way we're rebuilding the whole action, we'll update and restart this effect
333+
timeout = setTimeout(fetcher, dynamicDataConfig.delayMs);
334+
335+
return () => {
336+
clearTimeout(timeout);
337+
};
338+
}, [action, executionState.status]);
302339

303340
useEffect(() => {
304341
const checkSupportability = async (action: Action) => {
305-
if (action.isChained) {
342+
if (
343+
action.isChained ||
344+
executionState.status !== 'checking-supportability'
345+
) {
306346
return;
307347
}
308348
try {
309-
dispatch({ type: ExecutionType.CHECK_SUPPORTABILITY });
310349
const supportability = await action.isSupported();
311350
setSupportability(supportability);
312351
} finally {
313-
dispatch({ type: ExecutionType.RESET });
352+
dispatch({
353+
type:
354+
overallState !== 'malicious' && isPassingSecurityCheck
355+
? ExecutionType.RESET
356+
: ExecutionType.BLOCK,
357+
});
314358
}
315359
};
316360

317361
checkSupportability(action);
318-
}, [action]);
362+
}, [action, executionState.status, overallState, isPassingSecurityCheck]);
319363

320364
const buttons = useMemo(
321365
() =>
@@ -473,22 +517,24 @@ export const ActionContainer = ({
473517
}
474518
};
475519

476-
const asButtonProps = (it: ButtonActionComponent) => ({
477-
text: buttonLabelMap[executionState.status] ?? it.label,
478-
loading:
479-
executionState.status === 'executing' &&
480-
it === executionState.executingAction,
481-
disabled:
482-
action.disabled ||
483-
action.type === 'completed' ||
484-
executionState.status !== 'idle',
485-
variant:
486-
buttonVariantMap[
487-
action.type === 'completed' ? 'success' : executionState.status
488-
],
489-
onClick: (params?: Record<string, string | string[]>) =>
490-
execute(it.parentComponent ?? it, params),
491-
});
520+
const asButtonProps = (it: ButtonActionComponent) => {
521+
return {
522+
text: buttonLabelMap[executionState.status] ?? it.label,
523+
loading:
524+
executionState.status === 'executing' &&
525+
it === executionState.executingAction,
526+
disabled:
527+
action.disabled ||
528+
action.type === 'completed' ||
529+
executionState.status !== 'idle',
530+
variant:
531+
buttonVariantMap[
532+
action.type === 'completed' ? 'success' : executionState.status
533+
],
534+
onClick: (params?: Record<string, string | string[]>) =>
535+
execute(it.parentComponent ?? it, params),
536+
};
537+
};
492538

493539
const asInputProps = (
494540
it: SingleValueActionComponent | MultiValueActionComponent,
@@ -571,7 +617,7 @@ export const ActionContainer = ({
571617
: null
572618
}
573619
success={executionState.successMessage}
574-
buttons={buttons.map(asButtonProps)}
620+
buttons={buttons.map((button) => asButtonProps(button))}
575621
inputs={inputs.map((input) => asInputProps(input))}
576622
form={form ? asFormProps(form) : undefined}
577623
disclaimer={disclaimer}

0 commit comments

Comments
 (0)