From 49d5645c7aa37b15c7e48b73bdbe09f33eaa9ed0 Mon Sep 17 00:00:00 2001 From: Wukong Sun Date: Mon, 12 Oct 2026 15:22:15 +0800 Subject: [PATCH 1/4] fix: mf-6844 tips slot displaced the share button even when hidden --- .../twitter.com/injection/Tips/PostTipsButton.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx index 3d098664610..0c90a3a4905 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx +++ b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx @@ -51,7 +51,9 @@ export function injectTipsButtonOnPost(signal: AbortSignal) { afterShadowRootInit: Flags.shadowRootInit, }) proxy.realCurrent = ele - ele.style.flex = '1' + // Don't set flex on the share wrapper: X sizes it to its content, flush right. + // flex:1 turns it into an extra growing item, left-aligning the share icon and + // leaving dead space at the right end of the action bar (mf-6844). const root = attachReactTreeWithContainer(proxy.afterShadow, { signal, From 68b48c2b80eff0f3e9f7d7654bfad4a5c2e556cb Mon Sep 17 00:00:00 2001 From: Wukong Sun Date: Mon, 12 Oct 2026 17:08:44 +0800 Subject: [PATCH 2/4] fix: mf-6845 tolerate degraded twitter userinfo payloads in getUserInfo --- packages/web3-providers/src/Firefly/Twitter.ts | 15 ++++++++++++--- packages/web3-providers/src/types/Firefly.ts | 10 ++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/web3-providers/src/Firefly/Twitter.ts b/packages/web3-providers/src/Firefly/Twitter.ts index 8e47a457125..80e3e3f3d47 100644 --- a/packages/web3-providers/src/Firefly/Twitter.ts +++ b/packages/web3-providers/src/Firefly/Twitter.ts @@ -3,15 +3,24 @@ import { fetchJSON } from '../helpers/fetchJSON.js' import type { FireflyTwitterAPI } from '../types/Firefly.js' import { FIREFLY_BASE_URL } from './constants.js' +/** A `User` result that actually carries the `legacy` payload callers dereference. */ +export type AvailableTwitterUser = FireflyTwitterAPI.TwitterUserInfo & { + legacy: FireflyTwitterAPI.LegacyUserInfo +} + export const FireflyTwitter = { - async getUserInfo(screenName: string) { + async getUserInfo(screenName: string): Promise { if (!screenName) return null const url = urlcat(FIREFLY_BASE_URL, '/v1/twitter/userinfo', { screenName, }) const res = await fetchJSON(url) - if (res.code === 0 && res.data.data.user.result.__typename === 'User') return res.data.data.user.result - return null + if (res.code !== 0) return null + // `{code: 0, data: {}}` (live fetch failed, nothing cached) and `User` results without + // `legacy` both used to crash callers reading `user.legacy.*` — treat them as "no user". + const user = res.data.data?.user?.result + if (user?.__typename !== 'User' || !user.legacy) return null + return { ...user, legacy: user.legacy } }, async getUserInfoById(twitterId: string) { diff --git a/packages/web3-providers/src/types/Firefly.ts b/packages/web3-providers/src/types/Firefly.ts index ac505cbe73b..56925c283af 100644 --- a/packages/web3-providers/src/types/Firefly.ts +++ b/packages/web3-providers/src/types/Firefly.ts @@ -531,7 +531,8 @@ export namespace FireflyTwitterAPI { has_graduated_access: boolean is_blue_verified: boolean profile_image_shape: string - legacy: LegacyUserInfo + /** Absent on degraded `User` results (see TwitterUserInfoResponse). */ + legacy?: LegacyUserInfo professional?: { rest_id: string professional_type: string @@ -562,7 +563,12 @@ export namespace FireflyTwitterAPI { creator_subscriptions_count: number } - export type TwitterUserInfoResponse = FireflyResponse<{ data: { user: { result: TwitterUserInfo } } }> + /** + * The proxied GraphQL payload degrades: the backend replies `{code: 0, data: {}}` when the + * live fetch fails with no cached snapshot, and suspended/rate-limited lookups can come back + * as a `User` result without `legacy`. Every hop is therefore optional on the wire. + */ + export type TwitterUserInfoResponse = FireflyResponse<{ data?: { user?: { result?: TwitterUserInfo } } }> export type TwitterUserV2Response = FireflyResponse<{ result: { legacy: LegacyUserInfo } }> } From 520b8712b87d7a65ac5176ad530e6e3012a576bb Mon Sep 17 00:00:00 2001 From: Wukong Sun Date: Mon, 12 Oct 2026 21:59:33 +0800 Subject: [PATCH 3/4] fix: mf-6846 post tips button flickered: self-unmatching selector + per-mutation remount --- .../injection/Tips/PostTipsButton.tsx | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx index 0c90a3a4905..77d63333a0a 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx +++ b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx @@ -1,8 +1,6 @@ -import { DOMProxy, MutationObserverWatcher } from '@dimensiondev/holoflows-kit' -import { Flags } from '@masknet/flags' +import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' import { Plugin, createInjectHooksRenderer, useActivatedPluginsSiteAdaptor } from '@masknet/plugin-infra/content-script' -import { makeStyles } from '@masknet/theme' -import { noop } from 'lodash-es' +import { makeStyles, type ReactRootShadowed } from '@masknet/theme' import { memo, useState } from 'react' import { useThemeSettings } from '../../../../components/DataSource/useActivatedUI.js' import { attachReactTreeWithContainer } from '../../../../utils/shadow-root/renderInShadowRoot.js' @@ -12,7 +10,14 @@ import { querySelectorAll } from '../../utils/selector.js' import { useUserIdentity } from './hooks.js' function postShareButtonSelector() { - return querySelectorAll('article[data-testid="tweet"] [role="group"] > div:has([aria-haspopup="menu"]):last-child') + // The share wrapper is the action-bar cell whose menu button carries no data-testid (the + // retweet button also has aria-haspopup="menu" but data-testid="retweet"). + // Never match by :last-child here: the injected tips span is appended right after the + // wrapper, which un-matches a :last-child selector and makes the watcher add/remove the + // slot forever — the button flickers in/out at MutationObserver cadence. + return querySelectorAll( + 'article[data-testid="tweet"] [role="group"] > div:has(button[aria-haspopup="menu"]:not([data-testid]))', + ) } function getUserId(ele: HTMLElement) { @@ -36,38 +41,37 @@ function createRootElement() { export function injectTipsButtonOnPost(signal: AbortSignal) { const watcher = new MutationObserverWatcher(postShareButtonSelector()) startWatch( - watcher.useForeach((ele) => { - let remover = noop - const remove = () => remover() + watcher.useForeach((ele, _, proxy) => { + let root: ReactRootShadowed | undefined + let mountedUserId: string | undefined - const run = async () => { + const run = () => { + // `ele` is the watcher proxy's live node: reads follow the current DOM even + // after X replaces the action bar, and the proxy re-anchors itself then. const userId = getUserId(ele) + // The avatar may not exist yet when the action bar first matches; retry on + // the next mutation. if (!userId) return - // onNodeMutation/onTargetChanged re-run this for the same `ele`. A fresh DOMProxy() - // here creates a brand-new shadow-root sibling every time, so the previous one must - // be torn down first or it leaks a live React tree on every mutation of this post. - remove() - const proxy = DOMProxy({ - afterShadowRootInit: Flags.shadowRootInit, - }) - proxy.realCurrent = ele - // Don't set flex on the share wrapper: X sizes it to its content, flush right. - // flex:1 turns it into an extra growing item, left-aligning the share icon and - // leaving dead space at the right end of the action bar (mf-6844). - - const root = attachReactTreeWithContainer(proxy.afterShadow, { + // Tweets mutate constantly (counters, virtualization, hover states) and every + // mutation used to tear the whole React tree down and remount it, so the + // rendered button vanished while the fresh tree re-resolved identity/accounts + // — and a teardown racing a node replacement could leave the live slot empty + // with the tree orphaned off-DOM. Remount only when the author changes. + if (userId === mountedUserId) return + root?.destroy() + root = attachReactTreeWithContainer(proxy.afterShadow, { signal, tag: createRootElement, }) root.render() - remover = root.destroy + mountedUserId = userId } run() return { onNodeMutation: run, onTargetChanged: run, - onRemove: remove, + onRemove: () => root?.destroy(), } }), signal, From 197a5125d12b17ed67eaab41b986904352e70e78 Mon Sep 17 00:00:00 2001 From: Wukong Sun Date: Mon, 12 Oct 2026 22:52:29 +0800 Subject: [PATCH 4/4] fix: mf-6846 self-center the tips slot and add X-native icon spacing --- .../injection/Tips/PostTipsButton.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx index 77d63333a0a..ccbeb0d719a 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx +++ b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx @@ -31,7 +31,6 @@ function getUserId(ele: HTMLElement) { function createRootElement() { const root = document.createElement('div') Object.assign(root.style, { - height: '100%', display: 'flex', alignItems: 'center', }) @@ -59,6 +58,12 @@ export function injectTipsButtonOnPost(signal: AbortSignal) { // with the tree orphaned off-DOM. Remount only when the author changes. if (userId === mountedUserId) return root?.destroy() + // Center the slot in the action bar on its own instead of stretching to the + // row height and re-centering the content: X varies the row geometry between + // the main post and compact replies, and the stretch + height:100% chain used + // to leave the coin hanging below the icon line on some of them. The spacing + // lives on PostTipsSlot's span (below) so it collapses with the hidden button. + proxy.after.style.alignSelf = 'center' root = attachReactTreeWithContainer(proxy.afterShadow, { signal, tag: createRootElement, @@ -82,6 +87,13 @@ const useStyles = makeStyles()(() => ({ disabled: { display: 'none', }, + // Spacing must sit on this span — it is display:none while the button resolves, so + // tips-less posts keep the stock action-bar layout. The left margin mirrors X's own + // ~12px gap between action icons; no right margin — the coin's right edge then lines + // up with where the share glyph ends on tips-less posts. + slot: { + marginLeft: 12, + }, })) interface Props { @@ -95,7 +107,7 @@ const Component = createInjectHooksRenderer( const PostTipsSlot = memo(function PostTipsSlot({ userId }: Props) { const themeSetting = useThemeSettings() const tipStyle = TipButtonStyle[themeSetting.size] - const { classes } = useStyles() + const { classes, cx } = useStyles() const identity = useUserIdentity(userId) const [disabled, setDisabled] = useState(true) @@ -103,7 +115,7 @@ const PostTipsSlot = memo(function PostTipsSlot({ userId }: Props) { if (!identity?.identifier) return null return ( - +