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. Review in cubic 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)}`; };