fix: "Add to Twenty" button render fix (chrome-extension) (#5048)
fix - #5047
This commit is contained in:
@ -1,4 +1,5 @@
|
|||||||
import { openOptionsPage } from '~/background/utils/openOptionsPage';
|
import { openOptionsPage } from '~/background/utils/openOptionsPage';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
// Open options page programmatically in a new tab.
|
// Open options page programmatically in a new tab.
|
||||||
chrome.runtime.onInstalled.addListener((details) => {
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
@ -16,11 +17,10 @@ chrome.action.onClicked.addListener((tab) => {
|
|||||||
// The cases themselves are labelled such that their operations are reflected by their names.
|
// The cases themselves are labelled such that their operations are reflected by their names.
|
||||||
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
||||||
switch (message.action) {
|
switch (message.action) {
|
||||||
case 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/"
|
case 'getActiveTab': // e.g. "https://linkedin.com/company/twenty/"
|
||||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
if (tabs && tabs[0]) {
|
if (isDefined(tabs) && isDefined(tabs[0])) {
|
||||||
const activeTabUrl: string | undefined = tabs[0].url;
|
sendResponse({ tab: tabs[0] });
|
||||||
sendResponse({ url: activeTabUrl });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -34,27 +34,14 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep track of the tabs in which the "Add to Twenty" button has already been injected.
|
|
||||||
// Could be that the content script is executed at "https://linkedin.com/feed/", but is needed at "https://linkedin.com/in/mabdullahabaid/".
|
|
||||||
// However, since Linkedin is a SPA, the script would not be re-executed when you navigate to "https://linkedin.com/in/mabdullahabaid/" from a user action.
|
|
||||||
// Therefore, this tracks if the user is on desired route and then re-executes the content script to create the "Add to Twenty" button.
|
|
||||||
// We use a "Set" to keep track of tab ids because it could be that the "Add to Twenty" button was created at "https://linkedin/com/company/twenty".
|
|
||||||
// However, when we change to about on the company page, the url becomes "https://www.linkedin.com/company/twenty/about/" and the button is created again.
|
|
||||||
// This creates a duplicate button, which we want to avoid. So, we instruct the extension to only create the button once for any of the following urls.
|
|
||||||
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
|
|
||||||
const injectedTabs: Set<number> = new Set();
|
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
const isDesiredRoute =
|
const isDesiredRoute =
|
||||||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
|
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
|
||||||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);
|
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);
|
||||||
|
|
||||||
if (changeInfo.status === 'complete' && tab.active) {
|
if (changeInfo.status === 'complete' && tab.active) {
|
||||||
if (isDesiredRoute && !injectedTabs.has(tabId)) {
|
if (isDefined(isDesiredRoute)) {
|
||||||
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
|
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
|
||||||
injectedTabs.add(tabId);
|
|
||||||
} else if (!isDesiredRoute) {
|
|
||||||
injectedTabs.delete(tabId); // Clear entry if navigated away from LinkedIn company page.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
const createNewButton = (
|
import { isDefined } from '~/utils/isDefined';
|
||||||
text: string,
|
|
||||||
onClickHandler: () => void,
|
export const createDefaultButton = (
|
||||||
): HTMLDivElement => {
|
buttonId: string,
|
||||||
|
onClickHandler?: () => void,
|
||||||
|
buttonText = '',
|
||||||
|
) => {
|
||||||
|
const btn = document.getElementById(buttonId);
|
||||||
|
if (isDefined(btn)) return btn;
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
|
|
||||||
span.textContent = text;
|
span.textContent = buttonText;
|
||||||
img.src =
|
img.src =
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=';
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=';
|
||||||
img.height = 16;
|
img.height = 16;
|
||||||
@ -32,43 +37,38 @@ const createNewButton = (
|
|||||||
|
|
||||||
Object.assign(div.style, divStyles);
|
Object.assign(div.style, divStyles);
|
||||||
|
|
||||||
// // Apply common styles to the button.
|
// Apply common styles to specifc states of a button.
|
||||||
// Object.assign(buttonDiv.style, buttonDivStyles);
|
div.addEventListener('mouseenter', () => {
|
||||||
|
const hoverStyles = {
|
||||||
|
//eslint-disable-next-line @nx/workspace-no-hardcoded-colors
|
||||||
|
backgroundColor: '#5e5e5e',
|
||||||
|
//eslint-disable-next-line @nx/workspace-no-hardcoded-colors
|
||||||
|
borderColor: '#5e5e5e',
|
||||||
|
};
|
||||||
|
Object.assign(div.style, hoverStyles);
|
||||||
|
});
|
||||||
|
|
||||||
// // Apply common styles to specifc states of a button.
|
div.addEventListener('mouseleave', () => {
|
||||||
// newButton.addEventListener('mouseenter', () => {
|
Object.assign(div.style, divStyles);
|
||||||
// const hoverStyles = {
|
});
|
||||||
// backgroundColor: '#5e5e5e',
|
|
||||||
// borderColor: '#5e5e5e',
|
|
||||||
// };
|
|
||||||
// Object.assign(newButton.style, hoverStyles);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// newButton.addEventListener('mouseleave', () => {
|
div.addEventListener('click', async (e) => {
|
||||||
// Object.assign(newButton.style, buttonStyles);
|
e.preventDefault();
|
||||||
// });
|
const store = await chrome.storage.local.get();
|
||||||
|
|
||||||
// Handle the click event.
|
|
||||||
div.addEventListener('click', async () => {
|
|
||||||
const { apiKey } = await chrome.storage.local.get('apiKey');
|
|
||||||
|
|
||||||
// If an api key is not set, the options page opens up to allow the user to configure an api key.
|
// If an api key is not set, the options page opens up to allow the user to configure an api key.
|
||||||
if (!apiKey) {
|
if (!store.apiKey) {
|
||||||
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
|
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update content during the resolution of the request.
|
onClickHandler?.();
|
||||||
span.textContent = 'Saving...';
|
|
||||||
|
|
||||||
// Call the provided onClickHandler function to handle button click logic
|
|
||||||
onClickHandler();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
div.id = buttonId;
|
||||||
|
|
||||||
div.appendChild(img);
|
div.appendChild(img);
|
||||||
div.appendChild(span);
|
div.appendChild(span);
|
||||||
|
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createNewButton;
|
|
||||||
|
|||||||
@ -1,123 +1,117 @@
|
|||||||
import createNewButton from '~/contentScript/createButton';
|
import { createDefaultButton } from '~/contentScript/createButton';
|
||||||
import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink';
|
import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink';
|
||||||
import extractDomain from '~/contentScript/utils/extractDomain';
|
import extractDomain from '~/contentScript/utils/extractDomain';
|
||||||
import { createCompany, fetchCompany } from '~/db/company.db';
|
import { createCompany, fetchCompany } from '~/db/company.db';
|
||||||
import { CompanyInput } from '~/db/types/company.types';
|
import { CompanyInput } from '~/db/types/company.types';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
|
export const checkIfCompanyExists = async () => {
|
||||||
|
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||||
|
action: 'getActiveTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyURL = extractCompanyLinkedinLink(activeTab.url);
|
||||||
|
|
||||||
|
return await fetchCompany({
|
||||||
|
linkedinLink: {
|
||||||
|
url: { eq: companyURL },
|
||||||
|
label: { eq: companyURL },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addCompany = async () => {
|
||||||
|
// Extract company-specific data from the DOM
|
||||||
|
const companyNameElement = document.querySelector(
|
||||||
|
'.org-top-card-summary__title',
|
||||||
|
);
|
||||||
|
const domainNameElement = document.querySelector(
|
||||||
|
'.org-top-card-primary-actions__inner a',
|
||||||
|
);
|
||||||
|
const addressElement = document.querySelectorAll(
|
||||||
|
'.org-top-card-summary-info-list__info-item',
|
||||||
|
)[1];
|
||||||
|
const employeesNumberElement = document.querySelectorAll(
|
||||||
|
'.org-top-card-summary-info-list__info-item',
|
||||||
|
)[3];
|
||||||
|
|
||||||
|
// Get the text content or other necessary data from the DOM elements
|
||||||
|
const companyName = companyNameElement
|
||||||
|
? companyNameElement.getAttribute('title')
|
||||||
|
: '';
|
||||||
|
const domainName = extractDomain(
|
||||||
|
domainNameElement && domainNameElement.getAttribute('href'),
|
||||||
|
);
|
||||||
|
const address = addressElement
|
||||||
|
? addressElement.textContent?.trim().replace(/\s+/g, ' ')
|
||||||
|
: '';
|
||||||
|
const employees = employeesNumberElement
|
||||||
|
? Number(
|
||||||
|
employeesNumberElement.textContent
|
||||||
|
?.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.split('-')[0],
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Prepare company data to send to the backend
|
||||||
|
const companyInputData: CompanyInput = {
|
||||||
|
name: companyName ?? '',
|
||||||
|
domainName: domainName,
|
||||||
|
address: address ?? '',
|
||||||
|
employees: employees,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
|
||||||
|
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||||
|
action: 'getActiveTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty
|
||||||
|
const companyURL = extractCompanyLinkedinLink(activeTab.url);
|
||||||
|
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
|
||||||
|
|
||||||
|
const company = await createCompany(companyInputData);
|
||||||
|
return company;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertButtonForCompany = async () => {
|
||||||
|
const companyButtonDiv = createDefaultButton(
|
||||||
|
'twenty-company-btn',
|
||||||
|
async () => {
|
||||||
|
if (isDefined(companyButtonDiv)) {
|
||||||
|
const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0];
|
||||||
|
companyBtnSpan.textContent = 'Saving...';
|
||||||
|
const company = await addCompany();
|
||||||
|
if (isDefined(company)) {
|
||||||
|
companyBtnSpan.textContent = 'Saved';
|
||||||
|
Object.assign(companyButtonDiv.style, { pointerEvents: 'none' });
|
||||||
|
} else {
|
||||||
|
companyBtnSpan.textContent = 'Try again';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const insertButtonForCompany = async (): Promise<void> => {
|
|
||||||
// Select the element in which to create the button.
|
|
||||||
const parentDiv: HTMLDivElement | null = document.querySelector(
|
const parentDiv: HTMLDivElement | null = document.querySelector(
|
||||||
'.org-top-card-primary-actions__inner',
|
'.org-top-card-primary-actions__inner',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the button with desired callback funciton to execute upon click.
|
if (isDefined(parentDiv)) {
|
||||||
if (parentDiv) {
|
Object.assign(companyButtonDiv.style, {
|
||||||
// Extract company-specific data from the DOM
|
marginLeft: '.8rem',
|
||||||
const companyNameElement = document.querySelector(
|
marginTop: '.4rem',
|
||||||
'.org-top-card-summary__title',
|
|
||||||
);
|
|
||||||
const domainNameElement = document.querySelector(
|
|
||||||
'.org-top-card-primary-actions__inner a',
|
|
||||||
);
|
|
||||||
const addressElement = document.querySelectorAll(
|
|
||||||
'.org-top-card-summary-info-list__info-item',
|
|
||||||
)[1];
|
|
||||||
const employeesNumberElement = document.querySelectorAll(
|
|
||||||
'.org-top-card-summary-info-list__info-item',
|
|
||||||
)[3];
|
|
||||||
|
|
||||||
// Get the text content or other necessary data from the DOM elements
|
|
||||||
const companyName = companyNameElement
|
|
||||||
? companyNameElement.getAttribute('title')
|
|
||||||
: '';
|
|
||||||
const domainName = extractDomain(
|
|
||||||
domainNameElement && domainNameElement.getAttribute('href'),
|
|
||||||
);
|
|
||||||
const address = addressElement
|
|
||||||
? addressElement.textContent?.trim().replace(/\s+/g, ' ')
|
|
||||||
: '';
|
|
||||||
const employees = employeesNumberElement
|
|
||||||
? Number(
|
|
||||||
employeesNumberElement.textContent
|
|
||||||
?.trim()
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.split('-')[0],
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Prepare company data to send to the backend
|
|
||||||
const companyInputData: CompanyInput = {
|
|
||||||
name: companyName ?? '',
|
|
||||||
domainName: domainName,
|
|
||||||
address: address ?? '',
|
|
||||||
employees: employees,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
|
|
||||||
const { url: activeTabUrl } = await chrome.runtime.sendMessage({
|
|
||||||
action: 'getActiveTabUrl',
|
|
||||||
});
|
});
|
||||||
|
parentDiv.prepend(companyButtonDiv);
|
||||||
|
}
|
||||||
|
|
||||||
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty
|
const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0];
|
||||||
const companyURL = extractCompanyLinkedinLink(activeTabUrl);
|
const company = await checkIfCompanyExists();
|
||||||
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
|
|
||||||
|
|
||||||
const company = await fetchCompany({
|
if (isDefined(company)) {
|
||||||
linkedinLink: {
|
companyBtnSpan.textContent = 'Saved';
|
||||||
url: { eq: companyURL },
|
Object.assign(companyButtonDiv.style, { pointerEvents: 'none' });
|
||||||
label: { eq: companyURL },
|
} else {
|
||||||
},
|
companyBtnSpan.textContent = 'Add to Twenty';
|
||||||
});
|
|
||||||
if (company) {
|
|
||||||
const savedCompany: HTMLDivElement = createNewButton(
|
|
||||||
'Saved',
|
|
||||||
async () => {},
|
|
||||||
);
|
|
||||||
// Include the button in the DOM.
|
|
||||||
parentDiv.prepend(savedCompany);
|
|
||||||
|
|
||||||
// Write button specific styles here - common ones can be found in createButton.ts.
|
|
||||||
const buttonSpecificStyles = {
|
|
||||||
alignSelf: 'end',
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(savedCompany.style, buttonSpecificStyles);
|
|
||||||
} else {
|
|
||||||
const newButtonCompany: HTMLDivElement = createNewButton(
|
|
||||||
'Add to Twenty',
|
|
||||||
async () => {
|
|
||||||
const response = await createCompany(companyInputData);
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
newButtonCompany.textContent = 'Saved';
|
|
||||||
newButtonCompany.setAttribute('disabled', 'true');
|
|
||||||
|
|
||||||
// Button specific styles once the button is unclickable after successfully sending data to server.
|
|
||||||
newButtonCompany.addEventListener('mouseenter', () => {
|
|
||||||
const hoverStyles = {
|
|
||||||
backgroundColor: 'black',
|
|
||||||
borderColor: 'black',
|
|
||||||
cursor: 'default',
|
|
||||||
};
|
|
||||||
Object.assign(newButtonCompany.style, hoverStyles);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
newButtonCompany.textContent = 'Try Again';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Include the button in the DOM.
|
|
||||||
parentDiv.prepend(newButtonCompany);
|
|
||||||
|
|
||||||
// Write button specific styles here - common ones can be found in createButton.ts.
|
|
||||||
const buttonSpecificStyles = {
|
|
||||||
alignSelf: 'end',
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(newButtonCompany.style, buttonSpecificStyles);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default insertButtonForCompany;
|
|
||||||
|
|||||||
@ -1,127 +1,125 @@
|
|||||||
import createNewButton from '~/contentScript/createButton';
|
import { createDefaultButton } from '~/contentScript/createButton';
|
||||||
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
|
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
|
||||||
import { createPerson, fetchPerson } from '~/db/person.db';
|
import { createPerson, fetchPerson } from '~/db/person.db';
|
||||||
import { PersonInput } from '~/db/types/person.types';
|
import { PersonInput } from '~/db/types/person.types';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
const insertButtonForPerson = async (): Promise<void> => {
|
export const checkIfPersonExists = async () => {
|
||||||
// Select the element in which to create the button.
|
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||||
const parentDiv: HTMLDivElement | null = document.querySelector(
|
action: 'getActiveTab',
|
||||||
'.pv-top-card-v2-ctas',
|
});
|
||||||
|
|
||||||
|
let activeTabUrl = '';
|
||||||
|
if (isDefined(activeTab.url.endsWith('/'))) {
|
||||||
|
activeTabUrl = activeTab.url.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const personNameElement = document.querySelector('.text-heading-xlarge');
|
||||||
|
const personName = personNameElement ? personNameElement.textContent : '';
|
||||||
|
|
||||||
|
const { firstName, lastName } = extractFirstAndLastName(String(personName));
|
||||||
|
const person = await fetchPerson({
|
||||||
|
name: {
|
||||||
|
firstName: { eq: firstName },
|
||||||
|
lastName: { eq: lastName },
|
||||||
|
},
|
||||||
|
linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } },
|
||||||
|
});
|
||||||
|
return person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addPerson = async () => {
|
||||||
|
const personNameElement = document.querySelector('.text-heading-xlarge');
|
||||||
|
|
||||||
|
const separatorElement = document.querySelector(
|
||||||
|
'.pv-text-details__separator',
|
||||||
|
);
|
||||||
|
const personCityElement = separatorElement?.previousElementSibling;
|
||||||
|
|
||||||
|
const profilePictureElement = document.querySelector(
|
||||||
|
'.pv-top-card-profile-picture__image',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the button with desired callback funciton to execute upon click.
|
const firstListItem = document.querySelector(
|
||||||
if (parentDiv) {
|
'div[data-view-name="profile-component-entity"]',
|
||||||
// Extract person-specific data from the DOM.
|
);
|
||||||
const personNameElement = document.querySelector('.text-heading-xlarge');
|
const secondDivElement = firstListItem?.querySelector('div:nth-child(2)');
|
||||||
|
const ariaHiddenSpan = secondDivElement?.querySelector(
|
||||||
|
'span[aria-hidden="true"]',
|
||||||
|
);
|
||||||
|
|
||||||
const separatorElement = document.querySelector(
|
// Get the text content or other necessary data from the DOM elements.
|
||||||
'.pv-text-details__separator',
|
const personName = personNameElement ? personNameElement.textContent : '';
|
||||||
);
|
const personCity = personCityElement
|
||||||
const personCityElement = separatorElement?.previousElementSibling;
|
? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0]
|
||||||
|
: '';
|
||||||
|
const profilePicture = profilePictureElement
|
||||||
|
? profilePictureElement?.getAttribute('src')
|
||||||
|
: '';
|
||||||
|
const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : '';
|
||||||
|
|
||||||
const profilePictureElement = document.querySelector(
|
const { firstName, lastName } = extractFirstAndLastName(String(personName));
|
||||||
'.pv-top-card-profile-picture__image',
|
|
||||||
|
// Prepare person data to send to the backend.
|
||||||
|
const personData: PersonInput = {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
city: personCity ?? '',
|
||||||
|
avatarUrl: profilePicture ?? '',
|
||||||
|
jobTitle: jobTitle ?? '',
|
||||||
|
linkedinLink: { url: '', label: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
|
||||||
|
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||||
|
action: 'getActiveTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeTabUrl = '';
|
||||||
|
|
||||||
|
// Remove last slash from the URL for consistency when saving usernames.
|
||||||
|
if (isDefined(activeTab.url.endsWith('/'))) {
|
||||||
|
activeTabUrl = activeTab.url.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
|
||||||
|
const person = await createPerson(personData);
|
||||||
|
return person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertButtonForPerson = async () => {
|
||||||
|
const personButtonDiv = createDefaultButton('twenty-person-btn', async () => {
|
||||||
|
if (isDefined(personButtonDiv)) {
|
||||||
|
const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0];
|
||||||
|
personBtnSpan.textContent = 'Saving...';
|
||||||
|
const person = await addPerson();
|
||||||
|
if (isDefined(person)) {
|
||||||
|
personBtnSpan.textContent = 'Saved';
|
||||||
|
Object.assign(personButtonDiv.style, { pointerEvents: 'none' });
|
||||||
|
} else {
|
||||||
|
personBtnSpan.textContent = 'Try again';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDefined(personButtonDiv)) {
|
||||||
|
const parentDiv: HTMLDivElement | null = document.querySelector(
|
||||||
|
'.pv-top-card-v2-ctas',
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstListItem = document.querySelector(
|
if (isDefined(parentDiv)) {
|
||||||
'div[data-view-name="profile-component-entity"]',
|
Object.assign(personButtonDiv.style, {
|
||||||
);
|
marginRight: '.8rem',
|
||||||
const secondDivElement = firstListItem?.querySelector('div:nth-child(2)');
|
});
|
||||||
const ariaHiddenSpan = secondDivElement?.querySelector(
|
parentDiv.prepend(personButtonDiv);
|
||||||
'span[aria-hidden="true"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the text content or other necessary data from the DOM elements.
|
|
||||||
const personName = personNameElement ? personNameElement.textContent : '';
|
|
||||||
const personCity = personCityElement
|
|
||||||
? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0]
|
|
||||||
: '';
|
|
||||||
const profilePicture = profilePictureElement
|
|
||||||
? profilePictureElement?.getAttribute('src')
|
|
||||||
: '';
|
|
||||||
const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : '';
|
|
||||||
|
|
||||||
const { firstName, lastName } = extractFirstAndLastName(String(personName));
|
|
||||||
|
|
||||||
// Prepare person data to send to the backend.
|
|
||||||
const personData: PersonInput = {
|
|
||||||
name: { firstName, lastName },
|
|
||||||
city: personCity ?? '',
|
|
||||||
avatarUrl: profilePicture ?? '',
|
|
||||||
jobTitle: jobTitle ?? '',
|
|
||||||
linkedinLink: { url: '', label: '' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
|
|
||||||
let { url: activeTabUrl } = await chrome.runtime.sendMessage({
|
|
||||||
action: 'getActiveTabUrl',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove last slash from the URL for consistency when saving usernames.
|
|
||||||
if (activeTabUrl.endsWith('/')) {
|
|
||||||
activeTabUrl = activeTabUrl.slice(0, -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
|
const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0];
|
||||||
|
const person = await checkIfPersonExists();
|
||||||
const person = await fetchPerson({
|
if (isDefined(person)) {
|
||||||
name: {
|
personBtnSpan.textContent = 'Saved';
|
||||||
firstName: { eq: firstName },
|
Object.assign(personButtonDiv.style, { pointerEvents: 'none' });
|
||||||
lastName: { eq: lastName },
|
|
||||||
},
|
|
||||||
linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (person) {
|
|
||||||
const savedPerson: HTMLDivElement = createNewButton(
|
|
||||||
'Saved',
|
|
||||||
async () => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Include the button in the DOM.
|
|
||||||
parentDiv.prepend(savedPerson);
|
|
||||||
|
|
||||||
// Write button specific styles here - common ones can be found in createButton.ts.
|
|
||||||
const buttonSpecificStyles = {
|
|
||||||
marginRight: '0.5em',
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(savedPerson.style, buttonSpecificStyles);
|
|
||||||
} else {
|
} else {
|
||||||
const newButtonPerson: HTMLDivElement = createNewButton(
|
personBtnSpan.textContent = 'Add to Twenty';
|
||||||
'Add to Twenty',
|
|
||||||
async () => {
|
|
||||||
const response = await createPerson(personData);
|
|
||||||
if (response) {
|
|
||||||
newButtonPerson.textContent = 'Saved';
|
|
||||||
newButtonPerson.setAttribute('disabled', 'true');
|
|
||||||
|
|
||||||
// Button specific styles once the button is unclickable after successfully sending data to server.
|
|
||||||
newButtonPerson.addEventListener('mouseenter', () => {
|
|
||||||
const hoverStyles = {
|
|
||||||
backgroundColor: 'black',
|
|
||||||
borderColor: 'black',
|
|
||||||
cursor: 'default',
|
|
||||||
};
|
|
||||||
Object.assign(newButtonPerson.style, hoverStyles);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
newButtonPerson.textContent = 'Try Again';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Include the button in the DOM.
|
|
||||||
parentDiv.prepend(newButtonPerson);
|
|
||||||
|
|
||||||
// Write button specific styles here - common ones can be found in createButton.ts.
|
|
||||||
const buttonSpecificStyles = {
|
|
||||||
marginRight: '0.5em',
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.assign(newButtonPerson.style, buttonSpecificStyles);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default insertButtonForPerson;
|
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
import insertButtonForCompany from '~/contentScript/extractCompanyProfile';
|
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
|
||||||
import insertButtonForPerson from '~/contentScript/extractPersonProfile';
|
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
|
||||||
|
|
||||||
// Inject buttons into the DOM when SPA is reloaded on the resource url.
|
// Inject buttons into the DOM when SPA is reloaded on the resource url.
|
||||||
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
|
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
|
||||||
await insertButtonForCompany();
|
// await insertButtonForCompany();
|
||||||
await insertButtonForPerson();
|
(async () => {
|
||||||
|
await insertButtonForCompany();
|
||||||
|
await insertButtonForPerson();
|
||||||
|
})();
|
||||||
|
|
||||||
// The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/.
|
// The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/.
|
||||||
// However, there would never be another reload in a single page application unless triggered manually.
|
// However, there would never be another reload in a single page application unless triggered manually.
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page.
|
// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page.
|
||||||
|
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
|
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
|
||||||
const extractCompanyLinkedinLink = (activeTabUrl: string) => {
|
const extractCompanyLinkedinLink = (activeTabUrl: string) => {
|
||||||
// Regular expression to match the company ID
|
// Regular expression to match the company ID
|
||||||
@ -7,7 +10,7 @@ const extractCompanyLinkedinLink = (activeTabUrl: string) => {
|
|||||||
// Extract the company ID using the regex
|
// Extract the company ID using the regex
|
||||||
const match = activeTabUrl.match(regex);
|
const match = activeTabUrl.match(regex);
|
||||||
|
|
||||||
if (match && match[1]) {
|
if (isDefined(match) && isDefined(match[1])) {
|
||||||
const companyID = match[1];
|
const companyID = match[1];
|
||||||
const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`;
|
const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`;
|
||||||
return cleanCompanyURL;
|
return cleanCompanyURL;
|
||||||
|
|||||||
@ -6,33 +6,42 @@ import {
|
|||||||
import { Company, CompanyFilterInput } from '~/generated/graphql';
|
import { Company, CompanyFilterInput } from '~/generated/graphql';
|
||||||
import { CREATE_COMPANY } from '~/graphql/company/mutations';
|
import { CREATE_COMPANY } from '~/graphql/company/mutations';
|
||||||
import { FIND_COMPANY } from '~/graphql/company/queries';
|
import { FIND_COMPANY } from '~/graphql/company/queries';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
import { callMutation, callQuery } from '../utils/requestDb';
|
import { callMutation, callQuery } from '../utils/requestDb';
|
||||||
|
|
||||||
export const fetchCompany = async (
|
export const fetchCompany = async (
|
||||||
companyfilerInput: CompanyFilterInput,
|
companyfilerInput: CompanyFilterInput,
|
||||||
): Promise<Company | null> => {
|
): Promise<Company | null> => {
|
||||||
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
try {
|
||||||
filter: {
|
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
|
||||||
...companyfilerInput,
|
filter: {
|
||||||
},
|
...companyfilerInput,
|
||||||
});
|
},
|
||||||
if (data?.companies.edges) {
|
});
|
||||||
return data?.companies.edges.length > 0
|
if (isDefined(data?.companies.edges)) {
|
||||||
? data?.companies.edges[0].node
|
return data?.companies.edges.length > 0
|
||||||
: null;
|
? data?.companies.edges[0].node
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCompany = async (
|
export const createCompany = async (
|
||||||
company: CompanyInput,
|
company: CompanyInput,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
try {
|
||||||
input: company,
|
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
|
||||||
});
|
input: company,
|
||||||
if (data) {
|
});
|
||||||
return data.createCompany.id;
|
if (isDefined(data)) {
|
||||||
|
return data.createCompany.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,31 +6,40 @@ import {
|
|||||||
import { Person, PersonFilterInput } from '~/generated/graphql';
|
import { Person, PersonFilterInput } from '~/generated/graphql';
|
||||||
import { CREATE_PERSON } from '~/graphql/person/mutations';
|
import { CREATE_PERSON } from '~/graphql/person/mutations';
|
||||||
import { FIND_PERSON } from '~/graphql/person/queries';
|
import { FIND_PERSON } from '~/graphql/person/queries';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
import { callMutation, callQuery } from '../utils/requestDb';
|
import { callMutation, callQuery } from '../utils/requestDb';
|
||||||
|
|
||||||
export const fetchPerson = async (
|
export const fetchPerson = async (
|
||||||
personFilterData: PersonFilterInput,
|
personFilterData: PersonFilterInput,
|
||||||
): Promise<Person | null> => {
|
): Promise<Person | null> => {
|
||||||
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
try {
|
||||||
filter: {
|
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
|
||||||
...personFilterData,
|
filter: {
|
||||||
},
|
...personFilterData,
|
||||||
});
|
},
|
||||||
if (data?.people.edges) {
|
});
|
||||||
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null;
|
if (isDefined(data?.people.edges)) {
|
||||||
|
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPerson = async (
|
export const createPerson = async (
|
||||||
person: PersonInput,
|
person: PersonInput,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
try {
|
||||||
input: person,
|
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
|
||||||
});
|
input: person,
|
||||||
if (data?.createPerson) {
|
});
|
||||||
return data.createPerson.id;
|
if (isDefined(data?.createPerson)) {
|
||||||
|
return data.createPerson.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { H2Title } from '@/ui/display/typography/components/H2Title';
|
|||||||
import { Button } from '@/ui/input/button/Button';
|
import { Button } from '@/ui/input/button/Button';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
import { Toggle } from '@/ui/input/components/Toggle';
|
import { Toggle } from '@/ui/input/components/Toggle';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
const StyledContainer = styled.div<{ isToggleOn: boolean }>`
|
const StyledContainer = styled.div<{ isToggleOn: boolean }>`
|
||||||
width: 400px;
|
width: 400px;
|
||||||
@ -71,11 +72,11 @@ export const ApiKeyForm = () => {
|
|||||||
const getState = async () => {
|
const getState = async () => {
|
||||||
const localStorage = await chrome.storage.local.get();
|
const localStorage = await chrome.storage.local.get();
|
||||||
|
|
||||||
if (localStorage.apiKey) {
|
if (isDefined(localStorage.apiKey)) {
|
||||||
setApiKey(localStorage.apiKey);
|
setApiKey(localStorage.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localStorage.serverBaseUrl) {
|
if (isDefined(localStorage.serverBaseUrl)) {
|
||||||
setShowSection(true);
|
setShowSection(true);
|
||||||
setRoute(localStorage.serverBaseUrl);
|
setRoute(localStorage.serverBaseUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
export type ToggleSize = 'small' | 'medium';
|
export type ToggleSize = 'small' | 'medium';
|
||||||
|
|
||||||
type ContainerProps = {
|
type ContainerProps = {
|
||||||
@ -54,7 +56,7 @@ export const Toggle = ({
|
|||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setIsOn(!isOn);
|
setIsOn(!isOn);
|
||||||
|
|
||||||
if (onChange) {
|
if (isDefined(onChange)) {
|
||||||
onChange(!isOn);
|
onChange(!isOn);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const handleQueryParams = (inputData: { [x: string]: unknown }): string => {
|
|||||||
result = result.concat(`${key}: ${quote}${inputData[key]}${quote}, `);
|
result = result.concat(`${key}: ${quote}${inputData[key]}${quote}, `);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (result.length) result = result.slice(0, -2); // Remove the last ', '
|
if (result.length > 0) result = result.slice(0, -2); // Remove the last ', '
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
4
packages/twenty-chrome-extension/src/utils/isDefined.ts
Normal file
4
packages/twenty-chrome-extension/src/utils/isDefined.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { isNull, isUndefined } from '@sniptt/guards';
|
||||||
|
|
||||||
|
export const isDefined = <T>(value: T | null | undefined): value is T =>
|
||||||
|
!isUndefined(value) && !isNull(value);
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
"**/*.spec.tsx",
|
"**/*.spec.tsx",
|
||||||
"**/*.test.tsx",
|
"**/*.test.tsx",
|
||||||
"jest.config.ts",
|
"jest.config.ts"
|
||||||
],
|
],
|
||||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user