diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx index 42d6fa2604..0712d07dd7 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx @@ -1,6 +1,7 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation'; +import { RoundedLink, SocialLink } from 'twenty-ui/navigation'; import { checkUrlType } from '~/utils/checkUrlType'; +import { isSocialLinkType } from '~/utils/isSocialLinkType'; import { getSafeUrl } from 'twenty-shared/utils'; type LinkDisplayProps = { @@ -22,11 +23,7 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => { const type = checkUrlType(absoluteUrl); - if ( - type === LinkType.LinkedIn || - type === LinkType.Twitter || - type === LinkType.Facebook - ) { + if (isSocialLinkType(type)) { return ; } diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx index 828fee4750..33a7d47d8e 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx @@ -8,8 +8,9 @@ import { getUrlHostnameOrThrow, isDefined, } from 'twenty-shared/utils'; -import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation'; +import { RoundedLink, SocialLink } from 'twenty-ui/navigation'; import { checkUrlType } from '~/utils/checkUrlType'; +import { isSocialLinkType } from '~/utils/isSocialLinkType'; type LinksDisplayProps = { value?: FieldLinksValue; @@ -43,9 +44,7 @@ export const LinksDisplay = ({ value, onLinkClick }: LinksDisplayProps) => { return ( {links.map(({ url, label, type }, index) => - type === LinkType.LinkedIn || - type === LinkType.Twitter || - type === LinkType.Facebook ? ( + isSocialLinkType(type) ? ( { const type = checkUrlType(absoluteUrl); - if ( - type === LinkType.LinkedIn || - type === LinkType.Twitter || - type === LinkType.Facebook - ) { + if (isSocialLinkType(type)) { return ( { const links = canvas.queryAllByRole('link'); - expect(links).toHaveLength(2); + expect(links).toHaveLength(3); }); const linkedinLink = await canvas.findByText('twenty'); @@ -152,6 +156,44 @@ export const SocialMediaLinks: Story = { 'href', 'https://twitter.com/twentycrm', ); + + const instagramLink = await canvas.findByText('@twenty_hq'); + expect(instagramLink).toBeVisible(); + expect(instagramLink).toHaveAttribute( + 'href', + 'https://www.instagram.com/twenty_hq', + ); + }, +}; + +export const InstagramLinks: Story = { + args: { + value: { + primaryLinkUrl: 'https://www.instagram.com/twenty_hq', + primaryLinkLabel: 'Twenty on Instagram', + secondaryLinks: [ + { url: 'https://instagram.com/p/ABC123', label: 'A post' }, + { url: 'https://instagram.com/reel/XYZ789', label: 'A reel' }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + const links = canvas.queryAllByRole('link'); + expect(links).toHaveLength(3); + }); + + const handleLink = await canvas.findByText('@twenty_hq'); + expect(handleLink).toBeVisible(); + expect(handleLink).toHaveAttribute( + 'href', + 'https://www.instagram.com/twenty_hq', + ); + + const fallbackLinks = await canvas.findAllByText('Instagram'); + expect(fallbackLinks).toHaveLength(2); }, }; diff --git a/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts b/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts index 6533fb08c4..c99c3d1808 100644 --- a/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts +++ b/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts @@ -23,8 +23,16 @@ describe('checkUrlType', () => { ); }); + it('should detect Instagram urls', () => { + expect(checkUrlType('https://www.instagram.com/ptcrash')).toBe( + LinkType.Instagram, + ); + expect(checkUrlType('instagram.com/ptcrash')).toBe(LinkType.Instagram); + }); + 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); }); }); diff --git a/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts b/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts new file mode 100644 index 0000000000..1a91115913 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/isSocialLinkType.test.ts @@ -0,0 +1,15 @@ +import { isSocialLinkType } from '~/utils/isSocialLinkType'; +import { LinkType } from 'twenty-ui/navigation'; + +describe('isSocialLinkType', () => { + it('should return true for social link types', () => { + expect(isSocialLinkType(LinkType.LinkedIn)).toBe(true); + expect(isSocialLinkType(LinkType.Twitter)).toBe(true); + expect(isSocialLinkType(LinkType.Facebook)).toBe(true); + expect(isSocialLinkType(LinkType.Instagram)).toBe(true); + }); + + it('should return false for a generic url type', () => { + expect(isSocialLinkType(LinkType.Url)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/utils/checkUrlType.ts b/packages/twenty-front/src/utils/checkUrlType.ts index f156b33a15..b5ff2612dd 100644 --- a/packages/twenty-front/src/utils/checkUrlType.ts +++ b/packages/twenty-front/src/utils/checkUrlType.ts @@ -12,6 +12,9 @@ export const checkUrlType = (url: string) => { if (/^(https?:\/\/)?(www\.)?facebook\.com\/.+$/.test(url)) { return LinkType.Facebook; } + if (/^(https?:\/\/)?(www\.)?instagram\.com\/.+$/.test(url)) { + return LinkType.Instagram; + } return LinkType.Url; }; diff --git a/packages/twenty-front/src/utils/isSocialLinkType.ts b/packages/twenty-front/src/utils/isSocialLinkType.ts new file mode 100644 index 0000000000..f65d3dd08b --- /dev/null +++ b/packages/twenty-front/src/utils/isSocialLinkType.ts @@ -0,0 +1,12 @@ +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, +]; + +export const isSocialLinkType = (type: LinkType): boolean => + SOCIAL_LINK_TYPES.includes(type); diff --git a/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx b/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx index 2deea3b8e6..8d640e1224 100644 --- a/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx +++ b/packages/twenty-ui/src/navigation/SocialLink/SocialLink.tsx @@ -8,6 +8,7 @@ export enum LinkType { LinkedIn = 'linkedin', Twitter = 'twitter', Facebook = 'facebook', + Instagram = 'instagram', } type SocialLinkProps = { 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 b3ebc83ffb..fbac84805e 100644 --- a/packages/twenty-ui/src/utilities/utils/__tests__/getDisplayValueByUrlType.test.ts +++ b/packages/twenty-ui/src/utilities/utils/__tests__/getDisplayValueByUrlType.test.ts @@ -137,6 +137,80 @@ describe('getDisplayValueByUrlType', () => { }); }); + describe('instagram', () => { + it('should extract handle from Instagram profile URL with @ prefix', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://www.instagram.com/ptcrash', + }); + expect(result).toBe('@ptcrash'); + }); + + it('should handle Instagram URL without protocol', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'instagram.com/ptcrash', + }); + expect(result).toBe('@ptcrash'); + }); + + it('should handle Instagram URL without www', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://instagram.com/ptcrash', + }); + expect(result).toBe('@ptcrash'); + }); + + it('should ignore a trailing slash', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://instagram.com/ptcrash/', + }); + expect(result).toBe('@ptcrash'); + }); + + it('should ignore a query string', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://instagram.com/ptcrash?hl=en', + }); + expect(result).toBe('@ptcrash'); + }); + + it('should decode URL-encoded characters in the handle', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://instagram.com/john%20doe', + }); + expect(result).toBe('@john doe'); + }); + + it('should return "Instagram" for a post URL', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://instagram.com/p/ABC123', + }); + expect(result).toBe('Instagram'); + }); + + it('should return "Instagram" for a reel URL', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://instagram.com/reel/xyz', + }); + expect(result).toBe('Instagram'); + }); + + it('should return "Instagram" when no handle can be extracted', () => { + const result = getDisplayValueByUrlType({ + type: LinkType.Instagram, + href: 'https://www.instagram.com/', + }); + expect(result).toBe('Instagram'); + }); + }); + 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 fa704d0a24..8e8149efbf 100644 --- a/packages/twenty-ui/src/utilities/utils/getDisplayValueByUrlType.ts +++ b/packages/twenty-ui/src/utilities/utils/getDisplayValueByUrlType.ts @@ -1,6 +1,18 @@ import { type LinkType } from '@ui/navigation'; 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: LinkType; href: string; @@ -40,4 +52,19 @@ export const getDisplayValueByUrlType = ({ 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'; + } + } };