Skip to content

Commit

Permalink
Merge branch 'master' into next
Browse files Browse the repository at this point in the history
* master:
  feat: Add settings sync (#2208)
  test: Better condition check
  test: Better service worker e2e tests
  test: Validate HTML files
  • Loading branch information
maxmilton committed Dec 14, 2024
2 parents 82495d5 + 99596a8 commit 989ba84
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 25 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ What started as an experiment to play with the Chrome browser APIs and explore w
- Distraction-free, minimal design aesthetic with multiple themes.
- A list of your open tabs, recently closed tabs, and top sites.
- Search tabs, bookmarks, history, and top sites in one place.
- Simple bookmarks bar.
- Links to frequently used destinations in your browser.
- Simple bookmarks bar.
- Customisable UI.
- Optional automatic or manual settings sync between browsers.

### Design goals

<!-- prettier-ignore -->
| Issue | Why / How |
| --- | --- |
| Access | Still have access to common things like the bookmarks bar etc. |
Expand Down
145 changes: 137 additions & 8 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { append, clone, collect, h } from 'stage1';
import { compile } from 'stage1/macro' with { type: 'macro' };
import { reconcile } from 'stage1/reconcile/non-keyed';
import type { SectionOrderItem, ThemesData } from './types';
import type {
SectionOrderItem,
SyncStorageData,
ThemesData,
UserStorageData,
} from './types';
import { DEFAULT_SECTION_ORDER, storage } from './utils';

// TODO: Show errors in the UI.
Expand All @@ -10,6 +15,7 @@ import { DEFAULT_SECTION_ORDER, storage } from './utils';

interface SettingsState {
order: [SectionOrderItem[], SectionOrderItem[]];
pushSyncData?(forceUpdate?: boolean): Promise<void>;
}

type ItemIndex = [listIndex: 0 | 1, itemIndex: number];
Expand All @@ -28,6 +34,19 @@ const themesData = fetch('themes.json').then(
(response) => response.json() as Promise<ThemesData>,
);

const supportsSync = async (): Promise<boolean> => {
try {
await chrome.storage.sync.set({ _: 1 });
await chrome.storage.sync.remove('_');
if (chrome.runtime.lastError) {
return false;
}
return true;
} catch {
return false;
}
};

type SectionComponent = HTMLLIElement;

interface SectionRefs {
Expand Down Expand Up @@ -102,17 +121,23 @@ const SectionItem = (
};

interface Refs {
feedback: HTMLDivElement;
feedback: Text;
theme: HTMLSelectElement;
b: HTMLInputElement;
se: HTMLUListElement;
sd: HTMLUListElement;
reset: HTMLButtonElement;

feedback2: Text;
sync: HTMLInputElement;
pull: HTMLButtonElement;
push: HTMLButtonElement;
clear: HTMLButtonElement;
}

const meta = compile(`
<div>
<div @feedback></div>
<div>@feedback</div>
<div class=row>
<label>Theme</label>
Expand Down Expand Up @@ -151,6 +176,29 @@ const meta = compile(`
<label>Reset</label>
<button @reset>Reset all settings</button>
</div>
<hr>
<h2>Experimental</h2>
<h3>Sync Settings</h3>
<div class=row>
@feedback2
</div>
<div class=row>
<label>
<input @sync type=checkbox class=box disabled> Automatically sync settings
</label>
<small class=muted>Sync on profile startup (requires sign-in)</small>
</div>
<div class=row>
<button @pull disabled>Pull now (local ⟸ sync)</button>
<button @push disabled>Push now (local ⟹ sync)</button>
<button @clear disabled>Reset sync data</button>
</div>
</div>
`);
const view = h<HTMLDivElement>(meta.html);
Expand Down Expand Up @@ -192,8 +240,10 @@ const Settings = () => {
});

if (themeName === DEFAULT_THEME) {
void chrome.storage.local.remove('tn');
await chrome.storage.local.remove('tn');
}

void state.pushSyncData?.();
};

const updateOrder = (order: SettingsState['order'], skipSave?: boolean) => {
Expand All @@ -214,6 +264,8 @@ const Settings = () => {
o: order[0],
});
}

void state.pushSyncData?.();
}
};

Expand All @@ -234,15 +286,18 @@ const Settings = () => {

refs.theme.onchange = () => updateTheme(refs.theme.value);

refs.b.onchange = () => {
refs.b.onchange = async () => {
// eslint-disable-next-line unicorn/prefer-ternary
if (refs.b.checked) {
// When value is same as default, we don't need to store it
void chrome.storage.local.remove('b');
await chrome.storage.local.remove('b');
} else {
void chrome.storage.local.set({
await chrome.storage.local.set({
b: true,
});
}

void state.pushSyncData?.();
};

// eslint-disable-next-line no-multi-assign
Expand All @@ -266,10 +321,84 @@ const Settings = () => {
(item) => !orderEnabled.includes(item),
);

void updateTheme(themeName);
refs.theme.value = themeName;
refs.b.checked = !storage.b;
updateOrder([orderEnabled, orderDisabled], true);

/* ********************************** */
// Experimental sync settings feature //
/* ********************************** */

refs.sync.checked = !!storage.s;

const updateSync = (syncData: SyncStorageData) => {
if (syncData.ts) {
refs.feedback2.nodeValue = `Sync data found (last updated: ${new Date(
syncData.ts,
).toLocaleString()})`;
refs.pull.disabled = false;
refs.clear.disabled = false;
} else {
refs.feedback2.nodeValue = 'No sync data found';
refs.pull.disabled = true;
refs.clear.disabled = true;
}

refs.push.disabled = false;
refs.sync.disabled = false;

refs.sync.onchange = () => {
if (refs.sync.checked) {
void chrome.storage.local.set({
s: true,
});
// @ts-expect-error - doesn't need event argument
refs.pull.onclick?.();
} else {
void chrome.storage.local.remove('s');
}
};

refs.pull.onclick = () => {
if (syncData.data) {
void chrome.storage.local.set(syncData.data);
void updateTheme(syncData.data.tn ?? DEFAULT_THEME);
updateOrder([syncData.data.o ?? [...DEFAULT_SECTION_ORDER], []], true);
}
};

state.pushSyncData = async (forceUpdate?: boolean) => {
const { t, s, ...rest } =
await chrome.storage.local.get<UserStorageData>();

if (forceUpdate || s) {
const newSyncData: SyncStorageData = {
data: rest,
ts: Date.now(),
};
void chrome.storage.sync.set(newSyncData);
updateSync(newSyncData);
}
};

refs.push.onclick = () => state.pushSyncData!(true);

refs.clear.onclick = () => {
void chrome.storage.sync.clear();
updateSync({});
};
};

void supportsSync().then((canSync) => {
if (canSync) {
void chrome.storage.sync.get<SyncStorageData>().then(updateSync);
// TODO: Listen for sync data changes?
// chrome.storage.sync.onChanged.addListener((changes) => {});
} else {
refs.feedback2.nodeValue = 'Not signed in or sync not supported';
}
});

return root;
};

Expand Down
35 changes: 33 additions & 2 deletions src/sw.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/// <reference lib="webworker" />

import type { ThemesData, UserStorageData } from './types';
import type { SyncStorageData, ThemesData, UserStorageData } from './types';

// On install or subsequent update, preload the user's chosen theme into storage
// eslint-disable-next-line @typescript-eslint/no-misused-promises
chrome.runtime.onInstalled.addListener(async () => {
const [themes, settings] = await Promise.all([
fetch('themes.json').then((res) => res.json() as Promise<ThemesData>),
chrome.storage.local.get<UserStorageData>(),
chrome.storage.local.get<UserStorageData>('tn'),
]);

// TODO: Remove once most users have updated.
Expand All @@ -30,3 +30,34 @@ chrome.runtime.onInstalled.addListener(async () => {
// });
// }
});

/* ********************************** */
// Experimental sync settings feature //
/* ********************************** */

// On profile startup, pull remote user settings; local <- sync
chrome.runtime.onStartup.addListener(() => {
void chrome.storage.local
.get<UserStorageData>(['s', 'tn'])
.then((settings) => {
// Only when sync is enabled
if (!settings.s) return;

void chrome.storage.sync.get<SyncStorageData>().then((remote) => {
if (remote.data) {
if (remote.data.tn === settings.tn) {
void chrome.storage.local.set(remote.data);
} else {
void fetch('themes.json')
.then((res) => res.json() as Promise<ThemesData>)
.then((themes) => {
void chrome.storage.local.set({
t: themes[settings.tn ?? 'auto'],
...remote.data,
});
});
}
}
});
});
});
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,13 @@ export interface UserStorageData {
b?: boolean;
/** Sections order user preference. */
o?: SectionOrderItem[];
/** Settings sync enabled user preference. */
s?: boolean;
}

export interface SyncStorageData {
/** User settings data. */
data?: Omit<UserStorageData, 't' | 's'>;
/** Timestamp of the last sync.set operation. */
ts?: number;
}
7 changes: 4 additions & 3 deletions test/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import { expect, test } from './fixtures';
test('settings page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/settings.html`);

// FIXME: Better assertions
// FIXME: Better assertions.

await expect(page).toHaveTitle('New Tab');
await expect(page).toHaveURL(`chrome-extension://${extensionId}/settings.html`); // didn't redirect

const labels = await page.locator('label').all();
expect(labels).toHaveLength(4);
await expect(labels[0]).toHaveText('Theme');
await expect(labels[1]).toHaveText('Show bookmarks bar');
await expect(labels[2]).toHaveText('Sections');
await expect(labels[3]).toHaveText('Reset');
await expect(labels[4]).toHaveText('Automatically sync settings');
expect(labels).toHaveLength(5);
});

test.skip('matches screenshot', async ({ page, extensionId }) => {
Expand All @@ -32,4 +33,4 @@ test('has no console calls or unhandled errors', async ({ page, extensionId }) =
expect(consoleMessages).toHaveLength(0);
});

// TODO: Test it makes no external requests (other than fetch themes)
// TODO: Test it makes no external requests (other than fetch themes).
16 changes: 15 additions & 1 deletion test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ function setupMocks(): void {
history: {
search: noopAsyncArr,
},
// @ts-expect-error - partial mock
runtime: {
// @ts-expect-error - partial mock
onInstalled: {
addListener: noop,
},
// @ts-expect-error - partial mock
onStartup: {
addListener: noop,
},
openOptionsPage: noopAsync,
},
// @ts-expect-error - partial mock
Expand All @@ -38,6 +45,13 @@ function setupMocks(): void {
remove: noopAsync,
set: noopAsync,
},
// @ts-expect-error - partial mock
sync: {
clear: noopAsync,
get: noopAsyncObj,
remove: noopAsync,
set: noopAsync,
},
},
tabs: {
// @ts-expect-error - partial mock
Expand Down
4 changes: 2 additions & 2 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ describe('dist files', () => {
['newtab.js', 'text/javascript;charset=utf-8', 4000, 6000],
['settings.css', 'text/css;charset=utf-8', 1000, 1500],
['settings.html', 'text/html;charset=utf-8', 150, 200],
['settings.js', 'text/javascript;charset=utf-8', 4000, 6000],
['sw.js', 'text/javascript;charset=utf-8', 150, 300],
['settings.js', 'text/javascript;charset=utf-8', 6000, 8000],
['sw.js', 'text/javascript;charset=utf-8', 400, 600],
['themes.json', 'application/json;charset=utf-8'],
];

Expand Down
6 changes: 3 additions & 3 deletions test/unit/newtab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { reset } from '../setup';
// Completely reset DOM and global state between tests
afterEach(reset);

const MODULE_PATH = import.meta.resolveSync('../../dist/newtab.js');
const MODULE_PATH = Bun.resolveSync('./dist/newtab.js', '.');

async function load() {
// Workaround for hack in src/BookmarkBar.ts that waits for styles to be loaded
Expand All @@ -26,8 +26,8 @@ test('renders entire newtab app', async () => {
expect(document.body.querySelector('#m')).toBeTruthy();
expect(document.body.querySelector('#d')).toBeTruthy();

// TODO: More/better assertions
// TODO: Check all section headings exist; a h2 with text 'Open Tabs' x5
// TODO: More and better assertions.
// TODO: Check all section headings exist; a h2 with text 'Open Tabs' x5.
});

test('does not call any console methods', async () => {
Expand Down
Loading

0 comments on commit 989ba84

Please sign in to comment.