feat(ui): additional social providers to link components (#21716)

The current link component matches only to linkedin, twitter and
facebook.

It is currently missing the x handle. In addition to this, we should
also accomodate for instagram, bluesky and tiktok.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21716?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Vincent Vu
2026-06-24 20:21:17 +10:00
committed by GitHub
parent d2387430a1
commit 680e4a712b
11 changed files with 297 additions and 105 deletions
@@ -30,9 +30,24 @@ describe('checkUrlType', () => {
expect(checkUrlType('instagram.com/ptcrash')).toBe(LinkType.Instagram);
});
it('should detect TikTok urls', () => {
expect(checkUrlType('https://www.tiktok.com/@twenty')).toBe(
LinkType.TikTok,
);
expect(checkUrlType('tiktok.com/@twenty')).toBe(LinkType.TikTok);
});
it('should detect Bluesky urls', () => {
expect(checkUrlType('https://bsky.app/profile/twenty.bsky.social')).toBe(
LinkType.Bluesky,
);
});
it('should fall back to a generic url type', () => {
expect(checkUrlType('https://example.com')).toBe(LinkType.Url);
expect(checkUrlType('not-a-url')).toBe(LinkType.Url);
expect(checkUrlType('https://instagram.com')).toBe(LinkType.Url);
expect(checkUrlType('https://www.tiktok.com/video/123')).toBe(LinkType.Url);
expect(checkUrlType('https://bsky.app')).toBe(LinkType.Url);
});
});
@@ -7,6 +7,8 @@ describe('isSocialLinkType', () => {
expect(isSocialLinkType(LinkType.Twitter)).toBe(true);
expect(isSocialLinkType(LinkType.Facebook)).toBe(true);
expect(isSocialLinkType(LinkType.Instagram)).toBe(true);
expect(isSocialLinkType(LinkType.TikTok)).toBe(true);
expect(isSocialLinkType(LinkType.Bluesky)).toBe(true);
});
it('should return false for a generic url type', () => {
@@ -1,20 +1,9 @@
import { LinkType } from 'twenty-ui/navigation';
export const checkUrlType = (url: string) => {
if (/^(https?:\/\/)?(www\.)?linkedin\.com\/.+$/.test(url)) {
return LinkType.LinkedIn;
}
if (/^(https?:\/\/)?(www\.)?twitter\.com\/.+$/.test(url)) {
return LinkType.Twitter;
}
if (/^(https?:\/\/)?(www\.)?x\.com\/.+$/.test(url)) {
return LinkType.Twitter;
}
if (/^(https?:\/\/)?(www\.)?facebook\.com\/.+$/.test(url)) {
return LinkType.Facebook;
}
if (/^(https?:\/\/)?(www\.)?instagram\.com\/.+$/.test(url)) {
return LinkType.Instagram;
}
import { LinkType, SOCIAL_LINK_PROVIDERS } from 'twenty-ui/navigation';
return LinkType.Url;
export const checkUrlType = (url: string) => {
const provider = SOCIAL_LINK_PROVIDERS.find((socialLinkProvider) =>
socialLinkProvider.detectPattern.test(url),
);
return provider?.type ?? LinkType.Url;
};
@@ -1,12 +1,6 @@
import { LinkType } from 'twenty-ui/navigation';
// Link types rendered as a social handle (via SocialLink) instead of a plain RoundedLink
const SOCIAL_LINK_TYPES = [
LinkType.LinkedIn,
LinkType.Twitter,
LinkType.Facebook,
LinkType.Instagram,
];
import { type LinkType, SOCIAL_LINK_PROVIDERS } from 'twenty-ui/navigation';
export const isSocialLinkType = (type: LinkType): boolean =>
SOCIAL_LINK_TYPES.includes(type);
SOCIAL_LINK_PROVIDERS.some(
(socialLinkProvider) => socialLinkProvider.type === type,
);
@@ -0,0 +1,9 @@
export enum LinkType {
Url = 'url',
LinkedIn = 'linkedin',
Twitter = 'twitter',
Facebook = 'facebook',
Instagram = 'instagram',
TikTok = 'tiktok',
Bluesky = 'bluesky',
}
@@ -1,16 +1,9 @@
import * as React from 'react';
import { type LinkType } from '@ui/navigation/SocialLink/LinkType';
import { RoundedLink } from '@ui/navigation/RoundedLink/RoundedLink';
import { getDisplayValueByUrlType } from '@ui/utilities';
export enum LinkType {
Url = 'url',
LinkedIn = 'linkedin',
Twitter = 'twitter',
Facebook = 'facebook',
Instagram = 'instagram',
}
type SocialLinkProps = {
label: string;
href: string;
@@ -2,14 +2,15 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentWithRouterDecorator } from '@ui/testing';
import { LinkType, SocialLink } from '@ui/navigation/SocialLink/SocialLink';
import { LinkType } from '@ui/navigation/SocialLink/LinkType';
import { SocialLink } from '@ui/navigation/SocialLink/SocialLink';
const meta: Meta<typeof SocialLink> = {
title: 'UI/Navigation/Link/SocialLink',
component: SocialLink,
decorators: [ComponentWithRouterDecorator],
args: {
href: '/test',
href: 'https://twenty.com',
label: 'Social Link',
},
};
@@ -18,24 +19,21 @@ export default meta;
type Story = StoryObj<typeof SocialLink>;
const clickJestFn = fn();
const linkedin: LinkType = LinkType.LinkedIn;
const twitter: LinkType = LinkType.Twitter;
export const LinkedIn: Story = {
args: {
href: '/LinkedIn',
href: 'https://www.linkedin.com/in/johndoe',
label: 'LinkedIn',
onClick: clickJestFn,
type: linkedin,
type: LinkType.LinkedIn,
},
};
export const Twitter: Story = {
args: {
href: '/Twitter',
href: 'https://twitter.com/johndoe',
label: 'Twitter',
onClick: clickJestFn,
type: twitter,
type: LinkType.Twitter,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -47,3 +45,48 @@ export const Twitter: Story = {
await expect(clickJestFn).toHaveBeenCalledTimes(1);
},
};
export const X: Story = {
args: {
href: 'https://x.com/johndoe',
label: 'X',
onClick: clickJestFn,
type: LinkType.Twitter,
},
};
export const Facebook: Story = {
args: {
href: 'https://www.facebook.com/johndoe',
label: 'Facebook',
onClick: clickJestFn,
type: LinkType.Facebook,
},
};
export const Instagram: Story = {
args: {
href: 'https://www.instagram.com/johndoe',
label: 'Instagram',
onClick: clickJestFn,
type: LinkType.Instagram,
},
};
export const TikTok: Story = {
args: {
href: 'https://www.tiktok.com/@johndoe',
label: 'TikTok',
onClick: clickJestFn,
type: LinkType.TikTok,
},
};
export const Bluesky: Story = {
args: {
href: 'https://bsky.app/profile/johndoe.bsky.social',
label: 'Bluesky',
onClick: clickJestFn,
type: LinkType.Bluesky,
},
};
@@ -0,0 +1,80 @@
import { LinkType } from '@ui/navigation/SocialLink/LinkType';
export type SocialLinkProvider = {
type: LinkType;
detectPattern: RegExp;
handlePattern: RegExp;
reservedPaths: string[];
handlePrefix: string;
fallbackLabel: string;
};
export const SOCIAL_LINK_PROVIDERS: SocialLinkProvider[] = [
{
type: LinkType.LinkedIn,
detectPattern: /^(?:https?:\/\/)?(?:www\.)?linkedin\.com\/.+$/,
handlePattern:
/(?:https?:\/\/)?(?:www\.)?linkedin\.com\/(?:in|company|school)\/([^/?#]+)/,
reservedPaths: [],
handlePrefix: '',
fallbackLabel: 'LinkedIn',
},
{
type: LinkType.Twitter,
detectPattern: /^(?:https?:\/\/)?(?:www\.)?(?:twitter|x)\.com\/.+$/,
handlePattern: /(?:https?:\/\/)?(?:www\.)?(?:twitter|x)\.com\/([^/?#]+)/,
reservedPaths: [
'home',
'explore',
'notifications',
'messages',
'settings',
'search',
'i',
'intent',
],
handlePrefix: '@',
fallbackLabel: 'Twitter',
},
{
type: LinkType.Facebook,
detectPattern: /^(?:https?:\/\/)?(?:www\.)?facebook\.com\/.+$/,
handlePattern: /(?:https?:\/\/)?(?:www\.)?facebook\.com\/([^/?#]+)/,
reservedPaths: [],
handlePrefix: '',
fallbackLabel: 'Facebook',
},
{
type: LinkType.Instagram,
detectPattern: /^(?:https?:\/\/)?(?:www\.)?instagram\.com\/.+$/,
handlePattern: /(?:https?:\/\/)?(?:www\.)?instagram\.com\/([^/?#]+)/,
reservedPaths: [
'p',
'reel',
'reels',
'explore',
'stories',
'tv',
'accounts',
'about',
],
handlePrefix: '@',
fallbackLabel: 'Instagram',
},
{
type: LinkType.TikTok,
detectPattern: /^(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@.+$/,
handlePattern: /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@([^/?#]+)/,
reservedPaths: [],
handlePrefix: '@',
fallbackLabel: 'TikTok',
},
{
type: LinkType.Bluesky,
detectPattern: /^(?:https?:\/\/)?(?:www\.)?bsky\.app\/profile\/.+$/,
handlePattern: /(?:https?:\/\/)?(?:www\.)?bsky\.app\/profile\/([^/?#]+)/,
reservedPaths: [],
handlePrefix: '',
fallbackLabel: 'Bluesky',
},
];
+4 -1
View File
@@ -54,5 +54,8 @@ export { NavigationBar } from './NavigationBar/NavigationBar';
export { NavigationBarItem } from './NavigationBarItem/NavigationBarItem';
export { RawLink } from './RawLink/RawLink';
export { RoundedLink } from './RoundedLink/RoundedLink';
export { LinkType, SocialLink } from './SocialLink/SocialLink';
export { LinkType } from './SocialLink/LinkType';
export { SocialLink } from './SocialLink/SocialLink';
export type { SocialLinkProvider } from './SocialLink/socialLinkProviders';
export { SOCIAL_LINK_PROVIDERS } from './SocialLink/socialLinkProviders';
export { UndecoratedLink } from './UndecoratedLink/UndecoratedLink';
@@ -1,4 +1,4 @@
import { LinkType } from '@ui/navigation/SocialLink/SocialLink';
import { LinkType } from '@ui/navigation/SocialLink/LinkType';
import { getDisplayValueByUrlType } from '../getDisplayValueByUrlType';
@@ -86,12 +86,20 @@ describe('getDisplayValueByUrlType', () => {
expect(result).toBe('@johndoe');
});
it('should return "@twitter" when no username can be extracted', () => {
it('should return "Twitter" when no username can be extracted', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Twitter,
href: 'https://www.twitter.com',
});
expect(result).toBe('@twitter');
expect(result).toBe('Twitter');
});
it('should return "Twitter" for a reserved app route', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Twitter,
href: 'https://x.com/home',
});
expect(result).toBe('Twitter');
});
});
@@ -211,6 +219,100 @@ describe('getDisplayValueByUrlType', () => {
});
});
describe('x', () => {
it('should extract handle from an x.com URL as a Twitter type', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Twitter,
href: 'https://x.com/johndoe',
});
expect(result).toBe('@johndoe');
});
it('should handle an x.com URL without protocol', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Twitter,
href: 'x.com/johndoe',
});
expect(result).toBe('@johndoe');
});
it('should ignore the path segments of a tweet permalink', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Twitter,
href: 'https://x.com/johndoe/status/123456',
});
expect(result).toBe('@johndoe');
});
it('should ignore a query string', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Twitter,
href: 'https://x.com/johndoe?lang=en',
});
expect(result).toBe('@johndoe');
});
});
describe('tiktok', () => {
it('should extract handle from a TikTok profile URL', () => {
const result = getDisplayValueByUrlType({
type: LinkType.TikTok,
href: 'https://www.tiktok.com/@johndoe',
});
expect(result).toBe('@johndoe');
});
it('should handle a TikTok URL without protocol or www', () => {
const result = getDisplayValueByUrlType({
type: LinkType.TikTok,
href: 'tiktok.com/@johndoe',
});
expect(result).toBe('@johndoe');
});
it('should ignore a trailing path segment', () => {
const result = getDisplayValueByUrlType({
type: LinkType.TikTok,
href: 'https://www.tiktok.com/@johndoe/video/123',
});
expect(result).toBe('@johndoe');
});
it('should return "TikTok" when no handle can be extracted', () => {
const result = getDisplayValueByUrlType({
type: LinkType.TikTok,
href: 'https://www.tiktok.com/',
});
expect(result).toBe('TikTok');
});
});
describe('bluesky', () => {
it('should extract handle from a Bluesky profile URL', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Bluesky,
href: 'https://bsky.app/profile/johndoe.bsky.social',
});
expect(result).toBe('johndoe.bsky.social');
});
it('should handle a Bluesky URL without protocol', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Bluesky,
href: 'bsky.app/profile/johndoe.bsky.social',
});
expect(result).toBe('johndoe.bsky.social');
});
it('should return "Bluesky" when no handle can be extracted', () => {
const result = getDisplayValueByUrlType({
type: LinkType.Bluesky,
href: 'https://bsky.app/profile/',
});
expect(result).toBe('Bluesky');
});
});
describe('url type', () => {
it('should return undefined for generic url type', () => {
const result = getDisplayValueByUrlType({
@@ -1,19 +1,8 @@
import { type LinkType } from '@ui/navigation';
import { type LinkType } from '@ui/navigation/SocialLink/LinkType';
import { SOCIAL_LINK_PROVIDERS } from '@ui/navigation/SocialLink/socialLinkProviders';
import { isDefined } from '@ui/utilities/utils/isDefined';
// Instagram path segments that are app routes, not user handles
const INSTAGRAM_RESERVED_PATHS = [
'p',
'reel',
'reels',
'explore',
'stories',
'tv',
'accounts',
'about',
];
type getUrlDisplayValueByUrlTypeProps = {
type GetDisplayValueByUrlTypeProps = {
type: LinkType;
href: string;
};
@@ -21,50 +10,23 @@ type getUrlDisplayValueByUrlTypeProps = {
export const getDisplayValueByUrlType = ({
type,
href,
}: getUrlDisplayValueByUrlTypeProps) => {
if (type === 'linkedin') {
const matches = href.match(
/(?:https?:\/\/)?(?:www.)?linkedin.com\/(?:in|company|school)\/(.*)/,
);
if (isDefined(matches?.[1])) {
return decodeURIComponent(matches?.[1]);
} else {
return 'LinkedIn';
}
}: GetDisplayValueByUrlTypeProps) => {
const provider = SOCIAL_LINK_PROVIDERS.find(
(socialLinkProvider) => socialLinkProvider.type === type,
);
if (!isDefined(provider)) {
return undefined;
}
if (type === 'twitter') {
const matches = href.match(
/(?:https?:\/\/)?(?:www.)?twitter.com\/([-a-zA-Z0-9@:%_+.~#?&//=]*)/,
);
if (isDefined(matches?.[1])) {
return `@${matches?.[1]}`;
} else {
return '@twitter';
}
const handle = href.match(provider.handlePattern)?.[1];
if (
!isDefined(handle) ||
provider.reservedPaths.includes(handle.toLowerCase())
) {
return provider.fallbackLabel;
}
if (type === 'facebook') {
const matches = href.match(/(?:https?:\/\/)?(?:www.)?facebook.com\/(.+)/);
if (isDefined(matches?.[1])) {
return decodeURIComponent(matches?.[1]);
} else {
return 'Facebook';
}
}
if (type === 'instagram') {
const matches = href.match(
/(?:https?:\/\/)?(?:www\.)?instagram\.com\/([^/?#]+)/,
);
const handle = matches?.[1];
if (
isDefined(handle) &&
!INSTAGRAM_RESERVED_PATHS.includes(handle.toLowerCase())
) {
return `@${decodeURIComponent(handle)}`;
} else {
return 'Instagram';
}
}
return `${provider.handlePrefix}${decodeURIComponent(handle)}`;
};