From 2c9ffe4632a31de51d43c20b36e9a1f0c0542cce Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sat, 23 May 2026 11:56:23 +0200 Subject: [PATCH] Support multi-value social links and refactor link handling logic --- common/src/api/zod-types.ts | 4 +- common/src/socials.ts | 19 ++++- common/tests/unit/socials.test.ts | 18 ++++- web/components/social-links-section.tsx | 95 +++++++++++++++++-------- web/components/user/user-handles.tsx | 26 ++++--- 5 files changed, 119 insertions(+), 43 deletions(-) diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index d52edd64..5ab12a82 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -44,6 +44,8 @@ export const zBoolean = z .union([z.boolean(), z.string()]) .transform((val) => val === true || val === 'true') +const linkValueSchema = z.union([z.string(), z.array(z.string())]).nullable() + // TODO: merge the two below when the deprecated /create-profile is deleted export const baseProfilesSchema = z.object({ age: z.number().min(18).max(100).optional().nullable(), @@ -97,7 +99,7 @@ const optionalProfilesSchema = z.object({ image_descriptions: z.any().optional().nullable(), interests: z.array(z.string()).optional().nullable(), is_smoker: zBoolean.optional().nullable(), - links: z.record(z.string().nullable()).optional(), + links: z.record(linkValueSchema).optional(), mbti: z.string().optional().nullable(), occupation: z.string().optional().nullable(), occupation_title: z.string().optional().nullable(), diff --git a/common/src/socials.ts b/common/src/socials.ts index fa9e6102..dfdc3bea 100644 --- a/common/src/socials.ts +++ b/common/src/socials.ts @@ -26,7 +26,24 @@ export const SITE_ORDER = [ export type Site = (typeof SITE_ORDER)[number] // this is a lie, actually people can have anything in their links -export type Socials = {[key in Site]?: string} +export type SocialValue = string | string[] | null | undefined +export type Socials = {[key: string]: SocialValue} + +export const MULTI_VALUE_SITES = ['site'] as const + +export const isMultiValueSite = (site: string) => + (MULTI_VALUE_SITES as readonly string[]).includes(site) + +export const getSocialLinkValues = (value: SocialValue) => { + if (Array.isArray(value)) return value + if (value == null) return [] + return [value] +} + +export const getSocialEntries = (links: Socials | null | undefined) => + Object.entries(links ?? {}).flatMap(([platform, value]) => + getSocialLinkValues(value).map((value, index) => ({platform, value, index})), + ) export const strip = (site: Site, input: string) => stripper[site]?.(input) ?? input diff --git a/common/tests/unit/socials.test.ts b/common/tests/unit/socials.test.ts index 4cf53ef5..3da34310 100644 --- a/common/tests/unit/socials.test.ts +++ b/common/tests/unit/socials.test.ts @@ -1,5 +1,5 @@ import {discordLink} from 'common/constants' -import {getSocialUrl, strip} from 'common/socials' +import {getSocialEntries, getSocialUrl, strip} from 'common/socials' describe('strip', () => { describe('x/twitter', () => { @@ -81,3 +81,19 @@ describe('getSocialUrl', () => { expect(getSocialUrl('discord', 'not-an-id')).toBe(discordLink) }) }) + +describe('getSocialEntries', () => { + it('flattens multi-value website links while preserving single-value links', () => { + expect( + getSocialEntries({ + site: ['example.com', 'blog.example.com'], + github: 'username', + x: null, + }), + ).toEqual([ + {platform: 'site', value: 'example.com', index: 0}, + {platform: 'site', value: 'blog.example.com', index: 1}, + {platform: 'github', value: 'username', index: 0}, + ]) + }) +}) diff --git a/web/components/social-links-section.tsx b/web/components/social-links-section.tsx index a731a852..0eff99d2 100644 --- a/web/components/social-links-section.tsx +++ b/web/components/social-links-section.tsx @@ -1,7 +1,15 @@ import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid' import clsx from 'clsx' import {ProfileWithoutUser} from 'common/profiles/profile' -import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials' +import { + getSocialEntries, + getSocialLinkValues, + isMultiValueSite, + PLATFORM_LABELS, + type Site, + SITE_ORDER, + Socials, +} from 'common/socials' import {removeNullOrUndefinedProps} from 'common/util/object' import {Fragment, useState} from 'react' import {Button, IconButton} from 'web/components/buttons/button' @@ -19,20 +27,47 @@ interface SocialLinksSectionProps { export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProps) { const t = useT() - const [newLinkPlatform, setNewLinkPlatform] = useState('') + const [newLinkPlatform, setNewLinkPlatform] = useState('site') const [newLinkValue, setNewLinkValue] = useState('') - const updateUserLink = (platform: string, value: string | null) => { - setProfile( - 'links', - removeNullOrUndefinedProps({...((profile.links as Socials) ?? {}), [platform]: value}), - ) + const setLinks = (links: Socials) => { + setProfile('links', removeNullOrUndefinedProps(links)) + } + + const updateUserLink = (platform: string, value: string | null, index = 0) => { + const links = {...((profile.links as Socials) ?? {})} + const currentValue = links[platform] + + if (Array.isArray(currentValue)) { + const nextValues = [...currentValue] + if (value == null) { + nextValues.splice(index, 1) + } else { + nextValues[index] = value + } + setLinks({...links, [platform]: nextValues.length > 0 ? nextValues : null}) + return + } + + setLinks({...links, [platform]: value}) } const addNewLink = () => { if (newLinkPlatform && newLinkValue) { - updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim()) - setNewLinkPlatform('') + const platform = newLinkPlatform.toLowerCase().trim() + const value = newLinkValue.trim() + const links = {...((profile.links as Socials) ?? {})} + + if (isMultiValueSite(platform) && links[platform] != null) { + setLinks({ + ...links, + [platform]: [...getSocialLinkValues(links[platform]).filter(Boolean), value], + }) + } else { + updateUserLink(platform, value) + } + + setNewLinkPlatform('site') setNewLinkValue('') } } @@ -40,28 +75,26 @@ export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProp return (
- {Object.entries((profile.links ?? {}) as Socials) - .filter(([_, value]) => value != null) - .map(([platform, value]) => ( - -
- - {PLATFORM_LABELS[platform as Site] ?? platform} -
- ) => - updateUserLink(platform, e.target.value) - } - className="col-span-2 sm:col-span-1" - /> - updateUserLink(platform, null)}> - -
{t('common.remove', 'Remove')}
-
-
- ))} + {getSocialEntries((profile.links ?? {}) as Socials).map(({platform, value, index}) => ( + +
+ + {PLATFORM_LABELS[platform as Site] ?? platform} +
+ ) => + updateUserLink(platform, e.target.value, index) + } + className="col-span-2 sm:col-span-1" + /> + updateUserLink(platform, null, index)}> + +
{t('common.remove', 'Remove')}
+
+
+ ))} {/* Spacer */}
diff --git a/web/components/user/user-handles.tsx b/web/components/user/user-handles.tsx index d3b09e12..824e36a2 100644 --- a/web/components/user/user-handles.tsx +++ b/web/components/user/user-handles.tsx @@ -1,5 +1,12 @@ import clsx from 'clsx' -import {getSocialUrl, PLATFORM_LABELS, Site, SITE_ORDER, Socials} from 'common/socials' +import { + getSocialEntries, + getSocialUrl, + PLATFORM_LABELS, + Site, + SITE_ORDER, + Socials, +} from 'common/socials' import {sortBy} from 'lodash' import {Row} from '../layout/row' @@ -18,20 +25,21 @@ export function UserHandles(props: {links: Socials; className?: string}) { const {links, className} = props const display = sortBy( - Object.entries(links), - ([platform]) => -[...SITE_ORDER].reverse().indexOf(platform as Site), + getSocialEntries(links), + ({platform}) => -[...SITE_ORDER].reverse().indexOf(platform as Site), ) - .filter(([platform, label]) => !!label && !!platform) - .map(([platform, label]) => { + .filter(({platform, value}) => !!value && !!platform) + .map(({platform, value, index}) => { let renderedLabel: string = LABELS_TO_RENDER.includes(platform) ? PLATFORM_LABELS[platform as Site] - : label + : value renderedLabel = renderedLabel?.replace(/\/+$/, '') // remove trailing slashes renderedLabel = renderedLabel?.replace(/^(https?:\/\/)?(www\.)?/, '') // remove protocol and www return { platform, label: renderedLabel, - url: getSocialUrl(platform as any, label), + url: getSocialUrl(platform as any, value), + key: `${platform}-${index}`, } }) @@ -44,9 +52,9 @@ export function UserHandles(props: {links: Socials; className?: string}) { className={clsx('flex-wrap items-center gap-2', className)} data-testid="profile-social-media-accounts" > - {display.map(({platform, label, url}) => ( + {display.map(({platform, label, url, key}) => (