wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
421
apps/desktop/tests/staleFollow.test.ts
Normal file
421
apps/desktop/tests/staleFollow.test.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { installMutationGate } from '../src/fixes/mutationGate/index.ts';
|
||||
import {
|
||||
FOLLOW_QUIET_MS, FOLLOW_STALE_MAX_MS, createFollowHold, filterRelationshipText, isRelationshipReadUrl, stripRelationship,
|
||||
} from '../src/fixes/mutationGate/staleFollow.ts';
|
||||
|
||||
const U = '2086263555364691968';
|
||||
const OTHER = '1790000000000000001';
|
||||
const BASE = 'https://x.com/otheraccount';
|
||||
|
||||
// Shapes as X sends them (live 2026-09-28): GraphQL user result (UserByScreenName, URT timelines)
|
||||
// and a REST 1.1 user object (friendships/create.json, friends/following/list.json).
|
||||
const gqlUser = (id: string, following: boolean, followers = 21) => ({
|
||||
__typename: 'User', rest_id: id, follow_request_sent: false, privacy: { protected: false },
|
||||
core: { name: 'Sample', screen_name: 'otheraccount' },
|
||||
relationship_counts: { followers, following: 31 },
|
||||
relationship_perspectives: { followed_by: true, following, live_following: false, blocking: false, muting: false, blocked_by: false },
|
||||
});
|
||||
const ubsn = (following: boolean, followers = 21) => JSON.stringify({ data: { user: { result: gqlUser(U, following, followers) } } });
|
||||
const timeline = (following: boolean) => JSON.stringify({ data: { user: { result: { timeline: { instructions: [{ entries: [
|
||||
{ content: { itemContent: { tweet_results: { result: { rest_id: '2103816196386852995', core: { user_results: { result: gqlUser(U, following) } } } } } } },
|
||||
{ content: { itemContent: { tweet_results: { result: { rest_id: '2103816196386852996', core: { user_results: { result: gqlUser(OTHER, false) } } } } } } },
|
||||
] }] } } } } });
|
||||
|
||||
// X's own merge, as in its bundle: the GraphQL user normalizer copies every field of
|
||||
// `relationship_perspectives` when that object exists (missing fields as undefined) and
|
||||
// `relationship_counts.followers` when defined; the users entity reducer merges `{...stored, ...incoming}`.
|
||||
function xStore(following: boolean) {
|
||||
let user: Record<string, unknown> = { id_str: U, following, followers_count: 21 };
|
||||
const normalize = (result: Record<string, any>) => {
|
||||
const en: Record<string, unknown> = { id_str: result.rest_id };
|
||||
if (result.relationship_counts?.followers !== undefined) en.followers_count = result.relationship_counts.followers;
|
||||
if (result.follow_request_sent !== undefined) en.follow_request_sent = result.follow_request_sent;
|
||||
const rp = result.relationship_perspectives;
|
||||
if (rp !== undefined) Object.assign(en, { blocked_by: rp.blocked_by, blocking: rp.blocking, followed_by: rp.followed_by, following: rp.following, live_following: rp.live_following, muting: rp.muting });
|
||||
return en;
|
||||
};
|
||||
const walk = (node: unknown, out: Record<string, any>[]) => {
|
||||
if (Array.isArray(node)) { for (const item of node) walk(item, out); return; }
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const obj = node as Record<string, any>;
|
||||
if (obj.__typename === 'User' && typeof obj.rest_id === 'string') out.push(obj);
|
||||
for (const value of Object.values(obj)) walk(value, out);
|
||||
};
|
||||
return {
|
||||
get following() { return user.following; },
|
||||
get followers() { return user.followers_count; },
|
||||
/** `_REQUEST` optimistic reducer of X's follow/unfollow. */
|
||||
optimistic(want: boolean) { user = { ...user, following: want, followers_count: (user.followers_count as number) + (want ? 1 : -1) }; },
|
||||
/** redux-optimist REVERT of that transaction (the only in flight in these tests). */
|
||||
revert(want: boolean) { user = { ...user, following: !want, followers_count: (user.followers_count as number) - (want ? 1 : -1) }; },
|
||||
/** A read X merges into the users entity (ADD_ENTITIES / urt FETCH_SUCCESS). */
|
||||
merge(text: string) { const users: Record<string, any>[] = []; walk(JSON.parse(text), users); for (const r of users) if (r.rest_id === U) user = { ...user, ...normalize(r) }; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- pure parts ----------
|
||||
|
||||
test('follow hold: in flight, quiet window, reads opened before the end, expiry', () => {
|
||||
let clock = 1000;
|
||||
const hold = createFollowHold(() => clock);
|
||||
assert.deepEqual(hold.stale(0), []);
|
||||
hold.begin(U);
|
||||
assert.deepEqual(hold.stale(clock), [U], 'in flight: every read is stale');
|
||||
clock += 300;
|
||||
hold.end(U);
|
||||
const ended = clock;
|
||||
clock += FOLLOW_QUIET_MS - 1;
|
||||
assert.deepEqual(hold.stale(clock), [U], 'quiet window after the end');
|
||||
clock += 2;
|
||||
assert.deepEqual(hold.stale(clock), [], 'a read opened after the quiet window is fresh');
|
||||
assert.deepEqual(hold.stale(ended - 1), [U], 'a read opened before the end is stale');
|
||||
clock = ended + FOLLOW_STALE_MAX_MS;
|
||||
assert.deepEqual(hold.stale(ended - 1), [], 'up to FOLLOW_STALE_MAX_MS');
|
||||
assert.equal(hold.size(), 0, 'expired entries are dropped');
|
||||
hold.end('404');
|
||||
assert.equal(hold.size(), 0);
|
||||
// Overlapping requests for one user: stale until the last one ended.
|
||||
hold.begin(U); hold.begin(U); hold.end(U);
|
||||
clock += FOLLOW_QUIET_MS * 2;
|
||||
assert.deepEqual(hold.stale(clock), [U]);
|
||||
hold.end(U);
|
||||
assert.deepEqual(hold.stale(clock), [U]);
|
||||
});
|
||||
|
||||
test('strip: GraphQL and REST user objects of the listed users only', () => {
|
||||
const body = JSON.parse(timeline(false));
|
||||
assert.equal(stripRelationship(body, new Set([`follow:${U}`])), 1);
|
||||
const entries = body.data.user.result.timeline.instructions[0].entries;
|
||||
const mine = entries[0].content.itemContent.tweet_results.result.core.user_results.result;
|
||||
const other = entries[1].content.itemContent.tweet_results.result.core.user_results.result;
|
||||
assert.equal('relationship_perspectives' in mine, false);
|
||||
assert.equal('follow_request_sent' in mine, false);
|
||||
assert.deepEqual(mine.relationship_counts, { following: 31 });
|
||||
assert.deepEqual(mine.core, { name: 'Sample', screen_name: 'otheraccount' }, 'other fields untouched');
|
||||
assert.deepEqual(other, gqlUser(OTHER, false), 'other users untouched');
|
||||
const rest = { id: 1, id_str: U, screen_name: 'otheraccount', following: false, followers_count: 21, follow_request_sent: false, followed_by: true, legacy: { following: false, followers_count: 21, name: 'x' } };
|
||||
assert.equal(stripRelationship([rest], new Set([`follow:${U}`])), 1);
|
||||
assert.deepEqual(rest, { id: 1, id_str: U, screen_name: 'otheraccount', followed_by: true, legacy: { name: 'x' } });
|
||||
assert.equal(stripRelationship({ id_str: U, name: 'no relationship fields' }, new Set([`follow:${U}`])), 0);
|
||||
});
|
||||
|
||||
test('filter text: same string unless a listed user lost fields; big integers keep their digits', () => {
|
||||
const plain = ubsn(false);
|
||||
assert.equal(filterRelationshipText(plain, []), plain);
|
||||
assert.equal(filterRelationshipText(plain, [`follow:${OTHER}`]), plain, 'user not named: no parse');
|
||||
assert.equal(filterRelationshipText('{not json ' + U, [`follow:${U}`]), '{not json ' + U);
|
||||
assert.equal(filterRelationshipText(JSON.stringify({ id_str: U }), [`follow:${U}`]), JSON.stringify({ id_str: U }), 'nothing to drop: same string');
|
||||
let dropped = 0;
|
||||
const out = filterRelationshipText(plain, [`follow:${U}`], n => { dropped += n; });
|
||||
assert.equal(dropped, 1);
|
||||
assert.equal(JSON.parse(out).data.user.result.relationship_perspectives, undefined);
|
||||
const rest = `{"id":${U},"id_str":"${U}","following":false,"followers_count":21,"status":{"id":2103816196386852995}}`;
|
||||
assert.equal(filterRelationshipText(rest, [`follow:${U}`]), `{"id":${U},"id_str":"${U}","status":{"id":2103816196386852995}}`);
|
||||
});
|
||||
|
||||
test('read URLs: X API calls except the lane-gated mutations', () => {
|
||||
assert.equal(isRelationshipReadUrl('https://x.com/i/api/graphql/Kyb/UserByScreenName?variables=%7B%7D'), true);
|
||||
assert.equal(isRelationshipReadUrl('/i/api/1.1/friends/following/list.json'), true);
|
||||
assert.equal(isRelationshipReadUrl('https://x.com/i/api/1.1/friendships/create.json'), false);
|
||||
assert.equal(isRelationshipReadUrl('https://x.com/i/api/1.1/friendships/destroy.json'), false);
|
||||
assert.equal(isRelationshipReadUrl('https://x.com/i/api/graphql/q/FavoriteTweet'), false);
|
||||
assert.equal(isRelationshipReadUrl('https://abs.twimg.com/responsive-web/client-web/main.js'), false);
|
||||
});
|
||||
|
||||
// ---------- the sequences, through the real gate on a fake XMLHttpRequest ----------
|
||||
|
||||
function env() {
|
||||
const server = { following: false, wire: [] as string[] };
|
||||
const pending = new Map<XMLHttpRequest, (status: number, text: string) => void>();
|
||||
class FakeXHR extends EventTarget {
|
||||
#s = { readyState: 0, status: 0, text: '', method: '', url: '' };
|
||||
responseType: XMLHttpRequestResponseType = '';
|
||||
get readyState() { return this.#s.readyState; }
|
||||
get status() { return this.#s.status; }
|
||||
get responseText() { return this.#s.text; }
|
||||
get response() { return this.#s.text; }
|
||||
get responseURL() { return this.#s.readyState === 4 ? new URL(this.#s.url, BASE).href : ''; }
|
||||
getResponseHeader() { return 'application/json'; }
|
||||
getAllResponseHeaders() { return 'content-type: application/json\r\n'; }
|
||||
open(method: string, url: string) { Object.assign(this.#s, { method, url, readyState: 1, status: 0, text: '' }); }
|
||||
send(body?: string) {
|
||||
server.wire.push(`${this.#s.method} ${this.#s.url.split('?')[0]!.split('/').pop()}${body ? ' ' + body : ''}`);
|
||||
pending.set(this as unknown as XMLHttpRequest, (status, text) => {
|
||||
Object.assign(this.#s, { readyState: 4, status, text });
|
||||
for (const type of ['readystatechange', 'load', 'loadend']) this.dispatchEvent(new Event(type));
|
||||
});
|
||||
}
|
||||
abort() {}
|
||||
}
|
||||
const win = { fetch: (async () => new Response('{}')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest };
|
||||
const gate = installMutationGate(win);
|
||||
const store = xStore(false);
|
||||
/** X's read: the response text X gets is merged into its store when the read completes. */
|
||||
const read = (op: 'UserByScreenName' | 'UserOriginalsTimeline') => {
|
||||
const xhr = new win.XMLHttpRequest();
|
||||
xhr.addEventListener('load', () => store.merge(xhr.responseText));
|
||||
xhr.open('GET', `https://x.com/i/api/graphql/q/${op}?variables=%7B%7D`);
|
||||
xhr.send();
|
||||
// The server answers with the relationship as it is now (the snapshot a stale read carries).
|
||||
const snapshot = server.following;
|
||||
return (following = snapshot) => pending.get(xhr)!(200, op === 'UserByScreenName' ? ubsn(following) : timeline(following));
|
||||
};
|
||||
/** X's follow button: optimistic update, its own XHR, revert on failure (redux-optimist). */
|
||||
const toggle = (want: boolean) => {
|
||||
store.optimistic(want);
|
||||
const xhr = new win.XMLHttpRequest();
|
||||
xhr.addEventListener('loadend', () => { if (xhr.status < 200 || xhr.status >= 300) store.revert(want); });
|
||||
xhr.open('POST', `https://x.com/i/api/1.1/friendships/${want ? 'create' : 'destroy'}.json`);
|
||||
xhr.send(`include_profile_interstitial_type=1&user_id=${U}&skip_status=1`);
|
||||
return {
|
||||
ok() { server.following = want; pending.get(xhr)!(200, JSON.stringify({ id_str: U, following: !want, followers_count: 21 })); },
|
||||
limited() { pending.get(xhr)!(429, '{"errors":[{"code":88,"message":"Rate limit exceeded."}]}'); },
|
||||
};
|
||||
};
|
||||
return { gate, server, store, read, toggle };
|
||||
}
|
||||
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('sequence (e): a read requested before the follow and answered after it no longer reverts the button', async () => {
|
||||
const { gate, server, store, read, toggle } = env();
|
||||
const profile = read('UserOriginalsTimeline'); // profile still loading
|
||||
const follow = toggle(true);
|
||||
assert.equal(store.following, true, 'optimistic');
|
||||
follow.ok();
|
||||
await settle();
|
||||
profile(false); // stale snapshot: following false
|
||||
assert.equal(server.following, true);
|
||||
assert.equal(store.following, true, 'X keeps its own state; was "Follow back" until F5 before the fix');
|
||||
assert.equal(store.followers, 22);
|
||||
assert.equal(gate.live.staleStripped, 1);
|
||||
gate.dispose();
|
||||
});
|
||||
|
||||
test('sequence (e) control: without the fix the same read reverts the button (X on its own)', async () => {
|
||||
const { gate, server, store, read, toggle } = env();
|
||||
gate.dispose();
|
||||
const profile = read('UserByScreenName');
|
||||
const follow = toggle(true);
|
||||
follow.ok();
|
||||
await settle();
|
||||
profile(false);
|
||||
assert.equal(server.following, true);
|
||||
assert.equal(store.following, false, 'the owner symptom');
|
||||
});
|
||||
|
||||
test('sequence (a): 429 on a follow, then a successful follow, with a read spanning both', async () => {
|
||||
const { gate, server, store, read, toggle } = env();
|
||||
const early = read('UserByScreenName');
|
||||
const first = toggle(true);
|
||||
first.limited();
|
||||
await settle();
|
||||
assert.equal(store.following, false, 'X reverted the rate-limited follow');
|
||||
const second = toggle(true);
|
||||
second.ok();
|
||||
await settle();
|
||||
early(false);
|
||||
assert.equal(server.following, true);
|
||||
assert.equal(store.following, true);
|
||||
// A read opened after the quiet window would carry the server's state and be merged as-is (see the
|
||||
// hold test); a read opened now is still inside it and changes nothing either way.
|
||||
const fresh = read('UserByScreenName');
|
||||
fresh(true);
|
||||
assert.equal(store.following, true);
|
||||
gate.dispose();
|
||||
});
|
||||
|
||||
test('sequence (b): follow succeeds, a later unfollow is rate-limited, stale reads on both sides', async () => {
|
||||
const { gate, server, store, read, toggle } = env();
|
||||
const r1 = read('UserOriginalsTimeline');
|
||||
toggle(true).ok();
|
||||
await settle();
|
||||
const r2 = read('UserByScreenName');
|
||||
const unfollow = toggle(false);
|
||||
assert.equal(store.following, false, 'optimistic unfollow');
|
||||
unfollow.limited();
|
||||
await settle();
|
||||
assert.equal(store.following, true, 'X reverted the unfollow');
|
||||
r2(false); // answered from an older replica
|
||||
r1(false);
|
||||
assert.equal(server.following, true);
|
||||
assert.equal(store.following, true);
|
||||
gate.dispose();
|
||||
});
|
||||
|
||||
test('sequence (c): slow 200, reads opened while the follow is in flight', async () => {
|
||||
const { gate, server, store, read, toggle } = env();
|
||||
const follow = toggle(true);
|
||||
const during = read('UserByScreenName');
|
||||
during(false); // answered before the follow landed
|
||||
assert.equal(store.following, true, 'the optimistic value survives a read that raced the request');
|
||||
follow.ok();
|
||||
await settle();
|
||||
assert.equal(server.following, true);
|
||||
assert.equal(store.following, true);
|
||||
gate.dispose();
|
||||
});
|
||||
|
||||
test('sequence (d): lane pair-cancel (follow, unfollow, follow) answered by synthesized responses', async () => {
|
||||
const { gate, server, store, read, toggle } = env();
|
||||
const early = read('UserOriginalsTimeline');
|
||||
const a = toggle(true);
|
||||
toggle(false); // held behind the follow
|
||||
toggle(true); // pairs with the held unfollow: neither reaches the wire
|
||||
assert.equal(server.wire.filter(line => line.startsWith('POST')).length, 1);
|
||||
a.ok();
|
||||
await settle();
|
||||
await settle();
|
||||
early(false);
|
||||
assert.equal(server.following, true);
|
||||
assert.equal(store.following, true);
|
||||
assert.equal(gate.lanes.stats.synthesized, 2);
|
||||
gate.dispose();
|
||||
});
|
||||
|
||||
test('reads of other users and reads with no follow in progress are byte-identical', async () => {
|
||||
const { gate, store, read } = env();
|
||||
const r = read('UserByScreenName');
|
||||
r(true);
|
||||
assert.equal(store.following, true, 'no follow in progress: X merges the server state as before');
|
||||
assert.equal(gate.live.staleStripped, 0);
|
||||
gate.dispose();
|
||||
});
|
||||
|
||||
// ---------- likes, reposts, bookmarks: the same stale merge on the post ----------
|
||||
|
||||
const POST = '2103816196386852995';
|
||||
const OTHER_POST = '2103816196386852996';
|
||||
// A post as TweetDetail/timelines send it (live 2026-09-28): counts and flags in `legacy`.
|
||||
const gqlTweet = (id: string, s: { favorited?: boolean; likes?: number; retweeted?: boolean; reposts?: number; bookmarked?: boolean; bookmarks?: number }) => ({
|
||||
__typename: 'Tweet', rest_id: id,
|
||||
legacy: { id_str: id, full_text: 'hi', favorited: s.favorited ?? false, favorite_count: s.likes ?? 5, retweeted: s.retweeted ?? false, retweet_count: s.reposts ?? 0, bookmarked: s.bookmarked ?? false, bookmark_count: s.bookmarks ?? 1, reply_count: 0 },
|
||||
});
|
||||
const tweetDetail = (s: Parameters<typeof gqlTweet>[1]) => JSON.stringify({ data: { threaded_conversation_with_injections_v2: { instructions: [{ entries: [
|
||||
{ content: { itemContent: { tweet_results: { result: gqlTweet(POST, s) } } } },
|
||||
{ content: { itemContent: { tweet_results: { result: gqlTweet(OTHER_POST, { favorited: true, likes: 9 }) } } } },
|
||||
] }] } } });
|
||||
|
||||
test('strip: a post loses only the toggled flag and count', () => {
|
||||
const body = JSON.parse(tweetDetail({ favorited: false, likes: 5, retweeted: true, reposts: 2 }));
|
||||
assert.equal(stripRelationship(body, new Set([`like:${POST}`])), 1, 'one post object (its legacy fields)');
|
||||
const entries = body.data.threaded_conversation_with_injections_v2.instructions[0].entries;
|
||||
const legacy = entries[0].content.itemContent.tweet_results.result.legacy;
|
||||
assert.deepEqual(legacy, { id_str: POST, full_text: 'hi', retweeted: true, retweet_count: 2, bookmarked: false, bookmark_count: 1, reply_count: 0 });
|
||||
assert.deepEqual(entries[1].content.itemContent.tweet_results.result, gqlTweet(OTHER_POST, { favorited: true, likes: 9 }), 'other posts untouched');
|
||||
const both = JSON.parse(tweetDetail({}));
|
||||
stripRelationship(both, new Set([`retweet:${POST}`, `bookmark:${POST}`]));
|
||||
assert.deepEqual(Object.keys(both.data.threaded_conversation_with_injections_v2.instructions[0].entries[0].content.itemContent.tweet_results.result.legacy),
|
||||
['id_str', 'full_text', 'favorited', 'favorite_count', 'reply_count']);
|
||||
assert.equal(stripRelationship(JSON.parse(tweetDetail({})), new Set([`follow:${POST}`])), 0, 'a follow key never touches a post');
|
||||
});
|
||||
|
||||
type TweetKind = 'like' | 'retweet' | 'bookmark';
|
||||
const OPS: Record<TweetKind, [string, string, string, string, string]> = {
|
||||
// [do, undo, flag, count, request variable of undo]
|
||||
like: ['FavoriteTweet', 'UnfavoriteTweet', 'favorited', 'favorite_count', 'tweet_id'],
|
||||
retweet: ['CreateRetweet', 'DeleteRetweet', 'retweeted', 'retweet_count', 'source_tweet_id'],
|
||||
bookmark: ['CreateBookmark', 'DeleteBookmark', 'bookmarked', 'bookmark_count', 'tweet_id'],
|
||||
};
|
||||
const DONE: Record<string, unknown> = {
|
||||
FavoriteTweet: { favorite_tweet: 'Done' }, UnfavoriteTweet: { unfavorite_tweet: 'Done' },
|
||||
CreateBookmark: { tweet_bookmark_put: 'Done' }, DeleteBookmark: { tweet_bookmark_delete: 'Done' },
|
||||
CreateRetweet: { create_retweet: { retweet_results: { result: { rest_id: '1', legacy: {} } } } },
|
||||
DeleteRetweet: { unretweet: { source_tweet_results: { result: { rest_id: POST, legacy: {} } } } },
|
||||
};
|
||||
|
||||
/** X's tweet entity: optimistic flag ±1 on the toggle; reads merged `{...stored, ...legacy}` (X spreads `legacy`). */
|
||||
function tweetEnv(kind: TweetKind, on: boolean) {
|
||||
const [doOp, undoOp, flag, count, undoVar] = OPS[kind];
|
||||
let tweet: Record<string, unknown> = { id_str: POST, [flag]: on, [count]: 5 };
|
||||
const server = { on, count: 5 };
|
||||
const pending = new Map<XMLHttpRequest, (status: number, text: string) => void>();
|
||||
class FakeXHR extends EventTarget {
|
||||
#s = { readyState: 0, status: 0, text: '', method: '', url: '' };
|
||||
responseType: XMLHttpRequestResponseType = '';
|
||||
get readyState() { return this.#s.readyState; }
|
||||
get status() { return this.#s.status; }
|
||||
get responseText() { return this.#s.text; }
|
||||
get response() { return this.#s.text; }
|
||||
get responseURL() { return this.#s.readyState === 4 ? new URL(this.#s.url, BASE).href : ''; }
|
||||
getResponseHeader() { return 'application/json'; }
|
||||
getAllResponseHeaders() { return 'content-type: application/json\r\n'; }
|
||||
open(method: string, url: string) { Object.assign(this.#s, { method, url, readyState: 1, status: 0, text: '' }); }
|
||||
send() {
|
||||
pending.set(this as unknown as XMLHttpRequest, (status, text) => {
|
||||
Object.assign(this.#s, { readyState: 4, status, text });
|
||||
for (const type of ['readystatechange', 'load', 'loadend']) this.dispatchEvent(new Event(type));
|
||||
});
|
||||
}
|
||||
abort() {}
|
||||
}
|
||||
const win = { fetch: (async () => new Response('{}')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest };
|
||||
const gate = installMutationGate(win);
|
||||
const merge = (text: string) => {
|
||||
const walk = (node: unknown) => {
|
||||
if (Array.isArray(node)) { node.forEach(walk); return; }
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const obj = node as Record<string, any>;
|
||||
if (obj.__typename === 'Tweet' && obj.rest_id === POST) tweet = { ...tweet, ...obj.legacy };
|
||||
Object.values(obj).forEach(walk);
|
||||
};
|
||||
walk(JSON.parse(text));
|
||||
};
|
||||
const read = () => {
|
||||
const xhr = new win.XMLHttpRequest();
|
||||
xhr.addEventListener('load', () => merge(xhr.responseText));
|
||||
xhr.open('GET', 'https://x.com/i/api/graphql/q/TweetDetail?variables=%7B%7D');
|
||||
xhr.send();
|
||||
const snapshot = { ...server };
|
||||
return () => pending.get(xhr)!(200, tweetDetail(kind === 'like' ? { favorited: snapshot.on, likes: snapshot.count } : kind === 'retweet' ? { retweeted: snapshot.on, reposts: snapshot.count } : { bookmarked: snapshot.on, bookmarks: snapshot.count }));
|
||||
};
|
||||
const toggle = () => {
|
||||
const want = !(tweet[flag] as boolean);
|
||||
tweet = { ...tweet, [flag]: want, [count]: (tweet[count] as number) + (want ? 1 : -1) };
|
||||
const op = want ? doOp : undoOp;
|
||||
const xhr = new win.XMLHttpRequest();
|
||||
xhr.open('POST', `https://x.com/i/api/graphql/q/${op}`);
|
||||
xhr.send(JSON.stringify({ variables: { [want ? 'tweet_id' : undoVar]: POST }, queryId: 'q' }));
|
||||
return () => { server.on = want; server.count += want ? 1 : -1; pending.get(xhr)!(200, JSON.stringify({ data: DONE[op] })); };
|
||||
};
|
||||
return { gate, server, read, toggle, get ui() { return { on: tweet[flag], count: tweet[count] }; } };
|
||||
}
|
||||
|
||||
for (const kind of ['like', 'retweet', 'bookmark'] as const) {
|
||||
test(`sequence (${kind}): a post read requested before the toggle and answered after it no longer reverts it`, async () => {
|
||||
const h = tweetEnv(kind, false);
|
||||
const detail = h.read(); // TweetDetail still loading
|
||||
const ok = h.toggle();
|
||||
ok();
|
||||
await settle();
|
||||
detail(); // stale: not toggled, old count
|
||||
assert.deepEqual(h.server, { on: true, count: 6 });
|
||||
assert.deepEqual(h.ui, { on: true, count: 6 });
|
||||
assert.equal(h.gate.live.staleStripped, 1);
|
||||
h.gate.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test('sequence (like) control: without the fix the stale TweetDetail reverts the heart and the count (X on its own)', async () => {
|
||||
const h = tweetEnv('like', true);
|
||||
h.gate.dispose();
|
||||
const detail = h.read();
|
||||
h.toggle()(); // unlike, confirmed
|
||||
await settle();
|
||||
detail();
|
||||
assert.deepEqual(h.server, { on: false, count: 4 });
|
||||
assert.deepEqual(h.ui, { on: true, count: 5 }, 'the heart comes back until F5');
|
||||
});
|
||||
|
||||
test('a like hold strips only the liked post, not another post in the same response', async () => {
|
||||
const h = tweetEnv('like', false);
|
||||
const detail = h.read();
|
||||
const ok = h.toggle();
|
||||
ok();
|
||||
await settle();
|
||||
detail();
|
||||
assert.equal(h.gate.live.staleStripped, 1, 'only the liked post, not the other post in the same response');
|
||||
h.gate.dispose();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue