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 } }>
}