Skip to content

Commit ddd3f68

Browse files
committed
ntp: batched api
1 parent 836da75 commit ddd3f68

18 files changed

+628
-83
lines changed

special-pages/pages/new-tab/app/activity/ActivityProvider.js

Lines changed: 144 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { useMessaging } from '../types.js';
44
import { ActivityService } from './activity.service.js';
55
import { reducer, useConfigSubscription, useInitialDataAndConfig } from '../service.hooks.js';
66
import { eventToTarget } from '../utils.js';
7-
import { usePlatformName } from '../settings.provider.js';
7+
import { useBatchedActivityApi, usePlatformName } from '../settings.provider.js';
88
import { ACTION_ADD_FAVORITE, ACTION_BURN, ACTION_REMOVE, ACTION_REMOVE_FAVORITE } from './constants.js';
99
import { batch, signal, useSignal, useSignalEffect } from '@preact/signals';
1010
import { DDG_DEFAULT_ICON_SIZE } from '../favorites/constants.js';
11+
import { BatchedActivity } from './batched-activity.service.js';
1112

1213
/**
1314
* @typedef {import('../../types/new-tab.js').ActivityData} ActivityData
@@ -54,9 +55,10 @@ export function ActivityProvider(props) {
5455

5556
const [state, dispatch] = useReducer(reducer, initial);
5657
const platformName = usePlatformName();
58+
const batched = useBatchedActivityApi();
5759

5860
// create an instance of `ActivityService` for the lifespan of this component.
59-
const service = useService();
61+
const service = useService(batched);
6062

6163
// get initial data
6264
useInitialDataAndConfig({ dispatch, service });
@@ -142,64 +144,81 @@ export function ActivityProvider(props) {
142144
*/
143145
function normalizeItems(prev, data) {
144146
return {
145-
favorites: Object.fromEntries(
146-
data.activity.map((x) => {
147-
return [x.url, x.favorite];
148-
}),
149-
),
150-
items: Object.fromEntries(
151-
data.activity.map((x) => {
152-
/** @type {Item} */
153-
const next = {
154-
etldPlusOne: x.etldPlusOne,
155-
title: x.title,
156-
url: x.url,
157-
faviconMax: x.favicon?.maxAvailableSize ?? DDG_DEFAULT_ICON_SIZE,
158-
favoriteSrc: x.favicon?.src,
159-
trackersFound: x.trackersFound,
160-
};
161-
const differs = shallowDiffers(next, prev.items[x.url] || {});
162-
return [x.url, differs ? next : prev.items[x.url] || {}];
163-
}),
164-
),
165-
history: Object.fromEntries(
166-
data.activity.map((x) => {
167-
const differs = shallowDiffers(x.history, prev.history[x.url] || []);
168-
return [x.url, differs ? [...x.history] : prev.history[x.url] || []];
169-
}),
170-
),
171-
trackingStatus: Object.fromEntries(
172-
data.activity.map((x) => {
173-
const prevItem = prev.trackingStatus[x.url] || {
174-
totalCount: 0,
175-
trackerCompanies: [],
176-
};
177-
const differs = shallowDiffers(x.trackingStatus.trackerCompanies, prevItem.trackerCompanies);
178-
if (prevItem.totalCount !== x.trackingStatus.totalCount || differs) {
147+
favorites: {
148+
...prev.favorites,
149+
...Object.fromEntries(
150+
data.activity.map((x) => {
151+
return [x.url, x.favorite];
152+
}),
153+
),
154+
},
155+
items: {
156+
...prev.items,
157+
...Object.fromEntries(
158+
data.activity.map((x) => {
159+
/** @type {Item} */
179160
const next = {
180-
totalCount: x.trackingStatus.totalCount,
181-
trackerCompanies: [...x.trackingStatus.trackerCompanies],
161+
etldPlusOne: x.etldPlusOne,
162+
title: x.title,
163+
url: x.url,
164+
faviconMax: x.favicon?.maxAvailableSize ?? DDG_DEFAULT_ICON_SIZE,
165+
favoriteSrc: x.favicon?.src,
166+
trackersFound: x.trackersFound,
182167
};
183-
return [x.url, next];
184-
}
185-
return [x.url, prevItem];
186-
}),
187-
),
168+
const differs = shallowDiffers(next, prev.items[x.url] || {});
169+
return [x.url, differs ? next : prev.items[x.url] || {}];
170+
}),
171+
),
172+
},
173+
history: {
174+
...prev.history,
175+
...Object.fromEntries(
176+
data.activity.map((x) => {
177+
const differs = shallowDiffers(x.history, prev.history[x.url] || []);
178+
return [x.url, differs ? [...x.history] : prev.history[x.url] || []];
179+
}),
180+
),
181+
},
182+
trackingStatus: {
183+
...prev.trackingStatus,
184+
...Object.fromEntries(
185+
data.activity.map((x) => {
186+
const prevItem = prev.trackingStatus[x.url] || {
187+
totalCount: 0,
188+
trackerCompanies: [],
189+
};
190+
const differs = shallowDiffers(x.trackingStatus.trackerCompanies, prevItem.trackerCompanies);
191+
if (prevItem.totalCount !== x.trackingStatus.totalCount || differs) {
192+
const next = {
193+
totalCount: x.trackingStatus.totalCount,
194+
trackerCompanies: [...x.trackingStatus.trackerCompanies],
195+
};
196+
return [x.url, next];
197+
}
198+
return [x.url, prevItem];
199+
}),
200+
),
201+
},
188202
};
189203
}
190204

191205
/**
192-
* @param {{ available: string[]; max: number }} prev
193-
* @param {ActivityData} data
194-
* @return {{ available: string[]; max: number }}
206+
* @param {string[]} prev
207+
* @param {string[]} data
208+
* @return {string[]}
195209
*/
196210
function normalizeKeys(prev, data) {
197-
const keys = data.activity.map((x) => x.url);
198-
const next = shallowDiffers(prev, keys) ? keys : prev.available;
199-
return {
200-
available: next,
201-
max: keys.length,
202-
};
211+
const next = shallowDiffers(prev, data) ? [...data] : prev;
212+
return next;
213+
}
214+
215+
/**
216+
* @param {string[]} prev
217+
* @param {import('../../types/new-tab.js').UrlInfo} data
218+
* @return {string[]}
219+
*/
220+
function normalizeUrls(prev, data) {
221+
return shallowDiffers(prev, data.urls) ? [...data.urls] : prev;
203222
}
204223

205224
/**
@@ -216,16 +235,23 @@ export function shallowDiffers(a, b) {
216235

217236
export const SignalStateContext = createContext({
218237
activity: signal(/** @type {NormalizedActivity} */ ({})),
219-
keys: signal(/** @type {{available: string[]; max: number}} */ ({ available: [], max: 0 })),
238+
keys: signal(/** @type {string[]} */ ([])),
220239
});
221240

222241
export function SignalStateProvider({ children }) {
223242
const { state } = useContext(ActivityContext);
224-
const service = useContext(ActivityServiceContext);
243+
const service = /** @type {BatchedActivity} */ (useContext(ActivityServiceContext));
225244
if (state.status !== 'ready') throw new Error('must have ready status here');
226245
if (!service) throw new Error('must have service here');
246+
if (!service.urlService.data) throw new Error('must have initialised the url service by this point');
227247

228-
const keys = useSignal(normalizeKeys({ available: [], max: 0 }, state.data));
248+
const keys = useSignal(
249+
normalizeKeys(
250+
[],
251+
state.data.activity.map((x) => x.url),
252+
),
253+
);
254+
const urls = useSignal(normalizeUrls([], service.urlService.data));
229255
const activity = useSignal(
230256
normalizeItems(
231257
{
@@ -240,23 +266,69 @@ export function SignalStateProvider({ children }) {
240266

241267
useSignalEffect(() => {
242268
if (!service) return console.warn('could not access service');
243-
const unsub = service.onData((evt) => {
269+
const src = /** @type {BatchedActivity} */ (service);
270+
const unsub = src.onData((evt) => {
244271
batch(() => {
245-
keys.value = normalizeKeys(keys.value, evt.data);
246272
activity.value = normalizeItems(activity.value, evt.data);
273+
// const data = Object.keys(activity.value.items);
247274
});
248275
});
249-
const handler = () => {
250-
if (document.visibilityState === 'visible') {
251-
console.log('will fetch');
252-
service
253-
.triggerDataFetch()
254-
// eslint-disable-next-line promise/prefer-await-to-then
255-
.catch((e) => console.error('trigger fetch errored', e));
276+
const unsubPatch = src.onUrlData((evt) => {
277+
if (evt.data.patch) {
278+
activity.value = normalizeItems(activity.value, { activity: [evt.data.patch] });
256279
}
280+
urls.value = normalizeUrls(urls.value, evt.data);
281+
const visible = keys.value;
282+
const all = urls.value;
283+
const nextVisibleRange = all.slice(0, visible.length);
284+
setVisibleRange(nextVisibleRange);
285+
});
286+
287+
/**
288+
* @param {string[]} nextVisibleRange
289+
*/
290+
function setVisibleRange(nextVisibleRange) {
291+
keys.value = normalizeKeys(keys.value, nextVisibleRange);
292+
fillHoles();
293+
// todo, evict entries?
294+
}
295+
296+
function fillHoles() {
297+
const visible = keys.value;
298+
const data = Object.keys(activity.value.items);
299+
const missing = visible.filter((x) => !data.includes(x));
300+
src.next(missing);
301+
}
302+
303+
function updateVisible() {
304+
const visibleLength = keys.value.length;
305+
const end = visibleLength + service.SIZE;
306+
const nextVisibleRange = urls.value.slice(0, end);
307+
setVisibleRange(nextVisibleRange);
308+
}
309+
310+
window.addEventListener('activity.next', updateVisible);
311+
312+
return () => {
313+
unsub();
314+
unsubPatch();
315+
window.removeEventListener('activity.next', updateVisible);
316+
};
317+
});
318+
319+
useEffect(() => {
320+
const handler = () => {
321+
// todo: re-enable this
322+
// if (document.visibilityState === 'visible') {
323+
// // console.log('will fetch');
324+
// src.triggerDataFetch()
325+
// // eslint-disable-next-line promise/prefer-await-to-then
326+
// .catch((e) => console.error('trigger fetch errored', e));
327+
// }
257328
};
258329

259-
(() => {
330+
// eslint-disable-next-line no-labels,no-unused-labels
331+
$INTEGRATION: (() => {
260332
// export the event in tests
261333
if (window.__playwright_01) {
262334
/** @type {any} */ (window).__trigger_document_visibilty__ = handler;
@@ -265,26 +337,26 @@ export function SignalStateProvider({ children }) {
265337

266338
document.addEventListener('visibilitychange', handler);
267339
return () => {
268-
unsub();
269340
document.removeEventListener('visibilitychange', handler);
270341
};
271-
});
342+
}, []);
272343

273344
return <SignalStateContext.Provider value={{ activity, keys }}>{children}</SignalStateContext.Provider>;
274345
}
275346

276347
/**
277-
* @return {import("preact").RefObject<ActivityService>}
348+
* @param {boolean} useBatched
349+
* @return {import("preact").RefObject<ActivityService|BatchedActivity>}
278350
*/
279-
export function useService() {
280-
const service = useRef(/** @type {ActivityService|null} */ (null));
351+
export function useService(useBatched) {
352+
const service = useRef(/** @type {ActivityService|BatchedActivity|null} */ (null));
281353
const ntp = useMessaging();
282354
useEffect(() => {
283-
const stats = new ActivityService(ntp);
355+
const stats = useBatched ? new BatchedActivity(ntp) : new ActivityService(ntp);
284356
service.current = stats;
285357
return () => {
286358
stats.destroy();
287359
};
288-
}, [ntp]);
360+
}, [ntp, useBatched]);
289361
return service;
290362
}

special-pages/pages/new-tab/app/activity/activity.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,35 @@ Notes:
6262
- {@link "NewTab Messages".ActivityGetConfigRequest}
6363
- Used to fetch the initial config data (eg: expanded vs collapsed)
6464
- returns {@link "NewTab Messages".ActivityConfig}
65-
-
65+
66+
### `activity_getUrls`
67+
- {@link "NewTab Messages".ActivityGetUrlsRequest}
68+
- Used to fetch the initial config data (eg: expanded vs collapsed)
69+
- returns {@link "NewTab Messages".UrlInfo}
70+
71+
```json
72+
{
73+
"urls": ["..."],
74+
"totalTrackersBlocked": 123
75+
}
76+
```
77+
78+
### `activity_getDataForUrls`
79+
- {@link "NewTab Messages".ActivityGetDataForUrlsRequest}
80+
- Used to confirm the burn action - native side may or may not show a modal
81+
- sends {@link "NewTab Messages".DataForUrlsParams}
82+
- returns {@link "NewTab Messages".ActivityData}
83+
- Note: This response is the same format as `activity_getData`, where DomainActivity items are delivered under `.activity`
84+
85+
```json
86+
{
87+
"activity": [
88+
{"...": "..."}
89+
]
90+
}
91+
```
92+
93+
6694
### `activity_confirmBurn`
6795
- {@link "NewTab Messages".ActivityConfirmBurnRequest}
6896
- Used to confirm the burn action - native side may or may not show a modal
@@ -93,6 +121,27 @@ by sending the notification `activity_burnAnimationComplete`
93121
- The activity data used in the feed.
94122
- returns {@link "NewTab Messages".ActivityData}
95123

124+
### `activity_onDataPatch`
125+
- {@link "NewTab Messages".ActivityOnDataPatchSubscription}
126+
- The activity data used in the feed.
127+
- returns {@link "NewTab Messages".UrlInfo} + optional {@link "NewTab Messages".PatchData}
128+
129+
```json
130+
{
131+
"urls": ["..."],
132+
"totalTrackersBlocked": 123
133+
}
134+
```
135+
```json
136+
{
137+
"urls": ["..."],
138+
"totalTrackersBlocked": 123,
139+
"patch": {
140+
"...": "..."
141+
}
142+
}
143+
```
144+
96145
### `activity_onConfigUpdate`
97146
- {@link "NewTab Messages".ActivityOnDataUpdateSubscription }
98147
- The widget config

special-pages/pages/new-tab/app/activity/activity.service.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ export class ActivityService {
1616
initial: () => ntp.messaging.request('activity_getData'),
1717
subscribe: (cb) => ntp.messaging.subscribe('activity_onDataUpdate', cb),
1818
});
19-
2019
/** @type {Service<ActivityConfig>} */
2120
this.configService = new Service({
2221
initial: () => ntp.messaging.request('activity_getConfig'),

0 commit comments

Comments
 (0)