[HACKTOBERFEST] LINKEDIN EXTENSION (#15521)

# Twenty Browser Extension


A Chrome browser extension for capturing LinkedIn profiles (people and
companies) directly into Twenty CRM. This is a basic **v0** focused
mostly on establishing a architectural foundation.

## Overview

This extension integrates with LinkedIn to extract profile information
and create records in Twenty CRM. It uses **WXT** as the framework -
initially tried Plasmo, but found WXT to be significantly better due to
its extensibility and closer alignment with the Chrome extension APIs,
providing more control and flexibility.

## Architecture

### Package Structure

The extension consists of two main packages:

1. **`twenty-browser-extension`** - The main extension package (WXT +
React)
2. **`twenty-apps/browser-extension`** - Serverless functions for API
interactions

### Extension Components

#### Entrypoints

- **Background Script** (`src/entrypoints/background/index.ts`)
  - Handles extension messaging protocol
  - Manages API calls to serverless functions
  - Coordinates communication between content scripts and popup

- **Content Scripts**
- **`add-person.content`** - Injects UI button on LinkedIn person
profiles
- **`add-company.content`** - Injects UI button on LinkedIn company
profiles
- Both scripts use WXT's `createIntegratedUi` for seamless DOM injection
  - Extract profile data from LinkedIn DOM

- **Popup** (`src/entrypoints/popup/`)
  - React-based popup UI
  - Displays extracted profile information
  - Provides buttons to save person/company to Twenty

#### Messaging System

Uses `@webext-core/messaging` for type-safe communication between
extension components:

```typescript
// Defined in src/utils/messaging.ts
- getPersonviaRelay() - Relays extraction from content script
- getCompanyviaRelay() - Relays extraction from content script
- extractPerson() - Extracts person data from LinkedIn DOM
- extractCompany() - Extracts company data from LinkedIn DOM
- createPerson() - Creates person record via serverless function
- createCompany() - Creates company record via serverless function
- openPopup() - Opens extension popup
```

#### Serverless Functions

Located in
`packages/twenty-apps/browser-extension/serverlessFunctions/`:

- **`/s/create/person`** - Creates a new person record in Twenty
- **`/s/create/company`** - Creates a new company record in Twenty
- **`/s/get/person`** - Retrieves existing person record (placeholder)
- **`/s/get/company`** - Retrieves existing company record (placeholder)

## Development Guide

### Prerequisites

- Twenty CLI installed globally: `npm install -g twenty-cli`
- API key from Twenty: https://twenty.com/settings/api-webhooks

### Setup
   ```
1. **Configure environment variables:**
   - Set `TWENTY_API_URL` in the serverless function configuration
- Set `TWENTY_API_KEY` (marked as secret) in the serverless function
configuration
- For local development, create a `.env` file or configure via
`wxt.config.ts`

### Development Commands

```bash
# Start development server with hot reload
npx nx run dev twenty-browser-extension

# Build for production
npx nx run build twenty-browser-extension

# Package extension for distribution
npx nx run package twenty-browser-extension
```

### Development Workflow

1. **Start the dev server:**
   ```bash
   npx nx run dev twenty-browser-extension
   ```
   This starts WXT in development mode with hot module reloading.

2. **Load extension in Chrome:**
   - Navigate to `chrome://extensions/`
   - Enable "Developer mode"
   - Click "Load unpacked"
   - Select `packages/twenty-browser-extension/dist/chrome-mv3-dev/`

3. **Test on LinkedIn:**
- Navigate to a LinkedIn person profile:
`https://www.linkedin.com/in/...`
- Navigate to a LinkedIn company profile:
`https://www.linkedin.com/company/...`
   - The "Add to Twenty" button should appear in the profile header
   - Click the button to open the popup and save to Twenty

### Project Structure

```
packages/twenty-browser-extension/
├── src/
│   ├── common/
│   │   └── constants/      # LinkedIn URL patterns
│   ├── entrypoints/
│   │   ├── background/     # Background service worker
│   │   ├── popup/          # Extension popup UI
│   │   ├── add-person.content/  # Content script for person profiles
│   │   └── add-company.content/ # Content script for company profiles
│   ├── ui/                 # Shared UI components and theme
│   └── utils/              # Messaging utilities
├── public/                 # Static assets (icons)
├── wxt.config.ts          # WXT configuration
└── project.json            # Nx project configuration
```

## Current Status (v0)

This is a foundational version focused on architecture. Current
features:

 Inject UI buttons into LinkedIn profiles
 Extract person and company data from LinkedIn
 Display extracted data in popup
 Create person records in Twenty
 Create company records in Twenty

## Planned Features

- [ ] Provide a way to have API key and custom remote URLs.
- [ ] Detect if record already exists and prevent duplicates
- [ ] Open existing Twenty record when clicked (instead of creating
duplicate)
- [ ] Sidepanel Overlay UI for rich profile viewing/editing
- [ ] Enhanced data extraction (email, phone, etc.)
- [ ] Better error handling

# Demo


https://github.com/user-attachments/assets/0bbed724-a429-4af0-a0f1-fdad6997685e



https://github.com/user-attachments/assets/85d2301d-19ee-43ba-b7f9-13ed3915f676
This commit is contained in:
Nabhag Motivaras
2025-11-04 15:52:06 +01:00
committed by GitHub
parent 06f5ac63dc
commit e09b67158e
50 changed files with 3822 additions and 34 deletions
+1
View File
@@ -233,6 +233,7 @@
"packages/twenty-sdk", "packages/twenty-sdk",
"packages/twenty-apps", "packages/twenty-apps",
"packages/twenty-cli", "packages/twenty-cli",
"packages/twenty-browser-extension",
"tools/eslint-rules" "tools/eslint-rules"
] ]
} }
@@ -0,0 +1,2 @@
TWENTY_API_URL=
TWENTY_API_KEY=
@@ -0,0 +1,2 @@
.yarn/install-state.gz
.env
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -0,0 +1,113 @@
# Browser Extension Serverless Functions
Serverless functions for the Twenty browser extension. These functions handle API interactions between the browser extension and the Twenty backend.
## Overview
This package contains serverless functions that are deployed to your Twenty workspace. The browser extension calls these functions to create and retrieve records in Twenty CRM.
## Functions
### Create Person
**Endpoint:** `/s/create/person`
Creates a new person record in Twenty from LinkedIn profile data.
**Parameters:**
- `firstName` (string) - Person's first name
- `lastName` (string) - Person's last name
**Response:** Created person object
### Create Company
**Endpoint:** `/s/create/company`
Creates a new company record in Twenty from LinkedIn company profile data.
**Parameters:**
- `name` (string) - Company name
**Response:** Created company object
### Get Person
**Endpoint:** `/s/get/person`
Retrieves an existing person record from Twenty (placeholder implementation).
### Get Company
**Endpoint:** `/s/get/company`
Retrieves an existing company record from Twenty (placeholder implementation).
## Setup
### Prerequisites
- **Twenty CLI** installed globally:
```bash
npm install -g twenty-cli
```
- **API Key** from your Twenty workspace:
- Go to https://twenty.com/settings/api-webhooks
- Generate an API key
### Configuration
1. **Authenticate with Twenty CLI:**
```bash
twenty auth login
```
2. **Sync serverless functions to your workspace:**
```bash
twenty app sync
```
3. **Configure environment variables:**
- `TWENTY_API_URL` - Your Twenty API URL (e.g., `https://your-workspace.twenty.com`)
- `TWENTY_API_KEY` - Your Twenty API key (marked as secret)
Environment variables can be configured via the Twenty CLI or the Twenty web interface after syncing.
## How It Works
1. The browser extension extracts data from LinkedIn profiles
2. The extension calls the serverless functions via the background script
3. Serverless functions authenticate with your Twenty API using the configured API key
4. Functions create or retrieve records in your Twenty workspace
5. Response is sent back to the extension for user feedback
## File Structure
```
serverlessFunctions/
├── create-person/
│ ├── serverlessFunction.manifest.jsonc # Function configuration
│ └── src/
│ └── index.ts # Function implementation
├── create-company/
│ ├── serverlessFunction.manifest.jsonc
│ └── src/
│ └── index.ts
├── get-person/
│ ├── serverlessFunction.manifest.jsonc
│ └── src/
│ └── index.ts
└── get-company/
├── serverlessFunction.manifest.jsonc
└── src/
└── index.ts
```
## Development
These functions are managed by the Twenty CLI and are deployed to your workspace. After making changes:
1. Update the function code in `src/index.ts`
2. Run `twenty app sync` to deploy changes to your workspace
3. Test the functions via the browser extension or Twenty API directly
## Related Packages
- **`twenty-browser-extension`** - The main browser extension that calls these functions
- See `packages/twenty-browser-extension/README.md` for the complete extension documentation
@@ -0,0 +1,29 @@
{
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"devDependencies": {
"@types/node": "^24.7.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "627280a0-cb5b-40d3-a2e3-3e34b92926c8",
"name": "Browser Extension",
"description": "",
"env": {
"TWENTY_API_URL": {
"isSecret": false,
"value": "",
"description": "Twenty API URL"
},
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Twenty API Key"
}
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "cead3d1e-1fbd-4b09-86a9-f0bedf4d54fa",
"name": "create-company",
"triggers": [
{
"universalIdentifier": "57ff5ea2-c4b7-458c-9296-27bad6acdaf9",
"type": "route",
"path": "/create/company",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -0,0 +1,20 @@
export const main = async (params: {
name: string
}): Promise<object> => {
const response = await fetch(`${process.env.TWENTY_API_URL}/rest/companies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
body: JSON.stringify({
name: params.name
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as object;
};
@@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "7d38261b-99c5-43e7-83d8-bdcedc2dffdb",
"name": "create-person",
"triggers": [
{
"universalIdentifier": "ecf261b8-183b-4323-ab95-3b11009a0eae",
"type": "route",
"path": "/create/person",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -0,0 +1,24 @@
export const main = async (params: {
firstName: string;
lastName: string;
}): Promise<object> => {
const response = await fetch(`${process.env.TWENTY_API_URL}/rest/people`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
body: JSON.stringify({
name: {
firstName: params.firstName,
lastName: params.lastName,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as object;
};
@@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "8e43b96b-49a1-4e21-b257-e432a757b09f",
"name": "get-company",
"triggers": [
{
"universalIdentifier": "7a2bb8ad-6366-49ac-9f73-db9c4713c5af",
"type": "route",
"path": "/get/company",
"httpMethod": "GET",
"isAuthRequired": true
}
]
}
@@ -0,0 +1,14 @@
export const main = async (params: {
a: string;
b: number;
}): Promise<object> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// This is just an example
const message = `Hello, input: ${a} and ${b}`;
return { message };
};
@@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "87ea9816-c9e5-4860-b49f-a5f0759800f7",
"name": "get-person",
"triggers": [
{
"universalIdentifier": "54aec609-0518-4fb0-bd90-7cd21507fe11",
"type": "route",
"path": "/get/person",
"httpMethod": "GET",
"isAuthRequired": true
}
]
}
@@ -0,0 +1,14 @@
export const main = async (params: {
a: string;
b: number;
}): Promise<object> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// This is just an example
const message = `Hello, input: ${a} and ${b}`;
return { message };
};
@@ -0,0 +1,2 @@
WXT_TWENTY_API_URL=
WXT_TWENTY_API_KEY=
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+158
View File
@@ -0,0 +1,158 @@
# Twenty Browser Extension
A Chrome browser extension for capturing LinkedIn profiles (people and companies) directly into Twenty CRM. This is a basic **v0** focused mostly on establishing a solid architectural foundation.
## Overview
This extension integrates with LinkedIn to extract profile information and create records in Twenty CRM. It uses **WXT** as the framework - initially tried Plasmo, but found WXT to be significantly better due to its extensibility and closer alignment with the Chrome extension APIs, providing more control and flexibility.
## Architecture
### Package Structure
The extension consists of two main packages:
1. **`twenty-browser-extension`** - The main extension package (WXT + React)
2. **`twenty-apps/browser-extension`** - Serverless functions for API interactions
### Extension Components
#### Entrypoints
- **Background Script** (`src/entrypoints/background/index.ts`)
- Handles extension messaging protocol
- Manages API calls to serverless functions
- Coordinates communication between content scripts and popup
- **Content Scripts**
- **`add-person.content`** - Injects UI button on LinkedIn person profiles
- **`add-company.content`** - Injects UI button on LinkedIn company profiles
- Both scripts use WXT's `createIntegratedUi` for seamless DOM injection
- Extract profile data from LinkedIn DOM
- **Popup** (`src/entrypoints/popup/`)
- React-based popup UI
- Displays extracted profile information
- Provides buttons to save person/company to Twenty
#### Messaging System
Uses `@webext-core/messaging` for type-safe communication between extension components:
```typescript
// Defined in src/utils/messaging.ts
- getPersonviaRelay() - Relays extraction from content script
- getCompanyviaRelay() - Relays extraction from content script
- extractPerson() - Extracts person data from LinkedIn DOM
- extractCompany() - Extracts company data from LinkedIn DOM
- createPerson() - Creates person record via serverless function
- createCompany() - Creates company record via serverless function
- openPopup() - Opens extension popup
```
#### Serverless Functions
Located in `packages/twenty-apps/browser-extension/serverlessFunctions/`:
- **`/s/create/person`** - Creates a new person record in Twenty
- **`/s/create/company`** - Creates a new company record in Twenty
- **`/s/get/person`** - Retrieves existing person record (placeholder)
- **`/s/get/company`** - Retrieves existing company record (placeholder)
## Development Guide
### Prerequisites
- Twenty CLI installed globally: `npm install -g twenty-cli`
- API key from Twenty: https://twenty.com/settings/api-webhooks
### Setup
```
1. **Configure environment variables:**
- Set `TWENTY_API_URL` in the serverless function configuration
- Set `TWENTY_API_KEY` (marked as secret) in the serverless function configuration
- For local development, create a `.env` file or configure via `wxt.config.ts`
### Development Commands
```bash
# Start development server with hot reload
npx nx run dev twenty-browser-extension
# Build for production
npx nx run build twenty-browser-extension
# Package extension for distribution
npx nx run package twenty-browser-extension
```
### Development Workflow
1. **Start the dev server:**
```bash
npx nx run dev twenty-browser-extension
```
This starts WXT in development mode with hot module reloading.
2. **Load extension in Chrome:**
- Navigate to `chrome://extensions/`
- Enable "Developer mode"
- Click "Load unpacked"
- Select `packages/twenty-browser-extension/dist/chrome-mv3-dev/`
3. **Test on LinkedIn:**
- Navigate to a LinkedIn person profile: `https://www.linkedin.com/in/...`
- Navigate to a LinkedIn company profile: `https://www.linkedin.com/company/...`
- The "Add to Twenty" button should appear in the profile header
- Click the button to open the popup and save to Twenty
### Project Structure
```
packages/twenty-browser-extension/
├── src/
│ ├── common/
│ │ └── constants/ # LinkedIn URL patterns
│ ├── entrypoints/
│ │ ├── background/ # Background service worker
│ │ ├── popup/ # Extension popup UI
│ │ ├── add-person.content/ # Content script for person profiles
│ │ └── add-company.content/ # Content script for company profiles
│ ├── ui/ # Shared UI components and theme
│ └── utils/ # Messaging utilities
├── public/ # Static assets (icons)
├── wxt.config.ts # WXT configuration
└── project.json # Nx project configuration
```
## Current Status (v0)
This is a foundational version focused on architecture. Current features:
✅ Inject UI buttons into LinkedIn profiles
✅ Extract person and company data from LinkedIn
✅ Display extracted data in popup
✅ Create person records in Twenty
✅ Create company records in Twenty
## Planned Features
- [ ] Provide a way to have API key and custom remote URLs.
- [ ] Detect if record already exists and prevent duplicates
- [ ] Open existing Twenty record when clicked (instead of creating duplicate)
- [ ] Sidepanel Overlay UI for rich profile viewing/editing
- [ ] Enhanced data extraction (email, phone, etc.)
- [ ] Better error handling
## Why WXT?
We initially evaluated **Plasmo** but chose **WXT** for several reasons:
1. **Extensibility** - WXT provides more flexibility for custom extension patterns
2. **Chrome API Proximity** - Closer to native Chrome extension APIs, giving more control
3. **Type Safety** - Better TypeScript support for extension messaging
4. **Architecture** - More transparent build process and entrypoint management
## Contributing
This extension is in early development. The architecture is designed to be extensible, but the current implementation is intentionally minimal to establish solid foundations.
+69
View File
@@ -0,0 +1,69 @@
import typescriptParser from '@typescript-eslint/parser';
import path from 'path';
import { fileURLToPath } from 'url';
import reactConfig from '../../eslint.config.react.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default [
// Extend shared React configuration
...reactConfig,
// Global ignores
{
ignores: [
'**/node_modules/**',
],
},
// TypeScript project-specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
project: [path.resolve(__dirname, 'tsconfig.*.json')],
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:browser-extension',
onlyDependOnLibsWithTags: ['scope:twenty-ui', 'scope:browser-extension']
}
],
},
],
}
},
];
+22
View File
@@ -0,0 +1,22 @@
{
"name": "twenty-browser-extension",
"description": "Twenty Lead capture extension",
"private": true,
"version": "0.0.1",
"type": "module",
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@webext-core/messaging": "^2.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"twenty-ui": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.15",
"@wxt-dev/module-react": "^1.1.3",
"typescript": "^5.9.2",
"wxt": "^0.20.6"
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "twenty-browser-extension",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-browser-extension/src",
"projectType": "application",
"tags": ["scope:browser-extension"],
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"command": "wxt --mode development",
"cwd": "packages/twenty-browser-extension",
"color": true
},
"configurations": {
"development": {
"command": "wxt --mode development"
}
}
},
"build": {
"executor": "nx:run-commands",
"options": {
"command": "wxt build",
"cwd": "packages/twenty-browser-extension",
"color": true
},
"configurations": {
"production": {
"command": "wxt build"
}
}
},
"package": {
"executor": "nx:run-commands",
"options": {
"command": "wxt zip",
"cwd": "packages/twenty-browser-extension",
"color": true
},
"configurations": {
"production": {
"command": "wxt zip"
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_305_516)">
<g clip-path="url(#clip1_305_516)">
<path d="M49.0229 69.1875C54.1272 69.1875 58.265 65.0497 58.265 59.9454V50.7033H59.9454C65.0497 50.7033 69.1875 46.5655 69.1875 41.4612C69.1875 36.357 65.0497 32.2191 59.9454 32.2191H58.265V22.9771C58.265 17.8728 54.1272 13.735 49.0229 13.735H39.7809V12.0546C39.7809 6.95032 35.643 2.8125 30.5388 2.8125C25.4345 2.8125 21.2967 6.95032 21.2967 12.0546V13.735H12.0546C6.95032 13.735 2.8125 17.8728 2.8125 22.9771V32.2191H4.49288C9.59714 32.2191 13.735 36.357 13.735 41.4612C13.735 46.5655 9.59714 50.7033 4.49288 50.7033H2.8125V69.1875H21.2967V67.5071C21.2967 62.4029 25.4345 58.265 30.5388 58.265C35.643 58.265 39.7809 62.4029 39.7809 67.5071V69.1875H49.0229Z" stroke="#67D55E" stroke-width="5.625"/>
</g>
</g>
<defs>
<clipPath id="clip0_305_516">
<rect width="72" height="72" fill="white"/>
</clipPath>
<clipPath id="clip1_305_516">
<rect width="72" height="72" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,2 @@
export { LINKEDIN_MATCHES } from './linkedin';
@@ -0,0 +1,5 @@
export enum LINKEDIN_MATCHES {
COMPANY = "*://*.linkedin.com/company/*",
PERSON = "*://*.linkedin.com/in/*",
BASE_URL = "*://*.linkedin.com/*"
}
@@ -0,0 +1,2 @@
export { LINKEDIN_MATCHES } from './constants';
@@ -0,0 +1,73 @@
import { LINKEDIN_MATCHES } from '@/common';
import Main from '@/entrypoints/add-company.content/main';
import { ThemeContext } from '@/ui/theme/context';
import styled from '@emotion/styled';
import ReactDOM from 'react-dom/client';
const companyPattern = new MatchPattern(LINKEDIN_MATCHES.COMPANY);
const StyledContainer = styled.div`
margin: ${({theme}) => `${theme.spacing(1)} ${0} ${0} ${theme.spacing(2)}`};
`
export default defineContentScript({
matches: [LINKEDIN_MATCHES.BASE_URL],
runAt: 'document_end',
async main(ctx) {
async function waitFor(sel: string) {
return new Promise<Element>((resolve) => {
const tryFind = () => {
const el = document.querySelector(sel) as Element;
if (el) return resolve(el);
requestAnimationFrame(tryFind);
};
tryFind();
});
}
const anchor = await waitFor('[class*="org-top-card-primary-actions__inner"]');
const ui = await createIntegratedUi(ctx, {
position: 'inline',
anchor,
append:'last',
onMount: (container) => {
const app = document.createElement('div');
container.append(app);
const root = ReactDOM.createRoot(app);
const App = () => (
<StyledContainer>
<Main/>
</StyledContainer>
);
root.render(
<ThemeContext>
<App />
</ThemeContext>
);
return root;
},
onRemove: (root) => {
root?.unmount();
}
});
ctx.addEventListener(window, 'wxt:locationchange', ({newUrl}) => {
const injectedBtn = document.querySelector('[data-id="twenty-btn"]');
if(companyPattern.includes(newUrl) && !injectedBtn) ui.mount()
})
ui.mount();
onMessage('extractCompany', async () => {
const companyNameElement = document.querySelector('h1');
const companyName = companyNameElement?.textContent ?? '';
return {
companyName
}
});
},
});
@@ -0,0 +1,13 @@
import { Button } from "@/ui/components/button";
import { sendMessage } from "@/utils/messaging";
const Main = () => {
const handleClick = async () => {
await sendMessage('openPopup')
};
return <Button onClick={handleClick} data-id="twenty-btn">Add to Twenty</Button>;
};
export default Main;
@@ -0,0 +1,67 @@
import { LINKEDIN_MATCHES } from '@/common';
import Main from '@/entrypoints/add-person.content/main';
import { ThemeContext } from '@/ui/theme/context';
import ReactDOM from 'react-dom/client';
const personPattern = new MatchPattern(LINKEDIN_MATCHES.PERSON);
export default defineContentScript({
matches: [LINKEDIN_MATCHES.BASE_URL],
runAt: 'document_end',
async main(ctx) {
async function waitFor(sel: string) {
return new Promise<Element>((resolve) => {
const tryFind = () => {
const el = document.querySelector(sel);
if (el) return resolve(el);
requestAnimationFrame(tryFind);
};
tryFind();
});
}
const anchor = await waitFor('[class$="pv-top-card-v2-ctas__custom"]');
const ui = await createIntegratedUi(ctx, {
position: 'inline',
anchor,
append:'last',
onMount: (container) => {
const app = document.createElement('div');
container.append(app);
const root = ReactDOM.createRoot(app);
root.render(
<ThemeContext>
<Main />
</ThemeContext>
);
return root;
},
onRemove: (root) => {
root?.unmount();
},
});
ctx.addEventListener(window, 'wxt:locationchange', ({newUrl, }) => {
const injectedBtn = document.querySelector('[data-id="twenty-btn"]');
if(personPattern.includes(newUrl) && !injectedBtn) ui.mount();
});
ui.mount();
onMessage('extractPerson', async () => {
const personNameElement = document.querySelector('h1');
const personName = personNameElement ? personNameElement.textContent : '';
const extractFirstAndLastName = (fullName: string) => {
const spaceIndex = fullName.lastIndexOf(' ');
const firstName = fullName.substring(0, spaceIndex);
const lastName = fullName.substring(spaceIndex + 1);
return { firstName, lastName };
};
return extractFirstAndLastName(personName);
});
},
});
@@ -0,0 +1,13 @@
import { Button } from "@/ui/components/button";
import { sendMessage } from "@/utils/messaging";
const Main = () => {
const handleClick = async () => {
await sendMessage('openPopup')
};
return <Button onClick={handleClick} data-id="twenty-btn">Add to Twenty</Button>;
};
export default Main;
@@ -0,0 +1,70 @@
import { onMessage } from "@/utils/messaging";
import { SendMessageOptions } from "@webext-core/messaging";
export default defineBackground(async () => {
onMessage('openPopup', async () => {
await browser.action.openPopup();
})
onMessage('getPersonviaRelay', async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const {firstName, lastName} = await sendMessage('extractPerson', undefined, {
tabId: tab.id,
frameId: 0
} as SendMessageOptions);
return {
firstName,
lastName
}
})
onMessage('getCompanyviaRelay', async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const {companyName} = await sendMessage('extractCompany', undefined, {
tabId: tab.id,
frameId: 0
} as SendMessageOptions);
return {
companyName
}
})
onMessage('createPerson', async ({data}) => {
const response = await fetch(`${import.meta.env.WXT_TWENTY_API_URL}/s/create/person`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.WXT_TWENTY_API_KEY}`,
},
body: JSON.stringify({
firstName: data.firstName,
lastName: data.lastName,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as {firstName: string; lastName:string;};
})
onMessage('createCompany', async ({data}) => {
const response = await fetch(`${import.meta.env.WXT_TWENTY_API_URL}/s/create/company`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.WXT_TWENTY_API_KEY}`,
},
body: JSON.stringify({
name: data.name
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as {name: string};
})
})
@@ -0,0 +1,64 @@
import { sendMessage } from '@/utils/messaging';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
const StyledMain = styled.main``;
type PersonValue = { firstName: string; lastName: string; type: 'company' | 'person' };
type CompanyValue = { companyName: string; type: 'company' | 'person' };
type Value = PersonValue | CompanyValue | undefined;
function App() {
const [value, setValue] = useState<Value>();
useEffect(() => {
browser.tabs.query({ active: true, currentWindow: true }, function(tabs) {
const currentTab = tabs[0];
if(currentTab.url?.includes('https://www.linkedin.com/in')) {
sendMessage('getPersonviaRelay').then(data => {
setValue({...data, type: 'person' })
})
}
if(currentTab.url?.includes('https://www.linkedin.com/company')) {
sendMessage('getCompanyviaRelay').then(data => {
setValue({...data, type: 'company'})
})
}
});
}, []);
const isPersonValue = (val: Value): val is PersonValue => {
return val !== undefined && 'firstName' in val && 'lastName' in val;
};
const isCompanyValue = (val: Value): val is CompanyValue => {
return val !== undefined && 'companyName' in val;
};
return (
<StyledMain>
<h1>{JSON.stringify(value)}</h1>
{
isPersonValue(value) &&
<button onClick={async () => {
await sendMessage('createPerson', {
firstName: value.firstName,
lastName: value.lastName,
});
}}>save person to twenty</button>
}
{
isCompanyValue(value) &&
<button onClick={async () => {
await sendMessage('createCompany', {
name: value.companyName
});
}}>save company to twenty</button>
}
</StyledMain>
);
}
export default App;
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twenty</title>
<!-- <meta
name="manifest.default_icon"
content="{
16: '/icon-16.png',
32: '/icon-32.png',
48: '/icon-48.png',
96: '/icon-96.png',
128: '/icon-128.png'
}"
/> -->
<meta name="manifest.type" content="page_action|browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="main.tsx"></script>
</body>
</html>
@@ -0,0 +1,13 @@
import App from '@/entrypoints/popup/app';
import { ThemeContext } from '@/ui/theme/context/ThemeContext';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeContext>
<App/>
</ThemeContext>
</React.StrictMode>
);
@@ -0,0 +1,5 @@
body {
margin: 0;
min-width: 320px;
min-height: 500px;
}
@@ -0,0 +1,52 @@
import styled from "@emotion/styled";
import type { ComponentPropsWithRef } from "react";
const StyledButton = styled.button`
--hover-bg-color: #378fe91a;
--hover-color: #004182;
--hover-border-color: #004182;
--text-color: #0a66c2;
--bg-color: #00000000;
font-size: ${({theme}) => theme.spacing(3.5)};
font-weight: ${({theme}) => theme.font.weight.semiBold};
font-family: ${({theme}) => theme.font.family};
background-color: var(--bg-color);
min-height:${({theme}) => theme.spacing(8)};
padding: ${({theme}) => `${theme.spacing(1.5)} ${theme.spacing(4)}`};
color: var(--text-color);
cursor: pointer;
border-radius: ${({theme}) => theme.spacing(5)};
border: 1px solid var(--text-color);
transition-property: background-color, box-shadow, color;
transition-timing-function: cubic-bezier(.4, 0, .2, 1);
transition-duration: 167ms;
&:hover {
background-color: var(--hover-bg-color);
color: var(--hover-color);
box-shadow: inset 0px 0px 0px 1px var(--hover-border-color);
}
@media(max-width: 990px) {
width: 100%;
}
`;
const StyledSpan = styled.span`
display: flex;
justify-content: center;
align-items: center;
`
interface ButtonProps extends ComponentPropsWithRef<'button'> {
children: React.ReactNode
}
export const Button = ({children, ...rest}: ButtonProps) => {
return <StyledButton {...rest}><StyledSpan>{children}</StyledSpan></StyledButton>
}
@@ -0,0 +1,17 @@
import { ThemeProvider } from '@emotion/react';
import type React from 'react';
import { THEME_DARK, ThemeContextProvider } from 'twenty-ui/theme';
type ThemeContextProps = {
children: React.ReactNode;
};
export const ThemeContext = ({ children }: ThemeContextProps) => {
return (
<ThemeProvider theme={THEME_DARK}>
<ThemeContextProvider theme={THEME_DARK}>
{children}
</ThemeContextProvider>
</ThemeProvider>
);
};
@@ -0,0 +1 @@
export { ThemeContext } from './ThemeContext';
+5
View File
@@ -0,0 +1,5 @@
import type { ThemeType } from 'twenty-ui/theme';
declare module '@emotion/react' {
export interface Theme extends ThemeType {}
}
@@ -0,0 +1,13 @@
import { defineExtensionMessaging } from '@webext-core/messaging';
interface ProtocolMap {
getPersonviaRelay(): {firstName: string; lastName: string }
openPopup(): void;
extractPerson(): {firstName: string; lastName: string}
getCompanyviaRelay(): {companyName: string}
extractCompany(): {companyName: string}
createPerson({firstName, lastName}: {firstName: string; lastName: string}): {firstName: string; lastName: string}
createCompany({ name }: {name: string}): {name: string}
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>()
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react",
"baseUrl": "src",
"paths": {
"@/*": ["*"]
}
}
}
@@ -0,0 +1,12 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ['@wxt-dev/module-react'],
srcDir: 'src',
outDir: 'dist',
dev: {
server: {
port: 4000
}
}
});
+1786 -34
View File
File diff suppressed because it is too large Load Diff