diff --git a/.github/workflows/ci-chrome-extension.yaml b/.github/workflows/ci-chrome-extension.yaml new file mode 100644 index 0000000000..d450cc4378 --- /dev/null +++ b/.github/workflows/ci-chrome-extension.yaml @@ -0,0 +1,66 @@ +name: CI Chrome Extension +on: + push: + branches: + - main + pull_request: +jobs: + chrome-extension-yarn-install: + runs-on: ci-8-cores + env: + VITE_SERVER_BASE_URL: http://localhost:3000 + VITE_FRONT_BASE_URL: http://localhost:3001 + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + - name: Cache chrome extension node modules + uses: actions/cache@v3 + with: + path: packages/twenty-chrome-extension/node_modules + key: chrome-extension-node_modules-${{hashFiles('yarn.lock')}} + restore-keys: chrome-extension-node_modules- + - name: Cache root node modules + uses: actions/cache@v3 + with: + path: node_modules + key: root-node_modules-${{hashFiles('yarn.lock')}} + restore-keys: root-node_modules- + - name: Chrome Extension / Install Dependencies + run: yarn + chrome-extension-build: + needs: chrome-extension-yarn-install + runs-on: ubuntu-latest + env: + VITE_SERVER_BASE_URL: http://localhost:3000 + VITE_FRONT_BASE_URL: http://localhost:3001 + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + - name: Cache chrome extension node modules + uses: actions/cache@v3 + with: + path: packages/twenty-chrome-extension/node_modules + key: chrome-extension-node_modules-${{hashFiles('yarn.lock')}} + restore-keys: chrome-extension-node_modules- + - name: Cache root node modules + uses: actions/cache@v3 + with: + path: node_modules + key: root-node_modules-${{hashFiles('yarn.lock')}} + restore-keys: root-node_modules- + - name: Chrome Extension / Run build + run: yarn nx build twenty-chrome-extension diff --git a/.vscode/twenty.code-workspace b/.vscode/twenty.code-workspace index 8e4364504c..5abed2559c 100644 --- a/.vscode/twenty.code-workspace +++ b/.vscode/twenty.code-workspace @@ -4,6 +4,10 @@ "name": "ROOT", "path": "../" }, + { + "name": "packages/twenty-chrome-extension", + "path": "../packages/twenty-chrome-extension" + }, { "name": "packages/twenty-docker", "path": "../packages/twenty-docker" diff --git a/package.json b/package.json index 66d5e176aa..3bfe6fea22 100644 --- a/package.json +++ b/package.json @@ -283,6 +283,7 @@ "version": "0.2.1", "workspaces": { "packages": [ + "packages/twenty-chrome-extension", "packages/twenty-front", "packages/twenty-docs", "packages/twenty-server", diff --git a/packages/twenty-chrome-extension/.env.example b/packages/twenty-chrome-extension/.env.example new file mode 100644 index 0000000000..4ab48f1d92 --- /dev/null +++ b/packages/twenty-chrome-extension/.env.example @@ -0,0 +1,2 @@ +VITE_SERVER_BASE_URL=http://localhost:3000 +VITE_FRONT_BASE_URL=http://localhost:3001 \ No newline at end of file diff --git a/packages/twenty-chrome-extension/.eslintrc.cjs b/packages/twenty-chrome-extension/.eslintrc.cjs new file mode 100644 index 0000000000..d6c9537953 --- /dev/null +++ b/packages/twenty-chrome-extension/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/packages/twenty-chrome-extension/.gitignore b/packages/twenty-chrome-extension/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/packages/twenty-chrome-extension/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/twenty-chrome-extension/README.md b/packages/twenty-chrome-extension/README.md new file mode 100644 index 0000000000..fbe86fdfa9 --- /dev/null +++ b/packages/twenty-chrome-extension/README.md @@ -0,0 +1,51 @@ +# Twenty Chrome Extension. + +This extension allows you to save `company` and `people` information to your twenty workspace directly from LinkedIn. + +To install the extension in development mode with hmr (hot module reload), follow these steps. + +- STEP 1: Clone the repository and run `yarn install` in the root directory. +- STEP 2: Once the dependencies installation succeeds, create a file with env variables by executing the following command in the root directory. + +``` +cp ./packages/twenty-chrome-extension/.env.example ./packages/twenty-chrome-extension/.env +``` + +- STEP 3: Now, execute the following command in the root directory to start up the development server on Port 3002. This will create a `dist` folder in `twenty-chrome-extension`. + +``` +yarn nx start twenty-chrome-extension +``` + +- STEP 4: Open Google Chrome and head to the extensions page by typing `chrome://extensions` in the address bar. + +

+ +

+ +- STEP 5: Turn on the `Developer mode` from the top-right corner and click `Load unpacked`. + +

+ +

+ +- STEP 6: Select the `dist` folder from `twenty-chrome-extension`. + +

+ +

+ +- STEP 7: This opens up the `options` page, where you must enter your API key. + +

+ +

+ +- STEP 8: Reload any LinkedIn page that you opened before installing the extension for seamless experience. +- STEP 9: Visit any individual or company profile on LinkedIn and click the `Add to Twenty` button to test. + +

+ +

+ +To install the extension in production mode without hmr (hot module reload), replace the command in STEP THREE with `yarn nx build twenty-chrome-extension`. diff --git a/packages/twenty-chrome-extension/options.html b/packages/twenty-chrome-extension/options.html new file mode 100644 index 0000000000..f36fb44721 --- /dev/null +++ b/packages/twenty-chrome-extension/options.html @@ -0,0 +1,12 @@ + + + + + + Twenty + + +
+ + + diff --git a/packages/twenty-chrome-extension/package.json b/packages/twenty-chrome-extension/package.json new file mode 100644 index 0000000000..153c255dae --- /dev/null +++ b/packages/twenty-chrome-extension/package.json @@ -0,0 +1,18 @@ +{ + "name": "twenty-chrome-extension", + "description": "", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js", + "start": "vite", + "build": "tsc && vite build" + }, + "dependencies": { + "@types/chrome": "^0.0.256" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^1.0.14" + } +} diff --git a/packages/twenty-chrome-extension/public/logo/32-32.png b/packages/twenty-chrome-extension/public/logo/32-32.png new file mode 100644 index 0000000000..b373357081 Binary files /dev/null and b/packages/twenty-chrome-extension/public/logo/32-32.png differ diff --git a/packages/twenty-chrome-extension/public/readme-images/01-img-one.png b/packages/twenty-chrome-extension/public/readme-images/01-img-one.png new file mode 100644 index 0000000000..220d400ad5 Binary files /dev/null and b/packages/twenty-chrome-extension/public/readme-images/01-img-one.png differ diff --git a/packages/twenty-chrome-extension/public/readme-images/02-img-two.png b/packages/twenty-chrome-extension/public/readme-images/02-img-two.png new file mode 100644 index 0000000000..a80e7adc1e Binary files /dev/null and b/packages/twenty-chrome-extension/public/readme-images/02-img-two.png differ diff --git a/packages/twenty-chrome-extension/public/readme-images/03-img-three.png b/packages/twenty-chrome-extension/public/readme-images/03-img-three.png new file mode 100644 index 0000000000..2bcc5ec06d Binary files /dev/null and b/packages/twenty-chrome-extension/public/readme-images/03-img-three.png differ diff --git a/packages/twenty-chrome-extension/public/readme-images/04-img-four.png b/packages/twenty-chrome-extension/public/readme-images/04-img-four.png new file mode 100644 index 0000000000..eaea637fb9 Binary files /dev/null and b/packages/twenty-chrome-extension/public/readme-images/04-img-four.png differ diff --git a/packages/twenty-chrome-extension/public/readme-images/05-img-five.png b/packages/twenty-chrome-extension/public/readme-images/05-img-five.png new file mode 100644 index 0000000000..ab88203cde Binary files /dev/null and b/packages/twenty-chrome-extension/public/readme-images/05-img-five.png differ diff --git a/packages/twenty-chrome-extension/public/vite.svg b/packages/twenty-chrome-extension/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/packages/twenty-chrome-extension/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/twenty-chrome-extension/src/background/index.ts b/packages/twenty-chrome-extension/src/background/index.ts new file mode 100644 index 0000000000..8106da1637 --- /dev/null +++ b/packages/twenty-chrome-extension/src/background/index.ts @@ -0,0 +1,62 @@ +import { openOptionsPage } from './utils/openOptionsPage'; + +console.log('Background Script Works'); + +// Open options page programmatically in a new tab. +chrome.runtime.onInstalled.addListener(function (details) { + if (details.reason === 'install') { + openOptionsPage(); + } +}); + +// Open options page when extension icon is clicked. +chrome.action.onClicked.addListener(function () { + openOptionsPage(); +}); + +// This listens for an event from other parts of the extension, such as the content script, and performs the required tasks. +// The cases themselves are labelled such that their operations are reflected by their names. +chrome.runtime.onMessage.addListener((message, _, sendResponse) => { + switch (message.action) { + case 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/" + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs && tabs[0]) { + const activeTabUrl: string | undefined = tabs[0].url; + sendResponse({ url: activeTabUrl }); + } + }); + break; + case 'openOptionsPage': + openOptionsPage(); + break; + default: + break; + } + + 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 = new Set(); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + const isDesiredRoute = + tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) || + tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/); + + if (changeInfo.status === 'complete' && tab.active) { + if (isDesiredRoute && !injectedTabs.has(tabId)) { + chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' }); + injectedTabs.add(tabId); + } else if (!isDesiredRoute) { + injectedTabs.delete(tabId); // Clear entry if navigated away from LinkedIn company page. + } + } +}); diff --git a/packages/twenty-chrome-extension/src/background/utils/openOptionsPage.ts b/packages/twenty-chrome-extension/src/background/utils/openOptionsPage.ts new file mode 100644 index 0000000000..a86b5fd665 --- /dev/null +++ b/packages/twenty-chrome-extension/src/background/utils/openOptionsPage.ts @@ -0,0 +1,5 @@ +const openOptionsPage = () => { + chrome.runtime.openOptionsPage(); +}; + +export { openOptionsPage }; diff --git a/packages/twenty-chrome-extension/src/contentScript/createButton.ts b/packages/twenty-chrome-extension/src/contentScript/createButton.ts new file mode 100644 index 0000000000..855ea0e9c9 --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/createButton.ts @@ -0,0 +1,57 @@ +function createNewButton( + text: string, + onClickHandler: () => void, +): HTMLButtonElement { + const newButton: HTMLButtonElement = document.createElement('button'); + newButton.textContent = text; + + // Write universal styles for the button + const buttonStyles = { + border: '1px solid black', + borderRadius: '20px', + backgroundColor: 'black', + color: 'white', + fontSize: '1.5rem', + fontWeight: '600', + padding: '0.45em 1em', + width: '15rem', + height: '32px', + }; + + // Apply common styles to the button. + Object.assign(newButton.style, buttonStyles); + + // Apply common styles to specifc states of a button. + newButton.addEventListener('mouseenter', () => { + const hoverStyles = { + backgroundColor: '#5e5e5e', + borderColor: '#5e5e5e', + }; + Object.assign(newButton.style, hoverStyles); + }); + + newButton.addEventListener('mouseleave', () => { + Object.assign(newButton.style, buttonStyles); + }); + + // Handle the click event. + newButton.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 (!apiKey) { + chrome.runtime.sendMessage({ action: 'openOptionsPage' }); + return; + } + + // Update content during the resolution of the request. + newButton.textContent = 'Saving...'; + + // Call the provided onClickHandler function to handle button click logic + onClickHandler(); + }); + + return newButton; +} + +export default createNewButton; diff --git a/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts b/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts new file mode 100644 index 0000000000..1717ae6cf5 --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts @@ -0,0 +1,106 @@ +import handleQueryParams from '../utils/handleQueryParams'; +import requestDb from '../utils/requestDb'; +import createNewButton from './createButton'; +import extractCompanyLinkedinLink from './utils/extractCompanyLinkedinLink'; +import extractDomain from './utils/extractDomain'; + +function insertButtonForCompany(): void { + // Select the element in which to create the button. + const parentDiv: HTMLDivElement | null = document.querySelector( + '.org-top-card-primary-actions__inner', + ); + + // Create the button with desired callback funciton to execute upon click. + if (parentDiv) { + const newButtonCompany: HTMLButtonElement = createNewButton( + 'Add to Twenty', + 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 companyData = { + name: companyName, + domainName: domainName, + address: address, + employees: employees, + linkedinLink: { url: '', label: '' }, + }; + + // 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', + }); + + // Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty + const companyURL = extractCompanyLinkedinLink(activeTabUrl); + companyData.linkedinLink = { url: companyURL, label: companyURL }; + + const query = `mutation CreateOneCompany { createCompany(data:{${handleQueryParams( + companyData, + )}}) {id} }`; + + const response = await requestDb(query); + + if (response.data) { + 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; diff --git a/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts b/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts new file mode 100644 index 0000000000..367820dea6 --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts @@ -0,0 +1,119 @@ +import handleQueryParams from '../utils/handleQueryParams'; +import requestDb from '../utils/requestDb'; +import createNewButton from './createButton'; +import extractFirstAndLastName from './utils/extractFirstAndLastName'; + +function insertButtonForPerson(): void { + // Select the element in which to create the button. + const parentDiv: HTMLDivElement | null = document.querySelector( + '.pv-top-card-v2-ctas', + ); + + // Create the button with desired callback funciton to execute upon click. + if (parentDiv) { + const newButtonPerson: HTMLButtonElement = createNewButton( + 'Add to Twenty', + async () => { + // Extract person-specific data from the DOM. + 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', + ); + + const firstListItem = document.querySelector( + 'div[data-view-name="profile-component-entity"]', + ); + const secondDivElement = + firstListItem?.querySelector('div:nth-child(2)'); + const ariaHiddenSpan = secondDivElement?.querySelector( + '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 = { + name: { firstName, lastName }, + city: personCity, + avatarUrl: profilePicture, + 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 query = `mutation CreateOnePerson { createPerson(data:{${handleQueryParams( + personData, + )}}) {id} }`; + + const response = await requestDb(query); + + if (response.data) { + 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; diff --git a/packages/twenty-chrome-extension/src/contentScript/index.ts b/packages/twenty-chrome-extension/src/contentScript/index.ts new file mode 100644 index 0000000000..10a3208afd --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/index.ts @@ -0,0 +1,20 @@ +import insertButtonForPerson from './extractPersonProfile'; +import insertButtonForCompany from './extractCompanyProfile'; + +// 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/ +insertButtonForCompany(); +insertButtonForPerson(); + +// 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. +// Therefore, if the user navigates to a person or a company page, we must manually re-execute the content script to create the "Add to Twenty" button. +// e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/ +chrome.runtime.onMessage.addListener((message, _, sendResponse) => { + if (message.action === 'executeContentScript') { + insertButtonForCompany(); + insertButtonForPerson(); + } + + sendResponse('Executing!'); +}); diff --git a/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts b/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts new file mode 100644 index 0000000000..28e4f2d3ff --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts @@ -0,0 +1,19 @@ +// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page. +// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/". +const extractCompanyLinkedinLink = (activeTabUrl: string) => { + // Regular expression to match the company ID + const regex = /\/company\/([^/]*)/; + + // Extract the company ID using the regex + const match = activeTabUrl.match(regex); + + if (match && match[1]) { + const companyID = match[1]; + const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`; + return cleanCompanyURL; + } + + return ''; +}; + +export default extractCompanyLinkedinLink; diff --git a/packages/twenty-chrome-extension/src/contentScript/utils/extractDomain.ts b/packages/twenty-chrome-extension/src/contentScript/utils/extractDomain.ts new file mode 100644 index 0000000000..3cb5f8d00f --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/utils/extractDomain.ts @@ -0,0 +1,15 @@ +function extractDomain(url: string | null) { + if (!url) return ''; + + const hostname = new URL(url).hostname; + let domain = hostname.replace('www.', ''); + + const parts = domain.split('.'); + if (parts.length > 2) { + domain = parts.slice(1).join('.'); + } + + return domain; +} + +export default extractDomain; diff --git a/packages/twenty-chrome-extension/src/contentScript/utils/extractFirstAndLastName.ts b/packages/twenty-chrome-extension/src/contentScript/utils/extractFirstAndLastName.ts new file mode 100644 index 0000000000..5074609e91 --- /dev/null +++ b/packages/twenty-chrome-extension/src/contentScript/utils/extractFirstAndLastName.ts @@ -0,0 +1,9 @@ +// Separate first name and last name from a full name. +const extractFirstAndLastName = (fullName: string) => { + const spaceIndex = fullName.lastIndexOf(' '); + const firstName = fullName.substring(0, spaceIndex); + const lastName = fullName.substring(spaceIndex + 1); + return { firstName, lastName }; +}; + +export default extractFirstAndLastName; diff --git a/packages/twenty-chrome-extension/src/global.d.ts b/packages/twenty-chrome-extension/src/global.d.ts new file mode 100644 index 0000000000..dbb4c627d2 --- /dev/null +++ b/packages/twenty-chrome-extension/src/global.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string; diff --git a/packages/twenty-chrome-extension/src/index.css b/packages/twenty-chrome-extension/src/index.css new file mode 100644 index 0000000000..164e9067cc --- /dev/null +++ b/packages/twenty-chrome-extension/src/index.css @@ -0,0 +1,11 @@ +* { + margin: 0; + box-sizing: border-box; + font-family: 'Inter', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html { + font-size: 13px; +} diff --git a/packages/twenty-chrome-extension/src/manifest.ts b/packages/twenty-chrome-extension/src/manifest.ts new file mode 100644 index 0000000000..0905ca494a --- /dev/null +++ b/packages/twenty-chrome-extension/src/manifest.ts @@ -0,0 +1,36 @@ +import { defineManifest } from '@crxjs/vite-plugin'; +import packageData from '../package.json'; + +export default defineManifest({ + manifest_version: 3, + name: 'Twenty', + description: packageData.description, + version: packageData.version, + + icons: { + 16: 'logo/32-32.png', + 32: 'logo/32-32.png', + 48: 'logo/32-32.png', + }, + + action: {}, + + options_page: 'options.html', + + background: { + service_worker: 'src/background/index.ts', + type: 'module', + }, + + content_scripts: [ + { + matches: ['https://www.linkedin.com/*'], + js: ['src/contentScript/index.ts'], + run_at: 'document_end', + }, + ], + + permissions: ['activeTab', 'storage'], + + host_permissions: ['https://www.linkedin.com/*'], +}); diff --git a/packages/twenty-chrome-extension/src/options/Options.tsx b/packages/twenty-chrome-extension/src/options/Options.tsx new file mode 100644 index 0000000000..8b5c310d9f --- /dev/null +++ b/packages/twenty-chrome-extension/src/options/Options.tsx @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; +import { ApiKeyForm } from './modules/api-key/components/ApiKeyForm'; + +const StyledContainer = styled.div` + background: ${({ theme }) => theme.background.noisy}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; +`; + +const Options = () => { + return ( + + + + ); +}; + +export default Options; diff --git a/packages/twenty-chrome-extension/src/options/index.tsx b/packages/twenty-chrome-extension/src/options/index.tsx new file mode 100644 index 0000000000..2c19c33ebd --- /dev/null +++ b/packages/twenty-chrome-extension/src/options/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './Options'; +import '../index.css'; +import { AppThemeProvider } from './modules/ui/theme/components/AppThemeProvider'; +import { ThemeType } from './modules/ui/theme/constants/theme'; + +ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render( + + + + + , +); + +declare module '@emotion/react' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface Theme extends ThemeType {} +} diff --git a/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx b/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx new file mode 100644 index 0000000000..66631cca47 --- /dev/null +++ b/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx @@ -0,0 +1,146 @@ +import styled from '@emotion/styled'; +import { H2Title } from '../../ui/display/typography/components/H2Title'; +import { useEffect, useState } from 'react'; +import { TextInput } from '../../ui/input/components/TextInput'; +import { Button } from '../../ui/input/button/Button'; +import { Toggle } from '../../ui/input/components/Toggle'; + +const StyledContainer = styled.div<{ isToggleOn: boolean }>` + width: 400px; + margin: 0 auto; + background-color: ${({ theme }) => theme.background.primary}; + padding: ${({ theme }) => theme.spacing(10)}; + overflow: hidden; + transition: height 0.3s ease; + + height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')}; + max-height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')}; +`; + +const StyledHeader = styled.header` + text-align: center; + margin-bottom: ${({ theme }) => theme.spacing(8)}; +`; + +const StyledImg = styled.img``; + +const StyledMain = styled.main` + margin-bottom: ${({ theme }) => theme.spacing(8)}; +`; + +const StyledFooter = styled.footer` + display: flex; +`; + +const StyledTitleContainer = styled.div` + flex: 0 0 80%; +`; + +const StyledToggleContainer = styled.div` + flex: 0 0 20%; + display: flex; + justify-content: flex-end; +`; + +const StyledSection = styled.div<{ showSection: boolean }>` + transition: + max-height 0.3s ease, + opacity 0.3s ease; + overflow: hidden; + max-height: ${({ showSection }) => (showSection ? '200px' : '0')}; +`; + +export const ApiKeyForm = () => { + const [apiKey, setApiKey] = useState(''); + const [route, setRoute] = useState(''); + const [showSection, setShowSection] = useState(false); + + useEffect(() => { + const getState = async () => { + const localStorage = await chrome.storage.local.get(); + + if (localStorage.apiKey) { + setApiKey(localStorage.apiKey); + } + + if (localStorage.serverBaseUrl) { + setRoute(localStorage.serverBaseUrl); + } + }; + + void getState(); + }, []); + + useEffect(() => { + chrome.storage.local.set({ apiKey }); + }, [apiKey]); + + useEffect(() => { + chrome.storage.local.set({ serverBaseUrl: route }); + }, [route]); + + const handleGenerateClick = () => { + window.open( + `${import.meta.env.VITE_FRONT_BASE_URL}/settings/developers/api-keys`, + ); + }; + + const handleToggle = () => { + setShowSection(!showSection); + }; + + return ( + + + + + + + + +