@volter/twin-x 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/client/x-mirror.bundle.js +76 -76
- package/dist/src/x-capabilities.js +17 -0
- package/dist/src/x-twin.js +1 -1
- package/dist/src/x-web.js +16 -2
- package/package.json +3 -3
- package/src/x-capabilities.ts +18 -0
- package/src/x-twin.ts +1 -1
- package/src/x-web.ts +17 -2
|
@@ -1336,6 +1336,23 @@ export const X_CAPABILITIES = [
|
|
|
1336
1336
|
return wrong.status === 307 && wrong.headers.get('location') === canonical && cased.status === 307 && cased.headers.get('location') === canonical
|
|
1337
1337
|
&& slash.status === 307 && slash.headers.get('location') === canonical && right.status === 200 && (await right.text()).includes('where it lives');
|
|
1338
1338
|
})),
|
|
1339
|
+
done('x.web.default_avatar', 'web', "An account without a photo is drawn with X's default avatar on its x.com pages (the grey silhouette at the default profile_image_url the API answers for it, redrawn inline: the twin claims no abs.twimg.com), on its profile and beside its posts; an account with a photo is not given the default", 'ui', 'common', () => withWorld(async (h, root) => {
|
|
1340
|
+
const fetchTwin = createXTwinFetch({ root });
|
|
1341
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3200', username: 'plainface', name: 'Plain Face' }, headers: {} });
|
|
1342
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3201', username: 'photoface', name: 'Photo Face', profile_image_url: 'https://pbs.twimg.com/profile_images/2/photoface_normal.jpg' }, headers: {} });
|
|
1343
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'plainface-token', account_id: '3200', scopes: FULL_SCOPES }, headers: {} });
|
|
1344
|
+
const posted = (await h({ m: 'POST', p: '/2/tweets', b: { text: 'no photo yet' }, headers: { authorization: 'Bearer plainface-token' } })).body.data.id;
|
|
1345
|
+
const api = (await h({ m: 'GET', p: '/2/users/3200?user.fields=profile_image_url' })).body.data?.profile_image_url;
|
|
1346
|
+
const page = async (path) => (await fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host: 'x.com' } }))).text();
|
|
1347
|
+
const drawn = (html) => /class="avatar avatar-default"[^>]*data-src="([^"]+)"[^>]*><svg[^>]*><rect[^>]*fill="#ccd6dd"\/><path fill="#657786"/.exec(html)?.[1];
|
|
1348
|
+
const profile = await page('/plainface');
|
|
1349
|
+
const post = await page(`/plainface/status/${posted}`);
|
|
1350
|
+
const photo = await page('/photoface');
|
|
1351
|
+
return typeof api === 'string' && api.includes('default_profile_images')
|
|
1352
|
+
&& drawn(profile) === api && drawn(post) === api
|
|
1353
|
+
&& !photo.includes('avatar-default') && photo.includes('Photo Face');
|
|
1354
|
+
})),
|
|
1355
|
+
todo('x.web.profile_photo', 'web', "A photo the account set drawn on its x.com pages (the twin serves no profile photos: pbs.twimg.com/profile_images is not claimed, so the page keeps the initial on its hue)", 'ui', 'niche'),
|
|
1339
1356
|
todo('x.web.post_page_replies', 'web', "The replies under a post on its x.com page (the conversation), and the parent a reply answers", 'ui', 'common'),
|
|
1340
1357
|
todo('x.web.logged_out_prompts', 'web', "x.com's logged-out chrome around a public page (the Log in / Sign up bar and its flows)", 'ui', 'niche'),
|
|
1341
1358
|
todo('x.mirror.notifications_all', 'mirror', "Notifications beyond Mentions (likes, reposts, follows) — the twin models no engagement events to list", 'ui', 'common'),
|
package/dist/src/x-twin.js
CHANGED
|
@@ -1484,7 +1484,7 @@ function armedRateRefusal(all) {
|
|
|
1484
1484
|
/** The fields and expansions the web pages draw (the mirror's POST_READ_QUERY, plus the profile's user fields). */
|
|
1485
1485
|
const WEB_READ_SHAPE = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
|
|
1486
1486
|
+ '&expansions=author_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
|
|
1487
|
-
+ '&user.fields=created_at,description,location,url,verified,protected'
|
|
1487
|
+
+ '&user.fields=created_at,description,location,url,verified,protected,profile_image_url'
|
|
1488
1488
|
+ '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
|
|
1489
1489
|
function webShape(publicBase) {
|
|
1490
1490
|
const shape = parseReadShape(new URL(`http://twin.local/?${WEB_READ_SHAPE}`), (publicBase ?? PBS_BASE).replace(/\/+$/, ''));
|
package/dist/src/x-web.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// claim is two anchored path shapes and nothing else (X_WEB_PATHS); the router below answers exactly
|
|
16
16
|
// those shapes, only for a request the World routed from one of X's web hosts (X_WEB_HOSTS).
|
|
17
17
|
import { avatarHue, avatarInitial, displayUrl, durationLabel, fullTimestamp, joinedLabel, linkHref, mediaByKey, postMedia, referencedId, relativeTime, textSegments, tweetsById, usersById, videoSource } from "./x-mirror-ui.js";
|
|
18
|
-
import { xWebPost, xWebProfile } from "./x-twin.js";
|
|
18
|
+
import { X_DEFAULT_PROFILE_IMAGE_URL, xWebPost, xWebProfile } from "./x-twin.js";
|
|
19
19
|
/** x.com's top-level routes that are not accounts (and the twin's own doors): never a profile. Each was
|
|
20
20
|
* observed as a route of its own at x.com (2026-09-28, logged out, `curl -A 'Mozilla/5.0' -o /dev/null
|
|
21
21
|
* -w '%{http_code} %{redirect_url}' https://x.com/<name>`): home, explore, notifications, settings,
|
|
@@ -154,7 +154,21 @@ const ICON = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
|
|
|
154
154
|
function header(title, back, sub = '') {
|
|
155
155
|
return `<div class="col-header">${back ? '<button type="button" class="back" onclick="history.back()" aria-label="Back">←</button>' : ''}<div><div class="col-title">${esc(title)}</div>${sub ? `<div class="muted col-sub">${esc(sub)}</div>` : ''}</div></div>`;
|
|
156
156
|
}
|
|
157
|
+
/** X's default avatar as x.com draws it for an account without a photo — the image at
|
|
158
|
+
* `X_DEFAULT_PROFILE_IMAGE_URL`, redrawn inline because the twin claims no abs.twimg.com: a #657786
|
|
159
|
+
* head and shoulders on #ccd6dd (its colours and shape read off that PNG, 2026-09-28: the palette's
|
|
160
|
+
* background entry and the silhouette's pixels, head 124–276 × 88–256 and a shoulder circle centred
|
|
161
|
+
* at 200,409 radius 131 in its 400×400 rendition). */
|
|
162
|
+
const DEFAULT_AVATAR_SVG = '<svg viewBox="0 0 400 400" width="100%" height="100%" aria-hidden="true"><rect width="400" height="400" fill="#ccd6dd"/>'
|
|
163
|
+
+ '<path fill="#657786" d="M200 88C240 88 276 120 276 176C276 234 240 256 200 256C160 256 124 234 124 176C124 120 160 88 200 88Z"/>'
|
|
164
|
+
+ '<circle cx="200" cy="409" r="131" fill="#657786"/></svg>';
|
|
165
|
+
/** An account's picture: X's default avatar when it has none (its `profile_image_url` is the default,
|
|
166
|
+
* as the API answers it). A photo the account set is not drawn: the twin serves no profile photos
|
|
167
|
+
* (x.web.profile_photo, todo), so it keeps the initial on its hue. */
|
|
157
168
|
function avatar(user, size) {
|
|
169
|
+
if (user?.profile_image_url === X_DEFAULT_PROFILE_IMAGE_URL) {
|
|
170
|
+
return `<div class="avatar avatar-default" style="width:${size}px;height:${size}px" data-src="${esc(X_DEFAULT_PROFILE_IMAGE_URL)}" aria-hidden="true">${DEFAULT_AVATAR_SVG}</div>`;
|
|
171
|
+
}
|
|
158
172
|
return `<div class="avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size * 0.42)}px;background:hsl(${avatarHue(user?.username)} 45% 38%)" aria-hidden="true">${esc(avatarInitial(user))}</div>`;
|
|
159
173
|
}
|
|
160
174
|
function nameLine(user, tail) {
|
|
@@ -230,7 +244,7 @@ a{color:inherit;text-decoration:none}
|
|
|
230
244
|
.column{border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:100vh;min-width:0}
|
|
231
245
|
.col-header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:24px;min-height:53px;padding:0 16px;background:rgba(0,0,0,.65);backdrop-filter:blur(12px)}
|
|
232
246
|
.col-title{font-size:20px;line-height:24px;font-weight:700}.col-sub{font-size:13px;line-height:16px}.back{font-size:20px;border:0;background:none;color:inherit;cursor:pointer;padding:0}
|
|
233
|
-
.avatar{flex:none;border-radius:9999px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
|
|
247
|
+
.avatar{flex:none;border-radius:9999px;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
|
|
234
248
|
.name-line{display:flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}.display-name{font-weight:700;overflow:hidden;text-overflow:ellipsis}.badge{color:var(--blue);flex:none}
|
|
235
249
|
.post-row{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line)}.post-body{flex:1;min-width:0}
|
|
236
250
|
.post-text{white-space:pre-wrap;overflow-wrap:anywhere}.post-text-big{font-size:17px;line-height:24px;margin-top:12px}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-x",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Local X (Twitter) API v2 twin (post, reply, delete, timelines, user lookup) with an x.com mirror, built on @volter/world-core.",
|
|
5
5
|
"author": "Volter (https://github.com/volter-ai)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@volter/world-core": "2.0.
|
|
39
|
+
"@volter/world-core": "2.0.4"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/bun": "^1.2.20",
|
|
43
43
|
"@types/node": "^24.0.0",
|
|
44
44
|
"@types/react": "^19.2.17",
|
|
45
45
|
"@types/react-dom": "^19.2.3",
|
|
46
|
-
"@volter/world-core": "2.0.
|
|
46
|
+
"@volter/world-core": "2.0.4",
|
|
47
47
|
"@volter/world-tooling": "0.1.0",
|
|
48
48
|
"twitter-api-v2": "^1.29.1",
|
|
49
49
|
"typescript": "^5.9.0"
|
package/src/x-capabilities.ts
CHANGED
|
@@ -1398,6 +1398,24 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1398
1398
|
return wrong.status === 307 && wrong.headers.get('location') === canonical && cased.status === 307 && cased.headers.get('location') === canonical
|
|
1399
1399
|
&& slash.status === 307 && slash.headers.get('location') === canonical && right.status === 200 && (await right.text()).includes('where it lives');
|
|
1400
1400
|
})),
|
|
1401
|
+
done('x.web.default_avatar', 'web', "An account without a photo is drawn with X's default avatar on its x.com pages (the grey silhouette at the default profile_image_url the API answers for it, redrawn inline: the twin claims no abs.twimg.com), on its profile and beside its posts; an account with a photo is not given the default", 'ui', 'common', () =>
|
|
1402
|
+
withWorld(async (h, root) => {
|
|
1403
|
+
const fetchTwin = createXTwinFetch({ root });
|
|
1404
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3200', username: 'plainface', name: 'Plain Face' }, headers: {} });
|
|
1405
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3201', username: 'photoface', name: 'Photo Face', profile_image_url: 'https://pbs.twimg.com/profile_images/2/photoface_normal.jpg' }, headers: {} });
|
|
1406
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'plainface-token', account_id: '3200', scopes: FULL_SCOPES }, headers: {} });
|
|
1407
|
+
const posted = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'no photo yet' }, headers: { authorization: 'Bearer plainface-token' } })).body as Body).data.id as string;
|
|
1408
|
+
const api = ((await h({ m: 'GET', p: '/2/users/3200?user.fields=profile_image_url' })).body as Body).data?.profile_image_url;
|
|
1409
|
+
const page = async (path: string) => (await fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host: 'x.com' } }))).text();
|
|
1410
|
+
const drawn = (html: string) => /class="avatar avatar-default"[^>]*data-src="([^"]+)"[^>]*><svg[^>]*><rect[^>]*fill="#ccd6dd"\/><path fill="#657786"/.exec(html)?.[1];
|
|
1411
|
+
const profile = await page('/plainface');
|
|
1412
|
+
const post = await page(`/plainface/status/${posted}`);
|
|
1413
|
+
const photo = await page('/photoface');
|
|
1414
|
+
return typeof api === 'string' && api.includes('default_profile_images')
|
|
1415
|
+
&& drawn(profile) === api && drawn(post) === api
|
|
1416
|
+
&& !photo.includes('avatar-default') && photo.includes('Photo Face');
|
|
1417
|
+
})),
|
|
1418
|
+
todo('x.web.profile_photo', 'web', "A photo the account set drawn on its x.com pages (the twin serves no profile photos: pbs.twimg.com/profile_images is not claimed, so the page keeps the initial on its hue)", 'ui', 'niche'),
|
|
1401
1419
|
todo('x.web.post_page_replies', 'web', "The replies under a post on its x.com page (the conversation), and the parent a reply answers", 'ui', 'common'),
|
|
1402
1420
|
todo('x.web.logged_out_prompts', 'web', "x.com's logged-out chrome around a public page (the Log in / Sign up bar and its flows)", 'ui', 'niche'),
|
|
1403
1421
|
todo('x.mirror.notifications_all', 'mirror', "Notifications beyond Mentions (likes, reposts, follows) — the twin models no engagement events to list", 'ui', 'common'),
|
package/src/x-twin.ts
CHANGED
|
@@ -1498,7 +1498,7 @@ function armedRateRefusal(all: Resource[]): XResponse | undefined {
|
|
|
1498
1498
|
/** The fields and expansions the web pages draw (the mirror's POST_READ_QUERY, plus the profile's user fields). */
|
|
1499
1499
|
const WEB_READ_SHAPE = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
|
|
1500
1500
|
+ '&expansions=author_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
|
|
1501
|
-
+ '&user.fields=created_at,description,location,url,verified,protected'
|
|
1501
|
+
+ '&user.fields=created_at,description,location,url,verified,protected,profile_image_url'
|
|
1502
1502
|
+ '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
|
|
1503
1503
|
|
|
1504
1504
|
function webShape(publicBase?: string): ReadShape {
|
package/src/x-web.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// claim is two anchored path shapes and nothing else (X_WEB_PATHS); the router below answers exactly
|
|
16
16
|
// those shapes, only for a request the World routed from one of X's web hosts (X_WEB_HOSTS).
|
|
17
17
|
import { avatarHue, avatarInitial, displayUrl, durationLabel, fullTimestamp, joinedLabel, linkHref, mediaByKey, postMedia, referencedId, relativeTime, textSegments, tweetsById, usersById, videoSource, type XRow } from './x-mirror-ui.ts';
|
|
18
|
-
import { xWebPost, xWebProfile } from './x-twin.ts';
|
|
18
|
+
import { X_DEFAULT_PROFILE_IMAGE_URL, xWebPost, xWebProfile } from './x-twin.ts';
|
|
19
19
|
|
|
20
20
|
/** x.com's top-level routes that are not accounts (and the twin's own doors): never a profile. Each was
|
|
21
21
|
* observed as a route of its own at x.com (2026-09-28, logged out, `curl -A 'Mozilla/5.0' -o /dev/null
|
|
@@ -169,7 +169,22 @@ function header(title: string, back: boolean, sub = ''): string {
|
|
|
169
169
|
return `<div class="col-header">${back ? '<button type="button" class="back" onclick="history.back()" aria-label="Back">←</button>' : ''}<div><div class="col-title">${esc(title)}</div>${sub ? `<div class="muted col-sub">${esc(sub)}</div>` : ''}</div></div>`;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** X's default avatar as x.com draws it for an account without a photo — the image at
|
|
173
|
+
* `X_DEFAULT_PROFILE_IMAGE_URL`, redrawn inline because the twin claims no abs.twimg.com: a #657786
|
|
174
|
+
* head and shoulders on #ccd6dd (its colours and shape read off that PNG, 2026-09-28: the palette's
|
|
175
|
+
* background entry and the silhouette's pixels, head 124–276 × 88–256 and a shoulder circle centred
|
|
176
|
+
* at 200,409 radius 131 in its 400×400 rendition). */
|
|
177
|
+
const DEFAULT_AVATAR_SVG = '<svg viewBox="0 0 400 400" width="100%" height="100%" aria-hidden="true"><rect width="400" height="400" fill="#ccd6dd"/>'
|
|
178
|
+
+ '<path fill="#657786" d="M200 88C240 88 276 120 276 176C276 234 240 256 200 256C160 256 124 234 124 176C124 120 160 88 200 88Z"/>'
|
|
179
|
+
+ '<circle cx="200" cy="409" r="131" fill="#657786"/></svg>';
|
|
180
|
+
|
|
181
|
+
/** An account's picture: X's default avatar when it has none (its `profile_image_url` is the default,
|
|
182
|
+
* as the API answers it). A photo the account set is not drawn: the twin serves no profile photos
|
|
183
|
+
* (x.web.profile_photo, todo), so it keeps the initial on its hue. */
|
|
172
184
|
function avatar(user: XRow | undefined, size: number): string {
|
|
185
|
+
if (user?.profile_image_url === X_DEFAULT_PROFILE_IMAGE_URL) {
|
|
186
|
+
return `<div class="avatar avatar-default" style="width:${size}px;height:${size}px" data-src="${esc(X_DEFAULT_PROFILE_IMAGE_URL)}" aria-hidden="true">${DEFAULT_AVATAR_SVG}</div>`;
|
|
187
|
+
}
|
|
173
188
|
return `<div class="avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size * 0.42)}px;background:hsl(${avatarHue(user?.username)} 45% 38%)" aria-hidden="true">${esc(avatarInitial(user))}</div>`;
|
|
174
189
|
}
|
|
175
190
|
|
|
@@ -248,7 +263,7 @@ a{color:inherit;text-decoration:none}
|
|
|
248
263
|
.column{border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:100vh;min-width:0}
|
|
249
264
|
.col-header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:24px;min-height:53px;padding:0 16px;background:rgba(0,0,0,.65);backdrop-filter:blur(12px)}
|
|
250
265
|
.col-title{font-size:20px;line-height:24px;font-weight:700}.col-sub{font-size:13px;line-height:16px}.back{font-size:20px;border:0;background:none;color:inherit;cursor:pointer;padding:0}
|
|
251
|
-
.avatar{flex:none;border-radius:9999px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
|
|
266
|
+
.avatar{flex:none;border-radius:9999px;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
|
|
252
267
|
.name-line{display:flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}.display-name{font-weight:700;overflow:hidden;text-overflow:ellipsis}.badge{color:var(--blue);flex:none}
|
|
253
268
|
.post-row{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line)}.post-body{flex:1;min-width:0}
|
|
254
269
|
.post-text{white-space:pre-wrap;overflow-wrap:anywhere}.post-text-big{font-size:17px;line-height:24px;margin-top:12px}
|