Tokimeki DID Copy Plus

Adds "Copy URL with DID" to the post menu on TOKIMEKI(Bluesky client).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           Tokimeki DID Copy Plus
// @namespace      https://bsky.app/profile/neon-ai.art
// @homepage       https://neon-aiart.github.io/
// @icon           data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌈</text></svg>
// @version        1.1
// @description    Adds "Copy URL with DID" to the post menu on TOKIMEKI(Bluesky client).
// @description:ja TOKIMEKIのポストのメニューに「DIDでURLをコピー」を追加
// @author         ねおん
// @match          https://tokimeki.blue/*
// @grant          GM_addStyle
// @grant          GM_getValue
// @grant          GM_setValue
// @grant          GM_registerMenuCommand
// @grant          GM_unregisterMenuCommand
// @license        PolyForm Noncommercial 1.0.0; https://polyformproject.org/licenses/noncommercial/1.0.0/
// ==/UserScript==

/**
 * ==============================================================================
 * IMPORTANT NOTICE / 重要事項
 * ==============================================================================
 * Copyright (c) 2025 ねおん (Neon)
 * Licensed under the PolyForm Noncommercial License 1.0.0.
 * * [JP] 本スクリプトは個人利用・非営利目的でのみ使用・改変が許可されます。
 * 無断転載、作者名の書き換え、およびクレジットの削除は固く禁じます。
 * 本スクリプトを改変・配布(フォーク)する場合は、必ず元の作者名(ねおん)
 * およびこのクレジット表記を維持してください。
 * * [EN] This script is licensed for personal and non-commercial use only.
 * Unauthorized re-uploading, modification of authorship, or removal of
 * author credits is strictly prohibited. If you fork this project, you MUST
 * retain the original credits and authorship.
 * ==============================================================================
 */

(function() {
    'use strict';

    const VERSION = '1.1';
    const STORE_KEY = 'tokimeki_copy_plus';

    // ========= グローバル変数 =========
    let toastTimeoutId = null;
    // ① メニュー要素のセレクタ
    const MENU_SELECTOR = 'dialog.timeline-menu';
    // ② 投稿要素のコンテナセレクタ
    const POST_CONTAINER_SELECTOR = 'article.timeline__item';
    // ③ メニューリスト要素: 新しいボタンを挿入するUL要素
    const MENU_LIST_SELECTOR = 'ul.timeline-menu-list';
    // ④ 独自のボタンを識別するためのクラス
    const CUSTOM_BUTTON_CLASS = 'did-copy-button';
    // ⑤ 既存のメニューボタンクラス(スタイル合わせ用)
    const BASE_BUTTON_CLASS = 'timeline-menu-list__button';
    // 赤色とホバーのスタイルを持つクラス
    const DANGER_COLOR_CLASS = 'timeline-menu-list__item--delete';

    // ========= 設定 =========

    // スタイル定義(GM_addStyle)
    const COPY_SVG = `
        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-icon lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
    `;
    GM_addStyle(`
        /* Font Awesome 6 Free */
        @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css');
        /* Google Material Symbols & Icons (Rounded) */
        @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,[email protected],100..700,0..1,-50..200');
        /* Lucide Icons */
        @import url('https://cdn.jsdelivr.net/npm/lucide-static/icons/link.svg');
        /* アイコンのstrokeを直接赤色(var(--danger-color))に固定 */
        li.${CUSTOM_BUTTON_CLASS}-li.${DANGER_COLOR_CLASS} button.${CUSTOM_BUTTON_CLASS} svg {
            stroke: var(--danger-color) !important;
        }
    `);

    // 言語判定
    const getI18n = () => {
        const isJapanese = document.documentElement.lang === 'ja';
        return {
            buttonLabel: isJapanese ? 'DIDでURLをコピー' : 'Copy DID-based URL',
            successMsg: isJapanese ? 'DIDベースのURLをコピーしました!' : 'DID-based URL copied!',
            errorMsg: isJapanese ? 'コピーに失敗しました。' : 'Failed to copy URL.'
        };
    };

    // ========= トーストメッセージ =========
    function showToast(msg, isSuccess) {
        const toastId = 'tcp-toast-mei';
        console.log(`[TOAST] ${msg}`);

        if (toastTimeoutId) {
            clearTimeout(toastTimeoutId);
            toastTimeoutId = null;
        }

        // 20ms遅延させて、重いDOM操作中のレンダリング競合を回避
        setTimeout(() => {
            const existingToast = document.getElementById(toastId);
            if (existingToast) {
                existingToast.remove();
            }

            const toast = document.createElement('div');
            toast.textContent = msg;
            toast.id = toastId;
            toast.classList.add('tcp-toast-mei');

            let bgColor;
            if (isSuccess === true) {
                bgColor = '#007bff';
            } else if (isSuccess === false) {
                bgColor = '#dc3545';
            } else {
                bgColor = '#6c757d';
            }

            toast.style.cssText = `
                position: fixed; bottom: 0px; left: 50%; transform: translateX(-50%);
                background: ${bgColor}; color: white; padding: 4px 20px;
                border-radius: 14px; z-index: 100000;
                height: 24px;
                font-size: 14px; transition: opacity 1.0s ease, transform 1.0s ease; opacity: 0;
            `;
            toast.style.display = 'flex';          // Flexbox有効化
            toast.style.alignItems = 'center';     // 垂直方向の中央揃え
            toast.style.justifyContent = 'center'; // 水平方向の中央揃え
            document.body.appendChild(toast);

            // フェードインアニメーションを起動
            setTimeout(() => {
                toast.style.opacity = '1';
                toast.style.transform = 'translate(-50%, -16px)';
            }, 10);

            // 自動非表示ロジック
            if (isSuccess !== null) {
                toastTimeoutId = setTimeout(() => {
                    toast.style.opacity = '0';
                    toast.style.transform = 'translate(-50%, 0)';
                    setTimeout(() => {
                        if (document.body.contains(toast)) {
                            toast.remove();
                        }
                        if (toastTimeoutId) {
                            toastTimeoutId = null;
                        }
                    }, 1000);
                }, 3000);
            }
        }, 20);
    }

    // ====================================
    // ユーティリティ関数
    // ====================================

    // AT URIをBlueskyの永続URLに変換
    function atUriToUrl(atUri) {
        if (!atUri || !atUri.startsWith('at://')) return null;

        // at://did:plc:xxxx/app.bsky.feed.post/rkey の形式を想定
        const parts = atUri.replace('at://', '').split('/');
        if (parts.length !== 3 || parts[1] !== 'app.bsky.feed.post') return null;

        const did = parts[0];
        const rkey = parts[2];

        // Bluesky公式クライアントのURL形式
        return `https://bsky.app/profile/${did}/post/${rkey}`;
    }

    // ====================================
    // メインロジック
    // ====================================

    function addCopyIconToMenu(menuDialog) {
        // メニューリスト要素を取得
        const menuList = menuDialog.querySelector(MENU_LIST_SELECTOR);
        if (!menuList) {
            console.warn('メニューリストが見つかりません。');
            return;
        }

        // 既にアイコンが追加されていないかチェック
        if (menuList.querySelector(`.${CUSTOM_BUTTON_CLASS}`)) return;

        let atUri = null;

        // 1. メニューダイアログの「親」から見て、自分(menuDialog)の直前にあるトグルボタンを探す
        // ダイアログ自体が article の直下に置かれる構造を利用します
        const allToggles = Array.from(menuDialog.parentElement.querySelectorAll('.timeline-menu-toggle'));
        const menuToggle = allToggles.find(toggle => toggle.nextElementSibling === menuDialog);

        if (menuToggle) {
            // 2. 見つかったトグルボタンの「直前の兄弟要素」が、そのメニューが対応するポストの内容です
            const targetContent = menuToggle.previousElementSibling;
            if (targetContent && targetContent.classList.contains('timeline__content')) {
                atUri = targetContent.dataset.aturi;
                console.log('[TCP] 隣接要素から atUri を特定しました:', atUri); // デバッグ用
            }
        }

        // 3. フォールバック(念のため)
        if (!atUri) {
            const postContainer = menuDialog.closest(POST_CONTAINER_SELECTOR);
            if (postContainer) {
                // 返信の場合は「最後(一番下)」にある content が自分のポストであることが多いです
                const contents = postContainer.querySelectorAll('div.timeline__content[data-aturi]');
                const atUriElement = contents[contents.length - 1]; // 一番最後を取得
                atUri = atUriElement ? atUriElement.dataset.aturi : null;
            }
        }

        if (!atUri) {
            console.debug('[TCP] atUriが見つかりませんでした。');
            return;
        }

        const urlToCopy = atUriToUrl(atUri);
        if (!urlToCopy) {
            console.error('atUriから有効なURLを生成できませんでした:', atUri);
            return;
        }

        // 最新の言語設定を取得
        const i18n = getI18n();

        // 既存のメニュー項目からスタイルを継承するためのLi要素を取得
        const existingItemLi = menuList.querySelector('li.timeline-menu-list__item');

        // 新しいメニュー項目 (<li>) を作成
        const newLi = document.createElement('li');
        newLi.className = (existingItemLi ? existingItemLi.className : 'timeline-menu-list__item')
                          + ' ' + CUSTOM_BUTTON_CLASS + '-li'
                          + ' ' + DANGER_COLOR_CLASS;

        // ボタンの作成
        const newButton = document.createElement('button');
        newButton.className = BASE_BUTTON_CLASS + ' ' + CUSTOM_BUTTON_CLASS;
        newButton.setAttribute('role', 'menuitem');

        // アイコンとテキスト
        newButton.innerHTML = `
            ${COPY_SVG}
            <span class="text-danger">${i18n.buttonLabel}</span>
        `;

        newLi.appendChild(newButton);

        newButton.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();

            navigator.clipboard.writeText(urlToCopy)
                .then(() => {
                    showToast(i18n.successMsg, true);
                })
                .catch(err => {
                    showToast(i18n.errorMsg, false);
                    console.error('クリップボード操作エラー:', err);
                });

            // メニューを閉じる処理(dialog要素を削除)
            menuDialog.remove();
        });

        // 挿入位置: URLをコピー項目の「下」に挿入する
        const copyUrlLi = menuList.querySelector('.timeline-menu-list__item--copy-url');

        if (copyUrlLi) {
            menuList.insertBefore(newLi, copyUrlLi.nextSibling);
        } else {
            menuList.appendChild(newLi); // 無ければリストの最後に追加
        }
    }

    // MutationObserverの設定
    const observer = new MutationObserver((mutationsList, observer) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length) {
                for (const node of mutation.addedNodes) {
                    // 要素ノードでない場合はスキップ
                    if (node.nodeType !== 1) continue;

                    // メニュー要素を特定
                    const menu = node.matches(MENU_SELECTOR) ? node : node.querySelector(MENU_SELECTOR);

                    if (menu) {
                        addCopyIconToMenu(menu);
                    }
                }
            }
        }
    });

    // 監視を開始
    observer.observe(document.body, { childList: true, subtree: true });

    // GM_registerMenuCommand('キー設定', openSettings);

})();