Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/lib/strings/pronouns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {forceLTR} from './bidi'

export function sanitizePronouns(
pronouns: string,
forceLeftToRight = true,
): string {
if (!pronouns || pronouns.trim() === '') {
return ''
}

const trimmed = pronouns.trim().toLowerCase()
return forceLeftToRight ? forceLTR(trimmed) : trimmed
}
8 changes: 8 additions & 0 deletions src/lib/strings/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,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',
})
}
44 changes: 44 additions & 0 deletions src/lib/strings/website.ts
Original file line number Diff line number Diff line change
@@ -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)
}
169 changes: 165 additions & 4 deletions src/screens/Profile/Header/EditProfileDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {useCallback, useEffect, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {useCallback, useEffect, useRef, useState} from 'react'
import {Dimensions, useWindowDimensions, Pressable, 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 {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
import {isValidWebsiteFormat} from '#/lib/strings/website'
import {logger} from '#/logger'
import {type ImageMeta} from '#/state/gallery'
import {useProfileUpdateMutation} from '#/state/queries/profile'
Expand All @@ -15,17 +16,22 @@ 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'
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({
Expand Down Expand Up @@ -114,6 +120,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<any>(null)
const [userBanner, setUserBanner] = useState<string | undefined | null>(
profile.banner,
)
Expand All @@ -130,6 +141,8 @@ function DialogInner({
const dirty =
displayName !== initialDisplayName ||
description !== initialDescription ||
pronouns !== initialPronouns ||
website !== initialWebsite ||
userAvatar !== profile.avatar ||
userBanner !== profile.banner

Expand Down Expand Up @@ -173,14 +186,26 @@ function DialogInner({
[setNewUserBanner, setUserBanner, setImageError],
)

const onClearWebsite = useCallback(() => {
setWebsite('')
if (websiteInputRef.current) {
websiteInputRef.current.clear()
}
}, [setWebsite])

const onPressSave = useCallback(async () => {
setImageError('')
try {
const trimmedWebsite = website.trimEnd().toLowerCase()
const websiteToSave = trimmedWebsite || undefined

await updateProfileMutation({
profile,
updates: {
displayName: displayName.trimEnd(),
description: description.trimEnd(),
pronouns: pronouns.trimEnd().toLowerCase(),
website: websiteToSave,
},
newUserAvatar,
newUserBanner,
Expand All @@ -197,6 +222,8 @@ function DialogInner({
control,
displayName,
description,
pronouns,
website,
newUserAvatar,
newUserBanner,
setImageError,
Expand All @@ -207,6 +234,15 @@ function DialogInner({
text: displayName,
maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
})
const pronounsTooLong = useWarnMaxGraphemeCount({
text: pronouns,
maxCount: PRONOUNS_MAX_GRAPHEMES,
})
const websiteTooLong = useWarnMaxGraphemeCount({
text: website,
maxCount: WEBSITE_MAX_GRAPHEMES,
})
const websiteInvalidFormat = !isValidWebsiteFormat(website)
const descriptionTooLong = useWarnMaxGraphemeCount({
text: description,
maxCount: DESCRIPTION_MAX_GRAPHEMES,
Expand Down Expand Up @@ -239,7 +275,10 @@ function DialogInner({
!dirty ||
isUpdatingProfile ||
displayNameTooLong ||
descriptionTooLong
descriptionTooLong ||
pronounsTooLong ||
websiteTooLong ||
websiteInvalidFormat
}
size="small"
color="primary"
Expand All @@ -260,6 +299,9 @@ function DialogInner({
isUpdatingProfile,
displayNameTooLong,
descriptionTooLong,
pronounsTooLong,
websiteTooLong,
websiteInvalidFormat,
],
)

Expand Down Expand Up @@ -387,6 +429,125 @@ function DialogInner({
</Text>
)}
</View>

<View>
<TextField.LabelText>
<Trans>Pronouns</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={pronounsTooLong}>
<Dialog.Input
defaultValue={pronouns}
onChangeText={setPronouns}
label={_(msg`Pronouns`)}
placeholder={_(msg`Pronouns`)}
testID="editProfilePronounsInput"
/>
</TextField.Root>
{pronounsTooLong && (
<Text
style={[
a.text_sm,
a.mt_xs,
a.font_bold,
{color: t.palette.negative_400},
]}>
<Plural
value={PRONOUNS_MAX_GRAPHEMES}
other="The maximum number of characters is #."
/>
</Text>
)}
</View>

<View>
<TextField.LabelText>
<Trans>Website</Trans>
</TextField.LabelText>
<View style={[a.w_full, a.relative]}>
<TextField.Root isInvalid={websiteTooLong || websiteInvalidFormat}>
{website && <TextField.Icon icon={Globe} />}
<Dialog.Input
inputRef={websiteInputRef}
defaultValue={website}
onChangeText={setWebsite}
label={_(msg`EditWebsite`)}
placeholder={_(msg`URL`)}
testID="editProfileWebsiteInput"
autoCapitalize="none"
keyboardType="url"
style={[
website
? {
paddingRight: tokens.space._5xl,
}
: {},
]}
/>
</TextField.Root>

{website && (
<View
style={[
a.absolute,
a.z_10,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Pressable
testID="clearWebsiteBtn"
onPress={onClearWebsite}
accessibilityLabel={_(msg`Clear website`)}
accessibilityHint={_(msg`Removes the website URL`)}
hitSlop={HITSLOP_10}
style={[
a.flex_row,
a.align_center,
a.justify_center,
{
width: tokens.space._2xl,
height: tokens.space._2xl,
},
a.rounded_full,
]}>
<CircleX
width={tokens.space.lg}
style={{color: t.palette.contrast_600}}
/>
</Pressable>
</View>
)}
</View>
{websiteTooLong && (
<Text
style={[
a.text_sm,
a.mt_xs,
a.font_bold,
{color: t.palette.negative_400},
]}>
<Plural
value={WEBSITE_MAX_GRAPHEMES}
other="Website is too long. The maximum number of characters is #."
/>
</Text>
)}
{websiteInvalidFormat && (
<Text
style={[
a.text_sm,
a.mt_xs,
a.font_bold,
{color: t.palette.negative_400},
]}>
<Trans>
Website must be a valid URL (e.g., https://bsky.app)
</Trans>
</Text>
)}
</View>
</View>
</Dialog.ScrollableInner>
)
Expand Down
Loading