Compare commits

...

23 Commits

Author SHA1 Message Date
Samuel Newman 60be355991 handle via, verified notifs 2025-06-19 16:34:58 +03:00
Samuel Newman bb96f31b1f add notification channels for each type of notification 2025-06-19 16:30:32 +03:00
pfrazee 9be3b0794c Nightly source-language update 2025-06-19 02:41:35 +00:00
Samuel Newman 77db98de65 bump keyboard dep (#8528) 2025-06-18 13:42:06 -07:00
Eric Bailey 51babe0164 Fix anchor text overflow in threads v2 (#8530) 2025-06-18 13:41:58 -07:00
Eric Bailey 04a2a227fc Revert "Move dialog outputs inside navigation context (#8511)" (#8531) 2025-06-18 13:39:58 -07:00
Samuel Newman 42510c0515 gate notification settings (#8527) 2025-06-18 13:36:37 -07:00
hailey f36776a91e switch mmkv version before android eas bundle (#8526) 2025-06-18 12:46:24 -07:00
Samuel Newman eb18b6a49a add scrollbars back on android (#8529) 2025-06-18 21:37:04 +03:00
Eric Bailey 3eaa2d9ddf Disable android new arch (#8525)
* Disable new arch in android for now

* Temporarily comment out mmkv script
2025-06-18 11:58:37 -05:00
Eric Bailey 066fd8cbf9 Fix select text color if system theme mismatches app theme (#8523) 2025-06-18 11:34:52 -05:00
Samuel Newman 06ebd2964a Fix linking to specific search results (#8520)
* fix linking to bsky.app/search?q=xyz

* add name to comment

* skip if notfound
2025-06-18 09:25:14 -07:00
Samuel Newman 3304cd0424 notif settings lexicon change (#8518) 2025-06-18 09:24:55 -07:00
Eric Bailey 32ba17a4f1 Fix anchor follow button alignment (#8522) 2025-06-18 09:24:27 -07:00
Eric Bailey f83335f110 Move dialog outputs inside navigation context (#8511)
* Move all dialogs within NavigationContent on native

* Ok leave old ModalsContainer alone for now

* Do the same on web

* Gate browser consent dialog just to be clear
2025-06-18 09:53:34 -05:00
Samuel Newman 4da86e5864 Modernise link warning dialog (#8243)
* add link warning dialog

* add copy for if sharing

* delete old modal

* get web working
2025-06-18 06:17:54 -07:00
Samuel Newman dd86402763 rearrange settings (#8519) 2025-06-18 05:19:42 -07:00
Frudrax Cheng 13d21692bf Mark translatable text in PreferenceControls.tsx (#8516)
* Update PreferenceControls.tsx

* Update PreferenceControls.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update PreferenceControls.tsx

Co-authored-by: Minseo Lee <itoupluk427@gmail.com>

* Update PreferenceControls.tsx

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Minseo Lee <itoupluk427@gmail.com>
2025-06-18 04:57:49 -07:00
Samuel Newman e672f43ec8 fix other case of promise.all misuse (#8505) 2025-06-18 04:08:44 -07:00
pfrazee afd3d2829f Nightly source-language update 2025-06-18 02:40:54 +00:00
Eric Bailey 619fa0d0bb Delete some old dialogs (#8512)
* Delete old EditProfile dialog

* Delete old email verification dialogs
2025-06-17 18:12:37 -05:00
Samuel Newman 9cf457acf8 copy tweak (#8506) 2025-06-18 00:12:59 +03:00
hailey f1f9ca9606 facepalm (#8510) 2025-06-17 13:19:07 -07:00
35 changed files with 942 additions and 1708 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- name: "Use upgraded MMKV for Fabric"
run: |
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' filename.txt
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' package.json
- name: ⚙️ Install dependencies
run: yarn install
+10 -2
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- main
- hailey/eas-fab
workflow_dispatch:
inputs:
channel:
@@ -118,7 +119,14 @@ jobs:
- name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
run: |
SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export-ios
mv ./dist ./ios-dist
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' package.json
yarn install
SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export-android
mv ./dist ./android-dist
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
@@ -277,7 +285,7 @@ jobs:
- name: "Use upgraded MMKV for Fabric"
run: |
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' filename.txt
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' package.json
- name: ⚙️ Install dependencies
run: yarn install
@@ -15,6 +15,8 @@ class BackgroundNotificationHandler(
if (remoteMessage.data["reason"] == "chat-message") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
}
notifInterface.showMessage(remoteMessage)
@@ -39,4 +41,17 @@ class BackgroundNotificationHandler(
// TODO - Remove this once we have more backend capability
remoteMessage.data["badge"] = null
}
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
// If oreo or higher
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost"
// assign to it's eponymous channel. otherwise do nothing, let expo handle it
when (remoteMessage.data["reason"]) {
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> {
remoteMessage.data["channelId"] = remoteMessage.data["reason"]
}
}
}
}
}
+5 -4
View File
@@ -61,7 +61,8 @@
"intl:push": "crowdin push translations --verbose -b main",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
"export-ios": "npx expo export --platform ios --dump-sourcemap && yarn upload-native-sourcemaps",
"export-android": "npx expo export --platform android --dump-sourcemap && yarn upload-native-sourcemaps",
"upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist",
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
@@ -69,7 +70,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.15.15",
"@atproto/api": "^0.15.16",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -190,7 +191,7 @@
"react-native-gesture-handler": "2.25.0",
"react-native-get-random-values": "~1.11.0",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.17.1",
"react-native-keyboard-controller": "^1.17.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "^6.7.1",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -218,7 +219,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.142",
"@atproto/dev-env": "^0.3.144",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
+17 -9
View File
@@ -3,17 +3,25 @@ const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const DIST_DIR = './dist'
const IOS_DIST_DIR = './ios-dist'
const ANDROID_DIST_DIR = './android-dist'
const BUNDLES_DIR = '/_expo/static/js'
const IOS_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/ios')
const ANDROID_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/android')
const METADATA_PATH = path.join(DIST_DIR, '/metadata.json')
const IOS_BUNDLE_DIR = path.join(IOS_DIST_DIR, BUNDLES_DIR, '/ios')
const ANDROID_BUNDLE_DIR = path.join(ANDROID_DIST_DIR, BUNDLES_DIR, '/android')
const IOS_METADATA_PATH = path.join(IOS_DIST_DIR, '/metadata.json')
const ANDROID_METADATA_PATH = path.join(ANDROID_DIST_DIR, '/metadata.json')
const DEST_DIR = './bundleTempDir'
// Weird, don't feel like figuring out _why_ it wants this
const METADATA = require(`../${METADATA_PATH}`)
const IOS_METADATA_ASSETS = METADATA.fileMetadata.ios.assets
const ANDROID_METADATA_ASSETS = METADATA.fileMetadata.android.assets
const IOS_METADATA = require(`../${IOS_METADATA_PATH}`)
const ANDROID_METADATA = require(`../${ANDROID_METADATA_PATH}`)
const IOS_METADATA_ASSETS = IOS_METADATA.fileMetadata.ios.assets
const ANDROID_METADATA_ASSETS = ANDROID_METADATA.fileMetadata.android.assets
const getMd5 = async path => {
return new Promise(res => {
@@ -60,7 +68,7 @@ const moveFiles = async () => {
console.log('Getting ios asset md5s and moving them...')
for (const asset of IOS_METADATA_ASSETS) {
const currPath = path.join(DIST_DIR, asset.path)
const currPath = path.join(IOS_DIST_DIR, asset.path)
const md5 = await getMd5(currPath)
const withExtPath = `assets/${md5}.${asset.ext}`
iosAssets.push(withExtPath)
@@ -69,7 +77,7 @@ const moveFiles = async () => {
console.log('Getting android asset md5s and moving them...')
for (const asset of ANDROID_METADATA_ASSETS) {
const currPath = path.join(DIST_DIR, asset.path)
const currPath = path.join(ANDROID_DIST_DIR, asset.path)
const md5 = await getMd5(currPath)
const withExtPath = `assets/${md5}.${asset.ext}`
androidAssets.push(withExtPath)
+2 -2
View File
@@ -447,7 +447,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
name="LikesOnRepostsNotificationSettings"
getComponent={() => LikesOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Likes on your reposts notifications`),
title: title(msg`Likes of your reposts notifications`),
requireAuth: true,
}}
/>
@@ -455,7 +455,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
name="RepostsOnRepostsNotificationSettings"
getComponent={() => RepostsOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Reposts on your reposts notifications`),
title: title(msg`Reposts of your reposts notifications`),
requireAuth: true,
}}
/>
+53 -18
View File
@@ -9,7 +9,7 @@ import {
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type AllNavigatorParams} from '#/lib/routes/types'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
@@ -24,6 +24,7 @@ import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text, type TextProps} from '#/components/Typography'
import {router} from '#/routes'
import {useGlobalDialogsControlContext} from './dialogs/Context'
/**
* Only available within a `Link`, since that inherits from `Button`.
@@ -111,7 +112,8 @@ export function useLink({
}
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const onPress = React.useCallback(
@@ -132,10 +134,9 @@ export function useLink({
}
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
linkWarningDialogControl.open({
displayText,
href,
})
} else {
if (isExternal) {
@@ -154,15 +155,44 @@ export function useLink({
} else {
closeModal() // close any active modals
const [screen, params] = router.matchPath(href) as [
screen: keyof AllNavigatorParams,
params?: RouteParams,
]
// does not apply to web's flat navigator
if (isNative && screen !== 'NotFound') {
const state = navigation.getState()
// if screen is not in the current navigator, it means it's
// most likely a tab screen
if (!state.routeNames.includes(screen)) {
const parent = navigation.getParent()
if (
parent &&
parent.getState().routeNames.includes(`${screen}Tab`)
) {
// yep, it's a tab screen. i.e. SearchTab
// thus we need to navigate to the child screen
// via the parent navigator
// see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action
// TODO: can we support the other kinds of actions? push/replace -sfn
// @ts-expect-error include does not narrow the type unfortunately
parent.navigate(`${screen}Tab`, {screen, params})
return
} else {
// will probably fail, but let's try anyway
}
}
}
if (action === 'push') {
navigation.dispatch(StackActions.push(...router.matchPath(href)))
navigation.dispatch(StackActions.push(screen, params))
} else if (action === 'replace') {
navigation.dispatch(
StackActions.replace(...router.matchPath(href)),
)
navigation.dispatch(StackActions.replace(screen, params))
} else if (action === 'navigate') {
// @ts-ignore
navigation.navigate(...router.matchPath(href))
// @ts-expect-error not typed
navigation.navigate(screen, params)
} else {
throw Error('Unsupported navigator action.')
}
@@ -176,13 +206,13 @@ export function useLink({
displayText,
isExternal,
href,
openModal,
openLink,
closeModal,
action,
navigation,
overridePresentation,
shouldProxy,
linkWarningDialogControl,
],
)
@@ -195,16 +225,21 @@ export function useLink({
)
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
linkWarningDialogControl.open({
displayText,
href,
share: true,
})
} else {
shareUrl(href)
}
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
}, [
disableMismatchWarning,
displayText,
href,
isExternal,
linkWarningDialogControl,
])
const onLongPress = React.useCallback(
(e: GestureResponderEvent) => {
@@ -74,7 +74,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
return props.children
} else {
return (
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
<View onPointerMove={onPointerMove} style={[a.flex_shrink, props.style]}>
<ProfileHoverCardInner {...props} />
</View>
)
+4 -2
View File
@@ -1,7 +1,9 @@
import type React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
import {type ViewStyleProp} from '#/alf'
export type ProfileHoverCardProps = ViewStyleProp & {
children: React.ReactNode
did: string
disable?: boolean
}
+1
View File
@@ -244,6 +244,7 @@ export function Item({ref, value, style, children}: ItemProps) {
onFocus={onFocus}
onBlur={onBlur}
style={flatten([
t.atoms.text,
a.relative,
a.flex,
{minHeight: 25, paddingLeft: 30, paddingRight: 35},
@@ -1,259 +0,0 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function ChangeEmailDialog({
control,
verifyEmailControl,
}: {
control: Dialog.DialogControlProps
verifyEmailControl: Dialog.DialogControlProps
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner verifyEmailControl={verifyEmailControl} />
</Dialog.Outer>
)
}
export function Inner({
verifyEmailControl,
}: {
verifyEmailControl: Dialog.DialogControlProps
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints()
const [currentStep, setCurrentStep] = useState<
'StepOne' | 'StepTwo' | 'StepThree'
>('StepOne')
const [email, setEmail] = useState('')
const [confirmationCode, setConfirmationCode] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const currentEmail = currentAccount?.email || '(no email)'
const uiStrings = {
StepOne: {
title: _(msg`Change Your Email`),
message: '',
},
StepTwo: {
title: _(msg`Security Step Required`),
message: _(
msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`,
),
},
StepThree: {
title: _(msg`Email Updated!`),
message: _(
msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`,
),
},
}
const onRequestChange = async () => {
if (email === currentAccount?.email) {
setError(
_(
msg`The email address you entered is the same as your current email address.`,
),
)
return
}
setError('')
setIsProcessing(true)
try {
const res = await agent.com.atproto.server.requestEmailUpdate()
if (res.data.tokenRequired) {
setCurrentStep('StepTwo')
} else {
await agent.com.atproto.server.updateEmail({email: email.trim()})
await agent.resumeSession(agent.session!)
setCurrentStep('StepThree')
}
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onConfirm = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.updateEmail({
email: email.trim(),
token: confirmationCode.trim(),
})
await agent.resumeSession(agent.session!)
setCurrentStep('StepThree')
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onVerify = async () => {
control.close(() => {
verifyEmailControl.open()
})
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={web({maxWidth: 450})}>
<Dialog.Close />
<View style={[a.gap_xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
{currentStep === 'StepOne' ? (
<View>
<TextField.LabelText>
<Trans>Enter your new email address below.</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`New email address`)}
placeholder={_(msg`alice@example.com`)}
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoComplete="email"
/>
</TextField.Root>
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
{uiStrings[currentStep].message}
</Text>
)}
</View>
{currentStep === 'StepTwo' ? (
<View>
<TextField.LabelText>
<Trans>Confirmation code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
onChangeText={setConfirmationCode}
/>
</TextField.Root>
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Request change`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onRequestChange}>
<ButtonText>
<Trans>Request change</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I have a code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepTwo' ? (
<>
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onConfirm}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`Resend email`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => {
setConfirmationCode('')
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<>
<Button
label={_(msg`Verify email`)}
variant="solid"
color="primary"
size="large"
onPress={onVerify}>
<ButtonText>
<Trans>Verify email</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Close`)}
variant="solid"
color="secondary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
+12
View File
@@ -17,6 +17,11 @@ type ControlsContext = {
signinDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
emailDialogControl: StatefulControl<Screen>
linkWarningDialogControl: StatefulControl<{
href: string
displayText: string
share?: boolean
}>
}
const ControlsContext = createContext<ControlsContext | null>(null)
@@ -36,6 +41,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signinDialogControl = Dialog.useDialogControl()
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
const emailDialogControl = useStatefulDialogControl<Screen>()
const linkWarningDialogControl = useStatefulDialogControl<{
href: string
displayText: string
share?: boolean
}>()
const ctx = useMemo<ControlsContext>(
() => ({
@@ -43,12 +53,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
}),
[
mutedWordsDialogControl,
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
],
)
+161
View File
@@ -0,0 +1,161 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {shareUrl} from '#/lib/sharing'
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {useGlobalDialogsControlContext} from './Context'
export function LinkWarningDialog() {
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
return (
<Dialog.Outer
control={linkWarningDialogControl.control}
nativeOptions={{preventExpansion: true}}
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const openLink = useOpenLink()
const {gtMobile} = useBreakpoints()
const potentiallyMisleading = useMemo(
() => link && isPossiblyAUrl(link.displayText),
[link],
)
const onPressVisit = useCallback(() => {
control.close(() => {
if (!link) return
if (link.share) {
shareUrl(link.href)
} else {
openLink(link.href, undefined, true)
}
})
}, [control, link, openLink])
const onCancel = useCallback(() => {
control.close()
}, [control])
return (
<Dialog.ScrollableInner
style={web({maxWidth: 450})}
label={
potentiallyMisleading
? _(msg`Potentially misleading link warning`)
: _(msg`Leaving Bluesky`)
}>
<View style={[a.gap_2xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{potentiallyMisleading ? (
<Trans>Potentially misleading link</Trans>
) : (
<Trans>Leaving Bluesky</Trans>
)}
</Text>
<Text style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>This link is taking you to the following website:</Trans>
</Text>
{link && <LinkBox href={link.href} />}
{potentiallyMisleading && (
<Text
style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>Make sure this is where you intend to go!</Trans>
</Text>
)}
</View>
<View
style={[
a.flex_1,
a.gap_sm,
gtMobile && [a.flex_row_reverse, a.justify_start],
]}>
<Button
label={link?.share ? _(msg`Share link`) : _(msg`Visit site`)}
accessibilityHint={_(msg`Opens link ${link?.href ?? ''}`)}
onPress={onPressVisit}
size="large"
variant="solid"
color={potentiallyMisleading ? 'secondary_inverted' : 'primary'}>
<ButtonText>
{link?.share ? (
<Trans>Share link</Trans>
) : (
<Trans>Visit site</Trans>
)}
</ButtonText>
</Button>
<Button
label={_(msg`Go back`)}
onPress={onCancel}
size="large"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function LinkBox({href}: {href: string}) {
const t = useTheme()
const [scheme, hostname, rest] = useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
a.px_md,
{paddingVertical: 10},
a.rounded_sm,
a.border,
]}>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
{scheme}
<Text style={[a.text_md, a.leading_snug, t.atoms.text, a.font_bold]}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
@@ -1,360 +0,0 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChangeEmailDialog} from './ChangeEmailDialog'
export function VerifyEmailDialog({
control,
onCloseWithoutVerifying,
onCloseAfterVerifying,
reasonText,
changeEmailControl,
reminder,
}: {
control: Dialog.DialogControlProps
onCloseWithoutVerifying?: () => void
onCloseAfterVerifying?: () => void
reasonText?: string
/**
* if a changeEmailControl for a ChangeEmailDialog is not provided,
* this component will create one for you. Using this prop
* helps reduce duplication, since these dialogs are often used together.
*/
changeEmailControl?: Dialog.DialogControlProps
reminder?: boolean
}) {
const agent = useAgent()
const fallbackChangeEmailControl = Dialog.useDialogControl()
const [didVerify, setDidVerify] = useState(false)
return (
<>
<Dialog.Outer
control={control}
onClose={async () => {
if (!didVerify) {
onCloseWithoutVerifying?.()
return
}
try {
await agent.resumeSession(agent.session!)
onCloseAfterVerifying?.()
} catch (e: unknown) {
logger.error(String(e))
return
}
}}>
<Dialog.Handle />
<Inner
setDidVerify={setDidVerify}
reasonText={reasonText}
changeEmailControl={changeEmailControl ?? fallbackChangeEmailControl}
reminder={reminder}
/>
</Dialog.Outer>
{!changeEmailControl && (
<ChangeEmailDialog
control={fallbackChangeEmailControl}
verifyEmailControl={control}
/>
)}
</>
)
}
export function Inner({
setDidVerify,
reasonText,
changeEmailControl,
reminder,
}: {
setDidVerify: (value: boolean) => void
reasonText?: string
changeEmailControl: Dialog.DialogControlProps
reminder?: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [currentStep, setCurrentStep] = useState<
'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree'
>(reminder ? 'Reminder' : 'StepOne')
const [confirmationCode, setConfirmationCode] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const uiStrings = {
Reminder: {
title: _(msg`Please Verify Your Email`),
message: _(
msg`Your email has not yet been verified. This is an important security step which we recommend.`,
),
},
StepOne: {
title: _(msg`Verify Your Email`),
message: '',
},
StepTwo: {
title: _(msg`Enter Code`),
message: _(
msg`An email has been sent! Please enter the confirmation code included in the email below.`,
),
},
StepThree: {
title: _(msg`Success!`),
message: _(msg`Thank you! Your email has been successfully verified.`),
},
}
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.requestEmailConfirmation()
setCurrentStep('StepTwo')
} catch (e: unknown) {
setError(cleanError(e))
} finally {
setIsProcessing(false)
}
}
const onVerifyEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: confirmationCode.trim(),
})
} catch (e: unknown) {
setError(cleanError(String(e)))
setIsProcessing(false)
return
}
setIsProcessing(false)
setDidVerify(true)
setCurrentStep('StepThree')
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={web({maxWidth: 450})}>
<View style={[a.gap_xl]}>
{currentStep === 'Reminder' && (
<View
style={[
a.rounded_sm,
a.align_center,
a.justify_center,
{height: 150},
t.atoms.bg_contrast_100,
]}>
<EnvelopeIcon width={64} fill="white" />
</View>
)}
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
{currentStep === 'StepOne' ? (
<View>
{reasonText ? (
<View style={[a.gap_sm]}>
<Text style={[a.text_md, a.leading_snug]}>{reasonText}</Text>
<Text style={[a.text_md, a.leading_snug]}>
Don't have access to{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>
?{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
changeEmailControl.open()
})
return false
}}>
<Trans>Change your email address</Trans>
</InlineLinkText>
.
</Text>
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
You'll receive an email at{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>{' '}
to verify it's you.
</Trans>{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
changeEmailControl.open()
})
return false
}}>
<Trans>Need to change it?</Trans>
</InlineLinkText>
</Text>
)}
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
{uiStrings[currentStep].message}
</Text>
)}
</View>
{currentStep === 'StepTwo' ? (
<View>
<TextField.LabelText>
<Trans>Confirmation Code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
onChangeText={setConfirmationCode}
/>
</TextField.Root>
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'Reminder' ? (
<>
<Button
label={_(msg`Get started`)}
variant="solid"
color="primary"
size="large"
onPress={() => setCurrentStep('StepOne')}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Maybe later`)}
accessibilityHint={_(msg`Snoozes the reminder`)}
variant="ghost"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => control.close()}>
<ButtonText>
<Trans>Maybe later</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Send confirmation email`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onSendEmail}>
<ButtonText>
<Trans>Send confirmation</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I have a code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepTwo' ? (
<>
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onVerifyEmail}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`Resend email`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => {
setConfirmationCode('')
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<Button
label={_(msg`Close`)}
variant="solid"
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
+4
View File
@@ -14,6 +14,7 @@ export type DebouncedNavigationProp = Pick<
| 'dispatch'
| 'goBack'
| 'getState'
| 'getParent'
>
export function useNavigationDeduped() {
@@ -46,6 +47,9 @@ export function useNavigationDeduped() {
getState: () => {
return navigation.getState()
},
getParent: (...args: Parameters<typeof navigation.getParent>) => {
return navigation.getParent(...args)
},
}),
[dedupe, navigation],
)
+87 -7
View File
@@ -1,5 +1,8 @@
import React from 'react'
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
import {type AppBskyNotificationListNotifications} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -25,6 +28,10 @@ export type NotificationReason =
| 'quote'
| 'chat-message'
| 'starterpack-joined'
| 'like-via-repost'
| 'repost-via-repost'
| 'verified'
| 'unverified'
/**
* Manually overridden type, but retains the possibility of
@@ -66,34 +73,103 @@ export function useNotificationsHandler() {
const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const {_} = useLingui()
// On Android, we cannot control which sound is used for a notification on Android
// 28 or higher. Instead, we have to configure a notification channel ahead of time
// which has the sounds we want in the configuration for that channel. These two
// channels allow for the mute/unmute functionality we want for the background
// handler.
React.useEffect(() => {
useEffect(() => {
if (!isAndroid) return
// assign both chat notifications to a group
// NOTE: I don't think that it will retroactively move them into the group
// if the channels already exist. no big deal imo -sfn
const CHAT_GROUP = 'chat'
Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: _(msg`Chat`),
description: _(
msg`You can choose whether chat notifications have sound in the chat settings within the app`,
),
})
Notifications.setNotificationChannelAsync('chat-messages', {
name: 'Chat',
name: _(msg`Chat messages - sound`),
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: 'dm.mp3',
showBadge: true,
vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: 'Chat - Muted',
name: _(msg`Chat messages - silent`),
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: null,
showBadge: true,
vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
}, [])
React.useEffect(() => {
Notifications.setNotificationChannelAsync(
'like' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Likes`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Replies`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Mentions`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Quotes`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`New followers`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Likes of your reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
{
name: _(msg`Reposts of your reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
}, [_])
useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => {
if (!payload) return
@@ -151,6 +227,10 @@ export function useNotificationsHandler() {
case 'quote':
case 'reply':
case 'starterpack-joined':
case 'like-via-repost':
case 'repost-via-repost':
case 'verified':
case 'unverified':
resetToTab('NotificationsTab')
break
// TODO implement these after we have an idea of how to handle each individual case
+1
View File
@@ -7,6 +7,7 @@ export type Gate =
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'post_threads_v2_unspecced'
| 'reengagement_features'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
+5
View File
@@ -193,6 +193,11 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
return startUriToStarterPackUri(urlp.pathname)
}
// special-case search links
if (urlp.pathname === '/search') {
return `/search?q=${urlp.searchParams.get('q')}`
}
return urlp.pathname
} catch (e) {
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
File diff suppressed because it is too large Load Diff
@@ -311,33 +311,34 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
<ProfileHoverCard did={post.author.did}>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Link
to={authorHref}
style={[a.flex_shrink]}
label={sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.flex_shrink,
a.text_lg,
a.font_bold,
a.leading_snug,
a.self_start,
]}
numberOfLines={1}>
{sanitizeDisplayName(
@@ -346,32 +347,25 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
moderation.ui('displayName'),
)}
</Text>
</Link>
<View style={[{paddingLeft: 3, top: -1}]}>
<VerificationCheckButton profile={authorShadow} size="md" />
<View style={[{paddingLeft: 3, top: -1}]}>
<VerificationCheckButton profile={authorShadow} size="md" />
</View>
</View>
</View>
<View style={[a.align_start]}>
<Link
style={[a.flex_shrink]}
to={authorHref}
label={sanitizeHandle(post.author.handle, '@')}>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Link>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</ProfileHoverCard>
</View>
</ProfileHoverCard>
</Link>
{showFollowButton && (
<View>
<View collapsable={false}>
<PostThreadFollowBtn did={post.author.did} />
</View>
)}
+3 -7
View File
@@ -113,13 +113,9 @@ function Inner({
},
)
await Promise.all([
await qc.resetQueries({
queryKey: createSuggestedStarterPacksQueryKey(),
}),
await qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}),
await qc.resetQueries({
queryKey: createGetSuggestedUsersQueryKey({}),
}),
qc.resetQueries({queryKey: createSuggestedStarterPacksQueryKey()}),
qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}),
qc.resetQueries({queryKey: createGetSuggestedUsersQueryKey({})}),
])
Toast.show(
@@ -38,7 +38,7 @@ export function LikesOnRepostsNotificationSettingsScreen({}: Props) {
<SettingsList.ItemIcon icon={LikeRepostIcon} />
<ItemTextWithSubtitle
bold
titleText={<Trans>Likes on your reposts</Trans>}
titleText={<Trans>Likes of your reposts</Trans>}
subtitleText={
<Trans>
Get notifications when people like posts that you've reposted.
@@ -5,6 +5,7 @@ import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
import {atoms as a, platform, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
@@ -27,6 +28,10 @@ export function PreferenceControls({
preference?: AppBskyNotificationDefs.Preference | FilterablePreference
allowDisableInApp?: boolean
}) {
const gate = useGate()
if (!gate('reengagement_features')) return null
if (!preference)
return (
<View style={[a.w_full, a.pt_5xl, a.align_center]}>
@@ -85,7 +90,7 @@ export function Inner({
const newPreference = {
...preference,
filter: change,
include: change,
} satisfies typeof preference
mutate({
@@ -98,7 +103,7 @@ export function Inner({
<View style={[a.px_xl, a.pt_md, a.gap_sm]}>
<Toggle.Group
type="checkbox"
label={_(`Select your preferred notification channels`)}
label={_(msg`Select your preferred notification channels`)}
values={channels}
onChange={onChangeChannels}>
<View style={[a.gap_sm]}>
@@ -138,14 +143,16 @@ export function Inner({
)}
</View>
</Toggle.Group>
{'filter' in preference && (
{'include' in preference && (
<>
<Divider />
<Text style={[a.font_bold, a.text_md]}>From</Text>
<Text style={[a.font_bold, a.text_md]}>
<Trans>From</Trans>
</Text>
<Toggle.Group
type="radio"
label={_('Filter who you receive notifications from')}
values={[preference.filter]}
label={_(msg`Filter who you receive notifications from`)}
values={[preference.include]}
onChange={onChangeFilter}
disabled={channels.length === 0}>
<View style={[a.gap_sm]}>
@@ -117,6 +117,28 @@ export function NotificationSettingsScreen({}: Props) {
</View>
)}
<View style={[a.gap_sm]}>
<SettingsList.LinkItem
label={_(msg`Settings for like notifications`)}
to={{screen: 'LikeNotificationSettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={HeartIcon} />
<ItemTextWithSubtitle
titleText={<Trans>Likes</Trans>}
subtitleText={<SettingPreview preference={settings?.like} />}
showSkeleton={!settings}
/>
</SettingsList.LinkItem>
<SettingsList.LinkItem
label={_(msg`Settings for new follower notifications`)}
to={{screen: 'NewFollowerNotificationSettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={PersonPlusIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New followers</Trans>}
subtitleText={<SettingPreview preference={settings?.follow} />}
showSkeleton={!settings}
/>
</SettingsList.LinkItem>
<SettingsList.LinkItem
label={_(msg`Settings for reply notifications`)}
to={{screen: 'ReplyNotificationSettings'}}
@@ -150,17 +172,6 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings}
/>
</SettingsList.LinkItem>
<SettingsList.LinkItem
label={_(msg`Settings for like notifications`)}
to={{screen: 'LikeNotificationSettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={HeartIcon} />
<ItemTextWithSubtitle
titleText={<Trans>Likes</Trans>}
subtitleText={<SettingPreview preference={settings?.like} />}
showSkeleton={!settings}
/>
</SettingsList.LinkItem>
<SettingsList.LinkItem
label={_(msg`Settings for repost notifications`)}
to={{screen: 'RepostNotificationSettings'}}
@@ -172,17 +183,6 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings}
/>
</SettingsList.LinkItem>
<SettingsList.LinkItem
label={_(msg`Settings for new follower notifications`)}
to={{screen: 'NewFollowerNotificationSettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={PersonPlusIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New followers</Trans>}
subtitleText={<SettingPreview preference={settings?.follow} />}
showSkeleton={!settings}
/>
</SettingsList.LinkItem>
{/* <SettingsList.LinkItem
label={_(msg`Settings for activity alerts`)}
to={{screen: 'ActivityNotificationSettings'}}
@@ -199,13 +199,13 @@ export function NotificationSettingsScreen({}: Props) {
</SettingsList.LinkItem> */}
<SettingsList.LinkItem
label={_(
msg`Settings for notifications for likes on your reposts`,
msg`Settings for notifications for likes of your reposts`,
)}
to={{screen: 'LikesOnRepostsNotificationSettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={LikeRepostIcon} />
<ItemTextWithSubtitle
titleText={<Trans>Likes on your reposts</Trans>}
titleText={<Trans>Likes of your reposts</Trans>}
subtitleText={
<SettingPreview preference={settings?.likeViaRepost} />
}
@@ -260,8 +260,8 @@ function SettingPreview({
if (!preference) {
return null
} else {
if ('filter' in preference) {
if (preference.filter === 'all') {
if ('include' in preference) {
if (preference.include === 'all') {
if (preference.list && preference.push) {
return _(msg`In-app, Push, Everyone`)
} else if (preference.list) {
@@ -269,7 +269,7 @@ function SettingPreview({
} else if (preference.push) {
return _(msg`Push, Everyone`)
}
} else if (preference.filter === 'follows') {
} else if (preference.include === 'follows') {
if (preference.list && preference.push) {
return _(msg`In-app, Push, People you follow`)
} else if (preference.list) {
+12 -8
View File
@@ -16,6 +16,7 @@ import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -81,6 +82,7 @@ export function SettingsScreen({}: Props) {
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
const [showAccounts, setShowAccounts] = useState(false)
const [showDevOptions, setShowDevOptions] = useState(false)
const gate = useGate()
return (
<Layout.Screen>
@@ -181,14 +183,16 @@ export function SettingsScreen({}: Props) {
<Trans>Moderation</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.LinkItem
to="/settings/notifications"
label={_(msg`Notifications`)}>
<SettingsList.ItemIcon icon={NotificationIcon} />
<SettingsList.ItemText>
<Trans>Notifications</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
{gate('reengagement_features') && (
<SettingsList.LinkItem
to="/settings/notifications"
label={_(msg`Notifications`)}>
<SettingsList.ItemIcon icon={NotificationIcon} />
<SettingsList.ItemText>
<Trans>Notifications</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
)}
<SettingsList.LinkItem
to="/settings/content-and-media"
label={_(msg`Content and media`)}>
-10
View File
@@ -43,13 +43,6 @@ export interface ChangePasswordModal {
name: 'change-password'
}
export interface LinkWarningModal {
name: 'link-warning'
text: string
href: string
share?: boolean
}
export type Modal =
// Account
| DeleteAccountModal
@@ -67,9 +60,6 @@ export type Modal =
| WaitlistModal
| InviteCodesModal
// Generic
| LinkWarningModal
const ModalContext = React.createContext<{
isModalActive: boolean
activeModals: Modal[]
-335
View File
@@ -1,335 +0,0 @@
import {useCallback, useState} from 'react'
import {
ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import Animated, {FadeOut} from 'react-native-reanimated'
import {LinearGradient} from 'expo-linear-gradient'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {compressIfNeeded} from '#/lib/media/manip'
import {type PickerImage} from '#/lib/media/picker.shared'
import {cleanError} from '#/lib/strings/errors'
import {enforceLen} from '#/lib/strings/helpers'
import {colors, gradients, s} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useProfileUpdateMutation} from '#/state/queries/profile'
import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast'
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
import {UserBanner} from '#/view/com/util/UserBanner'
import {Admonition} from '#/components/Admonition'
import {InlineLinkText} from '#/components/Link'
import {useSimpleVerificationState} from '#/components/verification'
import {ErrorMessage} from '../util/error/ErrorMessage'
const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity)
export const snapPoints = ['fullscreen']
export function Component({
profile,
onUpdate,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
onUpdate?: () => void
}) {
const pal = usePalette('default')
const theme = useTheme()
const {_} = useLingui()
const {closeModal} = useModalControls()
const updateMutation = useProfileUpdateMutation()
const [imageError, setImageError] = useState<string>('')
const initialDisplayName = profile.displayName || ''
const [displayName, setDisplayName] = useState<string>(
profile.displayName || '',
)
const [description, setDescription] = useState<string>(
profile.description || '',
)
const [userBanner, setUserBanner] = useState<string | undefined | null>(
profile.banner,
)
const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
profile.avatar,
)
const [newUserBanner, setNewUserBanner] = useState<
PickerImage | undefined | null
>()
const [newUserAvatar, setNewUserAvatar] = useState<
PickerImage | undefined | null
>()
const onPressCancel = () => {
closeModal()
}
const onSelectNewAvatar = useCallback(
async (img: PickerImage | null) => {
setImageError('')
if (img === null) {
setNewUserAvatar(null)
setUserAvatar(null)
return
}
try {
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserAvatar(finalImg)
setUserAvatar(finalImg.path)
} catch (e: any) {
setImageError(cleanError(e))
}
},
[setNewUserAvatar, setUserAvatar, setImageError],
)
const onSelectNewBanner = useCallback(
async (img: PickerImage | null) => {
setImageError('')
if (!img) {
setNewUserBanner(null)
setUserBanner(null)
return
}
try {
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserBanner(finalImg)
setUserBanner(finalImg.path)
} catch (e: any) {
setImageError(cleanError(e))
}
},
[setNewUserBanner, setUserBanner, setImageError],
)
const onPressSave = useCallback(async () => {
setImageError('')
try {
await updateMutation.mutateAsync({
profile,
updates: {
displayName,
description,
},
newUserAvatar,
newUserBanner,
})
Toast.show(_(msg({message: 'Profile updated', context: 'toast'})))
onUpdate?.()
closeModal()
} catch (e: any) {
logger.error('Failed to update user profile', {message: String(e)})
}
}, [
updateMutation,
profile,
onUpdate,
closeModal,
displayName,
description,
newUserAvatar,
newUserBanner,
setImageError,
_,
])
const verification = useSimpleVerificationState({
profile,
})
return (
<KeyboardAvoidingView style={s.flex1} behavior="height">
<ScrollView style={[pal.view]} testID="editProfileModal">
<Text style={[styles.title, pal.text]}>
<Trans>Edit my profile</Trans>
</Text>
<View style={styles.photos}>
<UserBanner
banner={userBanner}
onSelectNewBanner={onSelectNewBanner}
/>
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
<EditableUserAvatar
size={80}
avatar={userAvatar}
onSelectNewAvatar={onSelectNewAvatar}
/>
</View>
</View>
{updateMutation.isError && (
<View style={styles.errorContainer}>
<ErrorMessage message={cleanError(updateMutation.error)} />
</View>
)}
{imageError !== '' && (
<View style={styles.errorContainer}>
<ErrorMessage message={imageError} />
</View>
)}
<View style={styles.form}>
<View>
<Text style={[styles.label, pal.text]}>
<Trans>Display Name</Trans>
</Text>
<TextInput
testID="editProfileDisplayNameInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder={_(msg`e.g. Alice Roberts`)}
placeholderTextColor={colors.gray4}
value={displayName}
onChangeText={v =>
setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
}
accessible={true}
accessibilityLabel={_(msg`Display name`)}
accessibilityHint={_(msg`Edit your display name`)}
/>
{verification.isVerified &&
verification.role === 'default' &&
displayName !== initialDisplayName && (
<View style={{paddingTop: 8}}>
<Admonition type="error">
<Trans>
You are verified. You will lose your verification status
if you change your display name.{' '}
<InlineLinkText
label={_(msg`Learn more`)}
to={urls.website.blog.initialVerificationAnnouncement}>
<Trans>Learn more.</Trans>
</InlineLinkText>
</Trans>
</Admonition>
</View>
)}
</View>
<View style={s.pb10}>
<Text style={[styles.label, pal.text]}>
<Trans>Description</Trans>
</Text>
<TextInput
testID="editProfileDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)}
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
accessible={true}
accessibilityLabel={_(msg`Description`)}
accessibilityHint={_(msg`Edit your profile description`)}
/>
</View>
{updateMutation.isPending ? (
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
<ActivityIndicator />
</View>
) : (
<TouchableOpacity
testID="editProfileSaveBtn"
style={s.mt10}
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel={_(msg`Save`)}
accessibilityHint={_(msg`Saves any changes to your profile`)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold]}>
<Trans>Save Changes</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
)}
{!updateMutation.isPending && (
<AnimatedTouchableOpacity
exiting={!isWeb ? FadeOut : undefined}
testID="editProfileCancelBtn"
style={s.mt5}
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel profile editing`)}
accessibilityHint=""
onAccessibilityEscape={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>
<Trans>Cancel</Trans>
</Text>
</View>
</AnimatedTouchableOpacity>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: '600',
fontSize: 24,
marginBottom: 18,
},
label: {
fontWeight: '600',
paddingHorizontal: 4,
paddingBottom: 4,
marginTop: 20,
},
form: {
paddingHorizontal: 14,
},
textInput: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 16,
},
textArea: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 12,
paddingTop: 10,
fontSize: 16,
height: 120,
textAlignVertical: 'top',
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 10,
marginBottom: 10,
},
avi: {
position: 'absolute',
top: 80,
left: 24,
width: 84,
height: 84,
borderWidth: 2,
borderRadius: 42,
},
photos: {
marginBottom: 36,
marginHorizontal: -14,
},
errorContainer: {marginTop: 20},
})
-180
View File
@@ -1,180 +0,0 @@
import React from 'react'
import {SafeAreaView, StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {shareUrl} from '#/lib/sharing'
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
import {colors, s} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from './util'
export const snapPoints = ['50%']
export function Component({
text,
href,
share,
}: {
text: string
href: string
share?: boolean
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const potentiallyMisleading = isPossiblyAUrl(text)
const openLink = useOpenLink()
const onPressVisit = () => {
closeModal()
if (share) {
shareUrl(href)
} else {
openLink(href, false, true)
}
}
return (
<SafeAreaView style={[s.flex1, pal.view]}>
<ScrollView
testID="linkWarningModal"
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
{potentiallyMisleading ? (
<>
<FontAwesomeIcon
icon="circle-exclamation"
color={pal.colors.text}
size={18}
/>
<Text type="title-lg" style={[pal.text, styles.title]}>
<Trans>Potentially Misleading Link</Trans>
</Text>
</>
) : (
<Text type="title-lg" style={[pal.text, styles.title]}>
<Trans>Leaving Bluesky</Trans>
</Text>
)}
</View>
<View style={{gap: 10}}>
<Text type="lg" style={pal.text}>
<Trans>This link is taking you to the following website:</Trans>
</Text>
<LinkBox href={href} />
{potentiallyMisleading && (
<Text type="lg" style={pal.text}>
<Trans>Make sure this is where you intend to go!</Trans>
</Text>
)}
</View>
<View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}>
<Button
testID="confirmBtn"
type="primary"
onPress={onPressVisit}
accessibilityLabel={share ? _(msg`Share Link`) : _(msg`Visit Site`)}
accessibilityHint={
share
? _(msg`Shares the linked website`)
: _(msg`Opens the linked website`)
}
label={share ? _(msg`Share Link`) : _(msg`Visit Site`)}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
<Button
testID="cancelBtn"
type="default"
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint={_(msg`Cancels opening the linked website`)}
label={_(msg`Cancel`)}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</ScrollView>
</SafeAreaView>
)
}
function LinkBox({href}: {href: string}) {
const pal = usePalette('default')
const [scheme, hostname, rest] = React.useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View style={[pal.view, pal.border, styles.linkBox]}>
<Text type="lg" style={pal.textLight}>
{scheme}
<Text type="lg-bold" style={pal.text}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 6,
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
},
linkBox: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
gap: 6,
},
})
-4
View File
@@ -13,7 +13,6 @@ import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as LinkWarningModal from './LinkWarning'
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
const DEFAULT_SNAPPOINTS = ['90%']
@@ -68,9 +67,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'change-password') {
snapPoints = ChangePasswordModal.snapPoints
element = <ChangePasswordModal.Component />
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else {
return null
}
-3
View File
@@ -12,7 +12,6 @@ import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as LinkWarningModal from './LinkWarning'
import * as UserAddRemoveLists from './UserAddRemoveLists'
export function ModalsContainer() {
@@ -65,8 +64,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <PostLanguagesSettingsModal.Component />
} else if (modal.name === 'change-password') {
element = <ChangePasswordModal.Component />
} else if (modal.name === 'link-warning') {
element = <LinkWarningModal.Component {...modal} />
} else {
return null
}
+6 -5
View File
@@ -30,6 +30,7 @@ import {emitSoftReset} from '#/state/events'
import {useModalControls} from '#/state/modals'
import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper'
import {useTheme} from '#/alf'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {router} from '../../../routes'
import {PressableWithHover} from './PressableWithHover'
import {Text} from './text/Text'
@@ -189,7 +190,8 @@ export const TextLink = memo(function TextLink({
onBeforePress?: () => void
} & TextProps) {
const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls()
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
if (!disableMismatchWarning && typeof text !== 'string') {
@@ -211,9 +213,8 @@ export const TextLink = memo(function TextLink({
linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) {
e?.preventDefault?.()
openModal({
name: 'link-warning',
text: typeof text === 'string' ? text : '',
linkWarningDialogControl.open({
displayText: typeof text === 'string' ? text : '',
href,
})
}
@@ -245,13 +246,13 @@ export const TextLink = memo(function TextLink({
onBeforePress,
onPressProp,
closeModal,
openModal,
navigation,
href,
text,
disableMismatchWarning,
navigationAction,
openLink,
linkWarningDialogControl,
],
)
const hrefAttrs = useMemo(() => {
+4 -4
View File
@@ -1,7 +1,7 @@
import React, {memo} from 'react'
import {RefreshControl, ViewToken} from 'react-native'
import {RefreshControl, type ViewToken} from 'react-native'
import {
FlatListPropsWithLayout,
type FlatListPropsWithLayout,
runOnJS,
useSharedValue,
} from 'react-native-reanimated'
@@ -11,7 +11,7 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles'
import {isAndroid, isIOS} from '#/platform/detection'
import {isIOS} from '#/platform/detection'
import {useLightbox} from '#/state/lightbox'
import {useTheme} from '#/alf'
import {FlatList_INTERNAL} from './Views'
@@ -152,7 +152,7 @@ let List = React.forwardRef<ListMethods, ListProps>(
return (
<FlatList_INTERNAL
showsVerticalScrollIndicator={!isAndroid} // overridable
showsVerticalScrollIndicator // overridable
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
{...props}
+2
View File
@@ -27,6 +27,7 @@ import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -155,6 +156,7 @@ function ShellInner() {
<SigninDialog />
<EmailDialog />
<InAppBrowserConsentDialog />
<LinkWarningDialog />
<Lightbox />
<PortalOutlet />
<BottomSheetOutlet />
+2
View File
@@ -18,6 +18,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -69,6 +70,7 @@ function ShellInner() {
<MutedWordsDialog />
<SigninDialog />
<EmailDialog />
<LinkWarningDialog />
<Lightbox />
<PortalOutlet />
+55 -55
View File
@@ -63,10 +63,10 @@
"@atproto/xrpc" "^0.7.0"
"@atproto/xrpc-server" "^0.8.0"
"@atproto/api@^0.15.15":
version "0.15.15"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.15.tgz#a506a1a26f3dfef9adb77234f451c0e784b071e7"
integrity sha512-Wn8jv76pCvffnkNj68w0CGZ3PT4DJGM8DUZnYq9kEW2im6jbRBYI0yYrHNhSiE92A5Ox0HjL2jMhalsI2p9VlQ==
"@atproto/api@^0.15.16":
version "0.15.16"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.16.tgz#1962e7067e03a661e17c3164874596ef1e7ed7ad"
integrity sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ==
dependencies:
"@atproto/common-web" "^0.4.2"
"@atproto/lexicon" "^0.4.11"
@@ -77,14 +77,14 @@
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/aws@^0.2.21":
version "0.2.21"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.21.tgz#06006a101c8004db11384a19366296cd87468326"
integrity sha512-bosExZ3YdFjOehNBcNWsC2mZBrAVLO8Ut/JquypXSahFeeXZP/9rd9F1VGf+vAmjFEKagHXQCb6CRFfJyN+I7A==
"@atproto/aws@^0.2.22":
version "0.2.22"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.22.tgz#14a664c06e3569945e4ab143d3a8a03400c7d1de"
integrity sha512-xZ+0/zHHmpgzdLJGTDkFl5Wd39Wm5MyyMLdGYSzyt0wGTBmH6Ktp7ZgR8rmQVNYN1+VkMcdClAiNhg+BSH3mRw==
dependencies:
"@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4"
"@atproto/repo" "^0.8.1"
"@atproto/repo" "^0.8.2"
"@aws-sdk/client-cloudfront" "^3.261.0"
"@aws-sdk/client-kms" "^3.196.0"
"@aws-sdk/client-s3" "^3.224.0"
@@ -94,21 +94,21 @@
multiformats "^9.9.0"
uint8arrays "3.0.0"
"@atproto/bsky@^0.0.159":
version "0.0.159"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.159.tgz#6fcfd7d6c73e4041c5abc9ac3b99dbe0e1e6cf76"
integrity sha512-kRjDCW6FbByeafrEoUD5YMhhjuKTvSqrE2/QJ5xe9CP8UIhl8BShm2PcBh9gJtYc7lO83aJPqDSqb5gJwNAJUg==
"@atproto/bsky@^0.0.161":
version "0.0.161"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.161.tgz#671280c1d40f5c4eb0cc31d338a9e950acbf0ce0"
integrity sha512-L4uzadjt+oyVq3+W7rc1A+X2DyZDsTfeSD15w7k6+6JzICp32qavDuVjut3CIBqXCt7ykvSDujApyLsB/lcWJQ==
dependencies:
"@atproto-labs/fetch-node" "0.1.9"
"@atproto-labs/xrpc-utils" "0.0.16"
"@atproto/api" "^0.15.15"
"@atproto/api" "^0.15.16"
"@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4"
"@atproto/did" "^0.1.5"
"@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11"
"@atproto/repo" "^0.8.1"
"@atproto/sync" "^0.1.25"
"@atproto/repo" "^0.8.2"
"@atproto/sync" "^0.1.26"
"@atproto/syntax" "^0.4.0"
"@atproto/xrpc-server" "^0.8.0"
"@bufbuild/protobuf" "^1.5.0"
@@ -218,21 +218,21 @@
"@noble/hashes" "^1.6.1"
uint8arrays "3.0.0"
"@atproto/dev-env@^0.3.142":
version "0.3.142"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.142.tgz#466cff00c92d53ad148709ae50bcca816fcee1bc"
integrity sha512-NiNb3Pdj93goEmKBIF5rIlLSmkfZiwnYmo7U6cGvGXoopRNG5e4Vm2PXb1n7uVdzuvEtiPtpswQxaf0x6jJsWA==
"@atproto/dev-env@^0.3.144":
version "0.3.144"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.144.tgz#cd2949ff870ca4cde23b4c377b08740a2e64151f"
integrity sha512-ND0oGp7itSnXxlAHlFxYjGFyCcu0f4eSucImVtKRxTcW8UeyyTtJcQP8OyNvtC8j13YjbW124r0g25Wlm0j9XQ==
dependencies:
"@atproto/api" "^0.15.15"
"@atproto/bsky" "^0.0.159"
"@atproto/api" "^0.15.16"
"@atproto/bsky" "^0.0.161"
"@atproto/bsync" "^0.0.20"
"@atproto/common-web" "^0.4.2"
"@atproto/crypto" "^0.4.4"
"@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11"
"@atproto/ozone" "^0.1.120"
"@atproto/pds" "^0.4.148"
"@atproto/sync" "^0.1.25"
"@atproto/ozone" "^0.1.121"
"@atproto/pds" "^0.4.150"
"@atproto/sync" "^0.1.26"
"@atproto/syntax" "^0.4.0"
"@atproto/xrpc-server" "^0.8.0"
"@did-plc/lib" "^0.0.1"
@@ -308,10 +308,10 @@
optionalDependencies:
"@atproto/oauth-provider-api" "0.1.4"
"@atproto/oauth-provider@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.0.tgz#3598924978c2e3d5fdf62bced54574156d15cf92"
integrity sha512-LbZS9rbR5l9gVO97wJ3ls+ENXwv6BakmArRyjc5EfaQ4Xc3eLbvE629hpu9LV8LyCkBpseum0l+D+rYXsemNUw==
"@atproto/oauth-provider@^0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.1.tgz#0147b75d1ad444455159f0a687ce87b3b49a2894"
integrity sha512-2Gm3jv45cGLmUQV0C4/orCJBsHu4wajy+JTN9f/ATX3vvjnFtAd/1GRvAMKDGXtdF7VIjNFlD+4lqhoDxYJpng==
dependencies:
"@atproto-labs/fetch" "0.2.3"
"@atproto-labs/fetch-node" "0.1.9"
@@ -347,12 +347,12 @@
"@atproto/jwk" "0.3.0"
zod "^3.23.8"
"@atproto/ozone@^0.1.120":
version "0.1.120"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.120.tgz#b5c431a538558179de0465cfbfea512d65e092bf"
integrity sha512-zu2f16K/z/3r4mC4z/8qISPt0j+Y0GwtjmSE+VOJvVT363iOd9a834K+QHJqnD6B3iTBHR1VPlZ/4fsZ3+4UaA==
"@atproto/ozone@^0.1.121":
version "0.1.121"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.121.tgz#309b7e876f3b598ed4e79bb5a79e2346931588fe"
integrity sha512-kc3NxiXSPqQmWz8yXlV5cFnZ469ViQd0AexEMw467AcB8ikK1WSxhLsa1EiNAQuLOOpyeXSmAKGAUFHzSOIMpw==
dependencies:
"@atproto/api" "^0.15.15"
"@atproto/api" "^0.15.16"
"@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4"
"@atproto/identity" "^0.4.8"
@@ -377,21 +377,21 @@
undici "^6.14.1"
ws "^8.12.0"
"@atproto/pds@^0.4.148":
version "0.4.148"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.148.tgz#7af82480e42174ea1c284f5975bf0aff3bd4da24"
integrity sha512-PbxTpxRAcsdu3zANjwNH+Pfbu0pfj5z6UDmcnc3eaEP11xsExu3+B84jOYKAkIN/PbM1A9EbiBjKb95yBQxGAw==
"@atproto/pds@^0.4.150":
version "0.4.150"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.150.tgz#45686b05b8ed46e265efa5231ab16e6eda72a8e8"
integrity sha512-CPT6H2uDTe4ZAyxQbws2dIlmdFFf6GQGwMc0OE3kI1wBBaLHprpexjM2Gd4ObtYNxGOOV0fwoCDAth8qqZ4XVw==
dependencies:
"@atproto-labs/fetch-node" "0.1.9"
"@atproto-labs/xrpc-utils" "0.0.16"
"@atproto/api" "^0.15.15"
"@atproto/aws" "^0.2.21"
"@atproto/api" "^0.15.16"
"@atproto/aws" "^0.2.22"
"@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4"
"@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11"
"@atproto/oauth-provider" "^0.9.0"
"@atproto/repo" "^0.8.1"
"@atproto/oauth-provider" "^0.9.1"
"@atproto/repo" "^0.8.2"
"@atproto/syntax" "^0.4.0"
"@atproto/xrpc" "^0.7.0"
"@atproto/xrpc-server" "^0.8.0"
@@ -424,10 +424,10 @@
undici "^6.19.8"
zod "^3.23.8"
"@atproto/repo@^0.8.1":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.1.tgz#be8c6b93c000944b81aaa1026d6c50d82c025d74"
integrity sha512-d1NtHhXYJVJlFVI6mbVOUnpB0rnhqxPnZcALkJoYJjaDPVr4NNqRFAtrwb+GHzxT6DhijoXYQf24pKGfEFDd4g==
"@atproto/repo@^0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.2.tgz#7953cb2c637c94505da76f74a784b2aae050c204"
integrity sha512-lP0g5Uw3TUC2Tc7te8YKCpRoIhBYI+Uvn11fupGEaMcMjgLdYtB0Kc0AiqWXF42KqlBG9dAEoJITi2GRzDNHUg==
dependencies:
"@atproto/common" "^0.4.11"
"@atproto/common-web" "^0.4.2"
@@ -439,15 +439,15 @@
varint "^6.0.0"
zod "^3.23.8"
"@atproto/sync@^0.1.25":
version "0.1.25"
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.25.tgz#66b3453e3cf0ba6a155dbbf207ea46d632c2d6b0"
integrity sha512-4UsQgQsUK+hKFAEDi10Ops6n2W/kfk2JYP8AU6FSHAzOadB1hKRDJbGF5vLiLP9ACBhCzoJerZ31DCnhjzRzfw==
"@atproto/sync@^0.1.26":
version "0.1.26"
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.26.tgz#6be2876be612d9cd704452598ee679b2e912cfe3"
integrity sha512-bpUIajtPrE3RgFW8mIfrI4EM/LJ4JjQhI5fsqc78zCHZawuflpllf1aH70roDWWiskMWoiLWnVRxdYXdeEgbXA==
dependencies:
"@atproto/common" "^0.4.11"
"@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11"
"@atproto/repo" "^0.8.1"
"@atproto/repo" "^0.8.2"
"@atproto/syntax" "^0.4.0"
"@atproto/xrpc-server" "^0.8.0"
multiformats "^9.9.0"
@@ -16843,10 +16843,10 @@ react-native-is-edge-to-edge@^1.1.6:
resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.6.tgz#69ec13f70d76e9245e275eed4140d0873a78f902"
integrity sha512-1pHnFTlBahins6UAajXUqeCOHew9l9C2C8tErnpGC3IyLJzvxD+TpYAixnCbrVS52f7+NvMttbiSI290XfwN0w==
react-native-keyboard-controller@^1.17.1:
version "1.17.1"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.17.1.tgz#46efe148c1bdd0ee22094dcb2660f70f81e6544e"
integrity sha512-YM3GYvtkuWimCKkZFURn5hIb1WiKOQqi2DijdwZSF5QSSzGqfqwzEEC3bm1xCN8HGHAEIXAaWIBUsc3Xp5L+Ng==
react-native-keyboard-controller@^1.17.5:
version "1.17.5"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.17.5.tgz#a517f0d42f73e69a03e768379934a3bb705595f5"
integrity sha512-2bZi4uH/beAcHiQ7nv6sxW03/UpNcnNAPpaSnQtg0cbU3ySThPRETMqr0ZupFLUSZovolyFhyFJLjxmQ7cavJg==
dependencies:
react-native-is-edge-to-edge "^1.1.6"