Bluesky URL Mention Sidebar

Display a sidebar with all mentions of the current URL on Bluesky, togglable via Alt+X, with logging, disabled in iframes, drag-resizeable, closeable, updates on navigation without monkey-patching, hidden if no mentions.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bluesky URL Mention Sidebar
// @namespace    http://tampermonkey.net/
// @version      3.2
// @description  Display a sidebar with all mentions of the current URL on Bluesky, togglable via Alt+X, with logging, disabled in iframes, drag-resizeable, closeable, updates on navigation without monkey-patching, hidden if no mentions.
//               ALSO if on a Bluesky profile page, show all that user's posts sorted by top.
// @match        *://*/*
// @exclude-match      *://localhost:*/*
// @exclude-match      *://127.0.0.1:*/*
// @grant        GM_xmlhttpRequest
// @connect      public.api.bsky.app
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(async function () {
    'use strict'

    if (window.self !== window.top) {
        console.log('[Bluesky Sidebar]: Running in iframe, script disabled')
        return
    }

    // Dynamically import dependencies
    const [React, ReactDOM, htm, bskyReactPost] = await Promise.all([
        import('https://esm.sh/react@19'),
        import('https://esm.sh/react-dom@19/client'),  // Changed to client import
        import('https://esm.sh/[email protected]'),
        import('https://esm.sh/[email protected]'),
    ])

    const html = htm.default.bind(React.default.createElement)
    const {EmbeddedPost: BskyPost} = bskyReactPost

    const localCSS = `
@import url('https://unpkg.com/[email protected]/index.esm.css');

:host {
  color: initial;
  font-size: 16px;
}

.bsky-react-post-theme {
  font-size: 16px;

  /* Header */
  --post-header-font-size: 0.9375em;
  --post-header-line-height: 1.25em;

  /* Text */
  --post-body-font-size: 1.25em;
  --post-body-line-height: 1.5em;

  /* Quoted Post */
  --post-quoted-container-margin: 0.75em 0;
  --post-quoted-body-font-size: 0.938em;
  --post-quoted-body-line-height: 1.25em;
  --post-quoted-body-margin: 0.25em 0 0.75em 0;

  /* Info */
  --post-info-font-size: 0.9375em;
  --post-info-line-height: 1.25em;

  /* Actions like the like, reply and copy buttons */
  --post-actions-font-size: 0.875em;
  --post-actions-line-height: 1em;
  --post-actions-icon-size: 1.25em;

  /* Reply button */
  --post-replies-font-size: 0.875em;
  --post-replies-line-height: 1em;
}
    
    
#bluesky-sidebar {
    position: fixed;
    top: 50px;
    right: 0;
    width: 450px;
    height: calc(100vh - 50px);
    background: #ffffff;
    border-left: 1px solid #e0e0e0;
    box-sizing: border-box;
    display: none;
    box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
    /*color: black;*/
    z-index: 10000;

    /* Make the entire sidebar a flex container so header stays at top */
    display: flex;
    flex-direction: column;
}

@media (prefers-color-scheme: dark) {
  #bluesky-sidebar {
    /*color: #ffffff;*/
  }
}

.bluesky-resize-handle {
    position: absolute;
    left: -3px;
    top: 0;
    width: 6px;
    height: 100%;
    cursor: ew-resize;
    background: transparent;
    z-index: 10001;
}
.bluesky-sidebar-header {
    flex: 0 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid #e0e0e0;
    padding: 16px;
}
.bluesky-sidebar-header > h2 {
    margin: 0;
    font-size: 22px;
}
.bluesky-sidebar-content {
    flex: 1 1 auto;
    overflow-y: auto;
    overflow-x: hidden;
    padding: 16px;
}
.bluesky-close-btn {
    background: transparent;
    border: none;
    font-size: 20px;
    cursor: pointer;
    color: #536471;
}
.bluesky-close-btn:hover {
    background: #f7f7f7;
    border-radius: 4px;
}
.bsky-react-post-theme {
    margin-bottom: 1em;
}
.bsky-react-post-theme a {
    text-decoration: none;
}
img, video {
    max-width: 100%;
    height: auto;
}
`
    const combinedCSS = localCSS

    // Components
    const Post = ({post}) => {
        return html`
            <${BskyPost} thread=${{post, parent: null, replies: []}}/>
        `
    }

    const Sidebar = ({posts, onClose, isLoading, width, handleResizeStart, sidebarRef}) => {
        return html`
            <div id="bluesky-sidebar" ref=${sidebarRef} style=${{width: `${width}px`}}>
                <div className="bluesky-resize-handle" onMouseDown=${handleResizeStart}></div>
                <div className="bluesky-sidebar-header">
                    <h2>Bluesky Mentions</h2>
                    <button className="bluesky-close-btn" onClick=${onClose}>×</button>
                </div>
                <div className="bluesky-sidebar-content">
                    ${
                            isLoading
                                    ? html`<p>Loading...</p>`
                                    : posts.length
                                            ? posts.map(post => html`
                                                <${Post} key=${post.uri} post=${post}/>`)
                                            : html`<p>No mentions found.</p>`
                    }
                </div>
            </div>
        `
    }

    const getPosts = (data) => {
        if (!data?.posts) return []
        return data.posts.map(post => ({
            ...post,
            author: {
                ...post.author,
                labels: post.author.labels?.filter(l => l.val !== "!no-unauthenticated"),
            },
        }))
    }

    const App = () => {
        const [posts, setPosts] = React.default.useState([])
        const [isLoading, setIsLoading] = React.default.useState(false)
        const [isVisible, setIsVisible] = React.default.useState(false)
        const [width, setWidth] = React.default.useState(450)
        const sidebarRef = React.default.useRef(null)
        const lastUrl = React.default.useRef(window.location.href)

        const fetchMentions = React.default.useCallback(async () => {
            setIsLoading(true)
            const handle = await extractProfileHandle()
            const query = handle ? `from:${handle}` : window.location.href
            const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&sort=top`
            console.log('[Bluesky Sidebar]: Fetching mentions:', apiUrl)

            try {
                const response = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: apiUrl,
                        headers: {'Accept': 'application/json'},
                        onload: resolve,
                        onerror: reject,
                    })
                })

                const data = JSON.parse(response.responseText)
                const newPosts = getPosts(data)
                setPosts(newPosts)

                // Only show sidebar if we got some results
                if (newPosts.length > 0) {
                    setIsVisible(true)
                }
            } catch (error) {
                console.error('[Bluesky Sidebar]: Error fetching mentions:', error)
                setPosts([])
            } finally {
                setIsLoading(false)
            }
        }, [])

        React.default.useEffect(() => {
            fetchMentions()
        }, [])

        React.default.useEffect(() => {
            const handleKeyDown = (e) => {
                // Alt+X toggles the sidebar
                if (e.altKey && e.code === 'KeyX') {
                    setIsVisible(v => !v)
                }
            }

            const checkUrlChange = () => {
                if (window.location.href !== lastUrl.current) {
                    lastUrl.current = window.location.href
                    fetchMentions()
                }
            }

            document.addEventListener('keydown', handleKeyDown)
            window.addEventListener('popstate', checkUrlChange)

            // Fallback interval to detect SPA navigations
            const interval = setInterval(checkUrlChange, 1000)

            return () => {
                document.removeEventListener('keydown', handleKeyDown)
                window.removeEventListener('popstate', checkUrlChange)
                clearInterval(interval)
            }
        }, [fetchMentions, posts.length])

        // Handle drag-resizing
        const handleResizeStart = React.default.useCallback((e) => {
            const startX = e.clientX
            const startWidth = width

            const handleMouseMove = (e) => {
                const deltaX = startX - e.clientX
                const newWidth = Math.max(startWidth + deltaX, 150)
                setWidth(newWidth)
            }

            const handleMouseUp = () => {
                document.removeEventListener('mousemove', handleMouseMove)
                document.removeEventListener('mouseup', handleMouseUp)
            }

            document.addEventListener('mousemove', handleMouseMove)
            document.addEventListener('mouseup', handleMouseUp)
        }, [width])

        // Render nothing if sidebar isn't visible
        if (!isVisible) return null

        return html`
            <${Sidebar}
                    posts=${posts}
                    isLoading=${isLoading}
                    onClose=${() => setIsVisible(false)}
                    width=${width}
                    handleResizeStart=${handleResizeStart}
                    sidebarRef=${sidebarRef}
            />
        `
    }

    async function extractProfileHandle() {
        // If we are on a Bluesky profile page, we'll show that user's top posts
        if (window.location.host !== 'bsky.app') return null
        const match = window.location.pathname.match(/\/profile\/([^/]+)$/)
        const rawHandle = match?.[1] ? decodeURIComponent(match[1]) : null
        return isDidPlc(rawHandle) ? fetchHandleFromDidPlc(rawHandle) : rawHandle
    }

    const isDidPlc = handle => /^did:plc:[a-zA-Z0-9]{24}$/.test(handle)

    function fetchHandleFromDidPlc(didPlc) {
        const url = `https://plc.directory/${didPlc}`;

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                headers: {
                    'Accept': 'application/json',
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            const alsoKnownAs = data.alsoKnownAs;
                            if (Array.isArray(alsoKnownAs) && alsoKnownAs.length > 0) {
                                // Extract the handle from the at:// URI
                                const handle = alsoKnownAs[0].replace('at://', '');
                                resolve(handle);
                            } else {
                                reject(new Error('No associated handle found.'));
                            }
                        } catch (error) {
                            reject(new Error('Error parsing response JSON.'));
                        }
                    } else {
                        reject(new Error(`Request failed with status: ${response.status}`));
                    }
                },
                onerror: function() {
                    reject(new Error('Network error occurred.'));
                }
            });
        });
    }

    // --- Create a Shadow DOM and render the app there ---
    const hostEl = document.createElement('div')
    hostEl.id = 'bluesky-host'
    document.body.appendChild(hostEl)

    const shadowRoot = hostEl.attachShadow({mode: 'open'})

    // Inject combined CSS inside shadow root instead of the main document
    const styleEl = document.createElement('style')
    styleEl.textContent = combinedCSS
    shadowRoot.appendChild(styleEl)

    // Create container for React
    const containerEl = document.createElement('div')
    containerEl.id = 'bluesky-root'
    shadowRoot.appendChild(containerEl)

    // Render the React app into the shadow root
    const reactRoot = ReactDOM.default.createRoot(containerEl)
    reactRoot.render(React.default.createElement(App))
})()