Bluesky app fork with some witchin' additions 💫

update: date joined, edge cases, and mobile and web support

authored by

Anastasiya Uraleva and committed by xan.lol 49e90f7b 59f827f9

verified
+304 -40
+1 -1
src/lib/strings/pronouns.ts
··· 8 8 return '' 9 9 } 10 10 11 - const trimmed = pronouns.trim() 11 + const trimmed = pronouns.trim().toLowerCase() 12 12 return forceLeftToRight ? forceLTR(trimmed) : trimmed 13 13 }
+8
src/lib/strings/time.ts
··· 54 54 a.getDate() === b.getDate() 55 55 ) 56 56 } 57 + 58 + export function formatJoinDate(date: number | string | Date): string { 59 + const d = new Date(date) 60 + return d.toLocaleDateString('en-US', { 61 + month: 'short', 62 + year: 'numeric', 63 + }) 64 + }
+44
src/lib/strings/website.ts
··· 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 + }
+153 -3
src/screens/Profile/Header/EditProfileDialog.tsx
··· 1 - import {useCallback, useEffect, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 1 + import {useCallback, useEffect, useRef, useState} from 'react' 2 + import {Pressable, useWindowDimensions, View} from 'react-native' 3 3 import {type AppBskyActorDefs} from '@atproto/api' 4 4 import {msg, Plural, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 - import {urls} from '#/lib/constants' 7 + import {HITSLOP_10, urls} from '#/lib/constants' 8 8 import {cleanError} from '#/lib/strings/errors' 9 9 import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 10 + import {isValidWebsiteFormat} from '#/lib/strings/website' 10 11 import {logger} from '#/logger' 11 12 import {type ImageMeta} from '#/state/gallery' 12 13 import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' ··· 17 18 import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 18 19 import {UserBanner} from '#/view/com/util/UserBanner' 19 20 import {atoms as a, useTheme} from '#/alf' 21 + import * as tokens from '#/alf/tokens' 20 22 import {Admonition} from '#/components/Admonition' 21 23 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 24 import * as Dialog from '#/components/Dialog' 23 25 import * as TextField from '#/components/forms/TextField' 26 + import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' 27 + import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 24 28 import {InlineLinkText} from '#/components/Link' 25 29 import {Loader} from '#/components/Loader' 26 30 import * as Prompt from '#/components/Prompt' ··· 28 32 import {useSimpleVerificationState} from '#/components/verification' 29 33 30 34 const DISPLAY_NAME_MAX_GRAPHEMES = 64 35 + const PRONOUNS_MAX_GRAPHEMES = 20 36 + const WEBSITE_MAX_GRAPHEMES = 28 31 37 const DESCRIPTION_MAX_GRAPHEMES = 256 32 38 33 39 export function EditProfileDialog({ ··· 117 123 const [displayName, setDisplayName] = useState(initialDisplayName) 118 124 const initialDescription = profile.description || '' 119 125 const [description, setDescription] = useState(initialDescription) 126 + const initialPronouns = profile.pronouns || '' 127 + const [pronouns, setPronouns] = useState(initialPronouns) 128 + const initialWebsite = profile.website || '' 129 + const [website, setWebsite] = useState(initialWebsite) 130 + const websiteInputRef = useRef<any>(null) 120 131 const [userBanner, setUserBanner] = useState<string | undefined | null>( 121 132 profile.banner, 122 133 ) ··· 178 189 [setNewUserBanner, setUserBanner, setImageError], 179 190 ) 180 191 192 + const onClearWebsite = useCallback(() => { 193 + setWebsite('') 194 + if (websiteInputRef.current) { 195 + websiteInputRef.current.clear() 196 + } 197 + }, [setWebsite]) 198 + 181 199 const onPressSave = useCallback(async () => { 182 200 setImageError('') 183 201 try { ··· 186 204 updates: { 187 205 displayName: displayName.trimEnd(), 188 206 description: description.trimEnd(), 207 + pronouns: pronouns.trimEnd().toLowerCase(), 208 + website: website.trimEnd().toLowerCase(), 189 209 }, 190 210 newUserAvatar, 191 211 newUserBanner, ··· 202 222 control, 203 223 displayName, 204 224 description, 225 + pronouns, 226 + website, 205 227 newUserAvatar, 206 228 newUserBanner, 207 229 setImageError, ··· 212 234 text: displayName, 213 235 maxCount: DISPLAY_NAME_MAX_GRAPHEMES, 214 236 }) 237 + const pronounsTooLong = isOverMaxGraphemeCount({ 238 + text: pronouns, 239 + maxCount: PRONOUNS_MAX_GRAPHEMES, 240 + }) 241 + const websiteTooLong = isOverMaxGraphemeCount({ 242 + text: website, 243 + maxCount: WEBSITE_MAX_GRAPHEMES, 244 + }) 245 + const websiteInvalidFormat = !isValidWebsiteFormat(website) 215 246 const descriptionTooLong = isOverMaxGraphemeCount({ 216 247 text: description, 217 248 maxCount: DESCRIPTION_MAX_GRAPHEMES, ··· 390 421 value={DESCRIPTION_MAX_GRAPHEMES} 391 422 other="Description is too long. The maximum number of characters is #." 392 423 /> 424 + </Text> 425 + )} 426 + </View> 427 + 428 + <View> 429 + <TextField.LabelText> 430 + <Trans>Pronouns</Trans> 431 + </TextField.LabelText> 432 + <TextField.Root isInvalid={pronounsTooLong}> 433 + <Dialog.Input 434 + defaultValue={pronouns} 435 + onChangeText={setPronouns} 436 + label={_(msg`Pronouns`)} 437 + placeholder={_(msg`Pronouns`)} 438 + testID="editProfilePronounsInput" 439 + /> 440 + </TextField.Root> 441 + {pronounsTooLong && ( 442 + <Text 443 + style={[ 444 + a.text_sm, 445 + a.mt_xs, 446 + a.font_bold, 447 + {color: t.palette.negative_400}, 448 + ]}> 449 + <Plural 450 + value={PRONOUNS_MAX_GRAPHEMES} 451 + other="The maximum number of characters is #." 452 + /> 453 + </Text> 454 + )} 455 + </View> 456 + 457 + <View> 458 + <TextField.LabelText> 459 + <Trans>Website</Trans> 460 + </TextField.LabelText> 461 + <View style={[a.w_full, a.relative]}> 462 + <TextField.Root isInvalid={websiteTooLong || websiteInvalidFormat}> 463 + {website && <TextField.Icon icon={Globe} />} 464 + <Dialog.Input 465 + inputRef={websiteInputRef} 466 + defaultValue={website} 467 + onChangeText={setWebsite} 468 + label={_(msg`EditWebsite`)} 469 + placeholder={_(msg`URL`)} 470 + testID="editProfileWebsiteInput" 471 + autoCapitalize="none" 472 + keyboardType="url" 473 + style={[ 474 + website 475 + ? { 476 + paddingRight: tokens.space._5xl, 477 + } 478 + : {}, 479 + ]} 480 + /> 481 + </TextField.Root> 482 + 483 + {website && ( 484 + <View 485 + style={[ 486 + a.absolute, 487 + a.z_10, 488 + a.my_auto, 489 + a.inset_0, 490 + a.justify_center, 491 + a.pr_sm, 492 + {left: 'auto'}, 493 + ]}> 494 + <Pressable 495 + testID="clearWebsiteBtn" 496 + onPress={onClearWebsite} 497 + accessibilityLabel={_(msg`Clear website`)} 498 + accessibilityHint={_(msg`Removes the website URL`)} 499 + hitSlop={HITSLOP_10} 500 + style={[ 501 + a.flex_row, 502 + a.align_center, 503 + a.justify_center, 504 + { 505 + width: tokens.space._2xl, 506 + height: tokens.space._2xl, 507 + }, 508 + a.rounded_full, 509 + ]}> 510 + <CircleX 511 + width={tokens.space.lg} 512 + style={{color: t.palette.contrast_600}} 513 + /> 514 + </Pressable> 515 + </View> 516 + )} 517 + </View> 518 + {websiteTooLong && ( 519 + <Text 520 + style={[ 521 + a.text_sm, 522 + a.mt_xs, 523 + a.font_bold, 524 + {color: t.palette.negative_400}, 525 + ]}> 526 + <Plural 527 + value={WEBSITE_MAX_GRAPHEMES} 528 + other="Website is too long. The maximum number of characters is #." 529 + /> 530 + </Text> 531 + )} 532 + {websiteInvalidFormat && ( 533 + <Text 534 + style={[ 535 + a.text_sm, 536 + a.mt_xs, 537 + a.font_bold, 538 + {color: t.palette.negative_400}, 539 + ]}> 540 + <Trans> 541 + Website must be a valid URL (e.g., https://bsky.app) 542 + </Trans> 393 543 </Text> 394 544 )} 395 545 </View>
+47 -34
src/screens/Profile/Header/Handle.tsx
··· 22 22 const t = useTheme() 23 23 const {_} = useLingui() 24 24 const invalidHandle = isInvalidHandle(profile.handle) 25 + const pronouns = profile.pronouns 25 26 const isBskySocialHandle = profile.handle.endsWith('.bsky.social') 26 27 const showProfileInHandle = useShowLinkInHandle() 27 28 const sanitized = sanitizeHandle( ··· 35 36 style={[a.flex_row, a.gap_sm, a.align_center, {maxWidth: '100%'}]} 36 37 pointerEvents={disableTaps ? 'none' : IS_IOS ? 'auto' : 'box-none'}> 37 38 <NewskieDialog profile={profile} disabled={disableTaps} /> 38 - 39 - <Text 40 - emoji 41 - numberOfLines={1} 42 - style={[ 43 - invalidHandle 44 - ? [ 45 - a.border, 46 - a.text_xs, 47 - a.px_sm, 48 - a.py_xs, 49 - a.rounded_xs, 50 - {borderColor: t.palette.contrast_200}, 51 - ] 52 - : [a.text_md, a.leading_snug, t.atoms.text_contrast_medium], 53 - web({ 54 - wordBreak: 'break-all', 55 - direction: 'ltr', 56 - unicodeBidi: 'isolate', 57 - }), 58 - ]}> 59 - {invalidHandle ? ( 60 - _(msg`⚠Invalid Handle`) 61 - ) : showProfileInHandle && !isBskySocialHandle ? ( 62 - <InlineLinkText 63 - to={`https://${profile.handle}`} 64 - label={profile.handle}> 65 - <Text style={[a.text_md, {color: t.palette.primary_500}]}> 66 - {sanitized} 67 - </Text> 68 - </InlineLinkText> 69 - ) : ( 70 - sanitized 39 + <View style={[a.flex_row, a.flex_wrap, {gap: 6}]}> 40 + <Text 41 + emoji 42 + numberOfLines={1} 43 + style={[ 44 + invalidHandle 45 + ? [ 46 + a.border, 47 + a.text_xs, 48 + a.px_sm, 49 + a.py_xs, 50 + a.rounded_xs, 51 + {borderColor: t.palette.contrast_200}, 52 + ] 53 + : [a.text_md, a.leading_snug, t.atoms.text_contrast_medium], 54 + web({ 55 + wordBreak: 'break-all', 56 + direction: 'ltr', 57 + unicodeBidi: 'isolate', 58 + }), 59 + ]}> 60 + {invalidHandle ? ( 61 + _(msg`⚠Invalid Handle`) 62 + ) : showProfileInHandle && !isBskySocialHandle ? ( 63 + <InlineLinkText 64 + to={`https://${profile.handle}`} 65 + label={profile.handle}> 66 + <Text style={[a.text_md, {color: t.palette.primary_500}]}> 67 + {sanitized} 68 + </Text> 69 + </InlineLinkText> 70 + ) : ( 71 + sanitized 72 + )} 73 + </Text> 74 + {pronouns && ( 75 + <Text 76 + style={[ 77 + t.atoms.text_contrast_low, 78 + a.text_md, 79 + a.leading_snug, 80 + a.pb_sm, 81 + ]}> 82 + {sanitizePronouns(pronouns, IS_NATIVE)} 83 + </Text> 71 84 )} 72 - </Text> 85 + </View> 73 86 </View> 74 87 ) 75 88 }
+43 -1
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 14 14 import {useHaptics} from '#/lib/haptics' 15 15 import {sanitizeDisplayName} from '#/lib/strings/display-names' 16 16 import {sanitizeHandle} from '#/lib/strings/handles' 17 + import {formatJoinDate} from '#/lib/strings/time' 18 + import { 19 + sanitizeWebsiteForDisplay, 20 + sanitizeWebsiteForLink, 21 + } from '#/lib/strings/website' 17 22 import {logger} from '#/logger' 18 23 import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' 19 24 import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' ··· 23 28 } from '#/state/queries/profile' 24 29 import {useRequireAuth, useSession} from '#/state/session' 25 30 import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 26 - import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' 31 + import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 27 32 import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' 28 33 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 34 import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 30 35 import {useDialogControl} from '#/components/Dialog' 31 36 import {MessageProfileButton} from '#/components/dms/MessageProfileButton' 37 + import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 38 + import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 32 39 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 33 40 import { 34 41 KnownFollowers, 35 42 shouldShowKnownFollowers, 36 43 } from '#/components/KnownFollowers' 44 + import {Link} from '#/components/Link' 37 45 import * as Prompt from '#/components/Prompt' 38 46 import {RichText} from '#/components/RichText' 39 47 import * as Toast from '#/components/Toast' ··· 78 86 profile.viewer?.blocking || 79 87 profile.viewer?.blockedBy || 80 88 profile.viewer?.blockingByList 89 + 90 + const website = profile.website 91 + const websiteFormatted = sanitizeWebsiteForDisplay(website ?? '') 92 + 93 + const dateJoined = useMemo(() => { 94 + if (!profile.createdAt) return '' 95 + return formatJoinDate(profile.createdAt) 96 + }, [profile.createdAt]) 81 97 82 98 const unblockAccount = async () => { 83 99 try { ··· 180 196 )} 181 197 </View> 182 198 )} 199 + 200 + <View style={[a.flex_row, a.flex_wrap, {gap: 10}, a.pt_md]}> 201 + {websiteFormatted && ( 202 + <Link 203 + to={sanitizeWebsiteForLink(websiteFormatted)} 204 + label={_(msg({message: `Visit ${websiteFormatted}`}))} 205 + style={[a.flex_row, a.align_center, a.gap_xs]}> 206 + <Globe 207 + width={tokens.space.lg} 208 + style={{color: t.palette.primary_500}} 209 + /> 210 + <Text style={[{color: t.palette.primary_500}]}> 211 + {websiteFormatted} 212 + </Text> 213 + </Link> 214 + )} 215 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 216 + <CalendarDays 217 + width={tokens.space.lg} 218 + style={{color: t.atoms.text_contrast_medium.color}} 219 + /> 220 + <Text style={[t.atoms.text_contrast_medium]}> 221 + <Trans>Joined {dateJoined}</Trans> 222 + </Text> 223 + </View> 224 + </View> 183 225 184 226 <DebugFieldDisplay subject={profile} /> 185 227 </View>
+8 -1
src/state/queries/profile.ts
··· 181 181 if ('pinnedPost' in updates) { 182 182 next.pinnedPost = updates.pinnedPost 183 183 } 184 + if ('pronouns' in updates) { 185 + next.pronouns = updates.pronouns 186 + } 187 + if ('website' in updates) { 188 + next.website = updates.website 189 + } 184 190 } 185 191 if (newUserAvatarPromise) { 186 192 const res = await newUserAvatarPromise ··· 225 231 return ( 226 232 res.data.displayName === updates.displayName && 227 233 res.data.description === updates.description && 228 - res.data.pronouns === updates.pronouns 234 + res.data.pronouns === updates.pronouns && 235 + res.data.website === updates.website 229 236 ) 230 237 }), 231 238 )