From 680e4a712b87bf5e73dc9b59ab65576ae130bf59 Mon Sep 17 00:00:00 2001
From: Vincent Vu <172068404+rubixvi@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:21:17 +1000
Subject: [PATCH] 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.
Co-authored-by: Charles Bochet
---
.../src/utils/__tests__/checkUrlType.test.ts | 15 +++
.../utils/__tests__/isSocialLinkType.test.ts | 2 +
.../twenty-front/src/utils/checkUrlType.ts | 25 ++--
.../src/utils/isSocialLinkType.ts | 14 +--
.../src/navigation/SocialLink/LinkType.ts | 9 ++
.../src/navigation/SocialLink/SocialLink.tsx | 9 +-
.../__stories__/SocialLink.stories.tsx | 61 ++++++++--
.../SocialLink/socialLinkProviders.ts | 80 +++++++++++++
packages/twenty-ui/src/navigation/index.ts | 5 +-
.../getDisplayValueByUrlType.test.ts | 108 +++++++++++++++++-
.../utils/getDisplayValueByUrlType.ts | 74 +++---------
11 files changed, 297 insertions(+), 105 deletions(-)
create mode 100644 packages/twenty-ui/src/navigation/SocialLink/LinkType.ts
create mode 100644 packages/twenty-ui/src/navigation/SocialLink/socialLinkProviders.ts
diff --git a/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts b/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts
index c99c3d1808..74f8436d70 100644
--- a/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts
+++ b/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts
@@ -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);
});
});
diff --git a/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts b/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts
index 1a91115913..1ba11b4e19 100644
--- a/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts
+++ b/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts
@@ -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', () => {
diff --git a/packages/twenty-front/src/utils/checkUrlType.ts b/packages/twenty-front/src/utils/checkUrlType.ts
index b5ff2612dd..6cf22dce10 100644
--- a/packages/twenty-front/src/utils/checkUrlType.ts
+++ b/packages/twenty-front/src/utils/checkUrlType.ts
@@ -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;
};
diff --git a/packages/twenty-front/src/utils/isSocialLinkType.ts b/packages/twenty-front/src/utils/isSocialLinkType.ts
index f65d3dd08b..f12179aa4e 100644
--- a/packages/twenty-front/src/utils/isSocialLinkType.ts
+++ b/packages/twenty-front/src/utils/isSocialLinkType.ts
@@ -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,
+ );
diff --git a/packages/twenty-ui/src/navigation/SocialLink/LinkType.ts b/packages/twenty-ui/src/navigation/SocialLink/LinkType.ts
new file mode 100644
index 0000000000..05f18d448f
--- /dev/null
+++ b/packages/twenty-ui/src/navigation/SocialLink/LinkType.ts
@@ -0,0 +1,9 @@
+export enum LinkType {
+ Url = 'url',
+ LinkedIn = 'linkedin',
+ Twitter = 'twitter',
+ Facebook = 'facebook',
+ Instagram = 'instagram',
+ TikTok = 'tiktok',
+ Bluesky = 'bluesky',
+}
diff --git a/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx b/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx
index 8d640e1224..7ed28907c4 100644
--- a/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx
+++ b/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx
@@ -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;
diff --git a/packages/twenty-ui/src/navigation/SocialLink/__stories__/SocialLink.stories.tsx b/packages/twenty-ui/src/navigation/SocialLink/__stories__/SocialLink.stories.tsx
index 067ced0f22..9d9c256a31 100644
--- a/packages/twenty-ui/src/navigation/SocialLink/__stories__/SocialLink.stories.tsx
+++ b/packages/twenty-ui/src/navigation/SocialLink/__stories__/SocialLink.stories.tsx
@@ -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 = {
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;
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,
+ },
+};
diff --git a/packages/twenty-ui/src/navigation/SocialLink/socialLinkProviders.ts b/packages/twenty-ui/src/navigation/SocialLink/socialLinkProviders.ts
new file mode 100644
index 0000000000..e9c0b711af
--- /dev/null
+++ b/packages/twenty-ui/src/navigation/SocialLink/socialLinkProviders.ts
@@ -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',
+ },
+];
diff --git a/packages/twenty-ui/src/navigation/index.ts b/packages/twenty-ui/src/navigation/index.ts
index 814004ccc6..481fa89200 100644
--- a/packages/twenty-ui/src/navigation/index.ts
+++ b/packages/twenty-ui/src/navigation/index.ts
@@ -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';
diff --git a/packages/twenty-ui/src/utilities/utils/__tests__/getDisplayValueByUrlType.test.ts b/packages/twenty-ui/src/utilities/utils/__tests__/getDisplayValueByUrlType.test.ts
index fbac84805e..11f460995b 100644
--- a/packages/twenty-ui/src/utilities/utils/__tests__/getDisplayValueByUrlType.test.ts
+++ b/packages/twenty-ui/src/utilities/utils/__tests__/getDisplayValueByUrlType.test.ts
@@ -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({
diff --git a/packages/twenty-ui/src/utilities/utils/getDisplayValueByUrlType.ts b/packages/twenty-ui/src/utilities/utils/getDisplayValueByUrlType.ts
index 8e8149efbf..bbfd75ec25 100644
--- a/packages/twenty-ui/src/utilities/utils/getDisplayValueByUrlType.ts
+++ b/packages/twenty-ui/src/utilities/utils/getDisplayValueByUrlType.ts
@@ -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)}`;
};