Skip to content

Commit 5e97edd

Browse files
committed
update: date joined, edge cases, and mobile and web support
1 parent d4578f9 commit 5e97edd

File tree

7 files changed

+197
-30
lines changed

7 files changed

+197
-30
lines changed

src/lib/strings/pronouns.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ export function sanitizePronouns(
88
return ''
99
}
1010

11-
const trimmed = pronouns.trim()
11+
const trimmed = pronouns.trim().toLowerCase()
1212
return forceLeftToRight ? forceLTR(trimmed) : trimmed
1313
}

src/lib/strings/time.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,11 @@ export function simpleAreDatesEqual(a: Date, b: Date): boolean {
4040
a.getDate() === b.getDate()
4141
)
4242
}
43+
44+
export function formatJoinDate(date: number | string | Date): string {
45+
const d = new Date(date)
46+
return d.toLocaleDateString('en-US', {
47+
month: 'short',
48+
year: 'numeric',
49+
})
50+
}

src/lib/strings/website.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export function sanitizeWebsiteForDisplay(website: string): string {
2+
return website.replace(/^https?:\/\//i, '').replace(/\/$/, '')
3+
}
4+
5+
export function sanitizeWebsiteForLink(website: string): string {
6+
const normalized = website.toLowerCase()
7+
return normalized.startsWith('https')
8+
? normalized
9+
: `https://${website.toLowerCase()}`
10+
}
11+
12+
export function isValidWebsiteFormat(website: string): boolean {
13+
const trimmedWebsite = website?.trim() || ''
14+
15+
if (!trimmedWebsite || trimmedWebsite.length === 0) {
16+
return true
17+
}
18+
19+
const normalizedWebsite = trimmedWebsite.toLowerCase()
20+
21+
if ('https://'.startsWith(normalizedWebsite)) {
22+
return true
23+
}
24+
25+
if (!normalizedWebsite.match(/^https:\/\/.+/)) {
26+
return false
27+
}
28+
29+
const domainMatch = normalizedWebsite.match(/^https:\/\/([^/\s]+)/)
30+
if (!domainMatch) {
31+
return false
32+
}
33+
34+
const domain = domainMatch[1]
35+
36+
// Check for valid domain structure:
37+
// - Must contain at least one dot
38+
// - Must have a valid TLD (at least 2 characters after the last dot)
39+
// - Cannot be just a single word without extension
40+
const domainPattern =
41+
/^[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,}$/
42+
43+
return domainPattern.test(domain)
44+
}

src/screens/Profile/Header/EditProfileDialog.tsx

Lines changed: 86 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import {useCallback, useEffect, useState} from 'react'
2-
import {Dimensions, View} from 'react-native'
1+
import {useCallback, useEffect, useRef, useState} from 'react'
2+
import {Dimensions, Pressable, View} from 'react-native'
33
import {type AppBskyActorDefs} from '@atproto/api'
44
import {msg, Plural, Trans} from '@lingui/macro'
55
import {useLingui} from '@lingui/react'
66

7-
import {urls} from '#/lib/constants'
7+
import {HITSLOP_10, urls} from '#/lib/constants'
88
import {cleanError} from '#/lib/strings/errors'
99
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
10+
import {isValidWebsiteFormat} from '#/lib/strings/website'
1011
import {logger} from '#/logger'
1112
import {type ImageMeta} from '#/state/gallery'
1213
import {useProfileUpdateMutation} from '#/state/queries/profile'
@@ -15,19 +16,22 @@ import * as Toast from '#/view/com/util/Toast'
1516
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
1617
import {UserBanner} from '#/view/com/util/UserBanner'
1718
import {atoms as a, useTheme} from '#/alf'
19+
import * as tokens from '#/alf/tokens'
1820
import {Admonition} from '#/components/Admonition'
1921
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
2022
import * as Dialog from '#/components/Dialog'
2123
import * as TextField from '#/components/forms/TextField'
24+
import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
25+
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
2226
import {InlineLinkText} from '#/components/Link'
2327
import {Loader} from '#/components/Loader'
2428
import * as Prompt from '#/components/Prompt'
2529
import {Text} from '#/components/Typography'
2630
import {useSimpleVerificationState} from '#/components/verification'
2731

2832
const DISPLAY_NAME_MAX_GRAPHEMES = 64
29-
const PRONOUNS_MAX_GRAPHEMES = 50
30-
const WEBSITE_MAX_GRAPHEMES = 300
33+
const PRONOUNS_MAX_GRAPHEMES = 20
34+
const WEBSITE_MAX_GRAPHEMES = 28
3135
const DESCRIPTION_MAX_GRAPHEMES = 256
3236

3337
const SCREEN_HEIGHT = Dimensions.get('window').height
@@ -117,10 +121,11 @@ function DialogInner({
117121
const [displayName, setDisplayName] = useState(initialDisplayName)
118122
const initialDescription = profile.description || ''
119123
const [description, setDescription] = useState(initialDescription)
120-
const initialPronouns = ''
124+
const initialPronouns = profile.pronouns || ''
121125
const [pronouns, setPronouns] = useState(initialPronouns)
122-
const initialWebsite = ''
126+
const initialWebsite = profile.website || ''
123127
const [website, setWebsite] = useState(initialWebsite)
128+
const websiteInputRef = useRef<any>(null)
124129
const [userBanner, setUserBanner] = useState<string | undefined | null>(
125130
profile.banner,
126131
)
@@ -182,15 +187,26 @@ function DialogInner({
182187
[setNewUserBanner, setUserBanner, setImageError],
183188
)
184189

190+
const onClearWebsite = useCallback(() => {
191+
setWebsite('')
192+
if (websiteInputRef.current) {
193+
websiteInputRef.current.clear()
194+
}
195+
}, [setWebsite])
196+
185197
const onPressSave = useCallback(async () => {
186198
setImageError('')
187199
try {
200+
const trimmedWebsite = website.trimEnd().toLowerCase()
201+
const websiteToSave = trimmedWebsite || undefined
202+
188203
await updateProfileMutation({
189204
profile,
190205
updates: {
191206
displayName: displayName.trimEnd(),
192207
description: description.trimEnd(),
193-
pronouns: pronouns.trimEnd(),
208+
pronouns: pronouns.trimEnd().toLowerCase(),
209+
website: websiteToSave,
194210
},
195211
newUserAvatar,
196212
newUserBanner,
@@ -209,6 +225,7 @@ function DialogInner({
209225
displayName,
210226
description,
211227
pronouns,
228+
website,
212229
newUserAvatar,
213230
newUserBanner,
214231
setImageError,
@@ -227,7 +244,7 @@ function DialogInner({
227244
text: website,
228245
maxCount: WEBSITE_MAX_GRAPHEMES,
229246
})
230-
const websiteInvalidFormat = !!(website && !website.match(/^https?:\/\/.+/))
247+
const websiteInvalidFormat = !isValidWebsiteFormat(website)
231248
const descriptionTooLong = useWarnMaxGraphemeCount({
232249
text: description,
233250
maxCount: DESCRIPTION_MAX_GRAPHEMES,
@@ -448,15 +465,63 @@ function DialogInner({
448465
<TextField.LabelText>
449466
<Trans>Website</Trans>
450467
</TextField.LabelText>
451-
<TextField.Root isInvalid={websiteTooLong || websiteInvalidFormat}>
452-
<Dialog.Input
453-
defaultValue={website}
454-
onChangeText={setWebsite}
455-
label={_(msg`Website`)}
456-
placeholder={_(msg`URL`)}
457-
testID="editProfileWebsiteInput"
458-
/>
459-
</TextField.Root>
468+
<View style={[a.w_full, a.relative]}>
469+
<TextField.Root isInvalid={websiteTooLong || websiteInvalidFormat}>
470+
{website && <TextField.Icon icon={Globe} />}
471+
<Dialog.Input
472+
inputRef={websiteInputRef}
473+
defaultValue={website}
474+
onChangeText={setWebsite}
475+
label={_(msg`EditWebsite`)}
476+
placeholder={_(msg`URL`)}
477+
testID="editProfileWebsiteInput"
478+
autoCapitalize="none"
479+
keyboardType="url"
480+
style={[
481+
website
482+
? {
483+
paddingRight: tokens.space._5xl,
484+
}
485+
: {},
486+
]}
487+
/>
488+
</TextField.Root>
489+
490+
{website && (
491+
<View
492+
style={[
493+
a.absolute,
494+
a.z_10,
495+
a.my_auto,
496+
a.inset_0,
497+
a.justify_center,
498+
a.pr_sm,
499+
{left: 'auto'},
500+
]}>
501+
<Pressable
502+
testID="clearWebsiteBtn"
503+
onPress={onClearWebsite}
504+
accessibilityLabel={_(msg`Clear website`)}
505+
accessibilityHint={_(msg`Removes the website URL`)}
506+
hitSlop={HITSLOP_10}
507+
style={[
508+
a.flex_row,
509+
a.align_center,
510+
a.justify_center,
511+
{
512+
width: tokens.space._2xl,
513+
height: tokens.space._2xl,
514+
},
515+
a.rounded_full,
516+
]}>
517+
<CircleX
518+
width={tokens.space.lg}
519+
style={{color: t.palette.contrast_600}}
520+
/>
521+
</Pressable>
522+
</View>
523+
)}
524+
</View>
460525
{websiteTooLong && (
461526
<Text
462527
style={[
@@ -479,7 +544,9 @@ function DialogInner({
479544
a.font_bold,
480545
{color: t.palette.negative_400},
481546
]}>
482-
<Trans>Website must start with http:// or https://</Trans>
547+
<Trans>
548+
Website must be a valid URL (e.g., https://bsky.app)
549+
</Trans>
483550
</Text>
484551
)}
485552
</View>

src/screens/Profile/Header/Handle.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function ProfileHeaderHandle({
2121
const t = useTheme()
2222
const {_} = useLingui()
2323
const invalidHandle = isInvalidHandle(profile.handle)
24-
24+
const pronouns = profile.pronouns
2525
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
2626

2727
return (
@@ -37,7 +37,7 @@ export function ProfileHeaderHandle({
3737
</View>
3838
) : undefined}
3939

40-
<View style={[a.flex_col, a.gap_xs]}>
40+
<View style={[a.flex_row, a.flex_wrap, {gap: 6}]}>
4141
<Text
4242
emoji
4343
numberOfLines={1}
@@ -68,12 +68,14 @@ export function ProfileHeaderHandle({
6868
)}
6969
</Text>
7070
{pronouns && (
71-
<Text style={[t.atoms.text_contrast_low, a.pb_md]}>
72-
{sanitizePronouns(
73-
pronouns,
74-
// forceLTR handled by the sanitization function
75-
isNative,
76-
)}
71+
<Text
72+
style={[
73+
t.atoms.text_contrast_low,
74+
a.text_md,
75+
a.leading_snug,
76+
a.pb_sm,
77+
]}>
78+
{sanitizePronouns(pronouns, isNative)}
7779
</Text>
7880
)}
7981
</View>

src/screens/Profile/Header/ProfileHeaderStandard.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {useLingui} from '@lingui/react'
1212
import {useActorStatus} from '#/lib/actor-status'
1313
import {sanitizeDisplayName} from '#/lib/strings/display-names'
1414
import {sanitizeHandle} from '#/lib/strings/handles'
15+
import {formatJoinDate} from '#/lib/strings/time'
16+
import {
17+
sanitizeWebsiteForDisplay,
18+
sanitizeWebsiteForLink,
19+
} from '#/lib/strings/website'
1520
import {logger} from '#/logger'
1621
import {isIOS} from '#/platform/detection'
1722
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -22,16 +27,19 @@ import {
2227
import {useRequireAuth, useSession} from '#/state/session'
2328
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
2429
import * as Toast from '#/view/com/util/Toast'
25-
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
30+
import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
2631
import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
2732
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
2833
import {useDialogControl} from '#/components/Dialog'
2934
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
35+
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
36+
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
3037
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
3138
import {
3239
KnownFollowers,
3340
shouldShowKnownFollowers,
3441
} from '#/components/KnownFollowers'
42+
import {Link} from '#/components/Link'
3543
import * as Prompt from '#/components/Prompt'
3644
import {RichText} from '#/components/RichText'
3745
import {Text} from '#/components/Typography'
@@ -80,6 +88,14 @@ let ProfileHeaderStandard = ({
8088
profile.viewer?.blockedBy ||
8189
profile.viewer?.blockingByList
8290

91+
const website = profile.website
92+
const websiteFormatted = sanitizeWebsiteForDisplay(website ?? '')
93+
94+
const dateJoined = useMemo(() => {
95+
if (!profile.createdAt) return ''
96+
return formatJoinDate(profile.createdAt)
97+
}, [profile.createdAt])
98+
8399
const editProfileControl = useDialogControl()
84100

85101
const onPressFollow = () => {
@@ -315,6 +331,32 @@ let ProfileHeaderStandard = ({
315331
)}
316332
</View>
317333
)}
334+
335+
<View style={[a.flex_row, a.flex_wrap, {gap: 10}, a.pt_md]}>
336+
{websiteFormatted && (
337+
<Link
338+
to={sanitizeWebsiteForLink(websiteFormatted)}
339+
label={_(msg({message: `Visit ${websiteFormatted}`}))}
340+
style={[a.flex_row, a.align_center, a.gap_xs]}>
341+
<Globe
342+
width={tokens.space.lg}
343+
style={{color: t.palette.primary_500}}
344+
/>
345+
<Text style={[{color: t.palette.primary_500}]}>
346+
{websiteFormatted}
347+
</Text>
348+
</Link>
349+
)}
350+
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
351+
<CalendarDays
352+
width={tokens.space.lg}
353+
style={{color: t.atoms.text_contrast_medium.color}}
354+
/>
355+
<Text style={[t.atoms.text_contrast_medium]}>
356+
<Trans>Joined {dateJoined}</Trans>
357+
</Text>
358+
</View>
359+
</View>
318360
</View>
319361

320362
<Prompt.Basic

src/state/queries/profile.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ export function useProfileUpdateMutation() {
180180
if ('pronouns' in updates) {
181181
next.pronouns = updates.pronouns
182182
}
183+
if ('website' in updates) {
184+
next.website = updates.website
185+
}
183186
}
184187
if (newUserAvatarPromise) {
185188
const res = await newUserAvatarPromise
@@ -224,7 +227,8 @@ export function useProfileUpdateMutation() {
224227
return (
225228
res.data.displayName === updates.displayName &&
226229
res.data.description === updates.description &&
227-
res.data.pronouns === updates.pronouns
230+
res.data.pronouns === updates.pronouns &&
231+
res.data.website === updates.website
228232
)
229233
}),
230234
)

0 commit comments

Comments
 (0)