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..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 @@ -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) { @@ -26,7 +31,6 @@ function getUserId(ele: HTMLElement) { function createRootElement() { const root = document.createElement('div') Object.assign(root.style, { - height: '100%', display: 'flex', alignItems: 'center', }) @@ -36,36 +40,43 @@ 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 - ele.style.flex = '1' - - 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() + // 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, }) root.render() - remover = root.destroy + mountedUserId = userId } run() return { onNodeMutation: run, onTargetChanged: run, - onRemove: remove, + onRemove: () => root?.destroy(), } }), signal, @@ -76,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 { @@ -89,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) @@ -97,7 +115,7 @@ const PostTipsSlot = memo(function PostTipsSlot({ userId }: Props) { if (!identity?.identifier) return null return ( - + { 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 } }> }