wayBetter for X
This commit is contained in:
commit
434f193217
720 changed files with 147465 additions and 0 deletions
423
apps/desktop/tests/area-profile.test.ts
Normal file
423
apps/desktop/tests/area-profile.test.ts
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// Profile+ (src/areas/profile): User Notes as a feature module, compared with the plugin it
|
||||
// replaces, and the features that are new with the area. The table-level checks (every module
|
||||
// starts and stops clean, one observer, the budget) are in areas.test.ts. Markup: fixtures/x/profile
|
||||
// (X-shaped, invented accounts); nothing here is a live X test.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { ageOf, ageText, applyAge } from '../src/areas/profile/accountAge.ts';
|
||||
import { FEATURES, POWER } from '../src/areas/profile/features.ts';
|
||||
import { PEEK_SCHEMA, peekFacts, type PeekSettings } from '../src/areas/profile/hoverPeek.ts';
|
||||
import { MODULES } from '../src/areas/profile/index.ts';
|
||||
import { LABEL_MAX, handleSpanOf, labelKey, labelText, nameRowOf, normalizeLabel, readLabel, readNote, writeLabel, type NameNode } from '../src/areas/profile/notes.ts';
|
||||
import { LABELS_CSS, LABELS_SCHEMA, markLabel } from '../src/areas/profile/privateLabels.ts';
|
||||
import { USER_NOTES_CSS } from '../src/areas/profile/userNotes.ts';
|
||||
import { surfacesOf } from '../src/areas/surfaces.ts';
|
||||
import { X_TABLE } from '../src/areas/table.ts';
|
||||
import type { PluginApi } from '../src/inject/pluginApi.ts';
|
||||
import type { UserInfo, UsersApi } from '../src/inject/userInfo.ts';
|
||||
import { createFeatureRegistry } from '../../../waybetter/packages/runtime/src/featureModules.ts';
|
||||
import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts';
|
||||
import { areaPage, flagsWith, messages } from './helpers/areaPage.ts';
|
||||
import { h, mount, type El } from './fixtures/x/profile/miniDom.ts';
|
||||
import { hoverCard, post, profile, profileMenu } from './fixtures/x/profile/x.ts';
|
||||
|
||||
const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url);
|
||||
const MAPLE = { name: 'Maple Harbor', handle: 'Maple_Harbor' };
|
||||
const ROBOT = { name: 'Tin Robot', handle: 'tin_robot' };
|
||||
|
||||
type Env = ReturnType<typeof areaPage>;
|
||||
/** The flags with every other area switched off: what Profile+ alone does to the page. */
|
||||
const only = (patch: Record<string, boolean>) => flagsWith({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'profile').map(area => [`area.${area.id}`, false])), ...patch });
|
||||
|
||||
/** A profile page of `account` in an area page: the primary column and X's `#layers`. */
|
||||
function profilePage(account = MAPLE, stored: Record<string, unknown> = {}) {
|
||||
const env = areaPage(`/${account.handle}`);
|
||||
for (const [key, value] of Object.entries(stored)) env.store.set(`bt-plugin:user-notes:${key}`, JSON.stringify(value));
|
||||
const layers = h('div', { id: 'layers' });
|
||||
const body = mount(env.doc, h('main', { role: 'main' }, profile(account)), layers);
|
||||
return { env, body, layers };
|
||||
}
|
||||
|
||||
/** The profile's ··· button is pressed and X opens its menu. */
|
||||
function openMenu(env: Env, body: El, layers: El, handle: string) {
|
||||
env.doc.emit('pointerdown', { target: body.querySelector('[data-testid="userActions"]') });
|
||||
layers.append(...profileMenu(handle));
|
||||
env.mutate();
|
||||
}
|
||||
|
||||
/** The old plugin, started the way native code starts it (its globals are this page's). */
|
||||
function startOldPlugin(file: string, env: Env): { stop(): void } {
|
||||
const module = { exports: {} as unknown };
|
||||
new Function('module', 'exports', readFileSync(new URL(file, BUNDLED), 'utf8'))(module, module.exports);
|
||||
const scope = globalThis as Record<string, unknown>;
|
||||
const before = { window: scope.window, document: scope.document };
|
||||
scope.window = { wayBetter: { api: { ...env.api, i18n: env.api.i18n.namespace(file.replace('.plugin.js', '')) } } };
|
||||
scope.document = env.doc;
|
||||
const plugin = new (module.exports as new () => { start(): void; stop(): void })();
|
||||
plugin.start();
|
||||
return { stop() { plugin.stop(); scope.window = before.window; scope.document = before.document; } };
|
||||
}
|
||||
|
||||
test('Profile+: the rows of its card and one module per module row', () => {
|
||||
assert.deepEqual(FEATURES.filter(feature => feature.backing !== 'module'), [], 'the two rules that were Tweaks are modules too');
|
||||
assert.deepEqual(FEATURES.slice(0, 2).map(feature => [feature.id, feature.default]), [['hideProfileWhoToFollow', false], ['hideSubscribeButtons', false]]);
|
||||
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.filter(feature => feature.backing === 'module').map(feature => feature.id).sort());
|
||||
const notes = FEATURES.find(feature => feature.id === 'profile.userNotes')!;
|
||||
assert.deepEqual([notes.default, notes.supersedes, notes.label], [false, 'bt-user-notes.plugin.js', 'plugin.user-notes.name']);
|
||||
for (const feature of FEATURES) assert.ok(!feature.surfaces?.includes('chat'), `${feature.id} stays out of X's chat`);
|
||||
});
|
||||
|
||||
test('User Notes: the same page as the plugin it replaces, from the notes the plugin stored', () => {
|
||||
const stored = { maple_harbor: 'met at a jam\nplays bass', tin_robot: 'sells synths' };
|
||||
const run = (start: (env: Env) => () => void) => {
|
||||
const { env, body, layers } = profilePage(MAPLE, stored);
|
||||
const stop = start(env);
|
||||
const seen: string[] = [body.outerHTML];
|
||||
openMenu(env, body, layers, MAPLE.handle);
|
||||
seen.push(body.outerHTML);
|
||||
layers.textContent = '';
|
||||
layers.append(hoverCard(ROBOT), hoverCard({ name: 'No Note', handle: 'no_note' }));
|
||||
env.mutate();
|
||||
seen.push(body.outerHTML);
|
||||
const css = env.live();
|
||||
stop();
|
||||
seen.push(body.outerHTML);
|
||||
env.registry.dispose();
|
||||
return { seen, css };
|
||||
};
|
||||
// (A template literal's line ends are line feeds whatever the file's are.)
|
||||
const oldCss = /scope\.css\('user-notes', `([^`]*)`\)/.exec(readFileSync(new URL('user-notes.plugin.js', BUNDLED), 'utf8'))![1]!.replace(/\r\n/g, '\n');
|
||||
const old = run(env => {
|
||||
const plugin = startOldPlugin('user-notes.plugin.js', env);
|
||||
return () => plugin.stop();
|
||||
});
|
||||
const now = run(env => {
|
||||
env.registry.setFlags(only({ 'profile.userNotes': true }));
|
||||
return () => env.registry.setFlags(only({}));
|
||||
});
|
||||
assert.deepEqual(old.css, ['user-notes']);
|
||||
assert.deepEqual(now.css, ['wbf-profile.userNotes']);
|
||||
assert.equal(USER_NOTES_CSS, oldCss, 'the same rules');
|
||||
// The module's menu item carries one more attribute (so another Profile+ item never copies it).
|
||||
assert.deepEqual(now.seen.map(html => html.replace(' data-bt-profile-item="note"', '')), old.seen);
|
||||
// And that page is: the note under the bio, "Edit private note" first in the menu, the note in the hover card.
|
||||
assert.match(now.seen[0]!, /<div data-testid="UserDescription">.*?<\/div><div class="bt-note" data-handle="maple_harbor" role="button" aria-label="Your private note about @maple_harbor. Edit"><span class="bt-note-label">Your private note<\/span><span class="bt-note-text">met at a jam\nplays bass<\/span><\/div>/);
|
||||
assert.match(now.seen[1]!, /<div role="menu"><div role="menuitem" tabindex="0" class="bt-note-item" data-bt-profile-item="note"><svg><path d="M14\.23[^"]*"><\/path><\/svg><div><span>Edit private note<\/span><\/div><\/div><div role="menuitem" data-testid="block"/);
|
||||
assert.match(now.seen[2]!, /<div class="bt-note-hover" data-prefix="Your note: ">sells synths<\/div>/);
|
||||
assert.equal(now.seen[2]!.match(/bt-note-hover/g)!.length, 1, 'a card of an account without a note gets nothing');
|
||||
assert.doesNotMatch(now.seen[3]!, /bt-note"|bt-note-hover/, 'off: the notes are out of the page');
|
||||
});
|
||||
|
||||
test('User Notes: a display name that looks like a handle never shows the note of another account', () => {
|
||||
const { env, body } = profilePage({ ...ROBOT, name: '@maple_harbor' }, { maple_harbor: 'met at a jam' });
|
||||
env.registry.setFlags(only({ 'profile.userNotes': true }));
|
||||
assert.equal(body.querySelectorAll('.bt-note').length, 0, 'the note is about @maple_harbor, this is @tin_robot');
|
||||
env.registry.dispose();
|
||||
});
|
||||
|
||||
test('User Notes: nothing without a note, the header follows the profile, and off leaves nothing', () => {
|
||||
const { env, body, layers } = profilePage(ROBOT, { maple_harbor: 'met at a jam' });
|
||||
env.registry.setFlags(only({ 'profile.userNotes': true }));
|
||||
assert.equal(body.querySelectorAll('.bt-note').length, 0, 'no note about this account');
|
||||
openMenu(env, body, layers, ROBOT.handle);
|
||||
assert.equal(body.querySelector('.bt-note-item')!.textContent, 'Add private note');
|
||||
// A menu X opens for anything else (a post's ···) is left alone.
|
||||
layers.textContent = '';
|
||||
env.doc.emit('pointerdown', { target: body });
|
||||
layers.append(...profileMenu('someone'));
|
||||
env.mutate();
|
||||
assert.equal(body.querySelectorAll('.bt-note-item').length, 0);
|
||||
// X keeps the header mounted from profile to profile and only changes its text.
|
||||
const handle = body.querySelectorAll('[data-testid="UserName"] span').find(span => span.textContent === '@tin_robot')!;
|
||||
handle.textContent = `@${MAPLE.handle}`;
|
||||
env.go(`/${MAPLE.handle}`);
|
||||
assert.equal(body.querySelector('.bt-note .bt-note-text')!.textContent, 'met at a jam');
|
||||
handle.textContent = '@tin_robot';
|
||||
env.go('/tin_robot');
|
||||
assert.equal(body.querySelectorAll('.bt-note').length, 0, 'the other profile\'s note is gone');
|
||||
// Every word it shows is one of the plugin's own keys.
|
||||
for (const key of ['hoverPrefix', 'boxLabel', 'label', 'placeholder', 'areaLabel', 'title', 'message', 'delete', 'deleteTitle', 'deleteMessage', 'deleted', 'cancel', 'save', 'saveFailed', 'edit', 'add']) {
|
||||
assert.ok(messages[`plugin.user-notes.${key}`], key);
|
||||
}
|
||||
env.registry.setFlags(only({}));
|
||||
assert.deepEqual([env.live(), body.querySelectorAll('.bt-note, .bt-note-hover, .bt-note-item').length], [[], 0]);
|
||||
assert.deepEqual([...env.store.keys()], ['bt-plugin:user-notes:maple_harbor'], 'it wrote nothing of its own');
|
||||
env.registry.dispose();
|
||||
});
|
||||
|
||||
test('whose block: the "@handle" span, lowercase; nothing when there is none', () => {
|
||||
assert.equal(handleSpanOf(node(profile(MAPLE).querySelector('[data-testid="UserName"]')))!.handle, 'maple_harbor');
|
||||
assert.equal(handleSpanOf(node(post(ROBOT)))!.handle, 'tin_robot');
|
||||
assert.equal(handleSpanOf(node(h('div', null, h('span', null, 'Follows you'), h('span', null, '@way_too_long_for_a_handle')))), null);
|
||||
assert.equal(handleSpanOf(null), null);
|
||||
});
|
||||
|
||||
// ---- New with the area: private labels, account age, hover card peek ---------------------------------
|
||||
|
||||
const node = (el: El | null) => el as unknown as NameNode;
|
||||
const element = (el: El) => el as unknown as Element;
|
||||
/** This area's word `key` of feature `name`, in English. */
|
||||
const word = (name: string) => (key: string, params: Record<string, unknown> = {}) =>
|
||||
(messages[`plugin.profile-plus.${name}.${key}`] ?? key).replace(/\{(\w+)\}/g, (_, param: string) => String(params[param]));
|
||||
|
||||
/** The user cache as modules see it (`api.users`), filled by hand: what X "already sent". */
|
||||
function knownUsers(initial: Record<string, Partial<UserInfo>> = {}) {
|
||||
const map = new Map<string, UserInfo>();
|
||||
const listeners = new Set<(handles: string[]) => void>();
|
||||
const put = (handle: string, info: Partial<UserInfo>) => map.set(handle, { xUserId: '1', handle, blue: null, verifiedType: null, following: null, followedBy: null, createdAt: null, ...map.get(handle), ...info });
|
||||
for (const [handle, info] of Object.entries(initial)) put(handle, info);
|
||||
const api: UsersApi = { get: handle => map.get(handle.toLowerCase()) ?? null, onUpdate: callback => { listeners.add(callback); return () => { listeners.delete(callback); }; } };
|
||||
return { api, listeners, /** A response X fetched adds to what is known. */ learn(handle: string, info: Partial<UserInfo>) { put(handle, info); for (const listener of [...listeners]) listener([handle]); } };
|
||||
}
|
||||
|
||||
/** Profile+'s modules over the page's API with `extra` in place (a user cache, a signed-in account). */
|
||||
function profileRegistry(env: Env, extra: Partial<PluginApi>) {
|
||||
const api = Object.freeze({ ...env.api, ...extra }) as PluginApi;
|
||||
const location = (env.win as unknown as { location: { pathname: string } }).location;
|
||||
const registry = createFeatureRegistry<PluginApi>({ win: env.win as unknown as Window & typeof globalThis, table: X_TABLE, api, label: 'wayBetter for X', surfaces: () => surfacesOf(location.pathname) });
|
||||
for (const module of MODULES) registry.define(module);
|
||||
return registry;
|
||||
}
|
||||
|
||||
test('Profile+: the new features are flags of their own, off until chosen, with their words and schemas', () => {
|
||||
const added = ['profile.privateLabels', 'profile.accountAge', 'profile.hoverPeek'];
|
||||
for (const id of added) {
|
||||
const feature = FEATURES.find(item => item.id === id)!;
|
||||
assert.deepEqual([feature.backing, feature.default, feature.supersedes], ['module', false, undefined], id);
|
||||
assert.ok(messages[feature.label!] && messages[feature.description!], id);
|
||||
assert.equal(!!feature.settings, !!MODULES.find(module => module.id === id)!.settings?.length, id);
|
||||
}
|
||||
assert.deepEqual([...POWER].sort(), [...added].sort(), 'Power User turns on what only adds information');
|
||||
assert.deepEqual(FEATURES.find(feature => feature.id === 'profile.accountAge')!.surfaces, ['profile']);
|
||||
for (const [name, schema] of [['privateLabels', LABELS_SCHEMA], ['hoverPeek', PEEK_SCHEMA]] as const) {
|
||||
for (const field of schema) if (field.type !== 'divider') {
|
||||
assert.ok(messages[`plugin.profile-plus.${name}.${field.label}`], field.label);
|
||||
if (field.hint) assert.ok(messages[`plugin.profile-plus.${name}.${field.hint}`], field.hint);
|
||||
}
|
||||
}
|
||||
// Notes and labels have one home: the namespace User Notes always used.
|
||||
assert.deepEqual(MODULES.filter(module => module.storage).map(module => [module.id, module.storage]), [['profile.userNotes', 'user-notes']]);
|
||||
});
|
||||
|
||||
test('labels: stored beside the note, one line of plain text, and a note is never touched', () => {
|
||||
const env = areaPage();
|
||||
const store = env.api.storage('user-notes');
|
||||
store.set('maple_harbor', 'met at a jam');
|
||||
assert.equal(writeLabel(store, 'Maple_Harbor', { text: ' close\n friend ', color: 'green' }), true);
|
||||
assert.deepEqual(readLabel(store, 'MAPLE_HARBOR'), { text: 'close friend', color: 'green' });
|
||||
assert.deepEqual(JSON.parse(env.store.get('bt-plugin:user-notes:label:maple_harbor')!), { text: 'close friend', color: 'green' });
|
||||
assert.equal(readNote(store, 'Maple_Harbor'), 'met at a jam', 'the note is the plugin\'s string, unchanged');
|
||||
assert.equal(labelKey('Tin_Robot'), 'label:tin_robot');
|
||||
assert.doesNotMatch(labelKey('x'), /^[A-Za-z0-9_]{1,15}$/, 'a label\'s key can never be a handle');
|
||||
// Removing: an empty text, or null.
|
||||
writeLabel(store, 'maple_harbor', { text: ' ', color: 'green' });
|
||||
assert.deepEqual([readLabel(store, 'maple_harbor'), env.store.has('bt-plugin:user-notes:label:maple_harbor'), readNote(store, 'maple_harbor')], [null, false, 'met at a jam']);
|
||||
// Whatever is stored, a label is short plain text in a known color.
|
||||
assert.equal(Array.from(labelText('x'.repeat(80))).length, LABEL_MAX);
|
||||
assert.equal(labelText('ab\u0007c'), 'a b c', 'no direction override, no control character');
|
||||
assert.deepEqual(normalizeLabel({ text: 'press', color: 'url(x)' }), { text: 'press', color: 'gray' });
|
||||
for (const odd of [null, 'press', 7, {}, { text: 5 }, { text: '' }, []]) assert.equal(normalizeLabel(odd), null, JSON.stringify(odd));
|
||||
assert.equal(readNote(store, 'nobody'), '');
|
||||
env.registry.dispose();
|
||||
});
|
||||
|
||||
test('whose name: a display name that looks like a handle never borrows another account\'s label', () => {
|
||||
const spoof = { name: '@maple_harbor', handle: 'tin_robot' };
|
||||
// In a post and a hover card the name and the handle are links to the account: only the real one counts.
|
||||
assert.equal(handleSpanOf(node(post(spoof)))!.handle, 'tin_robot');
|
||||
assert.equal(handleSpanOf(node(hoverCard({ ...spoof, bio: '@maple_harbor' })))!.handle, 'tin_robot');
|
||||
// A profile header has no links: the handle follows the name.
|
||||
assert.equal(handleSpanOf(node(profile(spoof).querySelector('[data-testid="UserName"]')))!.handle, 'tin_robot');
|
||||
// A bio is never read, so a block whose handle cannot be told names nobody.
|
||||
assert.equal(handleSpanOf(node(h('div', null, h('div', { 'data-testid': 'UserDescription' }, h('span', null, '@maple_harbor'))))), null);
|
||||
assert.equal(handleSpanOf(null), null);
|
||||
// The name row is the row just before the handle's row, in each shape X uses.
|
||||
for (const block of [post(MAPLE).querySelector('[data-testid="User-Name"]')!, post(ROBOT, 'text', MAPLE).querySelectorAll('[data-testid="User-Name"]')[1]!, profile(MAPLE).querySelector('[data-testid="UserName"]')!, hoverCard(MAPLE)]) {
|
||||
const found = handleSpanOf(node(block))!;
|
||||
assert.equal(found.handle, 'maple_harbor');
|
||||
assert.equal((nameRowOf(node(block), found.span) as unknown as El).className, 'name-row');
|
||||
}
|
||||
const flat = h('div', null, h('span', null, '@tin_robot'));
|
||||
assert.equal(nameRowOf(node(flat), handleSpanOf(node(flat))!.span), node(flat), 'an unknown shape: the block itself');
|
||||
});
|
||||
|
||||
test('Private labels: beside the name on the profile, on posts, on a quoted post and in the hover card; off leaves X\'s markup as it was', () => {
|
||||
const { env, body, layers } = profilePage(MAPLE, { 'label:maple_harbor': { text: 'friend', color: 'green' }, 'label:tin_robot': { text: 'press', color: 'blue' }, maple_harbor: 'met at a jam' });
|
||||
const column = body.querySelector('[data-testid="primaryColumn"]')!;
|
||||
column.append(post(MAPLE), post(ROBOT, 'Quoting.', MAPLE), post({ name: '@maple_harbor', handle: 'someone_else' }), post({ name: 'No Label', handle: 'no_label' }));
|
||||
const before = body.outerHTML;
|
||||
env.registry.setFlags(only({ 'profile.privateLabels': true }));
|
||||
const labelled = () => body.querySelectorAll('[data-bt-profile-label]').map(row => [row.className, row.getAttribute('data-bt-profile-label'), row.getAttribute('data-bt-profile-label-color'), row.textContent]);
|
||||
assert.deepEqual(labelled(), [
|
||||
['name-row', 'friend', 'green', 'Maple Harbor'], // the profile header
|
||||
['name-row', 'friend', 'green', 'Maple Harbor'], // their post
|
||||
['name-row', 'press', 'blue', 'Tin Robot'], // a post by the other account
|
||||
['name-row', 'friend', 'green', 'Maple Harbor'], // quoted in it
|
||||
], 'and nothing on the impostor or the account without a label');
|
||||
assert.deepEqual(env.live(), ['wbf-profile.privateLabels']);
|
||||
assert.match(LABELS_CSS, /\[data-bt-profile-label\]::after \{ content: attr\(data-bt-profile-label\)/, 'the chip is the stylesheet\'s: the text is never markup');
|
||||
// Posts X renders later, and its hover card once the account is in it.
|
||||
column.append(post(ROBOT));
|
||||
layers.append(hoverCard(ROBOT));
|
||||
env.mutate();
|
||||
assert.equal(labelled().filter(row => row[1] === 'press').length, 3);
|
||||
// The ··· menu of the profile offers the label, next to X's own items.
|
||||
layers.textContent = '';
|
||||
openMenu(env, body, layers, MAPLE.handle);
|
||||
assert.deepEqual(body.querySelectorAll('[role="menuitem"]').map(item => item.textContent), ['Edit private label', 'Block @Maple_Harbor']);
|
||||
assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]), [
|
||||
['bt-profile-plus.plugin.js:privateLabels.edit', 'Set a private label for this profile'], ['bt-profile-plus.plugin.js:settings', 'Profile+ settings'],
|
||||
]);
|
||||
// "Show labels on posts" off: only the header (and hover cards) keep theirs. Saved where the area's settings go.
|
||||
layers.textContent = '';
|
||||
column.querySelectorAll('[data-testid="cellInnerDiv"]').at(-1)!.remove();
|
||||
env.store.set('bt-plugin:profile-plus.privateLabels:settings', JSON.stringify({ posts: false }));
|
||||
env.registry.setFlags(only({}));
|
||||
assert.deepEqual([env.live(), env.api.commands.list(), body.outerHTML], [[], [], before], 'off: attributes, item, command and rules are gone');
|
||||
env.registry.setFlags(only({ 'profile.privateLabels': true }));
|
||||
assert.deepEqual(labelled().map(row => row[1]), ['friend']);
|
||||
// X reuses the header for the next profile: the label follows the account, not the element.
|
||||
const handle = body.querySelectorAll('[data-testid="UserName"] span').find(span => span.textContent === '@Maple_Harbor')!;
|
||||
handle.textContent = '@no_label';
|
||||
env.go('/no_label');
|
||||
assert.deepEqual(labelled(), []);
|
||||
handle.textContent = '@tin_robot';
|
||||
env.go('/tin_robot');
|
||||
assert.deepEqual(labelled().map(row => row[1]), ['press']);
|
||||
assert.equal(readNote(env.api.storage('user-notes'), 'maple_harbor'), 'met at a jam');
|
||||
env.registry.dispose();
|
||||
});
|
||||
|
||||
test('Private labels and User Notes together: two items in the profile\'s menu, each its own', () => {
|
||||
const { env, body, layers } = profilePage(MAPLE, { maple_harbor: 'met at a jam' });
|
||||
env.registry.setFlags(only({ 'profile.userNotes': true, 'profile.privateLabels': true }));
|
||||
openMenu(env, body, layers, MAPLE.handle);
|
||||
assert.deepEqual(body.querySelectorAll('[role="menuitem"]').map(item => `${item.getAttribute('data-bt-profile-item')}: ${item.textContent}`).sort(), ['label: Add private label', 'note: Edit private note', 'null: Block @Maple_Harbor']);
|
||||
assert.equal(body.querySelectorAll('.bt-note-item').length, 1, 'the label\'s item is not a copy of the note\'s');
|
||||
// markLabel takes a label off a block X reused for an account without one.
|
||||
const block = post(MAPLE).querySelector('[data-testid="User-Name"]')!;
|
||||
const store = env.api.storage('user-notes');
|
||||
writeLabel(store, 'maple_harbor', { text: 'friend', color: 'pink' });
|
||||
markLabel(element(block), store);
|
||||
assert.equal(block.querySelector('.name-row')!.getAttribute('data-bt-profile-label'), 'friend');
|
||||
writeLabel(store, 'maple_harbor', null);
|
||||
markLabel(element(block), store);
|
||||
assert.equal(block.querySelectorAll('[data-bt-profile-label], [data-bt-profile-label-color]').length, 0);
|
||||
env.registry.dispose();
|
||||
});
|
||||
|
||||
test('account age: whole years and months from the creation time, never from a guess', () => {
|
||||
const at = (y: number, m: number, d: number) => Date.UTC(y, m - 1, d, 12);
|
||||
const now = at(2026, 9, 30);
|
||||
const text = word('accountAge');
|
||||
const cases: Array<[number, string]> = [
|
||||
[at(2017, 7, 12), '9 y 2 mo'], [at(2017, 9, 30), '9 y'], [at(2017, 10, 1), '8 y 11 mo'], [at(2026, 7, 31), '1 mo'],
|
||||
[at(2026, 8, 31), '30 d'], [at(2026, 9, 18), '12 d'], [now, 'today'], [now - 1000, 'today'],
|
||||
];
|
||||
for (const [created, expected] of cases) assert.equal(ageText(ageOf(created, now)!, text), expected, new Date(created).toISOString());
|
||||
assert.equal(ageOf(now + 1, now), null, 'a creation time ahead of now is not an age');
|
||||
assert.equal(ageOf(NaN, now), null);
|
||||
|
||||
// On the page: only where X already sent the account's creation time.
|
||||
const body = h('body', null, profile(MAPLE));
|
||||
const joined = body.querySelector('[data-testid="UserJoinDate"]')!;
|
||||
const users = knownUsers({ maple_harbor: { createdAt: at(2017, 7, 12) }, tin_robot: {} });
|
||||
applyAge(body as unknown as ParentNode, users.api, text, now);
|
||||
assert.equal(joined.getAttribute('data-bt-profile-age'), '9 y 2 mo');
|
||||
assert.equal(joined.textContent, 'Joined June 2015', 'X\'s own line is not rewritten (and its month is not read)');
|
||||
const handle = body.querySelectorAll('[data-testid="UserName"] span').find(span => span.textContent === '@Maple_Harbor')!;
|
||||
for (const unknown of ['@tin_robot', '@never_seen']) {
|
||||
handle.textContent = unknown;
|
||||
applyAge(body as unknown as ParentNode, users.api, text, now);
|
||||
assert.equal(joined.hasAttribute('data-bt-profile-age'), false, `${unknown}: absent is unknown`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Account age: on profiles only, follows the header and the cache, and off takes it away', () => {
|
||||
const { env, body } = profilePage(MAPLE);
|
||||
const created = Date.UTC(2015, 5, 6);
|
||||
const users = knownUsers({ maple_harbor: { createdAt: created } });
|
||||
const registry = profileRegistry(env, { users: users.api });
|
||||
const joined = body.querySelector('[data-testid="UserJoinDate"]')!;
|
||||
const expected = () => ageText(ageOf(created, Date.now())!, word('accountAge'));
|
||||
registry.setFlags(only({ 'profile.accountAge': true }));
|
||||
assert.deepEqual([registry.active(), env.live()], [['profile.accountAge'], ['wbf-profile.accountAge']]);
|
||||
assert.equal(joined.getAttribute('data-bt-profile-age'), expected());
|
||||
assert.match(expected(), /^\d+ y( \d+ mo)?$/);
|
||||
// The next profile in the same header: unknown until X's response for it is seen.
|
||||
const handle = body.querySelectorAll('[data-testid="UserName"] span').find(span => span.textContent === '@Maple_Harbor')!;
|
||||
handle.textContent = '@tin_robot';
|
||||
env.mutate();
|
||||
assert.equal(joined.hasAttribute('data-bt-profile-age'), false);
|
||||
users.learn('tin_robot', { createdAt: created });
|
||||
assert.equal(joined.getAttribute('data-bt-profile-age'), expected());
|
||||
env.go('/home');
|
||||
assert.deepEqual([registry.active(), joined.hasAttribute('data-bt-profile-age')], [[], false], 'not a profile: not running');
|
||||
env.go('/tin_robot');
|
||||
assert.equal(joined.getAttribute('data-bt-profile-age'), expected());
|
||||
registry.setFlags(only({}));
|
||||
assert.deepEqual([env.live(), joined.hasAttribute('data-bt-profile-age'), users.listeners.size], [[], false, 0]);
|
||||
registry.dispose();
|
||||
env.registry.dispose();
|
||||
});
|
||||
|
||||
test('peek facts: what is known is shown, what is not is left out (absent is unknown, not "no")', () => {
|
||||
const all = normalizeSettings(PEEK_SCHEMA, null) as PeekSettings;
|
||||
assert.deepEqual(all, { age: true, relationship: true, note: true });
|
||||
const now = Date.UTC(2026, 8, 30);
|
||||
const base = { self: false, label: null, note: '', settings: all, now };
|
||||
const user = (info: Partial<UserInfo>) => ({ createdAt: null, following: null, followedBy: null, ...info });
|
||||
assert.deepEqual(peekFacts({ ...base, user: null }), { age: null, relation: null, label: null, note: '' }, 'an account X has not described');
|
||||
assert.deepEqual(peekFacts({ ...base, user: user({}) }), { age: null, relation: null, label: null, note: '' }, 'described, but without these fields');
|
||||
assert.equal(peekFacts({ ...base, user: user({ followedBy: true }) }).relation, 'followsYou');
|
||||
assert.equal(peekFacts({ ...base, user: user({ followedBy: true, following: false }) }).relation, 'followsYou');
|
||||
assert.equal(peekFacts({ ...base, user: user({ followedBy: true, following: true }) }).relation, 'mutual');
|
||||
assert.equal(peekFacts({ ...base, user: user({ followedBy: false, following: true }) }).relation, 'notFollowingYou', 'only when X said so');
|
||||
assert.equal(peekFacts({ ...base, user: user({ following: true }) }).relation, null, 'your own follow says nothing about theirs');
|
||||
assert.equal(peekFacts({ ...base, self: true, user: user({ followedBy: false }) }).relation, null, 'your own card');
|
||||
assert.deepEqual(peekFacts({ ...base, user: user({ createdAt: Date.UTC(2017, 6, 12) }) }).age, { years: 9, months: 2, days: 3367 });
|
||||
const mine = { label: { text: 'press', color: 'blue' as const }, note: 'sells synths' };
|
||||
assert.deepEqual(peekFacts({ ...base, ...mine, user: null }), { age: null, relation: null, ...mine });
|
||||
const none: PeekSettings = { age: false, relationship: false, note: false };
|
||||
assert.deepEqual(peekFacts({ ...base, ...mine, settings: none, user: user({ createdAt: Date.UTC(2017, 6, 12), followedBy: true }) }), { age: null, relation: null, label: null, note: '' });
|
||||
});
|
||||
|
||||
test('Hover card peek: adds what is in memory to X\'s card, more when X sends more, and off removes it', () => {
|
||||
const { env, body, layers } = profilePage(MAPLE, { tin_robot: 'sells synths', 'label:tin_robot': { text: 'press', color: 'blue' } });
|
||||
const created = Date.UTC(2015, 5, 6);
|
||||
const users = knownUsers({ tin_robot: { createdAt: created } });
|
||||
const registry = profileRegistry(env, { users: users.api, account: () => ({ handle: 'Maple_Harbor', name: 'Maple Harbor', avatar: null }) });
|
||||
const age = () => `Account age: ${ageText(ageOf(created, Date.now())!, word('accountAge'))}`;
|
||||
registry.setFlags(only({ 'profile.hoverPeek': true }));
|
||||
assert.deepEqual([registry.active(), body.querySelectorAll('.bt-profile-peek').length], [['profile.hoverPeek'], 0], 'no card, nothing added');
|
||||
const peek = () => body.querySelectorAll('.bt-profile-peek').map(block => [block.getAttribute('data-handle'), ...block.children.map(part => `${part.className.replace('bt-profile-peek-', '')}: ${part.getAttribute('data-prefix') ?? ''}${part.textContent}`)]);
|
||||
layers.append(hoverCard(ROBOT));
|
||||
env.mutate();
|
||||
assert.deepEqual(peek(), [['tin_robot', `age: ${age()}`, 'label: press', 'note: Your note: sells synths']]);
|
||||
assert.equal(body.querySelector('.bt-profile-peek-label')!.getAttribute('data-bt-profile-peek-color'), 'blue');
|
||||
assert.equal(body.querySelector('[data-testid="HoverCard"] [data-testid="UserDescription"]')!.nextElementSibling!.className, 'bt-profile-peek', 'under the bio, inside X\'s card');
|
||||
// X fetches the account while the card is open: the relationship it sent is added, once.
|
||||
users.learn('tin_robot', { followedBy: true, following: true });
|
||||
assert.deepEqual(peek(), [['tin_robot', `age: ${age()}`, 'relation: You follow each other', 'label: press', 'note: Your note: sells synths']]);
|
||||
users.learn('tin_robot', { followedBy: false });
|
||||
assert.equal(peek()[0]![2], 'relation: Does not follow you');
|
||||
// A card about an account nothing is known of gets no block at all; your own card says nothing about following.
|
||||
layers.textContent = '';
|
||||
layers.append(hoverCard({ name: 'No Label', handle: 'no_label' }), hoverCard(MAPLE));
|
||||
env.mutate();
|
||||
users.learn('maple_harbor', { followedBy: false });
|
||||
assert.deepEqual(peek(), []);
|
||||
// Its settings choose the facts.
|
||||
layers.textContent = '';
|
||||
layers.append(hoverCard(ROBOT));
|
||||
env.mutate();
|
||||
env.store.set('bt-plugin:profile-plus.hoverPeek:settings', JSON.stringify({ age: false, relationship: true, note: false }));
|
||||
users.learn('tin_robot', {});
|
||||
assert.deepEqual(peek(), [['tin_robot', 'relation: Does not follow you']]);
|
||||
registry.setFlags(only({}));
|
||||
assert.deepEqual([env.live(), body.querySelectorAll('.bt-profile-peek').length, users.listeners.size], [[], 0, 0]);
|
||||
assert.deepEqual([...env.store.keys()].sort(), ['bt-plugin:profile-plus.hoverPeek:settings', 'bt-plugin:user-notes:label:tin_robot', 'bt-plugin:user-notes:tin_robot'], 'it reads the notes and writes none');
|
||||
registry.dispose();
|
||||
env.registry.dispose();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue