// ==UserScript==
// @name LinkedIn Post Enhancer: Copy Link & Save to Raindrop.io
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Adds "Copy Link" and "Save to Raindrop.io" buttons to LinkedIn posts, including single post pages.
// @author Gemini assisted by @ProtoPioneer
// @match https://www.linkedin.com/feed/*
// @match https://www.linkedin.com/posts/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=linkedin.com
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// SVG icon for the "Copy Link" button (clipboard)
const copySVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="artdeco-button__icon">
<path d="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
`;
// SVG icon for the "Copied!" state (clipboard with checkmark)
const copiedSVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0073B1" class="artdeco-button__icon">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
`;
// SVG icon for the "Save to Raindrop.io" button (simple raindrop/cloud)
const raindropSVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="artdeco-button__icon">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</svg>
`;
// Base URL for the Raindrop.io bookmarklet to add new links
const raindropBaseUrl = 'https://app.raindrop.io/add';
/**
* Creates a styled button element that matches LinkedIn's social action bar buttons.
* @param {string} iconHtml - The SVG HTML string to be used as the button's icon.
* @param {string} text - The text label for the button.
* @param {function} onClickHandler - The event handler function to execute when the button is clicked.
* @returns {HTMLElement} The created HTML button element, wrapped in a span for correct LinkedIn styling.
*/
function createActionButton(iconHtml, text, onClickHandler) {
// Create the wrapper span that LinkedIn uses for its action buttons
const buttonSpan = document.createElement('span');
buttonSpan.classList.add(
'feed-shared-social-action-bar__action-button',
'feed-shared-social-action-bar--new-padding' // Maintains consistent padding with other buttons
);
buttonSpan.setAttribute('tabindex', '-1'); // Ensures proper tab navigation behavior
// Create the actual button element
const button = document.createElement('button');
button.classList.add(
'artdeco-button',
'artdeco-button--muted',
'artdeco-button--3',
'artdeco-button--tertiary',
'ember-view', // Mimic ember-view for consistent behavior, though not strictly functional
'social-actions-button',
'flex-wrap' // For flexible content layout within the button
);
button.setAttribute('role', 'button'); // Explicitly define role for accessibility
button.setAttribute('type', 'button'); // Standard button type
button.setAttribute('aria-label', text); // Provide an accessible label for screen readers
// Create the inner content for the button (icon and text)
const buttonContent = document.createElement('span');
buttonContent.classList.add('artdeco-button__text');
buttonContent.innerHTML = `
<div class="flex-wrap justify-center artdeco-button__text align-items-center">
${iconHtml} <!-- Inject the SVG icon -->
<span aria-hidden="true" class="artdeco-button__text social-action-button__text">${text}</span>
</div>
`;
// Append content to button, and button to its wrapper span
button.appendChild(buttonContent);
button.addEventListener('click', onClickHandler); // Attach the click handler
buttonSpan.appendChild(button);
return buttonSpan;
}
/**
* Extracts the canonical LinkedIn post URL from a given post element.
* LinkedIn posts have a unique URN (Uniform Resource Name) in their data-id or data-urn attribute.
* This URN is used to construct the direct link to the post.
* @param {HTMLElement} postElement - The DOM element representing a single LinkedIn post.
* @returns {string|null} The full URL of the post, or null if the URN cannot be found.
*/
function extractLinkedInPostUrl(postElement) {
let urn = postElement.getAttribute('data-id') || postElement.getAttribute('data-urn');
if (urn && urn.startsWith('urn:li:activity:')) {
const postId = urn.split(':').pop();
return `https://www.linkedin.com/feed/update/urn:li:activity:${postId}/`;
}
return null;
}
/**
* Extracts a suitable title, author, and content snippet for the LinkedIn post for bookmarking purposes.
* @param {HTMLElement} postElement - The DOM element representing a single LinkedIn post.
* @returns {{title: string, author: string, description: string}} An object containing the extracted data.
*/
function extractLinkedInPostDetails(postElement) {
let authorName = '';
let postDescription = '';
let postTitle = 'LinkedIn Post'; // Default title
// Extract author name
const actorNameElement = postElement.querySelector('.update-components-actor__title span[dir="ltr"] span[aria-hidden="true"]');
if (actorNameElement) {
authorName = actorNameElement.innerText.trim();
}
// Extract main text content/commentary of the post
const commentaryElement = postElement.querySelector('.update-components-text span[dir="ltr"]');
if (commentaryElement) {
postDescription = commentaryElement.innerText.trim();
}
// Construct a more descriptive title for Raindrop.io
if (authorName && postDescription) {
postTitle = `${authorName} - ${postDescription.substring(0, 70).replace(/\n/g, ' ')}... - LinkedIn`;
} else if (authorName) {
postTitle = `${authorName}'s LinkedIn Post`;
} else if (postDescription) {
postTitle = `${postDescription.substring(0, 70).replace(/\n/g, ' ')}... - LinkedIn`;
}
return {
title: postTitle,
author: authorName,
description: postDescription
};
}
/**
* Adds the custom "Copy Link" and "Save to Raindrop.io" buttons to a specific LinkedIn post.
* It ensures buttons are only added once per post by marking the post element.
* @param {HTMLElement} postElement - The DOM element of the LinkedIn post to which buttons should be added.
*/
function addCustomButtons(postElement) {
const socialActionBar = postElement.querySelector('.feed-shared-social-action-bar');
// Check if the social action bar exists and if buttons have already been added to this specific post
if (socialActionBar && !postElement.dataset.customButtonsAdded) {
const postUrl = extractLinkedInPostUrl(postElement);
const postDetails = extractLinkedInPostDetails(postElement);
// If a URL cannot be extracted, log a warning and do not proceed with adding buttons
if (!postUrl) {
console.warn('Could not extract LinkedIn post URL for:', postElement);
return;
}
// Mark this post element to indicate that custom buttons have been added,
// preventing duplicate buttons on subsequent DOM mutations.
postElement.dataset.customButtonsAdded = 'true';
// --- 1. Create and configure the "Copy Link" Button ---
const copyButton = createActionButton(copySVG, 'Copy Link', (event) => {
event.stopPropagation(); // Stop event propagation to prevent triggering LinkedIn's default click handlers on the parent button area.
// Attempt to copy the URL to the clipboard using the modern Clipboard API
navigator.clipboard.writeText(postUrl)
.then(() => {
// Store original icon and text to revert after visual feedback
const originalIconHtml = copyButton.querySelector('.artdeco-button__icon').outerHTML;
const originalText = copyButton.querySelector('.social-action-button__text').innerText;
// Provide visual feedback: change icon to a checkmark and text to "Copied!"
copyButton.querySelector('.artdeco-button__icon').outerHTML = copiedSVG;
copyButton.querySelector('.social-action-button__text').innerText = 'Copied!';
// Revert the button's appearance back to its original state after a short delay
setTimeout(() => {
copyButton.querySelector('.artdeco-button__icon').outerHTML = originalIconHtml;
copyButton.querySelector('.social-action-button__text').innerText = originalText;
}, 1500); // Revert after 1.5 seconds
console.log('LinkedIn post link copied:', postUrl);
})
.catch(err => {
// Fallback for environments where navigator.clipboard.writeText() is not supported or blocked (e.g., some older browsers or stricter iframe policies)
console.error('Error copying LinkedIn link with Clipboard API, attempting fallback: ', err);
const tempInput = document.createElement('input');
tempInput.value = postUrl;
document.body.appendChild(tempInput);
tempInput.select(); // Select the text in the input
try {
document.execCommand('copy'); // Execute the copy command
console.log('LinkedIn post link copied (fallback):', postUrl);
// Provide visual feedback for fallback success
const originalIconHtml = copyButton.querySelector('.artdeco-button__icon').outerHTML;
const originalText = copyButton.querySelector('.social-action-button__text').innerText;
copyButton.querySelector('.artdeco-button__icon').outerHTML = copiedSVG;
copyButton.querySelector('.social-action-button__text').innerText = 'Copied!';
setTimeout(() => {
copyButton.querySelector('.artdeco-button__icon').outerHTML = originalIconHtml;
copyButton.querySelector('.social-action-button__text').innerText = originalText;
}, 1500);
} catch (fallbackErr) {
console.error('Fallback copy failed: ', fallbackErr);
} finally {
document.body.removeChild(tempInput); // Clean up the temporary input element
}
});
});
// --- 2. Create and configure the "Save to Raindrop.io" Button ---
// Use dash as separator for author name in tags, and add "author-" prefix
const sanitizedAuthorTag = 'author-' + postDetails.author.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-').toLowerCase();
const tags = `linkedin.com,${sanitizedAuthorTag}`;
// Construct the Raindrop.io bookmarklet URL with all extracted details
const raindropUrl = `${raindropBaseUrl}?link=${encodeURIComponent(postUrl)}&title=${encodeURIComponent(postDetails.title)}&description=${encodeURIComponent(postDetails.description)}&tags=${encodeURIComponent(tags)}`;
const raindropButton = createActionButton(raindropSVG, 'Save to Raindrop', (event) => {
event.stopPropagation(); // Stop event propagation
// Open the Raindrop.io bookmarking page in a new browser tab/window
window.open(raindropUrl, '_blank');
console.log('Opening Raindrop.io for:', postUrl);
});
// --- Append the new buttons to the social action bar ---
// Find the last existing social action button (e.g., "Enviar" / "Send")
const existingButtons = socialActionBar.querySelectorAll('.feed-shared-social-action-bar__action-button');
if (existingButtons.length > 0) {
const lastButton = existingButtons[existingButtons.length - 1];
// Insert the new buttons immediately after the last existing button
lastButton.after(copyButton);
copyButton.after(raindropButton); // Insert Raindrop button after Copy button
} else {
// Fallback: If for some reason no existing buttons are found, just append to the action bar
socialActionBar.appendChild(copyButton);
socialActionBar.appendChild(raindropButton);
}
}
}
/**
* Finds all LinkedIn post elements on the page and adds custom buttons if not already present.
* This function now also accounts for the structure of single post pages.
*/
function processAllLinkedInPosts() {
// Select all elements that are identified as LinkedIn posts on the feed page
document.querySelectorAll('div[data-id^="urn:li:activity:"]').forEach(postElement => {
addCustomButtons(postElement);
});
// Additionally, select the main post element if on a single post detail page
// The HTML structure on a single post page is slightly different.
// It uses `data-urn` on the `feed-shared-update-v2` element itself.
const singlePostElement = document.querySelector('.feed-shared-update-v2[data-urn^="urn:li:activity:"]');
if (singlePostElement) {
addCustomButtons(singlePostElement);
}
}
/**
* Sets up a MutationObserver to continuously watch for new LinkedIn posts being added to the DOM.
* LinkedIn dynamically loads content, so this ensures that new posts also get the custom buttons.
*/
function observeLinkedInFeed() {
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // Ensure it's an element node
// Check if the added node is directly a LinkedIn feed post
if (node.matches('div[data-id^="urn:li:activity:"]')) {
addCustomButtons(node);
}
// Check if the added node is directly a single LinkedIn post on a detail page
else if (node.matches('.feed-shared-update-v2[data-urn^="urn:li:activity:"]')) {
addCustomButtons(node);
}
// Also, check for posts nested within the added node's subtree.
// This catches cases where a larger container element is added, which then holds posts.
node.querySelectorAll('div[data-id^="urn:li:activity:"]').forEach(nestedPostElement => {
addCustomButtons(nestedPostElement);
});
node.querySelectorAll('.feed-shared-update-v2[data-urn^="urn:li:activity:"]').forEach(nestedSinglePostElement => {
addCustomButtons(nestedSinglePostElement);
});
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Perform an initial scan on page load to add buttons to any posts
// already present in the DOM before dynamic loading occurs.
processAllLinkedInPosts();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeLinkedInFeed);
} else {
observeLinkedInFeed();
}
})();