From 49e90f7b84a57ab25add029d1417f76cdd5a5ee3 Mon Sep 17 00:00:00 2001 From: Anastasiya Uraleva Date: Thu, 25 Sep 2025 16:45:37 -0700 Subject: [PATCH] update: date joined, edge cases, and mobile and web support --- src/lib/strings/pronouns.ts | 2 +- src/lib/strings/time.ts | 8 + src/lib/strings/website.ts | 44 +++++ .../Profile/Header/EditProfileDialog.tsx | 156 +++++++++++++++++- src/screens/Profile/Header/Handle.tsx | 81 +++++---- .../Profile/Header/ProfileHeaderStandard.tsx | 44 ++++- src/state/queries/profile.ts | 9 +- 7 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 src/lib/strings/website.ts diff --git a/src/lib/strings/pronouns.ts b/src/lib/strings/pronouns.ts index e43ae6498..33dae6c32 100644 --- a/src/lib/strings/pronouns.ts +++ b/src/lib/strings/pronouns.ts @@ -8,6 +8,6 @@ export function sanitizePronouns( return '' } - const trimmed = pronouns.trim() + const trimmed = pronouns.trim().toLowerCase() return forceLeftToRight ? forceLTR(trimmed) : trimmed } diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index 7a3ce5c0a..66fe64da3 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -54,3 +54,11 @@ export function simpleAreDatesEqual(a: Date, b: Date): boolean { a.getDate() === b.getDate() ) } + +export function formatJoinDate(date: number | string | Date): string { + const d = new Date(date) + return d.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }) +} diff --git a/src/lib/strings/website.ts b/src/lib/strings/website.ts new file mode 100644 index 000000000..4e7db3371 --- /dev/null +++ b/src/lib/strings/website.ts @@ -0,0 +1,44 @@ +export function sanitizeWebsiteForDisplay(website: string): string { + return website.replace(/^https?:\/\//i, '').replace(/\/$/, '') +} + +export function sanitizeWebsiteForLink(website: string): string { + const normalized = website.toLowerCase() + return normalized.startsWith('https') + ? normalized + : `https://${website.toLowerCase()}` +} + +export function isValidWebsiteFormat(website: string): boolean { + const trimmedWebsite = website?.trim() || '' + + if (!trimmedWebsite || trimmedWebsite.length === 0) { + return true + } + + const normalizedWebsite = trimmedWebsite.toLowerCase() + + if ('https://'.startsWith(normalizedWebsite)) { + return true + } + + if (!normalizedWebsite.match(/^https:\/\/.+/)) { + return false + } + + const domainMatch = normalizedWebsite.match(/^https:\/\/([^/\s]+)/) + if (!domainMatch) { + return false + } + + const domain = domainMatch[1] + + // Check for valid domain structure: + // - Must contain at least one dot + // - Must have a valid TLD (at least 2 characters after the last dot) + // - Cannot be just a single word without extension + const domainPattern = + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/ + + return domainPattern.test(domain) +} diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx index 428e2f500..5f5d8bf5d 100644 --- a/src/screens/Profile/Header/EditProfileDialog.tsx +++ b/src/screens/Profile/Header/EditProfileDialog.tsx @@ -1,12 +1,13 @@ -import {useCallback, useEffect, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' +import {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, useWindowDimensions, View} from 'react-native' import {type AppBskyActorDefs} from '@atproto/api' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {urls} from '#/lib/constants' +import {HITSLOP_10, urls} from '#/lib/constants' import {cleanError} from '#/lib/strings/errors' import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' +import {isValidWebsiteFormat} from '#/lib/strings/website' import {logger} from '#/logger' import {type ImageMeta} from '#/state/gallery' import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' @@ -17,10 +18,13 @@ import * as Toast from '#/view/com/util/Toast' import {EditableUserAvatar} from '#/view/com/util/UserAvatar' import {UserBanner} from '#/view/com/util/UserBanner' import {atoms as a, useTheme} from '#/alf' +import * as tokens from '#/alf/tokens' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' +import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' @@ -28,6 +32,8 @@ import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' const DISPLAY_NAME_MAX_GRAPHEMES = 64 +const PRONOUNS_MAX_GRAPHEMES = 20 +const WEBSITE_MAX_GRAPHEMES = 28 const DESCRIPTION_MAX_GRAPHEMES = 256 export function EditProfileDialog({ @@ -117,6 +123,11 @@ function DialogInner({ const [displayName, setDisplayName] = useState(initialDisplayName) const initialDescription = profile.description || '' const [description, setDescription] = useState(initialDescription) + const initialPronouns = profile.pronouns || '' + const [pronouns, setPronouns] = useState(initialPronouns) + const initialWebsite = profile.website || '' + const [website, setWebsite] = useState(initialWebsite) + const websiteInputRef = useRef(null) const [userBanner, setUserBanner] = useState( profile.banner, ) @@ -178,6 +189,13 @@ function DialogInner({ [setNewUserBanner, setUserBanner, setImageError], ) + const onClearWebsite = useCallback(() => { + setWebsite('') + if (websiteInputRef.current) { + websiteInputRef.current.clear() + } + }, [setWebsite]) + const onPressSave = useCallback(async () => { setImageError('') try { @@ -186,6 +204,8 @@ function DialogInner({ updates: { displayName: displayName.trimEnd(), description: description.trimEnd(), + pronouns: pronouns.trimEnd().toLowerCase(), + website: website.trimEnd().toLowerCase(), }, newUserAvatar, newUserBanner, @@ -202,6 +222,8 @@ function DialogInner({ control, displayName, description, + pronouns, + website, newUserAvatar, newUserBanner, setImageError, @@ -212,6 +234,15 @@ function DialogInner({ text: displayName, maxCount: DISPLAY_NAME_MAX_GRAPHEMES, }) + const pronounsTooLong = isOverMaxGraphemeCount({ + text: pronouns, + maxCount: PRONOUNS_MAX_GRAPHEMES, + }) + const websiteTooLong = isOverMaxGraphemeCount({ + text: website, + maxCount: WEBSITE_MAX_GRAPHEMES, + }) + const websiteInvalidFormat = !isValidWebsiteFormat(website) const descriptionTooLong = isOverMaxGraphemeCount({ text: description, maxCount: DESCRIPTION_MAX_GRAPHEMES, @@ -393,6 +424,125 @@ function DialogInner({ )} + + + + Pronouns + + + + + {pronounsTooLong && ( + + + + )} + + + + + Website + + + + {website && } + + + + {website && ( + + + + + + )} + + {websiteTooLong && ( + + + + )} + {websiteInvalidFormat && ( + + + Website must be a valid URL (e.g., https://bsky.app) + + + )} + ) diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index 8ec423610..3b688ebd9 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -22,6 +22,7 @@ export function ProfileHeaderHandle({ const t = useTheme() const {_} = useLingui() const invalidHandle = isInvalidHandle(profile.handle) + const pronouns = profile.pronouns const isBskySocialHandle = profile.handle.endsWith('.bsky.social') const showProfileInHandle = useShowLinkInHandle() const sanitized = sanitizeHandle( @@ -35,41 +36,53 @@ export function ProfileHeaderHandle({ style={[a.flex_row, a.gap_sm, a.align_center, {maxWidth: '100%'}]} pointerEvents={disableTaps ? 'none' : IS_IOS ? 'auto' : 'box-none'}> - - - {invalidHandle ? ( - _(msg`⚠Invalid Handle`) - ) : showProfileInHandle && !isBskySocialHandle ? ( - - - {sanitized} - - - ) : ( - sanitized + + + {invalidHandle ? ( + _(msg`⚠Invalid Handle`) + ) : showProfileInHandle && !isBskySocialHandle ? ( + + + {sanitized} + + + ) : ( + sanitized + )} + + {pronouns && ( + + {sanitizePronouns(pronouns, IS_NATIVE)} + )} - + ) } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index f02fedeb8..4fccddcd0 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -14,6 +14,11 @@ import {useActorStatus} from '#/lib/actor-status' import {useHaptics} from '#/lib/haptics' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' +import {formatJoinDate} from '#/lib/strings/time' +import { + sanitizeWebsiteForDisplay, + sanitizeWebsiteForLink, +} from '#/lib/strings/website' import {logger} from '#/logger' import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' @@ -23,17 +28,20 @@ import { } from '#/state/queries/profile' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' -import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {useDialogControl} from '#/components/Dialog' import {MessageProfileButton} from '#/components/dms/MessageProfileButton' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import { KnownFollowers, shouldShowKnownFollowers, } from '#/components/KnownFollowers' +import {Link} from '#/components/Link' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import * as Toast from '#/components/Toast' @@ -79,6 +87,14 @@ let ProfileHeaderStandard = ({ profile.viewer?.blockedBy || profile.viewer?.blockingByList + const website = profile.website + const websiteFormatted = sanitizeWebsiteForDisplay(website ?? '') + + const dateJoined = useMemo(() => { + if (!profile.createdAt) return '' + return formatJoinDate(profile.createdAt) + }, [profile.createdAt]) + const unblockAccount = async () => { try { await queueUnblock() @@ -181,6 +197,32 @@ let ProfileHeaderStandard = ({ )} + + {websiteFormatted && ( + + + + {websiteFormatted} + + + )} + + + + Joined {dateJoined} + + + + diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index d3bf988a4..460ac22b0 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -181,6 +181,12 @@ export function useProfileUpdateMutation() { if ('pinnedPost' in updates) { next.pinnedPost = updates.pinnedPost } + if ('pronouns' in updates) { + next.pronouns = updates.pronouns + } + if ('website' in updates) { + next.website = updates.website + } } if (newUserAvatarPromise) { const res = await newUserAvatarPromise @@ -225,7 +231,8 @@ export function useProfileUpdateMutation() { return ( res.data.displayName === updates.displayName && res.data.description === updates.description && - res.data.pronouns === updates.pronouns + res.data.pronouns === updates.pronouns && + res.data.website === updates.website ) }), ) -- 2.51.2