// ==UserScript==
// @name Bluesky⇔Tokimeki 切り替え
// @namespace https://bsky.app/profile/neon-ai.art
// @homepage https://bsky.app/profile/neon-ai.art
// @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 7.5
// @description BlueskyとTokimekiのURLを、ボタン、キーボードショートカット、右クリックメニューで切り替え。
// @author ねおん
// @match https://bsky.app/*
// @match https://tokimeki.blue/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @license CC BY-NC 4.0
// ==/UserScript==
(function() {
'use strict';
// --- バージョン情報 ---
const SCRIPT_VERSION = "7.5";
// 設定を保存するキー
const CONFIG_KEY = 'bskyTokimekiConfig';
// 現在のドメインを判定
function getCurrentDomain() {
if (window.location.hostname.includes('bsky.app')) {
return 'bsky';
} else if (window.location.hostname.includes('tokimeki.blue')) {
return 'tokimeki';
}
return null;
}
// --- カスタムアラート関数 ---
function showMessage(message) {
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: '99999',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontFamily: 'Inter, sans-serif'
});
const container = document.createElement('div');
Object.assign(container.style, {
backgroundColor: '#1a1a1a',
color: '#f0f0f0',
padding: '20px',
borderRadius: '12px',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.2)',
textAlign: 'center',
maxWidth: '90%',
width: '300px',
border: '1px solid #333'
});
container.innerHTML = `
<p style="margin: 0 0 15px; font-size: 16px;">${message}</p>
<button class="close-button" style="padding: 10px 20px; border-radius: 6px; border: none; background-color: #007bff; color: white; font-weight: bold; cursor: pointer; transition: all 0.2s ease;">閉じる</button>
`;
container.querySelector('.close-button').addEventListener('click', () => {
document.body.removeChild(overlay);
});
overlay.appendChild(container);
document.body.appendChild(overlay);
}
/**
* bsky.appで現在選択中のタブのパスを取得する関数
* @returns {string|null} tokimeki.blueで対応するパス名、または見つからない場合はnull
*/
function getActiveBskyTabPath() {
const tabMappings = {
'メディア': '/media', 'Media': '/media',
'ビデオ': '/video', 'Videos': '/video',
'いいね': '/likes', 'Likes': '/likes',
'フィード': '/feed', 'Feeds': '/feed',
'リスト': '/lists', 'Lists': '/lists'
};
// 選択中のタブを示す青い下線のstyle属性を直接探す
const selectedIndicator = document.querySelector('[style*="background-color: rgb(16, 131, 254)"]');
if (selectedIndicator) {
// 下線要素の親要素(タブ本体)を取得
const tabContentElement = selectedIndicator.parentElement;
if (tabContentElement && tabContentElement.dataset.testid && tabContentElement.dataset.testid.startsWith('profilePager-')) {
const testId = tabContentElement.getAttribute('data-testid');
const activeTabName = testId.replace('profilePager-', '');
if (tabMappings[activeTabName]) {
console.log(`アクティブなbskyタブを検出: ${activeTabName}`);
return tabMappings[activeTabName];
}
}
}
console.log('アクティブなbskyタブは検出されませんでした(投稿タブなど)。');
return null;
}
// --- URL切り替え処理 ---
function switchUrl() {
const currentUrl = window.location.href;
let newUrl = currentUrl;
let bskyTabToClick = null;
// bsky.app → tokimeki.blue
if (currentUrl.includes('bsky.app')) {
// プロフィールページの場合、アクティブなタブのパスを追加
if (currentUrl.includes('/profile/')) {
const activeTabPath = getActiveBskyTabPath();
if (activeTabPath) {
const urlObject = new URL(currentUrl);
// 投稿、フィード、リストの詳細ページではないことを確認
if (!urlObject.pathname.match(/\/(post|feed|lists)\//)) {
// クエリパラメータやハッシュを維持しつつ、パスの末尾に追加
newUrl = `${urlObject.origin}${urlObject.pathname.replace(/\/$/, '')}${activeTabPath}${urlObject.search}${urlObject.hash}`;
}
}
}
// 設定ページの場合、特定のURLに変換
if (currentUrl.includes('/settings')) {
newUrl = 'https://tokimeki.blue/settings/general';
}
// メッセージページの場合、特定のURLに変換
else if (currentUrl.includes('/messages')) {
newUrl = 'https://tokimeki.blue/chat';
}
// フォローリストのパスを変換
else if (currentUrl.endsWith('/follows')) {
newUrl = newUrl.slice(0, -8) + '/follow';
}
// フォロワーリストのパスを変換
else if (currentUrl.endsWith('/followers')) {
newUrl = newUrl.slice(0, -10) + '/follower';
}
// ドメインをtokimeki.blueに切り替え
newUrl = newUrl.replace('bsky.app', 'tokimeki.blue');
// bskyのハッシュタグをtokimekiの検索クエリに変換
newUrl = newUrl.replace(/hashtag\/([^/]+)/, 'search?q=%23$1');
// bskyの?author=をtokimekiのfrom:に変換
newUrl = newUrl.replace(/\?author=([^&]+)/, ' from:$1');
// bsky.appで末尾がquotesの場合は削除
if (currentUrl.endsWith('quotes')) {
newUrl = newUrl.slice(0, -7);
}
console.log("URLを切り替えます:", newUrl);
window.location.href = newUrl;
// tokimeki.blue → bsky.app
} else if (currentUrl.includes('tokimeki.blue')) {
// 設定ページの場合、特定のURLに変換
if (currentUrl.includes('/settings/')) {
newUrl = 'https://bsky.app/settings';
}
// チャットページの場合、特定のURLに変換
else if (currentUrl.includes('/chat')) {
newUrl = 'https://bsky.app/messages';
}
// tokimeki.blue独自のプロフィールページパスを検出
const profileMatch = currentUrl.match(/\/profile\/[^/]+\/(media|video|likes|feed|lists)$/);
if (profileMatch) {
const tab = profileMatch[1];
// bsky.appのプロフィールURLに変換
newUrl = newUrl.replace(/\/(media|video|likes|feed|lists)$/, '');
// タブを切り替えるフラグを設定
if (tab === 'media') bskyTabToClick = 'media';
else if (tab === 'video') bskyTabToClick = 'video';
else if (tab === 'likes') bskyTabToClick = 'likes';
else if (tab === 'feed') bskyTabToClick = 'feeds';
else if (tab === 'lists') bskyTabToClick = 'lists';
}
// フォローリストのパスを変換
else if (currentUrl.endsWith('/follow')) {
newUrl = newUrl.slice(0, -7) + '/follows';
}
// フォロワーリストのパスを変換
else if (currentUrl.endsWith('/follower')) {
newUrl = newUrl.slice(0, -9) + '/followers';
}
// ドメインをbsky.appに切り替え
newUrl = newUrl.replace('tokimeki.blue', 'bsky.app');
console.log("URLを切り替えます:", newUrl);
// タブを切り替える必要があれば、URLにパラメータを付加
if (bskyTabToClick) {
newUrl += '?switchTab=' + bskyTabToClick;
}
window.location.href = newUrl;
} else {
showMessage('bsky.app または tokimeki.blue ではありません。');
console.log("無効なURLです:", currentUrl);
}
}
// ブラウザの言語設定を取得する
function getBrowserLanguage() {
const lang = navigator.language || navigator.userLanguage;
return lang.startsWith('ja') ? 'ja' : 'en';
}
// --- bsky.app側のページロード後の処理 ---
function handleBskyPageLoad() {
const urlParams = new URLSearchParams(window.location.search);
const tabToClick = urlParams.get('switchTab');
if (tabToClick) {
console.log(`BSkyのタブを切り替えます: ${tabToClick}`);
const language = getBrowserLanguage();
let tabName = null;
if (language === 'ja') {
switch (tabToClick) {
case 'media': tabName = 'メディア'; break;
case 'video': tabName = 'ビデオ'; break;
case 'likes': tabName = 'いいね'; break;
case 'feeds': tabName = 'フィード'; break;
case 'lists': tabName = 'リスト'; break;
}
} else {
switch (tabToClick) {
case 'media': tabName = 'Media'; break;
case 'video': tabName = 'Videos'; break;
case 'likes': tabName = 'Likes'; break;
case 'feeds': tabName = 'Feeds'; break;
case 'lists': tabName = 'Lists'; break;
}
}
if (tabName) {
const selector = `div[data-testid="profilePager-${tabName}"]`;
const observer = new MutationObserver((mutations, obs) => {
const tabElement = document.querySelector(selector);
if (tabElement) {
tabElement.click();
console.log(`タブをクリックしました: ${tabName}`);
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
const newUrl = window.location.href.split('?')[0];
window.history.replaceState({}, document.title, newUrl);
}
}
// --- 設定の読み込みと保存 ---
function getConfig() {
const defaultConfig = {
global: {
enabled: true,
shortcutKey: 'Ctrl+Shift+K'
},
bsky: {
buttonPosition: 'bottom-left',
buttonPadding: 60
},
tokimeki: {
buttonPosition: 'bottom-left',
buttonPadding: 60
}
};
const savedConfig = GM_getValue(CONFIG_KEY);
let currentConfig;
try {
currentConfig = savedConfig ? JSON.parse(savedConfig) : defaultConfig;
if (!currentConfig.global) currentConfig.global = defaultConfig.global;
if (!currentConfig.bsky) currentConfig.bsky = defaultConfig.bsky;
if (!currentConfig.tokimeki) currentConfig.tokimeki = defaultConfig.tokimeki;
} catch (e) {
console.error("設定の読み込みに失敗しました。デフォルト設定を使用します。", e);
currentConfig = defaultConfig;
}
const domain = getCurrentDomain();
return {
...currentConfig.global,
...(domain ? currentConfig[domain] : {})
};
}
function saveConfig(config) {
let savedData;
try {
const savedConfig = GM_getValue(CONFIG_KEY);
savedData = savedConfig ? JSON.parse(savedConfig) : {};
} catch (e) {
savedData = {};
}
const domain = getCurrentDomain();
savedData.global = {
enabled: config.enabled,
shortcutKey: config.shortcutKey
};
if (domain) {
savedData[domain] = {
buttonPosition: config.buttonPosition,
buttonPadding: config.buttonPadding
};
}
GM_setValue(CONFIG_KEY, JSON.stringify(savedData));
}
// --- ボタンのUI生成とイベントリスナー設定 ---
function createButtonUI() {
const existingButton = document.getElementById('bsky-tokimeki-switch-button');
if (existingButton) existingButton.remove();
const config = getConfig();
if (!config.enabled) return;
const button = document.createElement('button');
button.id = 'bsky-tokimeki-switch-button';
Object.assign(button.style, {
position: 'fixed',
padding: '12px 18px',
borderRadius: '9999px',
border: 'none',
backgroundColor: '#2563eb',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer',
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.2)',
zIndex: '99998',
transition: 'all 0.3s ease-in-out',
});
const domain = getCurrentDomain();
button.textContent = domain === 'bsky' ? '⇆' : (domain === 'tokimeki' ? '⇆' : '切り替え');
button.addEventListener('click', switchUrl);
document.body.appendChild(button);
updateButtonPosition(button, config.buttonPosition, config.buttonPadding);
}
// --- ボタン位置の更新関数 ---
function updateButtonPosition(button, position, padding) {
button.style.removeProperty('top');
button.style.removeProperty('bottom');
button.style.removeProperty('left');
button.style.removeProperty('right');
switch (position) {
case 'top-left':
Object.assign(button.style, { top: `${padding}px`, left: `${padding}px` });
break;
case 'top-right':
Object.assign(button.style, { top: `${padding}px`, right: `${padding}px` });
break;
case 'bottom-left':
Object.assign(button.style, { bottom: `${padding}px`, left: `${padding}px` });
break;
case 'bottom-right':
default:
Object.assign(button.style, { bottom: `${padding}px`, right: `${padding}px` });
break;
}
}
// --- キーボードショートカットのイベントハンドラ ---
function handleKeyDown(e) {
const config = getConfig();
const shortcut = config.shortcutKey;
if (!shortcut) return;
// 設定画面が開いてるときは無効化
if (document.querySelector('.bsky-settings-modal-overlay')) return;
const keys = shortcut.split('+');
const isCtrl = keys.includes('Ctrl');
const isShift = keys.includes('Shift');
const isAlt = keys.includes('Alt');
const key = keys.pop().toLowerCase();
if (e.ctrlKey === isCtrl && e.shiftKey === isShift && e.altKey === isAlt && e.key.toLowerCase() === key) {
const tag = (e.target && e.target.tagName) || '';
if (/(INPUT|TEXTAREA|SELECT)/.test(tag)) return;
e.preventDefault();
switchUrl();
}
}
function showToast(msg) {
const toast = document.createElement('div');
toast.textContent = msg;
toast.style.cssText = `
position: fixed; bottom: 20px; left: 50%;
transform: translateX(-50%);
background: var(--primary-color);
color: white; padding: 10px 20px;
border-radius: 6px;
z-index: 100000;
font-size: 14px;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
// --- カスタム設定画面のUIとロジック ---
function createSettingsUI() {
const styles = `
:root { --bg-color: #1a1a1a; --text-color: #f0f0f0; --border-color: #333; --primary-color: #007bff; --primary-hover: #0056b3; --secondary-color: #343a40; --modal-bg: #212529; --shadow: 0 8px 16px rgba(0, 0, 0, 0.5); --border-radius: 12px; }
.bsky-settings-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 100000; display: flex; justify-content: center; align-items: center; }
.bsky-settings-modal { background-color: var(--modal-bg); color: var(--text-color); width: 90%; max-width: 400px; border-radius: var(--border-radius); box-shadow: var(--shadow); border: 1px solid var(--border-color); font-family: 'Inter', sans-serif; overflow: hidden; }
.bsky-settings-header { padding: 15px 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.bsky-settings-header h2 { margin: 0; font-size: 1.25rem; font-weight: 600; display: flex; align-items: center; gap: 10px; }
.bsky-settings-header button { background: none; border: none; color: var(--text-color); font-size: 1.5rem; cursor: pointer; }
.bsky-settings-body { padding: 20px; }
.bsky-settings-section { background-color: #2c2c2c; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
.bsky-settings-section-title { font-size: 1.1rem; font-weight: bold; margin-top: 0; margin-bottom: 15px; color: #e0e0e0; border-bottom: 1px solid #444; padding-bottom: 5px; }
.bsky-settings-option { margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; }
.bsky-settings-option:last-child { margin-bottom: 0; }
.bsky-settings-option label { font-size: 1rem; font-weight: 500; }
.bsky-settings-option select, .bsky-settings-option input[type="number"], .bsky-settings-option input[type="text"] { background-color: var(--secondary-color); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 6px; padding: 8px 12px; cursor: pointer; width: 100px; }
.bsky-settings-option input[type="text"] { width: 150px; cursor: text; }
.bsky-settings-option input:focus { border-color: var(--primary-color); box-shadow: 0 0 4px var(--primary-color); }
.bsky-settings-footer { padding: 15px 20px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.bsky-settings-footer .version { font-size: 0.8rem; font-weight: 400; color: #aaa; }
.bsky-settings-footer button { padding: 10px 20px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: all 0.2s ease; }
.bsky-settings-footer .save-button { background-color: var(--primary-color); color: white; }
.bsky-settings-footer .save-button:hover { background-color: var(--primary-hover); }
.bsky-switch { position: relative; display: inline-block; width: 50px; height: 28px; }
.bsky-switch input { opacity: 0; width: 0; height: 0; }
.bsky-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 28px; }
.bsky-slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .bsky-slider { background-color: var(--primary-color); }
input:focus + .bsky-slider { box-shadow: 0 0 1px var(--primary-color); }
input:checked + .bsky-slider:before { transform: translateX(22px); }
`;
const domain = getCurrentDomain();
const domainName = domain === 'bsky' ? 'bsky.app' : 'tokimeki.blue';
const html = `
<div class="bsky-settings-modal-overlay">
<div class="bsky-settings-modal">
<div class="bsky-settings-header">
<h2>設定</h2>
<button class="close-modal-button">×</button>
</div>
<div class="bsky-settings-body">
<div class="bsky-settings-section">
<h3 class="bsky-settings-section-title">共通設定</h3>
<div class="bsky-settings-option">
<label>ボタンを表示する</label>
<label class="bsky-switch">
<input type="checkbox" id="bsky-enabled-toggle">
<span class="bsky-slider"></span>
</label>
</div>
<div class="bsky-settings-option">
<label for="bsky-shortcut-key-input">ショートカットキー</label>
<input type="text" id="bsky-shortcut-key-input" placeholder="キーを押して設定" value="">
</div>
</div>
<div class="bsky-settings-section">
<h3 class="bsky-settings-section-title">${domainName}</h3>
<div class="bsky-settings-option">
<label for="bsky-button-position-select">ボタンの表示位置</label>
<select id="bsky-button-position-select">
<option value="top-left">左上</option>
<option value="top-right">右上</option>
<option value="bottom-left">左下</option>
<option value="bottom-right">右下</option>
</select>
</div>
<div class="bsky-settings-option">
<label for="bsky-button-padding-input">ボタンの余白 (px)</label>
<input type="number" id="bsky-button-padding-input" min="0" max="100" value="60">
</div>
</div>
</div>
<div class="bsky-settings-footer">
<span class="version">(v${SCRIPT_VERSION})</span>
<button class="save-button">保存</button>
</div>
</div>
</div>
`;
if (!document.getElementById('bsky-settings-style')) {
const styleSheet = document.createElement('style');
styleSheet.id = 'bsky-settings-style';
styleSheet.innerText = styles;
document.head.appendChild(styleSheet);
}
const existingModal = document.querySelector('.bsky-settings-modal-overlay');
if (existingModal) existingModal.remove();
document.body.insertAdjacentHTML('beforeend', html);
const currentConfig = getConfig();
const enabledToggle = document.getElementById('bsky-enabled-toggle');
const positionSelect = document.getElementById('bsky-button-position-select');
const paddingInput = document.getElementById('bsky-button-padding-input');
const shortcutInput = document.getElementById('bsky-shortcut-key-input');
shortcutInput.readOnly = true; // 入力欄は直接入力できないようにする
enabledToggle.checked = currentConfig.enabled;
positionSelect.value = currentConfig.buttonPosition;
paddingInput.value = currentConfig.buttonPadding;
shortcutInput.value = currentConfig.shortcutKey;
const modalOverlay = document.querySelector('.bsky-settings-modal-overlay');
const closeModalButton = document.querySelector('.close-modal-button');
const saveButton = document.querySelector('.save-button');
closeModalButton.addEventListener('click', () => modalOverlay.remove());
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) modalOverlay.remove();
});
shortcutInput.addEventListener('keydown', (e) => {
e.preventDefault();
// ESCキーは閉じる用にパス
if (e.key === 'Escape') {
modalOverlay.remove();
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('Ctrl');
if (e.shiftKey) modifiers.push('Shift');
if (e.altKey) modifiers.push('Alt');
if (!['Control', 'Shift', 'Alt', 'Meta', 'Tab', 'Escape', 'Backspace', 'Delete'].includes(e.key)) {
const key = e.key.length === 1 ? e.key.toUpperCase() : e.key;
modifiers.push(key);
}
shortcutInput.value = modifiers.join('+');
});
saveButton.addEventListener('click', () => {
const newConfig = {
enabled: enabledToggle.checked,
buttonPosition: positionSelect.value,
buttonPadding: parseInt(paddingInput.value, 10) || 0,
shortcutKey: shortcutInput.value
};
saveConfig(newConfig);
createButtonUI();
modalOverlay.remove();
showToast('設定を保存しました!');
});
// ESCキーで閉じる(任意のキーで消えないように once を使わない)
const onEsc = (e) => {
if (e.key === 'Escape') {
modalOverlay.remove();
document.removeEventListener('keydown', onEsc);
}
};
document.addEventListener('keydown', onEsc);
}
// --- 初期化処理 ---
function init() {
createButtonUI();
document.removeEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleKeyDown);
if (getCurrentDomain() === 'bsky') {
handleBskyPageLoad();
}
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand("URLを切り替える", switchUrl);
GM_registerMenuCommand("設定", createSettingsUI);
}
}
init();
})();