Bluesky Keyboard Shortcuts

Add Twitter/Mastodon-style keyboard shortcuts to Bluesky

// ==UserScript==
// @name         Bluesky Keyboard Shortcuts
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Add Twitter/Mastodon-style keyboard shortcuts to Bluesky
// @author       You
// @match        https://bsky.app/*
// @match        https://deer.social/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";

  // Add CSS styles
  const css = `
    .shortcuts-modal {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: rgba(0, 0, 0, 0.4);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 10000;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    }

    .shortcuts-content {
      background: white;
      border-radius: 16px;
      padding: 24px;
      max-width: 600px;
      width: 90%;
      max-height: 80vh;
      overflow-y: auto;
      box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
        0 10px 10px -5px rgba(0, 0, 0, 0.04);
    }

    [data-colormode="dark"] .shortcuts-content {
      background: #16181c;
      color: #e7e9ea;
    }

    .shortcuts-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 24px;
      padding-bottom: 16px;
      border-bottom: 1px solid #eff3f4;
    }

    [data-colormode="dark"] .shortcuts-header {
      border-bottom-color: #2f3336;
    }

    .shortcuts-title {
      font-size: 20px;
      font-weight: 700;
      margin: 0;
    }

    .shortcuts-close {
      background: none;
      border: none;
      font-size: 24px;
      cursor: pointer;
      padding: 8px;
      border-radius: 50%;
      color: #536471;
      transition: background-color 0.2s;
    }

    .shortcuts-close:hover {
      background: rgba(15, 20, 25, 0.1);
    }

    [data-colormode="dark"] .shortcuts-close:hover {
      background: rgba(231, 233, 234, 0.1);
    }

    .shortcuts-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 24px;
    }

    @media (max-width: 640px) {
      .shortcuts-grid {
        grid-template-columns: 1fr;
      }
    }

    .shortcuts-section {
      margin-bottom: 24px;
    }

    .shortcuts-section h3 {
      font-size: 16px;
      font-weight: 700;
      margin: 0 0 12px 0;
      color: #0f1419;
    }

    [data-colormode="dark"] .shortcuts-section h3 {
      color: #e7e9ea;
    }

    .shortcut-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 8px 0;
    }

    .shortcut-description {
      font-size: 15px;
      color: #536471;
    }

    .shortcut-keys {
      display: flex;
      gap: 4px;
    }

    .shortcut-key {
      background: #f7f9fa;
      border: 1px solid #cfd9de;
      border-radius: 4px;
      padding: 2px 6px;
      font-size: 12px;
      font-weight: 600;
      font-family: monospace;
      color: #0f1419;
    }

    [data-colormode="dark"] .shortcut-key {
      background: #202327;
      border-color: #2f3336;
      color: #e7e9ea;
    }

    .shortcuts-footer {
      margin-top: 24px;
      padding-top: 16px;
      border-top: 1px solid #eff3f4;
      text-align: center;
      font-size: 14px;
      color: #536471;
    }

    [data-colormode="dark"] .shortcuts-footer {
      border-top-color: #2f3336;
    }

    .shortcuts-notification {
      position: fixed;
      top: 20px;
      right: 20px;
      background: #1d9bf0;
      color: white;
      padding: 12px 16px;
      border-radius: 8px;
      font-size: 14px;
      font-weight: 500;
      z-index: 10001;
      opacity: 0;
      transform: translateY(-10px);
      transition: all 0.3s ease;
    }

    .shortcuts-notification.show {
      opacity: 1;
      transform: translateY(0);
    }

    .shortcuts-notification.error {
      background: #f91880;
    }

    [data-colormode="dark"] .shortcuts-notification {
      background: #1d9bf0;
    }

    [data-colormode="dark"] .shortcuts-notification.error {
      background: #f91880;
    }
  `;

  // Inject CSS
  const styleSheet = document.createElement("style");
  styleSheet.textContent = css;
  document.head.appendChild(styleSheet);

  class BlueskyKeyboardShortcuts {
    constructor() {
      this.gPressed = false;
      this.gTimeout = null;
      this.failureCount = {};
      this.currentFeedIndex = 0;
      this.shortcuts = {
        navigation: {
          "g+h": { action: "home", description: "Go to Home" },
          "g+e": { action: "explore", description: "Go to Explore" },
          "g+n": { action: "notifications", description: "Go to Notifications" },
          "g+c": { action: "chat", description: "Go to Chat" },
          "g+f": { action: "feeds", description: "Go to Feeds" },
          "g+l": { action: "lists", description: "Go to Lists" },
          "g+p": { action: "profile", description: "Go to Profile" },
          "g+s": { action: "settings", description: "Go to Settings" },
        },
        feeds: {
          "alt+↓": { action: "nextFeed", description: "Next feed" },
          "alt+↑": { action: "prevFeed", description: "Previous feed" },
        },
        general: {
          "/": { action: "search", description: "Focus search" },
          "shift+?": {
            action: "help",
            description: "Show keyboard shortcuts",
          },
          "escape": { action: "closeModal", description: "Close modal" },
        },
      };

      // Add numbered feed shortcuts
      for (let i = 1; i <= 9; i++) {
        this.shortcuts.feeds[i.toString()] = {
          action: "feed",
          feedIndex: i - 1,
          description: `Go to feed ${i}`,
        };
      }

      this.init();
    }

    init() {
      this.bindEvents();
      this.createHelpModal();
      console.log("Bluesky Keyboard Shortcuts loaded! Press Shift+? for help");
    }

    bindEvents() {
      document.addEventListener("keydown", (e) => this.handleKeyDown(e), true);
      document.addEventListener("keyup", (e) => this.handleKeyUp(e));
    }

    isTyping() {
      const activeElement = document.activeElement;
      if (!activeElement) return false;

      return (
        activeElement.tagName === "INPUT" ||
        activeElement.tagName === "TEXTAREA" ||
        activeElement.contentEditable === "true" ||
        activeElement.closest('[contenteditable="true"]') ||
        activeElement.closest('[data-testid="textInput"]') ||
        activeElement.closest('[data-testid="composer"]') ||
        activeElement.closest('[aria-multiline="true"]') ||
        activeElement.closest('textarea') ||
        activeElement.closest('input[type="text"]') ||
        activeElement.closest('input[type="search"]')
      );
    }

    showNotification(message, isError = false) {
      const notification = document.createElement("div");
      notification.className = `shortcuts-notification ${isError ? 'error' : ''}`;
      notification.textContent = message;
      document.body.appendChild(notification);

      // Trigger animation
      setTimeout(() => notification.classList.add("show"), 10);

      // Remove after 2 seconds
      setTimeout(() => {
        notification.classList.remove("show");
        setTimeout(() => notification.remove(), 300);
      }, 2000);
    }

    trackFailure(action) {
      this.failureCount[action] = (this.failureCount[action] || 0) + 1;
      if (this.failureCount[action] >= 10) {
        this.showNotification(`${action} shortcut appears to be broken`, true);
        this.failureCount[action] = 0; // Reset counter
      }
    }

    handleKeyDown(e) {
      // Always allow escape to close modals
      if (e.key === "Escape") {
        e.preventDefault();
        this.closeHelp();
        return;
      }

      // Handle Shift + ? for help (works even when typing)
      if (e.shiftKey && e.key === "?") {
        e.preventDefault();
        this.showHelp();
        return;
      }

      // Handle / for search (only when not typing)
      if (e.key === "/" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !this.isTyping()) {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
        this.focusSearch();
        return;
      }

      // Skip other shortcuts if typing
      if (this.isTyping()) {
        return;
      }

      // Handle Alt + Arrow keys for feed navigation
      if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();
        if (e.key === "ArrowDown") {
          this.navigateToNextFeed();
        } else {
          this.navigateToPrevFeed();
        }
        return;
      }

      // Handle G key sequences
      if (e.key.toLowerCase() === "g" && !e.ctrlKey && !e.altKey && !e.metaKey) {
        e.preventDefault();
        this.gPressed = true;
        
        // Clear any existing timeout
        if (this.gTimeout) {
          clearTimeout(this.gTimeout);
        }
        
        // Reset after 1 second
        this.gTimeout = setTimeout(() => {
          this.gPressed = false;
          this.gTimeout = null;
        }, 1000);
        return;
      }

      // Handle G + letter combinations
      if (this.gPressed && !e.ctrlKey && !e.altKey && !e.metaKey) {
        const combination = `g+${e.key.toLowerCase()}`;
        if (this.shortcuts.navigation[combination]) {
          e.preventDefault();
          e.stopPropagation();
          e.stopImmediatePropagation();
          this.executeAction(this.shortcuts.navigation[combination]);
          this.gPressed = false;
          if (this.gTimeout) {
            clearTimeout(this.gTimeout);
            this.gTimeout = null;
          }
          return;
        }
      }

      // Handle number keys for feeds (only when not typing)
      if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.altKey && !e.metaKey && !this.gPressed) {
        const shortcut = this.shortcuts.feeds[e.key];
        if (shortcut) {
          e.preventDefault();
          this.executeAction(shortcut);
          return;
        }
      }
    }

    handleKeyUp(e) {
      // Additional key up handling if needed
    }

    executeAction(shortcut) {
      switch (shortcut.action) {
        case "home":
          this.navigateTo("/", "home");
          break;
        case "explore":
          this.navigateTo("/search", "explore");
          break;
        case "notifications":
          this.navigateTo("/notifications", "notifications");
          break;
        case "chat":
          this.navigateTo("/messages", "chat");
          break;
        case "feeds":
          this.navigateTo("/feeds", "feeds");
          break;
        case "lists":
          this.navigateTo("/lists", "lists");
          break;
        case "profile":
          this.navigateToProfile();
          break;
        case "settings":
          this.navigateTo("/settings", "settings");
          break;
        case "feed":
          this.navigateToFeed(shortcut.feedIndex);
          break;
        case "nextFeed":
          this.navigateToNextFeed();
          break;
        case "prevFeed":
          this.navigateToPrevFeed();
          break;
        case "search":
          this.focusSearch();
          break;
        case "help":
          this.showHelp();
          break;
        case "closeModal":
          this.closeHelp();
          break;
      }
    }

    focusSearch() {
      try {
        // Look for search input in various locations
        const searchSelectors = [
          'input[placeholder*="Search" i]',
          'input[aria-label*="Search" i]',
          'input[type="search"]',
          '[data-testid="searchInput"]',
          '[data-testid="searchTextInput"]',
          'input[name*="search" i]',
          'input[id*="search" i]',
          // Look in header/navigation areas
          'header input[type="text"]',
          'nav input[type="text"]',
          // Generic text inputs that might be search
          'input[type="text"]:not([data-testid*="composer"]):not([aria-label*="post" i])',
        ];

        for (const selector of searchSelectors) {
          const searchInput = document.querySelector(selector);
          if (searchInput && searchInput.offsetParent !== null) { // Check if visible
            searchInput.focus();
            searchInput.select();
            return;
          }
        }

        // If no search input found, try to navigate to search page first
        this.navigateTo("/search", "search");
        
        // Then try to focus search input after a brief delay
        setTimeout(() => {
          for (const selector of searchSelectors) {
            const searchInput = document.querySelector(selector);
            if (searchInput && searchInput.offsetParent !== null) {
              searchInput.focus();
              searchInput.select();
              return;
            }
          }
        }, 200);

      } catch (error) {
        console.log("Search focus error:", error);
        this.trackFailure("search");
      }
    }

    navigateTo(path, action) {
      try {
        // Look for navigation links in the sidebar
        const navSelectors = [
          `nav a[href="${path}"]`,
          `a[href="${path}"]`,
          `a[href$="${path}"]`,
          `[data-testid="homeTab"] a`,
          `[data-testid="searchTab"] a`, 
          `[data-testid="notificationsTab"] a`,
          `[data-testid="messagesTab"] a`,
          `[data-testid="feedsTab"] a`,
          `[data-testid="listsTab"] a`,
          `[data-testid="settingsTab"] a`,
        ];

        for (const selector of navSelectors) {
          const link = document.querySelector(selector);
          if (link && (link.href.endsWith(path) || link.href.includes(path))) {
            link.click();
            return;
          }
        }

        // Fallback: use history API for SPA navigation
        if (window.history && window.history.pushState) {
          const newUrl = window.location.origin + path;
          window.history.pushState({}, "", newUrl);
          window.dispatchEvent(new PopStateEvent("popstate"));
          return;
        }

        // If we get here, navigation failed
        this.trackFailure(action);
      } catch (error) {
        console.log("Navigation error:", error);
        this.trackFailure(action);
      }
    }

    navigateToProfile() {
      try {
        // Look for the user's own profile link in the sidebar
        const profileSelectors = [
          'nav a[href*="/profile/"][aria-label*="Profile"]',
          'nav a[href*="/profile/"] img[alt*="avatar"]',
          '[data-testid="profileTab"] a',
          'nav a[href*="/profile/"]:not([href*="/profile/undefined"])',
        ];

        // Try to find the user's own profile by looking at the current user context
        const userMenuButton = document.querySelector('[data-testid="userMenu"], [aria-label*="Account menu"], button[aria-haspopup="menu"]');
        if (userMenuButton) {
          const userHandle = this.getCurrentUserHandle();
          if (userHandle) {
            const userProfileLink = document.querySelector(`a[href="/profile/${userHandle}"], a[href$="/profile/${userHandle}"]`);
            if (userProfileLink) {
              userProfileLink.click();
              return;
            }
          }
        }

        // Try the profile selectors
        for (const selector of profileSelectors) {
          const profileLink = document.querySelector(selector);
          if (profileLink) {
            profileLink.click();
            return;
          }
        }

        // Fallback: construct profile URL from user handle
        const userHandle = this.getCurrentUserHandle();
        if (userHandle) {
          this.navigateTo(`/profile/${userHandle}`, "profile");
          return;
        }

        this.trackFailure("profile");
      } catch (error) {
        console.log("Profile navigation error:", error);
        this.trackFailure("profile");
      }
    }

    getFeedLinks() {
      // Look for feeds in the right sidebar or main feed area
      const feedSelectors = [
        // Right sidebar feed links
        'aside a[href*="/feed/"]',
        '[data-testid="rightColumn"] a[href*="/feed/"]',
        // Feed tabs or buttons in the main area
        '[role="tablist"] a[href*="/feed/"]',
        '[role="tablist"] button[data-testid*="feed"]',
        // My Feeds or pinned feeds section
        '[data-testid="myFeeds"] a',
        '[data-testid="savedFeeds"] a',
        '[data-testid="pinnedFeeds"] a',
        // General feed links in sidebars
        'nav a[href*="/feed/"]',
        // Feed selector dropdown or menu
        '[data-testid="feedSelector"] a',
        '[aria-label*="feed" i] a[href*="/feed/"]',
        // Look for "Following", "Discover", etc. tabs
        '[role="tab"][href*="/feed/"]',
        'button[role="tab"][data-testid*="feed"]',
      ];

      let feedLinks = [];
      
      for (const selector of feedSelectors) {
        const links = document.querySelectorAll(selector);
        if (links.length > 0) {
          const validLinks = Array.from(links).filter(link => {
            const href = link.href || link.getAttribute('data-href') || '';
            return href.includes('/feed/') && 
                   !href.includes('undefined') &&
                   href !== window.location.href;
          });
          if (validLinks.length > 0) {
            feedLinks = validLinks;
            break;
          }
        }
      }

      // If no feed links found, look for tab-like elements or buttons
      if (feedLinks.length === 0) {
        const tabSelectors = [
          '[role="tablist"] [role="tab"]',
          '[role="tablist"] button',
          '[data-testid*="tab"]',
          'nav button[data-testid*="feed"]',
        ];

        for (const selector of tabSelectors) {
          const tabs = document.querySelectorAll(selector);
          if (tabs.length > 0) {
            feedLinks = Array.from(tabs);
            break;
          }
        }
      }

      return feedLinks;
    }

    navigateToFeed(index) {
      try {
        const feedLinks = this.getFeedLinks();
        
        if (feedLinks[index]) {
          feedLinks[index].click();
          this.currentFeedIndex = index;
        } else {
          this.navigateTo("/feeds", `feed-${index + 1}`);
        }
      } catch (error) {
        console.log("Feed navigation error:", error);
        this.trackFailure(`feed-${index + 1}`);
      }
    }

    navigateToNextFeed() {
      try {
        const feedLinks = this.getFeedLinks();
        if (feedLinks.length === 0) {
          this.trackFailure("nextFeed");
          return;
        }

        this.currentFeedIndex = (this.currentFeedIndex + 1) % feedLinks.length;
        feedLinks[this.currentFeedIndex].click();
      } catch (error) {
        console.log("Next feed navigation error:", error);
        this.trackFailure("nextFeed");
      }
    }

    navigateToPrevFeed() {
      try {
        const feedLinks = this.getFeedLinks();
        if (feedLinks.length === 0) {
          this.trackFailure("prevFeed");
          return;
        }

        this.currentFeedIndex = this.currentFeedIndex <= 0 ? feedLinks.length - 1 : this.currentFeedIndex - 1;
        feedLinks[this.currentFeedIndex].click();
      } catch (error) {
        console.log("Previous feed navigation error:", error);
        this.trackFailure("prevFeed");
      }
    }

    getCurrentUserHandle() {
      try {
        const handleSelectors = [
          '[data-testid="userMenu"] [data-testid="handle"]',
          '[aria-label*="Account menu"] + * [data-testid="handle"]',
          'nav [data-testid="handle"]',
          '[data-testid="profileTab"] a',
        ];

        for (const selector of handleSelectors) {
          const element = document.querySelector(selector);
          if (element) {
            if (element.href && element.href.includes('/profile/')) {
              const match = element.href.match(/\/profile\/(.+?)(?:\?|$)/);
              if (match) return match[1];
            }
            const text = element.textContent;
            if (text && text.startsWith('@')) {
              return text.substring(1);
            }
          }
        }

        if (window.location.pathname.includes('/profile/')) {
          const match = window.location.pathname.match(/\/profile\/(.+?)(?:\/|$)/);
          if (match) return match[1];
        }

        return null;
      } catch (error) {
        console.log("Error getting user handle:", error);
        return null;
      }
    }

    createHelpModal() {
      const modal = document.createElement("div");
      modal.className = "shortcuts-modal";
      modal.style.display = "none";
      modal.id = "keyboard-shortcuts-modal";

      modal.innerHTML = `
        <div class="shortcuts-content">
          <div class="shortcuts-header">
            <h2 class="shortcuts-title">Keyboard shortcuts</h2>
            <button class="shortcuts-close" aria-label="Close">&times;</button>
          </div>
          
          <div class="shortcuts-grid">
            <div class="shortcuts-section">
              <h3>Navigation</h3>
              ${Object.entries(this.shortcuts.navigation)
                .map(
                  ([key, shortcut]) => `
                <div class="shortcut-item">
                  <span class="shortcut-description">${shortcut.description}</span>
                  <div class="shortcut-keys">
                    ${this.formatShortcutKey(key)}
                  </div>
                </div>
              `
                )
                .join("")}
              <div class="shortcut-item">
                <span class="shortcut-description">Focus search</span>
                <div class="shortcut-keys">
                  <span class="shortcut-key">/</span>
                </div>
              </div>
            </div>
            
            <div class="shortcuts-section">
              <h3>Feeds</h3>
              <div class="shortcut-item">
                <span class="shortcut-description">Next feed</span>
                <div class="shortcut-keys">
                  <span class="shortcut-key">Alt</span>
                  <span class="shortcut-key">↓</span>
                </div>
              </div>
              <div class="shortcut-item">
                <span class="shortcut-description">Previous feed</span>
                <div class="shortcut-keys">
                  <span class="shortcut-key">Alt</span>
                  <span class="shortcut-key">↑</span>
                </div>
              </div>
              ${Object.entries(this.shortcuts.feeds)
                .filter(([key]) => /^[1-9]$/.test(key))
                .slice(0, 5)
                .map(
                  ([key, shortcut]) => `
                <div class="shortcut-item">
                  <span class="shortcut-description">${shortcut.description}</span>
                  <div class="shortcut-keys">
                    <span class="shortcut-key">${key}</span>
                  </div>
                </div>
              `
                )
                .join("")}
              ${
                Object.keys(this.shortcuts.feeds).filter(k => /^[1-9]$/.test(k)).length > 5
                  ? `<div class="shortcut-item">
                      <span class="shortcut-description">And more...</span>
                      <div class="shortcut-keys">
                        <span class="shortcut-key">6-9</span>
                      </div>
                    </div>`
                  : ""
              }
            </div>
          </div>
          
          <div class="shortcuts-footer">
            Press <strong>Shift + ?</strong> to toggle this help
          </div>
        </div>
      `;

      // Add click handlers
      modal.addEventListener("click", (e) => {
        if (e.target === modal) {
          this.closeHelp();
        }
      });

      modal
        .querySelector(".shortcuts-close")
        .addEventListener("click", () => this.closeHelp());

      document.body.appendChild(modal);
    }

    formatShortcutKey(key) {
      if (key === "shift+?") {
        return `
          <span class="shortcut-key">Shift</span>
          <span class="shortcut-key">?</span>
        `;
      }
      if (key.includes("+")) {
        return key
          .split("+")
          .map((k) => `<span class="shortcut-key">${k.toUpperCase()}</span>`)
          .join("");
      }
      return `<span class="shortcut-key">${key.toUpperCase()}</span>`;
    }

    showHelp() {
      const modal = document.getElementById("keyboard-shortcuts-modal");
      if (modal) {
        modal.style.display = "flex";
        document.body.style.overflow = "hidden";
      }
    }

    closeHelp() {
      const modal = document.getElementById("keyboard-shortcuts-modal");
      if (modal) {
        modal.style.display = "none";
        document.body.style.overflow = "";
      }
    }
  }

  // Initialize when DOM is ready
  function initShortcuts() {
    if (document.querySelector('#keyboard-shortcuts-modal')) {
      return; // Already initialized
    }
    new BlueskyKeyboardShortcuts();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initShortcuts);
  } else {
    initShortcuts();
  }

  // Handle SPA navigation - reinitialize if needed
  let lastUrl = location.href;
  new MutationObserver(() => {
    const url = location.href;
    if (url !== lastUrl) {
      lastUrl = url;
      setTimeout(() => {
        if (!document.querySelector('#keyboard-shortcuts-modal')) {
          initShortcuts();
        }
      }, 100);
    }
  }).observe(document, { subtree: true, childList: true });

})();