Compare commits

...

45 Commits

Author SHA1 Message Date
Eric Bailey 0f6fb86750 Clarify comment 2025-06-25 21:21:43 -05:00
Eric Bailey b5dcbe6128 Put app view DIDs in constants 2025-06-25 21:18:13 -05:00
Eric Bailey 7b194452de Comment 2025-06-24 20:27:59 -05:00
Eric Bailey b285714534 Update import 2025-06-24 20:24:39 -05:00
Eric Bailey eeacf049a2 Use hooks 2025-06-24 19:21:58 -05:00
Eric Bailey dba9f59a70 Simplify 2025-06-24 19:04:33 -05:00
Eric Bailey c79234ec03 Clean up 2025-06-24 18:09:24 -05:00
Eric Bailey d54907eca0 Align handling across native devices 2025-06-24 16:44:41 -05:00
Eric Bailey 1b12e17bbe Comment 2025-06-24 15:56:31 -05:00
Eric Bailey 6a6a68f347 Debounce push token registration by 100ms 2025-06-24 15:43:45 -05:00
Eric Bailey e52478e032 Formatting nits 2025-06-24 15:23:01 -05:00
pfrazee cd820709b6 Nightly source-language update 2025-06-24 02:42:09 +00:00
hailey c894dda0bf disable new arch (#8561) 2025-06-23 10:11:17 -07:00
Samuel Newman c634cd9071 Upgrade prettier to 3.6 (#8558)
* upgrade prettier

* run prettier

* more files
2025-06-23 07:44:40 -07:00
pfrazee 3c92714e4e Nightly source-language update 2025-06-21 02:39:45 +00:00
Samuel Newman 25baf1f661 Allow disabling replies (#8537) 2025-06-20 22:44:53 +03:00
Samuel Newman dcbcd1bb80 simple heuristic for reducing footer height in thread (#8549) 2025-06-20 14:19:48 -05:00
Samuel Newman 92ee6260c1 Ignore common video errors (#8548) 2025-06-20 09:50:21 -07:00
Samuel Newman 9c7b330f92 Catch web browser errors (#8547)
* catch web browser errors

* use catch instead to ensure sheetWrapper works
2025-06-20 09:49:45 -07:00
Eric Bailey bc95c0e50c Don't send any debugs to Sentry (#8533)
* Don't send any debugs to Sentry

* Fix tests
2025-06-20 11:44:39 -05:00
Eric Bailey aeafb14fb4 Couple of Sentry cleanups (#8532)
* Temp ignore a couple logs

* Add debug for unknown notifications

* Nvm let's do this in Sentry

* Downgrade two DMs network related issues

* Check for network errors before sending in Convo

* Do the same for event bus

* Fix mistake
2025-06-20 09:30:52 -07:00
Samuel Newman 4c75b568df Android notification channels (#8539) 2025-06-20 11:01:55 +03:00
pfrazee 9702b210cc Nightly source-language update 2025-06-20 02:41:14 +00:00
Samuel Newman d53fdb0dda fix prefetch returning undefined (#8538) 2025-06-19 09:25:17 -07: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
105 changed files with 1805 additions and 2327 deletions
@@ -52,10 +52,6 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: "Use upgraded MMKV for Fabric"
run: |
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' filename.txt
- name: ⚙️ Install dependencies
run: yarn install
@@ -275,10 +275,6 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: "Use upgraded MMKV for Fabric"
run: |
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' filename.txt
- name: ⚙️ Install dependencies
run: yarn install
+3 -3
View File
@@ -29,8 +29,8 @@ module.exports = function (_config) {
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
? 'production'
: undefined
? 'production'
: undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
@@ -219,7 +219,7 @@ module.exports = function (_config) {
compileSdkVersion: 35,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
newArchEnabled: true,
newArchEnabled: false,
},
},
],
+1 -3
View File
@@ -1,9 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: ['variant', [
'&:is(.dark *):not(:is(.dark .light *))',
]],
darkMode: ['variant', ['&:is(.dark *):not(:is(.dark .light *))']],
theme: {
extend: {
colors: {
-1
View File
@@ -1,4 +1,3 @@
{
"compilerOptions": {
"target": "ES5",
+11 -8
View File
@@ -1,27 +1,30 @@
import assert from 'assert'
import {
Kysely,
KyselyPlugin,
type KyselyPlugin,
Migrator,
PluginTransformQueryArgs,
PluginTransformResultArgs,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
PostgresDialect,
QueryResult,
RootOperationNode,
UnknownRow,
type QueryResult,
type RootOperationNode,
type UnknownRow,
} from 'kysely'
import {default as Pg} from 'pg'
import {dbLogger as log} from '../logger.js'
import {default as migrations} from './migrations/index.js'
import {DbMigrationProvider} from './migrations/provider.js'
import {DbSchema} from './schema.js'
import {type DbSchema} from './schema.js'
export class Database {
migrator: Migrator
destroyed = false
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
constructor(
public db: Kysely<DbSchema>,
public cfg: PgConfig,
) {
this.migrator = new Migrator({
db,
migrationTableSchema: cfg.schema,
+7 -4
View File
@@ -1,11 +1,11 @@
import events from 'node:events'
import http from 'node:http'
import type http from 'node:http'
import cors from 'cors'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {type Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
@@ -17,7 +17,10 @@ export class LinkService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
static async create(cfg: Config): Promise<LinkService> {
let app = express()
+7 -4
View File
@@ -1,10 +1,10 @@
import events from 'node:events'
import http from 'node:http'
import type http from 'node:http'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {type Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
@@ -15,7 +15,10 @@ export class CardService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
@@ -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"]
}
}
}
}
}
@@ -12,7 +12,7 @@ const EmojiPicker = ({onEmojiSelected}: EmojiPickerViewProps) => {
flex: 1,
width: '100%',
backgroundColor: scheme === 'dark' ? '#000' : '#fff',
} as const),
}) as const,
[scheme],
)
+4 -4
View File
@@ -69,7 +69,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 +190,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 +218,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",
@@ -265,7 +265,7 @@
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"metro-react-native-babel-preset": "^0.77.0",
"prettier": "^2.8.3",
"prettier": "^3.6.0",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
+3 -4
View File
@@ -9,10 +9,9 @@ const templateFile = path.join(
'scripts.html',
)
const {entrypoints} = require(path.join(
projectRoot,
'web-build/asset-manifest.json',
))
const {entrypoints} = require(
path.join(projectRoot, 'web-build/asset-manifest.json'),
)
console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
+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,
}}
/>
+3 -3
View File
@@ -190,7 +190,7 @@ export function Root({children}: {children: React.ReactNode}) {
if (item) playHaptic('Light')
setHoveredMenuItem(item)
},
} satisfies ContextType),
}) satisfies ContextType,
[
measurement,
setMeasurement,
@@ -710,8 +710,8 @@ export function Item({
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
? measurement.x
: measurement.x + measurement.width - layout.width
registerHoverable(
id,
+57 -22
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`.
@@ -98,10 +99,10 @@ export function useLink({
return typeof to === 'string'
? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
: to.screen
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
}, [to])
if (!href) {
@@ -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) => {
+3 -3
View File
@@ -77,9 +77,9 @@ export function ImageEmbed({
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
@@ -317,8 +317,8 @@ export function Controls({
!focused
? msg`Unmute video`
: playing
? msg`Pause video`
: msg`Play video`,
? msg`Pause video`
: msg`Play video`,
)}
accessibilityHint=""
style={[
@@ -1,6 +1,7 @@
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
import {isSafari} from '#/lib/browser'
import {logger} from '#/logger'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
@@ -79,7 +80,12 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
await ref.current.play()
} catch (e: any) {
if (
!e.message?.includes(`The request is not allowed by the user agent`)
!e.message?.includes(
`The request is not allowed by the user agent`,
) &&
!e.message?.includes(
`The play() request was interrupted by a call to pause()`,
)
) {
throw e
}
@@ -176,8 +182,15 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
} else {
const promise = ref.current.play()
if (promise !== undefined) {
promise.catch(err => {
console.error('Error playing video:', err)
promise.catch((err: any) => {
if (
// ignore this common error. it's fine
!err.message?.includes(
`The play() request was interrupted by a call to pause()`,
)
) {
logger.error('Error playing video:', {message: err})
}
})
}
}
@@ -600,8 +600,8 @@ let PostMenuItems = ({
isDetachPending
? Loader
: quoteEmbed.isDetached
? Eye
: EyeSlash
? Eye
: EyeSlash
}
position="right"
/>
@@ -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
}
+3 -2
View File
@@ -111,8 +111,8 @@ export function Trigger({children, label}: TriggerProps) {
borderColor: focused
? t.palette.primary_500
: hovered
? t.palette.contrast_100
: t.palette.contrast_25,
? t.palette.contrast_100
: t.palette.contrast_25,
},
])}>
{children}
@@ -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},
+12 -6
View File
@@ -1,9 +1,15 @@
import React from 'react'
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
Keyboard,
Platform,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {
type AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
type AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -13,7 +19,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {isNative} from '#/platform/detection'
import {
ThreadgateAllowUISetting,
type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
@@ -70,8 +76,8 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const description = anyoneCanReply
? _(msg`Everybody can reply`)
: noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
@@ -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,
],
)
@@ -185,8 +185,8 @@ export function Disable() {
state.emailStatus === 'pending'
? Loader
: state.emailStatus === 'success'
? Check
: Envelope
? Check
: Envelope
}
/>
</Button>
@@ -116,8 +116,8 @@ export function Enable() {
state.status === 'pending'
? Loader
: state.status === 'success'
? Check
: ShieldIcon
? Check
: ShieldIcon
}
/>
</Button>
+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,6 +1,10 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedPostgate,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -22,7 +26,7 @@ import {
import {
createThreadgateViewQueryKey,
getThreadgateView,
ThreadgateAllowUISetting,
type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
@@ -558,7 +562,8 @@ export function usePrefetchPostInteractionSettings({
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () => getPostgateRecord({agent, postUri}),
queryFn: () =>
getPostgateRecord({agent, postUri}).then(res => res ?? null),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
@@ -390,8 +390,8 @@ function DefaultProfileCard({
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
@@ -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>
)
}
+2 -2
View File
@@ -98,8 +98,8 @@ export function EmojiReactionPicker({
: t.palette.primary_500,
}
: alreadyReacted
? {backgroundColor: t.palette.primary_200}
: bgColor,
? {backgroundColor: t.palette.primary_200}
: bgColor,
{height: 40, width: 40},
a.justify_center,
a.align_center,
+1 -2
View File
@@ -128,8 +128,7 @@ export let MessageContextMenu = ({
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
sender?.handle ?? // should always be defined
'unknown'
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`,
)}>
{children}
+23 -24
View File
@@ -3,28 +3,27 @@ import Svg, {Circle, Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
export const VerifiedCheck = React.forwardRef<Svg, Props>(function LogoImpl(
props,
ref,
) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
export const VerifiedCheck = React.forwardRef<Svg, Props>(
function LogoImpl(props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Circle cx="12" cy="12" r="11.5" fill={fill} />
<Path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
/>
</Svg>
)
})
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Circle cx="12" cy="12" r="11.5" fill={fill} />
<Path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
/>
</Svg>
)
},
)
+28 -29
View File
@@ -3,33 +3,32 @@ import Svg, {Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
export const VerifierCheck = React.forwardRef<Svg, Props>(function LogoImpl(
props,
ref,
) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
export const VerifierCheck = React.forwardRef<Svg, Props>(
function LogoImpl(props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
/>
<Path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
/>
</Svg>
)
})
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
/>
<Path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
/>
</Svg>
)
},
)
+4 -4
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -148,8 +148,8 @@ function ContentHiderActive({
modui.noOverride
? _(msg`Learn more about the moderation applied to this content`)
: override
? _(msg`Hides the content`)
: _(msg`Shows the content`)
? _(msg`Hides the content`)
: _(msg`Shows the content`)
}>
{state => (
<View
@@ -512,13 +512,13 @@ function StepTitle({
backgroundColor: active
? t.palette.primary_500
: completed
? t.palette.primary_100
: t.atoms.bg_contrast_25.backgroundColor,
? t.palette.primary_100
: t.atoms.bg_contrast_25.backgroundColor,
borderColor: active
? t.palette.primary_500
: completed
? t.palette.primary_400
: t.atoms.border_contrast_low.borderColor,
? t.palette.primary_400
: t.atoms.border_contrast_low.borderColor,
},
]}>
{completed ? (
@@ -533,8 +533,8 @@ function StepTitle({
color: active
? 'white'
: completed
? t.palette.primary_700
: t.atoms.text_contrast_medium.color,
? t.palette.primary_700
: t.atoms.text_contrast_medium.color,
fontVariant: ['tabular-nums'],
width: 24,
height: 24,
@@ -130,8 +130,8 @@ export function Badge({
verifiedByHidden
? t.atoms.bg_contrast_100.backgroundColor
: state.profile.isVerified
? t.palette.primary_500
: t.atoms.bg_contrast_100.backgroundColor
? t.palette.primary_500
: t.atoms.bg_contrast_100.backgroundColor
}
verifier={state.profile.role === 'verifier'}
/>
@@ -64,13 +64,13 @@ function Inner({
? _(msg`You are verified`)
: _(msg`Your verifications`)
: state.profile.isVerified
? _(msg`${userName} is verified`)
: _(
msg({
message: `${userName}'s verifications`,
comment: `Possessive, meaning "the verifications of {userName}"`,
}),
)
? _(msg`${userName} is verified`)
: _(
msg({
message: `${userName}'s verifications`,
comment: `Possessive, meaning "the verifications of {userName}"`,
}),
)
return (
<Dialog.ScrollableInner
+3
View File
@@ -201,3 +201,6 @@ export const urls = {
},
},
}
export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app'
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
+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],
)
+100 -8
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
@@ -242,7 +322,19 @@ export function useNotificationsHandler() {
const payload = e.notification.request.trigger
.payload as NotificationPayload
if (!payload) return
if (!payload) {
logger.error('useNotificationsHandler: received no payload', {
identifier: e.notification.request.identifier,
})
return
}
if (!payload.reason) {
logger.error('useNotificationsHandler: received unknown payload', {
payload,
identifier: e.notification.request.identifier,
})
return
}
logger.debug(
'User pressed a notification, opening notifications tab',
+5
View File
@@ -11,6 +11,7 @@ import {
isRelativeUrl,
toNiceDomain,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {useTheme} from '#/alf'
@@ -64,6 +65,10 @@ export function useOpenLink() {
toolbarColor: t.atoms.bg.backgroundColor,
controlsColor: t.palette.primary_500,
createTask: false,
}).catch(err => {
if (__DEV__)
logger.error('Could not open web browser', {message: err})
Linking.openURL(url)
}),
)
return
+9 -9
View File
@@ -1,18 +1,18 @@
import React from 'react'
import {
AppBskyLabelerDefs,
type AppBskyLabelerDefs,
BskyAgent,
ComAtprotoLabelDefs,
InterpretedLabelValueDefinition,
type ComAtprotoLabelDefs,
type InterpretedLabelValueDefinition,
LABELS,
ModerationCause,
ModerationOpts,
ModerationUI,
type ModerationCause,
type ModerationOpts,
type ModerationUI,
} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {AppModerationCause} from '#/components/Pills'
import {type AppModerationCause} from '#/components/Pills'
export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
export const OTHER_SELF_LABELS = ['graphic-media']
@@ -29,8 +29,8 @@ export function getModerationCauseKey(
cause.source.type === 'labeler'
? cause.source.did
: cause.source.type === 'list'
? cause.source.list.uri
: 'user'
? cause.source.list.uri
: 'user'
if (cause.type === 'label') {
return `label:${cause.label.val}:${source}`
}
@@ -1,8 +1,8 @@
import React from 'react'
import {
BSKY_LABELER_DID,
ModerationCause,
ModerationCauseSource,
type ModerationCause,
type ModerationCauseSource,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,10 +12,10 @@ import {useLabelDefinitions} from '#/state/preferences'
import {useSession} from '#/state/session'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {AppModerationCause} from '#/components/Pills'
import {type AppModerationCause} from '#/components/Pills'
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from './useLabelInfo'
@@ -153,8 +153,8 @@ export function useModerationCauseDescription(
def.identifier === '!no-unauthenticated'
? EyeSlash
: def.severity === 'alert'
? Warning
: CircleInfo,
? Warning
: CircleInfo,
name: strings.name,
description: strings.description,
source,
+160 -65
View File
@@ -1,91 +1,185 @@
import React from 'react'
import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native'
import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
import {BskyAgent} from '@atproto/api'
import {type AtpAgent} from '@atproto/api'
import debounce from 'lodash.debounce'
import {logEvent} from '#/lib/statsig/statsig'
import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
import {Logger} from '#/logger'
import {devicePlatform, isAndroid, isNative} from '#/platform/detection'
import {SessionAccount, useAgent, useSession} from '#/state/session'
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging')
? 'did:web:api.staging.bsky.dev'
: 'did:web:api.bsky.app'
import {isNative} from '#/platform/detection'
import {type SessionAccount, useAgent, useSession} from '#/state/session'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
const logger = Logger.create(Logger.Context.Notifications)
async function registerPushToken(
agent: BskyAgent,
account: SessionAccount,
token: Notifications.DevicePushToken,
) {
/**
* @private
* Registers the device's push notification token with the Bluesky server.
*/
async function _registerPushToken({
agent,
currentAccount,
token,
}: {
agent: AtpAgent
currentAccount: SessionAccount
token: Notifications.DevicePushToken
}) {
try {
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
platform: devicePlatform,
await agent.app.bsky.notification.registerPush({
serviceDid: currentAccount.service?.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID,
platform: Platform.OS,
token: token.data,
appId: 'xyz.blueskyweb.app',
})
logger.debug('Notifications: Sent push token (init)', {
logger.debug(`registerPushToken: success`, {
tokenType: token.type,
token: token.data,
})
} catch (error) {
logger.error('Notifications: Failed to set push token', {message: error})
logger.error(`registerPushToken: failed`, {safeMessage: error})
}
}
async function getPushToken(skipPermissionCheck = false) {
const granted =
skipPermissionCheck || (await Notifications.getPermissionsAsync()).granted
/**
* @private
* Debounced version of `_registerPushToken` to prevent multiple calls.
*/
const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
/**
* Hook to register the device's push notification token with the Bluesky. If
* the user is not logged in, this will do nothing.
*
* Use this instead of using `_registerPushToken` or
* `_registerPushTokenDebounced` directly.
*/
export function useRegisterPushToken() {
const agent = useAgent()
const {currentAccount} = useSession()
return useCallback(
({token}: {token: Notifications.DevicePushToken}) => {
if (!currentAccount) return
return _registerPushTokenDebounced({
agent,
currentAccount,
token,
})
},
[agent, currentAccount],
)
}
/**
* Retreive the device's push notification token, if permissions are granted.
*/
async function getPushToken() {
const granted = (await Notifications.getPermissionsAsync()).granted
logger.debug(`getPushToken`, {granted})
if (granted) {
return Notifications.getDevicePushTokenAsync()
}
}
/**
* Hook to get the device push token and register it with the Bluesky server.
* Should only be called after a user has logged-in, since registration is an
* authed endpoint.
*
* N.B. A previous regression in `expo-notifications` caused
* `addPushTokenListener` to not fire on Android after calling
* `getPushToken()`. Therefore, as insurance, we also call
* `registerPushToken` here.
*
* Because `registerPushToken` is debounced, even if the the listener _does_
* fire, it's OK to also call `registerPushToken` below since only a single
* call will be made to the server (ideally). This does race the listener (if
* it fires), so there's a possibility that multiple calls will be made, but
* that is acceptable.
*
* @see https://github.com/bluesky-social/social-app/pull/4467
* @see https://github.com/expo/expo/issues/28656
* @see https://github.com/expo/expo/issues/29909
*/
export function useGetAndRegisterPushToken() {
const registerPushToken = useRegisterPushToken()
return useCallback(async () => {
/**
* This will also fire the listener added via `addPushTokenListener`. That
* listener also handles registration.
*/
const token = await getPushToken()
logger.debug(`useGetAndRegisterPushToken`, {token: token ?? 'undefined'})
if (token) {
/**
* The listener should have registered the token already, but just in
* case, call the debounced function again.
*/
registerPushToken({token})
}
return token
}, [registerPushToken])
}
/**
* Hook to register the device's push notification token with the Bluesky
* server, as well as listen for push token updates, should they occurr.
*
* Registered via the shell, which wraps the navigation stack, meaning if we
* have a current account, this handling will be registered and ready to go.
*/
export function useNotificationsRegistration() {
const agent = useAgent()
const {currentAccount} = useSession()
const registerPushToken = useRegisterPushToken()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
React.useEffect(() => {
if (!currentAccount) {
return
}
useEffect(() => {
/**
* We want this to init right away _after_ we have a logged in user.
*/
if (!currentAccount) return
// HACK - see https://github.com/bluesky-social/social-app/pull/4467
// An apparent regression in expo-notifications causes `addPushTokenListener` to not fire on Android whenever the
// token changes by calling `getPushToken()`. This is a workaround to ensure we register the token once it is
// generated on Android.
if (isAndroid) {
;(async () => {
const token = await getPushToken()
logger.debug(`useNotificationsRegistration`)
// Token will be undefined if we don't have notifications permission
if (token) {
registerPushToken(agent, currentAccount, token)
}
})()
} else {
getPushToken()
}
/**
* Init push token, if permissions are granted already. If they weren't,
* they'll be requested by the `useRequestNotificationsPermission` hook
* below.
*/
getAndRegisterPushToken()
// According to the Expo docs, there is a chance that the token will change while the app is open in some rare
// cases. This will fire `registerPushToken` whenever that happens.
const subscription = Notifications.addPushTokenListener(async newToken => {
registerPushToken(agent, currentAccount, newToken)
/**
* Register the push token with the Bluesky server, whenever it changes.
* This is also fired any time `getDevicePushTokenAsync` is called.
*
* According to the Expo docs, there is a chance that the token will change
* while the app is open in some rare cases. This will fire
* `registerPushToken` whenever that happens.
*
* @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
*/
const subscription = Notifications.addPushTokenListener(async token => {
registerPushToken({token})
logger.debug(`addPushTokenListener callback`, {token})
})
return () => {
subscription.remove()
}
}, [currentAccount, agent])
}, [currentAccount, getAndRegisterPushToken, registerPushToken])
}
export function useRequestNotificationsPermission() {
const {currentAccount} = useSession()
const agent = useAgent()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
return async (
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',
@@ -107,26 +201,27 @@ export function useRequestNotificationsPermission() {
}
const res = await Notifications.requestPermissionsAsync()
logEvent('notifications:request', {
logger.metric(`notifications:request`, {
context: context,
status: res.status,
})
if (res.granted) {
// This will fire a pushTokenEvent, which will handle registration of the token
const token = await getPushToken(true)
// Same hack as above. We cannot rely on the `addPushTokenListener` to fire on Android due to an Expo bug, so we
// will manually register it here. Note that this will occur only:
// 1. right after the user signs in, leading to no `currentAccount` account being available - this will be instead
// picked up from the useEffect above on `currentAccount` change
// 2. right after onboarding. In this case, we _need_ this registration, since `currentAccount` will not change
// and we need to ensure the token is registered right after permission is granted. `currentAccount` will already
// be available in this case, so the registration will succeed.
// We should remove this once expo-notifications (and possibly FCMv1) is fixed and the `addPushTokenListener` is
// working again. See https://github.com/expo/expo/issues/28656
if (isAndroid && currentAccount && token) {
registerPushToken(agent, currentAccount, token)
if (currentAccount) {
/**
* If we have an account in scope, we can safely call
* `getAndRegisterPushToken`.
*/
getAndRegisterPushToken()
} else {
/**
* Right after login, `currentAccount` in this scope will be undefined,
* but calling `getPushToken` will result in `addPushTokenListener`
* listeners being called, which will handle the registration with the
* Bluesky server.
*/
getPushToken()
}
}
}
+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 -5
View File
@@ -1,17 +1,17 @@
import React from 'react'
import {Platform} from 'react-native'
import {AppState, AppStateStatus} from 'react-native'
import {AppState, type AppStateStatus} from 'react-native'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
import {logger} from '#/logger'
import {MetricEvents} from '#/logger/metrics'
import {type MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {Gate} from './gates'
import {type Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
@@ -51,8 +51,8 @@ function createStatsigOptions(prefetchUsers: StatsigUser[]) {
process.env.NODE_ENV === 'development'
? 'development'
: IS_TESTFLIGHT
? 'staging'
: 'production',
? 'staging'
: 'production',
},
// Don't block on waiting for network. The fetched config will kick in on next load.
// This ensures the UI is always consistent and doesn't update mid-session.
+2 -2
View File
@@ -11,8 +11,8 @@ const IFRAME_HOST = isWeb
? 'http://localhost:8100'
: 'https://bsky.app'
: __DEV__ && !process.env.JEST_WORKER_ID
? 'http://localhost:8100'
: 'https://bsky.app'
? 'http://localhost:8100'
: 'https://bsky.app'
export const embedPlayerSources = [
'youtube',
+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
+5 -3
View File
@@ -110,6 +110,7 @@ describe('general functionality', () => {
const timestamp = Date.now()
const sentryTimestamp = timestamp / 1000
/*
sentryTransport(
LogLevel.Debug,
Logger.Context.Default,
@@ -125,6 +126,7 @@ describe('general functionality', () => {
level: LogLevel.Debug,
timestamp: sentryTimestamp,
})
*/
sentryTransport(
LogLevel.Info,
@@ -154,7 +156,7 @@ describe('general functionality', () => {
message,
data: {__context__: 'logger'},
type: 'default',
level: 'debug', // Sentry bug, log becomes debug
level: 'log',
timestamp: sentryTimestamp,
})
jest.runAllTimers()
@@ -220,7 +222,7 @@ describe('general functionality', () => {
const sentryTimestamp = timestamp / 1000
sentryTransport(
LogLevel.Debug,
LogLevel.Info,
undefined,
message,
{error: new Error('foo')},
@@ -230,7 +232,7 @@ describe('general functionality', () => {
message,
data: {error: 'Error: foo'},
type: 'default',
level: LogLevel.Debug,
level: LogLevel.Info,
timestamp: sentryTimestamp,
})
})
+4 -1
View File
@@ -1,6 +1,6 @@
import {isNetworkError} from '#/lib/strings/errors'
import {Sentry} from '#/logger/sentry/lib'
import {LogLevel, Transport} from '#/logger/types'
import {LogLevel, type Transport} from '#/logger/types'
import {prepareMetadata} from '#/logger/util'
export const sentryTransport: Transport = (
@@ -10,6 +10,9 @@ export const sentryTransport: Transport = (
{type, tags, ...metadata},
timestamp,
) => {
// Skip debug messages entirely for now - esb
if (level === LogLevel.Debug) return
const meta = {
__context__: context,
...prepareMetadata(metadata),
-1
View File
@@ -3,7 +3,6 @@ import {Platform} from 'react-native'
export const isIOS = Platform.OS === 'ios'
export const isAndroid = Platform.OS === 'android'
export const isNative = isIOS || isAndroid
export const devicePlatform = isIOS ? 'ios' : isAndroid ? 'android' : 'web'
export const isWeb = !isNative
export const isMobileWebMediaQuery = 'only screen and (max-width: 1300px)'
export const isMobileWeb =
+6 -6
View File
@@ -41,12 +41,12 @@ globalThis.atob = (str: string): string => {
r1 === 64
? String.fromCharCode((bitmap >> 16) & 255)
: r2 === 64
? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
: String.fromCharCode(
(bitmap >> 16) & 255,
(bitmap >> 8) & 255,
bitmap & 255,
)
? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
: String.fromCharCode(
(bitmap >> 16) & 255,
(bitmap >> 8) & 255,
bitmap & 255,
)
}
return result
}
+2 -2
View File
@@ -49,8 +49,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
requestedAccount
? Forms.Login
: accounts.length
? Forms.ChooseAccount
: Forms.Login,
? Forms.ChooseAccount
: Forms.Login,
)
const {
+1 -1
View File
@@ -155,7 +155,7 @@ export function MessagesScreen({navigation, route}: Props) {
profiles: inboxPreviewConvos.slice(0, 3),
},
...conversations.map(
convo => ({type: 'CONVERSATION', conversation: convo} as const),
convo => ({type: 'CONVERSATION', conversation: convo}) as const,
),
] satisfies ListItem[]
}
+2 -2
View File
@@ -176,8 +176,8 @@ export function StepFinished() {
avatarResult: profileStepResults.isCreatedAvatar
? 'created'
: profileStepResults.image
? 'uploaded'
: 'default',
? 'uploaded'
: 'default',
})
})(),
requestNotificationsPermission('AfterOnboarding'),
@@ -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>
)}
+12 -5
View File
@@ -54,13 +54,16 @@ export function PostThread({uri}: {uri: string}) {
* One query to rule them all
*/
const thread = usePostThread({anchor: uri})
const anchor = useMemo(() => {
const {anchor, hasParents} = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
let hasParents = false
for (const item of thread.data.items) {
if (item.type === 'threadPost' && item.depth === 0) {
return item
return {anchor: item, hasParents}
}
hasParents = true
}
return
return {hasParents}
}, [thread.data.items])
const {openComposer} = useOpenComposer()
@@ -481,6 +484,8 @@ export function PostThread({uri}: {uri: string}) {
],
)
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
return (
<>
<Layout.Header.Outer headerRef={headerRef}>
@@ -537,8 +542,10 @@ export function PostThread({uri}: {uri: string}) {
* back to the top of the screen when handling scroll.
*/
height={platform({
web: windowHeight - 200,
default: deferParents ? windowHeight * 2 : windowHeight - 200,
web: defaultListFooterHeight,
default: deferParents
? windowHeight * 2
: defaultListFooterHeight,
})}
style={isTombstoneView ? {borderTopWidth: 0} : undefined}
/>
@@ -214,8 +214,8 @@ let ProfileHeaderLabeler = ({
? t.palette.contrast_50
: t.palette.contrast_25
: state.hovered || state.pressed
? tokens.color.temp_purple_dark
: tokens.color.temp_purple,
? tokens.color.temp_purple_dark
: tokens.color.temp_purple,
},
]}>
<Text
+2 -2
View File
@@ -342,11 +342,11 @@ export function Explore({
])
const topBorder = useMemo(
() => ({type: 'topBorder', key: 'top-border'} as const),
() => ({type: 'topBorder', key: 'top-border'}) as const,
[],
)
const trendingTopicsModule = useMemo(
() => ({type: 'trendingTopics', key: 'trending-topics'} as const),
() => ({type: 'trendingTopics', key: 'trending-topics'}) as const,
[],
)
const suggestedFollowsModule = useMemo(() => {
@@ -2,7 +2,7 @@ import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppIconSet} from '#/screens/Settings/AppIconSettings/types'
import {type AppIconSet} from '#/screens/Settings/AppIconSettings/types'
export function useAppIconSets() {
const {_} = useLingui()
@@ -13,20 +13,28 @@ export function useAppIconSets() {
id: 'default_light',
name: _(msg({context: 'Name of app icon variant', message: 'Light'})),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_default_light.png`)
return require(
`../../../../assets/app-icons/ios_icon_default_light.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_default_light.png`)
return require(
`../../../../assets/app-icons/android_icon_default_light.png`,
)
},
},
{
id: 'default_dark',
name: _(msg({context: 'Name of app icon variant', message: 'Dark'})),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_default_dark.png`)
return require(
`../../../../assets/app-icons/ios_icon_default_dark.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_default_dark.png`)
return require(
`../../../../assets/app-icons/android_icon_default_dark.png`,
)
},
},
] satisfies AppIconSet[]
@@ -39,10 +47,14 @@ export function useAppIconSets() {
id: 'core_aurora',
name: _(msg({context: 'Name of app icon variant', message: 'Aurora'})),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_aurora.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_aurora.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_aurora.png`)
return require(
`../../../../assets/app-icons/android_icon_core_aurora.png`,
)
},
},
// {
@@ -59,20 +71,28 @@ export function useAppIconSets() {
id: 'core_sunrise',
name: _(msg({context: 'Name of app icon variant', message: 'Sunrise'})),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_sunrise.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_sunrise.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_sunrise.png`)
return require(
`../../../../assets/app-icons/android_icon_core_sunrise.png`,
)
},
},
{
id: 'core_sunset',
name: _(msg({context: 'Name of app icon variant', message: 'Sunset'})),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_sunset.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_sunset.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_sunset.png`)
return require(
`../../../../assets/app-icons/android_icon_core_sunset.png`,
)
},
},
{
@@ -81,10 +101,14 @@ export function useAppIconSets() {
msg({context: 'Name of app icon variant', message: 'Midnight'}),
),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_midnight.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_midnight.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_midnight.png`)
return require(
`../../../../assets/app-icons/android_icon_core_midnight.png`,
)
},
},
{
@@ -93,10 +117,14 @@ export function useAppIconSets() {
msg({context: 'Name of app icon variant', message: 'Flat Blue'}),
),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_flat_blue.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_flat_blue.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_flat_blue.png`)
return require(
`../../../../assets/app-icons/android_icon_core_flat_blue.png`,
)
},
},
{
@@ -105,10 +133,14 @@ export function useAppIconSets() {
msg({context: 'Name of app icon variant', message: 'Flat White'}),
),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_flat_white.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_flat_white.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_flat_white.png`)
return require(
`../../../../assets/app-icons/android_icon_core_flat_white.png`,
)
},
},
{
@@ -117,10 +149,14 @@ export function useAppIconSets() {
msg({context: 'Name of app icon variant', message: 'Flat Black'}),
),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_flat_black.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_flat_black.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_flat_black.png`)
return require(
`../../../../assets/app-icons/android_icon_core_flat_black.png`,
)
},
},
{
@@ -132,10 +168,14 @@ export function useAppIconSets() {
}),
),
iosImage: () => {
return require(`../../../../assets/app-icons/ios_icon_core_classic.png`)
return require(
`../../../../assets/app-icons/ios_icon_core_classic.png`,
)
},
androidImage: () => {
return require(`../../../../assets/app-icons/android_icon_core_classic.png`)
return require(
`../../../../assets/app-icons/android_icon_core_classic.png`,
)
},
},
] satisfies AppIconSet[]
+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.
@@ -53,11 +53,7 @@ export function ReplyNotificationSettingsScreen({}: Props) {
</Admonition>
</View>
) : (
<PreferenceControls
name="reply"
preference={preferences?.reply}
allowDisableInApp={false}
/>
<PreferenceControls name="reply" preference={preferences?.reply} />
)}
</SettingsList.Container>
</Layout.Content>
@@ -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`)}>
@@ -525,8 +525,8 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
isVerified
? _(msg`Update to ${domain}`)
: dnsPanel
? _(msg`Verify DNS Record`)
: _(msg`Verify Text File`)
? _(msg`Verify DNS Record`)
: _(msg`Verify Text File`)
}
variant="solid"
size="large"
+2 -2
View File
@@ -144,8 +144,8 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
'status' in shadow
? shadow.status
: 'status' in profile
? profile.status
: undefined,
? profile.status
: undefined,
})
}
+44 -30
View File
@@ -10,6 +10,7 @@ import EventEmitter from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry'
import {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {
@@ -130,7 +131,7 @@ export class Convo {
getSnapshot(): ConvoState {
if (!this.snapshot) this.snapshot = this.generateSnapshot()
// logger.debug('Convo: snapshotted', {})
// logger.debug('snapshotted', {})
return this.snapshot
}
@@ -392,7 +393,7 @@ export class Convo {
break
}
logger.debug(`Convo: dispatch '${action.event}'`, {
logger.debug(`dispatch '${action.event}'`, {
id: this.id,
prev: prevStatus,
next: this.status,
@@ -467,13 +468,13 @@ export class Convo {
* Some validation prior to `Ready` status
*/
if (!this.convo) {
throw new Error('Convo: could not find convo')
throw new Error('could not find convo')
}
if (!this.sender) {
throw new Error('Convo: could not find sender in convo')
throw new Error('could not find sender in convo')
}
if (!this.recipients) {
throw new Error('Convo: could not find recipients in convo')
throw new Error('could not find recipients in convo')
}
const userIsDisabled = Boolean(this.sender.chatDisabled)
@@ -484,7 +485,11 @@ export class Convo {
this.dispatch({event: ConvoDispatchEvent.Ready})
}
} catch (e: any) {
logger.error(e, {message: 'Convo: setup failed'})
if (!isNetworkError(e)) {
logger.error('setup failed', {
safeMessage: e.message,
})
}
this.dispatch({
event: ConvoDispatchEvent.Error,
@@ -589,7 +594,11 @@ export class Convo {
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
} catch (e: any) {
logger.error(e, {message: `Convo: failed to refresh convo`})
if (!isNetworkError(e)) {
logger.error(`failed to refresh convo`, {
safeMessage: e.message,
})
}
}
}
@@ -599,7 +608,7 @@ export class Convo {
}
| undefined
async fetchMessageHistory() {
logger.debug('Convo: fetch message history', {})
logger.debug('fetch message history', {})
/*
* If oldestRev is null, we've fetched all history.
@@ -653,7 +662,11 @@ export class Convo {
}
}
} catch (e: any) {
logger.error('Convo: failed to fetch message history')
if (!isNetworkError(e)) {
logger.error('failed to fetch message history', {
safeMessage: e.message,
})
}
this.fetchMessageHistoryError = {
retry: () => {
@@ -802,7 +815,7 @@ export class Convo {
// Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim() && !message.embed) return
logger.debug('Convo: send message', {})
logger.debug('send message', {})
const tempId = nanoid()
@@ -836,7 +849,7 @@ export class Convo {
async processPendingMessages() {
logger.debug(
`Convo: processing messages (${this.pendingMessages.size} remaining)`,
`processing messages (${this.pendingMessages.size} remaining)`,
{},
)
@@ -881,7 +894,6 @@ export class Convo {
// continue queue processing
await this.processPendingMessages()
} catch (e: any) {
logger.error(e, {message: `Convo: failed to send message`})
this.handleSendMessageFailure(e)
this.isProcessingPendingMessages = false
}
@@ -914,21 +926,23 @@ export class Convo {
case 'recipient has disabled incoming messages':
break
default:
logger.warn(
`Convo handleSendMessageFailure could not handle error`,
{
if (!isNetworkError(e)) {
logger.warn(`handleSendMessageFailure could not handle error`, {
status: e.status,
message: e.message,
},
)
})
}
break
}
}
} else {
this.pendingMessageFailure = 'unrecoverable'
logger.error(e, {
message: `Convo handleSendMessageFailure received unknown error`,
})
if (!isNetworkError(e)) {
logger.error(`handleSendMessageFailure received unknown error`, {
safeMessage: e.message,
})
}
}
this.commit()
@@ -944,7 +958,7 @@ export class Convo {
this.commit()
logger.debug(
`Convo: batch retrying ${this.pendingMessages.size} pending messages`,
`batch retrying ${this.pendingMessages.size} pending messages`,
{},
)
@@ -977,18 +991,14 @@ export class Convo {
this.commit()
logger.debug(
`Convo: sent ${this.pendingMessages.size} pending messages`,
{},
)
logger.debug(`sent ${this.pendingMessages.size} pending messages`, {})
} catch (e: any) {
logger.error(e, {message: `Convo: failed to batch retry messages`})
this.handleSendMessageFailure(e)
}
}
async deleteMessage(messageId: string) {
logger.debug('Convo: delete message', {})
logger.debug('delete message', {})
this.deletedMessages.add(messageId)
this.commit()
@@ -1004,7 +1014,11 @@ export class Convo {
)
})
} catch (e: any) {
logger.error(e, {message: `Convo: failed to delete message`})
if (!isNetworkError(e)) {
logger.error(`failed to delete message`, {
safeMessage: e.message,
})
}
this.deletedMessages.delete(messageId)
this.commit()
throw e
@@ -1232,7 +1246,7 @@ export class Convo {
}
try {
logger.info(`Adding reaction ${emoji} to message ${messageId}`)
logger.debug(`Adding reaction ${emoji} to message ${messageId}`)
const {data} = await this.agent.chat.bsky.convo.addReaction(
{messageId, value: emoji, convoId: this.convoId},
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
@@ -1297,7 +1311,7 @@ export class Convo {
}
try {
logger.info(`Removing reaction ${emoji} from message ${messageId}`)
logger.debug(`Removing reaction ${emoji} from message ${messageId}`)
await this.agent.chat.bsky.convo.removeReaction(
{messageId, value: emoji, convoId: this.convoId},
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
+18 -18
View File
@@ -3,6 +3,7 @@ import EventEmitter from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry'
import {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import {
BACKGROUND_POLL_INTERVAL,
@@ -18,7 +19,6 @@ import {
} from '#/state/messages/events/types'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
const LOGGER_CONTEXT = 'MessagesEventBus'
const logger = Logger.create(Logger.Context.DMsAgent)
export class MessagesEventBus {
@@ -91,17 +91,17 @@ export class MessagesEventBus {
}
background() {
logger.debug(`${LOGGER_CONTEXT}: background`, {})
logger.debug(`background`, {})
this.dispatch({event: MessagesEventBusDispatchEvent.Background})
}
suspend() {
logger.debug(`${LOGGER_CONTEXT}: suspend`, {})
logger.debug(`suspend`, {})
this.dispatch({event: MessagesEventBusDispatchEvent.Suspend})
}
resume() {
logger.debug(`${LOGGER_CONTEXT}: resume`, {})
logger.debug(`resume`, {})
this.dispatch({event: MessagesEventBusDispatchEvent.Resume})
}
@@ -228,7 +228,7 @@ export class MessagesEventBus {
break
}
logger.debug(`${LOGGER_CONTEXT}: dispatch '${action.event}'`, {
logger.debug(`dispatch '${action.event}'`, {
id: this.id,
prev: prevStatus,
next: this.status,
@@ -236,7 +236,7 @@ export class MessagesEventBus {
}
private async init() {
logger.debug(`${LOGGER_CONTEXT}: init`, {})
logger.debug(`init`, {})
try {
const response = await networkRetry(2, () => {
@@ -260,9 +260,11 @@ export class MessagesEventBus {
this.dispatch({event: MessagesEventBusDispatchEvent.Ready})
} catch (e: any) {
logger.error(e, {
message: `${LOGGER_CONTEXT}: init failed`,
})
if (!isNetworkError(e)) {
logger.error(`init failed`, {
safeMessage: e.message,
})
}
this.dispatch({
event: MessagesEventBusDispatchEvent.Error,
@@ -324,7 +326,7 @@ export class MessagesEventBus {
this.isPolling = true
// logger.debug(
// `${LOGGER_CONTEXT}: poll`,
// `poll`,
// {
// requestedPollIntervals: Array.from(
// this.requestedPollIntervals.values(),
@@ -370,16 +372,14 @@ export class MessagesEventBus {
}
if (needsEmit) {
try {
this.emitter.emit('event', {type: 'logs', logs: batch})
} catch (e: any) {
logger.error(e, {
message: `${LOGGER_CONTEXT}: process latest events`,
})
}
this.emitter.emit('event', {type: 'logs', logs: batch})
}
} catch (e: any) {
logger.error(e, {message: `${LOGGER_CONTEXT}: poll events failed`})
if (!isNetworkError(e)) {
logger.error(`poll events failed`, {
safeMessage: e.message,
})
}
this.dispatch({
event: MessagesEventBusDispatchEvent.Error,
-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[]
+11 -22
View File
@@ -36,38 +36,27 @@ const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
const LIMIT = 8 // sliced to 6, overfetch to account for moderation
const PINNED_POST_URIS: Record<string, boolean> = {
// 📰 News
'at://did:plc:kkf4naxqmweop7dv4l2iqqf5/app.bsky.feed.post/3lgh27w2ngc2b':
true,
'at://did:plc:kkf4naxqmweop7dv4l2iqqf5/app.bsky.feed.post/3lgh27w2ngc2b': true,
// Gardening
'at://did:plc:5rw2on4i56btlcajojaxwcat/app.bsky.feed.post/3kjorckgcwc27':
true,
'at://did:plc:5rw2on4i56btlcajojaxwcat/app.bsky.feed.post/3kjorckgcwc27': true,
// Web Development Trending
'at://did:plc:m2sjv3wncvsasdapla35hzwj/app.bsky.feed.post/3lfaw445axs22':
true,
'at://did:plc:m2sjv3wncvsasdapla35hzwj/app.bsky.feed.post/3lfaw445axs22': true,
// Anime & Manga EN
'at://did:plc:tazrmeme4dzahimsykusrwrk/app.bsky.feed.post/3knxx2gmkns2y':
true,
'at://did:plc:tazrmeme4dzahimsykusrwrk/app.bsky.feed.post/3knxx2gmkns2y': true,
// 📽️ Film
'at://did:plc:2hwwem55ce6djnk6bn62cstr/app.bsky.feed.post/3llhpzhbq7c2g':
true,
'at://did:plc:2hwwem55ce6djnk6bn62cstr/app.bsky.feed.post/3llhpzhbq7c2g': true,
// PopSky
'at://did:plc:lfdf4srj43iwdng7jn35tjsp/app.bsky.feed.post/3lbblgly65c2g':
true,
'at://did:plc:lfdf4srj43iwdng7jn35tjsp/app.bsky.feed.post/3lbblgly65c2g': true,
// Science
'at://did:plc:hu2obebw3nhfj667522dahfg/app.bsky.feed.post/3kl33otd6ob2s':
true,
'at://did:plc:hu2obebw3nhfj667522dahfg/app.bsky.feed.post/3kl33otd6ob2s': true,
// Birds! 🦉
'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.post/3lbg4r57yk22d':
true,
'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.post/3lbg4r57yk22d': true,
// Astronomy
'at://did:plc:xy2zorw2ys47poflotxthlzg/app.bsky.feed.post/3kyzye4lujs2w':
true,
'at://did:plc:xy2zorw2ys47poflotxthlzg/app.bsky.feed.post/3kyzye4lujs2w': true,
// What's Cooking 🍽️
'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3lfqhgvxbqc2q':
true,
'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3lfqhgvxbqc2q': true,
// BookSky 💙📚 #booksky
'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3kgrm2rw5ww2e':
true,
'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3kgrm2rw5ww2e': true,
}
export type FeedPreviewItem =
+6 -4
View File
@@ -14,7 +14,9 @@ import * as Toast from '#/view/com/util/Toast'
const RQKEY_ROOT = 'notification-settings'
const RQKEY = [RQKEY_ROOT]
export function useNotificationSettingsQuery() {
export function useNotificationSettingsQuery({
enabled,
}: {enabled?: boolean} = {}) {
const agent = useAgent()
return useQuery({
@@ -23,6 +25,7 @@ export function useNotificationSettingsQuery() {
const response = await agent.app.bsky.notification.getPreferences()
return response.data.preferences
},
enabled,
})
}
export function useNotificationSettingsUpdateMutation() {
@@ -33,9 +36,8 @@ export function useNotificationSettingsUpdateMutation() {
mutationFn: async (
update: Partial<AppBskyNotificationDefs.Preferences>,
) => {
const response = await agent.app.bsky.notification.putPreferencesV2(
update,
)
const response =
await agent.app.bsky.notification.putPreferencesV2(update)
return response.data.preferences
},
onMutate: update => {
+5 -5
View File
@@ -14,7 +14,7 @@ import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {truncateAndInvalidate} from '../util'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {CachedFeedPage, FeedPage} from './types'
import {type CachedFeedPage, type FeedPage} from './types'
import {fetchPage} from './util'
const UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -94,8 +94,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
data.event === '30+'
? 30
: data.event === ''
? 0
: parseInt(data.event, 10) || 1,
? 0
: parseInt(data.event, 10) || 1,
}
setNumUnread(data.event)
}
@@ -164,8 +164,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
unreadCount >= 30
? '30+'
: unreadCount === 0
? ''
: String(unreadCount)
? ''
: String(unreadCount)
// track last sync
const now = new Date()
+3 -3
View File
@@ -2,10 +2,10 @@ import React from 'react'
import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
type AppBskyFeedDefs,
AppBskyFeedPostgate,
AtUri,
BskyAgent,
type BskyAgent,
} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -139,7 +139,7 @@ export function usePostgateQuery({postUri}: {postUri: string}) {
staleTime: STALE.SECONDS.THIRTY,
queryKey: createPostgateQueryKey(postUri),
async queryFn() {
return (await getPostgateRecord({agent, postUri})) ?? null
return await getPostgateRecord({agent, postUri}).then(res => res ?? null)
},
})
}
+2 -2
View File
@@ -52,8 +52,8 @@ export function usePostThread({anchor}: {anchor?: string}) {
return view === 'linear'
? LINEAR_VIEW_BELOW
: isWeb && gtPhone
? TREE_VIEW_BELOW_DESKTOP
: TREE_VIEW_BELOW
? TREE_VIEW_BELOW_DESKTOP
: TREE_VIEW_BELOW
}, [view, gtPhone])
const postThreadQueryKey = createPostThreadQueryKey({
+1 -1
View File
@@ -444,7 +444,7 @@ export function buildThread({
const anchorPost = items.at(0)
const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
const skeletonReplies = hasAnchorFromCache
? anchorPost.value.post.replyCount ?? 4
? (anchorPost.value.post.replyCount ?? 4)
: 4
if (!items.length) {
+5 -13
View File
@@ -1,7 +1,7 @@
import {useCallback, useEffect, useState} from 'react'
import {MMKV} from 'react-native-mmkv'
import {Account, Device} from '#/storage/schema'
import {type Account, type Device} from '#/storage/schema'
export * from '#/storage/schema'
@@ -83,18 +83,10 @@ export class Storage<Scopes extends unknown[], Schema> {
}
}
type StorageSchema<T extends Storage<any, any>> = T extends Storage<
any,
infer U
>
? U
: never
type StorageScopes<T extends Storage<any, any>> = T extends Storage<
infer S,
any
>
? S
: never
type StorageSchema<T extends Storage<any, any>> =
T extends Storage<any, infer U> ? U : never
type StorageScopes<T extends Storage<any, any>> =
T extends Storage<infer S, any> ? S : never
/**
* Hook to use a storage instance. Acts like a useState hook, but persists the
+2 -1
View File
@@ -328,7 +328,8 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
/* #/components/Select/index.web.tsx */
.radix-select-content {
box-shadow: 0px 6px 24px -10px rgba(22, 23, 24, 0.25),
box-shadow:
0px 6px 24px -10px rgba(22, 23, 24, 0.25),
0px 6px 12px -12px rgba(22, 23, 24, 0.15);
min-width: var(--radix-select-trigger-width);
max-height: var(--radix-select-content-available-height);
+16 -16
View File
@@ -518,8 +518,8 @@ export const ComposePost = ({
thread.posts.length > 1
? _(msg`Your posts have been published`)
: replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
}, [
_,
@@ -1000,20 +1000,20 @@ function ComposerTopBar({
}),
)
: isThread
? _(
msg({
message: 'Publish posts',
comment:
'Accessibility label for button to publish multiple posts in a thread',
}),
)
: _(
msg({
message: 'Publish post',
comment:
'Accessibility label for button to publish a single post',
}),
)
? _(
msg({
message: 'Publish posts',
comment:
'Accessibility label for button to publish multiple posts in a thread',
}),
)
: _(
msg({
message: 'Publish post',
comment:
'Accessibility label for button to publish a single post',
}),
)
}
variant="solid"
color="primary"
+10 -10
View File
@@ -1,12 +1,12 @@
import React from 'react'
import {
ImageStyle,
type ImageStyle,
Keyboard,
LayoutChangeEvent,
type LayoutChangeEvent,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -14,14 +14,14 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {Dimensions} from '#/lib/media/types'
import {type Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
import {ComposerImage, cropImage} from '#/state/gallery'
import {type ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {PostAction} from '../state/composer'
import {type PostAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -74,8 +74,8 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
altTextControlStyle: isOverflow
? {left: 4, bottom: 4}
: !isMobile && images.length < 3
? {left: 8, top: 8}
: {left: 4, top: 4},
? {left: 8, top: 8}
: {left: 4, top: 4},
imageControlsStyle: {
display: 'flex' as const,
flexDirection: 'row' as const,
@@ -83,8 +83,8 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
...(isOverflow
? {top: 4, right: 4, gap: 4}
: !isMobile && images.length < 3
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
zIndex: 1,
},
imageStyle: {
+21 -16
View File
@@ -1,13 +1,13 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedPostgate,
type AppBskyFeedPostgate,
AppBskyRichtextFacet,
BskyPreferences,
type BskyPreferences,
RichText,
} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {SelfLabel} from '#/lib/moderation'
import {type SelfLabel} from '#/lib/moderation'
import {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {
@@ -15,17 +15,22 @@ import {
postUriToRelativePath,
toBskyAppUrl,
} from '#/lib/strings/url-helpers'
import {ComposerImage, createInitialImages} from '#/state/gallery'
import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {Gif} from '#/state/queries/tenor'
import {type Gif} from '#/state/queries/tenor'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {ComposerOpts} from '#/state/shell/composer'
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {type ComposerOpts} from '#/state/shell/composer'
import {
LinkFacetMatch,
type LinkFacetMatch,
suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util'
import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
import {
createVideoState,
type VideoAction,
videoReducer,
type VideoState,
} from './video'
type ImagesMedia = {
type: 'images'
@@ -514,12 +519,12 @@ export function createComposerState({
text: initText
? initText
: initMention
? insertMentionAt(
`@${initMention}`,
initMention.length + 1,
`${initMention}`,
)
: '',
? insertMentionAt(
`@${initMention}`,
initMention.length + 1,
`${initMention}`,
)
: '',
})
let link: Link | undefined
@@ -209,8 +209,8 @@ function AutocompleteProfileCard({
itemIndex === 0
? styles.firstMention
: itemIndex === totalItems - 1
? styles.lastMention
: undefined,
? styles.lastMention
: undefined,
]}
onPress={onPress}
accessibilityRole="button">
+6 -6
View File
@@ -1,17 +1,17 @@
import React, {useCallback, useEffect, useState} from 'react'
import {
Image,
ImageStyle,
type ImageStyle,
Pressable,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
type FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -21,7 +21,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {colors, s} from '#/lib/styles'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {Text} from '../util/text/Text'
import {ImageSource} from './ImageViewing/@types'
import {type ImageSource} from './ImageViewing/@types'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
export function Lightbox() {
@@ -121,8 +121,8 @@ function LightboxInner({
img.type === 'circle-avi'
? '50%'
: img.type === 'rect-avi'
? '10%'
: 0,
? '10%'
: 0,
} as ImageStyle
}
alt={img.alt}
-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
}
@@ -16,7 +16,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {List, type ListRef} from '#/view/com/util/List'
import {List, type ListProps, type ListRef} from '#/view/com/util/List'
import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {NotificationFeedItem} from './NotificationFeedItem'
@@ -39,7 +39,7 @@ export function NotificationFeed({
scrollElRef?: ListRef
onPressTryAgain?: () => void
onScrolledDownChange: (isScrolledDown: boolean) => void
ListHeaderComponent?: () => JSX.Element
ListHeaderComponent?: ListProps['ListHeaderComponent']
refreshNotifications: () => Promise<void>
}) {
const initialNumToRender = useInitialNumToRender()
+2 -2
View File
@@ -630,8 +630,8 @@ let PostThreadItemLoaded = ({
showChildReplyLine && !isThreadedChild
? 0
: isThreadedChildAdjacentBot
? 4
: 8,
? 4
: 8,
},
]}>
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
+5 -6
View File
@@ -132,12 +132,11 @@ type FeedRow =
key: string
}
export function getItemsForFeedback(feedRow: FeedRow):
| {
item: FeedPostSliceItem
feedContext: string | undefined
reqId: string | undefined
}[] {
export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem
feedContext: string | undefined
reqId: string | undefined
}[] {
if (feedRow.type === 'sliceItem') {
return feedRow.slice.items.map(item => ({
item,
+8 -4
View File
@@ -1,15 +1,19 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
import {
type AppBskyActorDefs,
AppBskyFeedGetAuthorFeed,
AtUri,
} from '@atproto/api'
import {msg as msgLingui, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from '#/lib/hooks/usePalette'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useRemoveFeedMutation} from '#/state/queries/preferences'
import * as Prompt from '#/components/Prompt'
import {EmptyState} from '../util/EmptyState'
@@ -119,7 +123,7 @@ function FeedgenErrorMessage({
[KnownError.FeedTooManyRequests]: _l(
msgLingui`This feed is currently receiving high traffic and is temporarily unavailable. Please try again later.`,
),
}[knownError]),
})[knownError],
[_l, knownError],
)
const [_, uri] = feedDesc.split('|')
+6 -6
View File
@@ -461,12 +461,12 @@ let ProfileMenu = ({
msg`The account will be able to interact with you after unblocking.`,
)
: profile.associated?.labeler
? _(
msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`,
)
: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)
? _(
msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`,
)
: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)
}
onConfirm={blockAccount}
confirmButtonCta={
+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}
+8 -3
View File
@@ -1,6 +1,11 @@
import React, {isValidElement, memo, startTransition, useRef} from 'react'
import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {
type FlatListProps,
StyleSheet,
View,
type ViewProps,
} from 'react-native'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -205,7 +210,7 @@ function ListImpl<ItemT>(
behavior: animated ? 'smooth' : 'instant',
})
},
} as any), // TODO: Better types.
}) as any, // TODO: Better types.
[getScrollableNode],
)
+8 -8
View File
@@ -112,14 +112,14 @@ export const DebugModScreen = ({}: NativeStackScreenProps<
}),
]
: scenario[0] === 'label' && target[0] === 'profile'
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/app.bsky.actor.profile/self`,
}),
]
: undefined,
? [
mock.label({
src: isSelfLabel ? did : undefined,
val: label[0],
uri: `at://${did}/app.bsky.actor.profile/self`,
}),
]
: undefined,
viewer: mock.actorViewerState({
following: isFollowing
? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234`

Some files were not shown because too many files have changed in this diff Show More