Merge branch 'kindgracekind/third-party-feed-feedback' of github.com:kindgracekind/social-app into kindgracekind/third-party-feed-feedback

This commit is contained in:
Grace Kind
2025-07-28 14:54:57 -05:00
129 changed files with 34260 additions and 20473 deletions
@@ -13,6 +13,7 @@ on:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: ubuntu-latest
steps:
+1
View File
@@ -13,6 +13,7 @@ on:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-15
steps:
@@ -20,6 +20,7 @@ on:
jobs:
bundleDeploy:
if: github.repository == 'bluesky-social/social-app'
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
concurrency:
@@ -150,7 +151,7 @@ jobs:
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }}
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -239,7 +240,7 @@ jobs:
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected }}
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app'}}
steps:
- name: Check for EXPO_TOKEN
+202
View File
@@ -0,0 +1,202 @@
---
name: PR Comment Trigger
on:
issue_comment:
types: [created]
# Permissiosn to make comments in the pull request
permissions:
pull-requests: write
actions: write
contents: read
jobs:
handle-comment:
if: github.event.issue.pull_request
runs-on: ubuntu-latest
outputs:
should-deploy: ${{ steps.check-org.outputs.result }}
steps:
- name: Check if bot is mentioned
id: check-mention
env:
COMMENT: ${{ github.event.comment.body }}
run: |
if [[ "$COMMENT" == *"@github-actions"* ]] || \
[[ "$COMMENT" == *"github-actions[bot]"* ]]; then
bot_mentioned=true
else
bot_mentioned=false
fi
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
has_ota=true
else
has_ota=false
fi
if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then
echo "mentioned=true" >> $GITHUB_OUTPUT
else
echo "mentioned=false" >> $GITHUB_OUTPUT
fi
- name: Check organization membership
if: steps.check-mention.outputs.mentioned == 'true'
id: check-org
uses: actions/github-script@v7
with:
script: |
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
const hasAccess = ['admin', 'write'].includes(perm.permission);
console.log(`User has ${perm.permission} access`);
return hasAccess;
} catch(error) {
console.log('User has no repository access');
return false;
}
bundle-deploy:
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
needs: [handle-comment]
if: needs.handle-comment.outputs.should-deploy == 'true'
steps:
- name: Get PR HEAD SHA
id: pr-info
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: ${{ github.event.issue.number }}
});
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
core.setOutput('head-sha', pr.data.head.sha);
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
An OTA deployment has been requested and is now running for `${{ steps.pr-info.outputs.head-sha }}`.
[Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY)
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Type check
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: ✏️ Write environment variables
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "$json" > google-services.json
- name: Setup Sentry vars for build-time injection
id: sentry
run: |
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: 🏗️ Create Bundle
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="testflight" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was successful! You may now apply it by opening the deep link below in your browser:
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was unsuccessful. See action logs for more details.
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+31
View File
@@ -0,0 +1,31 @@
name: Sync to internal repo
on:
push:
branches: [main]
jobs:
sync:
runs-on: ubuntu-latest
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal
- name: Push to internal repo
env:
TOKEN: ${{ steps.app-token.outputs.token }}
run: |
git config user.name "github-actions"
git config user.email "test@users.noreply.github.com"
git config --unset-all http.https://github.com/.extraheader
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force
@@ -32,6 +32,7 @@ describe('getMentionAt', () => {
['@alice hello', 7, undefined],
['alice@alice', 0, undefined],
['alice@alice', 6, undefined],
['hello @alice-com goodbye', 8, 'alice-com'],
]
it.each(cases)(
@@ -72,6 +73,7 @@ describe('insertMentionAt', () => {
['@alice hello', 7, '@alice hello'],
['alice@alice', 0, 'alice@alice'],
['alice@alice', 6, 'alice@alice'],
['hello @alice-com goodbye', 10, 'hello @alice.com goodbye'],
]
it.each(cases)(
+1 -7
View File
@@ -26,12 +26,7 @@ module.exports = function (_config) {
...(IS_DEV || IS_TESTFLIGHT ? [] : []),
]
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
? 'production'
: undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL
const UPDATES_ENABLED = IS_TESTFLIGHT || IS_PRODUCTION
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
@@ -190,7 +185,6 @@ module.exports = function (_config) {
}
: undefined,
checkAutomatically: 'NEVER',
channel: UPDATES_CHANNEL,
},
plugins: [
'expo-video',
+1 -1
View File
@@ -160,7 +160,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
richText.push(
<Link
key={counter}
href={`/tag/${segment.tag.tag}`}
href={`/hashtag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.105.0",
"version": "1.106.0",
"private": true,
"engines": {
"node": ">=20"
@@ -12,7 +12,8 @@
"buildFromSource": [
"expo-notifications",
"expo-haptics",
"expo-media-library"
"expo-media-library",
"expo-image-picker"
]
}
}
+38
View File
@@ -0,0 +1,38 @@
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
index c863fb8..cde8859 100644
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
@@ -101,16 +101,30 @@ internal class MediaHandler(
val fileData = getAdditionalFileData(sourceUri)
val mimeType = getType(context.contentResolver, sourceUri)
+ // Extract basic metadata
+ var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
+ var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
+ val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
+
+ // Android returns the encoded width/height which do not take the display rotation into
+ // account. For videos recorded in portrait mode the encoded dimensions are often landscape
+ // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before
+ // reporting them, so to keep the behaviour consistent across platforms we swap the width
+ // and height when the rotation indicates the video should be displayed in portrait.
+ if (rotation % 180 != 0) {
+ width = height.also { height = width }
+ }
+
return ImagePickerAsset(
type = MediaType.VIDEO,
uri = outputUri.toString(),
- width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
- height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
+ width = width,
+ height = height,
fileName = fileData?.fileName,
fileSize = fileData?.fileSize,
mimeType = mimeType,
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
- rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
+ rotation = rotation,
assetId = sourceUri.getMediaStoreAssetId()
)
} catch (cause: FailedToExtractVideoMetadataException) {
@@ -0,0 +1,5 @@
# Expo Image Picker patch
Cherry-picked https://github.com/expo/expo/pull/37849
Remove when we update to a version that includes this commit.
+10 -36
View File
@@ -1,4 +1,5 @@
import {useCallback, useRef} from 'react'
import {Linking} from 'react-native'
import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
@@ -872,9 +873,9 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
} else {
// @ts-expect-error nested navigators aren't typed -sfn
navigate('MessagesTab', {
screen: 'MessagesConversation',
screen: 'Messages',
params: {
conversation: payload.convoId,
pushToConversation: payload.convoId,
},
})
}
@@ -884,6 +885,13 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
async function handlePushNotificationEntry() {
if (!isNative) return
// deep links take precedence - on android,
// getLastNotificationResponseAsync returns a "notification"
// that is actually a deep link. avoid handling it twice -sfn
if (await Linking.getInitialURL()) {
return
}
/**
* The notification that caused the app to open, if applicable
*/
@@ -1041,39 +1049,6 @@ function reset(): Promise<void> {
}
}
function handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
const [name, params] = router.matchPath(path)
if (isNative) {
if (name === 'Search') {
resetToTab('SearchTab')
} else if (name === 'Notifications') {
resetToTab('NotificationsTab')
} else {
resetToTab('HomeTab')
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
} else {
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
}
let didInit = false
function logModuleInitTime() {
if (didInit) {
@@ -1114,7 +1089,6 @@ function logModuleInitTime() {
export {
FlatNavigator,
handleLink,
navigate,
reset,
resetToTab,
+3
View File
@@ -67,6 +67,9 @@ export const atoms = {
zIndex: 50,
},
overflow_visible: {
overflow: 'visible',
},
overflow_hidden: {
overflow: 'hidden',
},
+10 -6
View File
@@ -1,6 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {
type AppBskyActorDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,9 +12,9 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link, LinkProps} from '#/components/Link'
import {Link, type LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20
@@ -137,9 +141,9 @@ function KnownFollowersInner({
<>
<View
style={[
a.flex_row,
{
height: SIZE,
width: SIZE + (slice.length - 1) * a.gap_md.gap,
},
pressed && {
opacity: 0.5,
@@ -149,15 +153,14 @@ function KnownFollowersInner({
<View
key={prof.did}
style={[
a.absolute,
a.rounded_full,
{
borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor,
width: SIZE + AVI_BORDER * 2,
height: SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i,
marginLeft: i > 0 ? -8 : 0,
},
]}>
<UserAvatar
@@ -165,6 +168,7 @@ function KnownFollowersInner({
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
type={prof.associated?.labeler ? 'labeler' : 'user'}
noBorder
/>
</View>
))}
@@ -62,11 +62,17 @@ export const RepostButton = ({
</Menu.Trigger>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Item
label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
label={
isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: `action`}))
}
testID="repostDropdownRepostBtn"
onPress={onRepost}>
<Menu.ItemText>
{isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
{isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: `action`}))}
</Menu.ItemText>
<Menu.ItemIcon icon={Repost} position="right" />
</Menu.Item>
+9 -3
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
@@ -277,7 +277,13 @@ export function Name({
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
style={[
a.text_md,
a.font_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
@@ -345,7 +351,7 @@ export function Description({
numberOfLines?: number
}) {
const profile = useProfileShadow(profileUnshadowed)
const rt = React.useMemo(() => {
const rt = useMemo(() => {
if (!('description' in profile)) return
const rt = new RichTextApi({text: profile.description || ''})
rt.detectFacetsWithoutResolution()
+45
View File
@@ -0,0 +1,45 @@
import {View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a, useBreakpoints} from '#/alf'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
export function SearchError({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
const {gtMobile} = useBreakpoints()
const pal = usePalette('default')
return (
<Layout.Content>
<View
style={[
a.align_center,
a.gap_4xl,
a.px_xl,
{
paddingVertical: 150,
},
]}>
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
<View
style={[
a.align_center,
{maxWidth: gtMobile ? 394 : 294},
gtMobile ? a.gap_md : a.gap_sm,
]}>
<Text style={[a.font_bold, a.text_lg, a.text_center, a.leading_snug]}>
{title}
</Text>
{children}
</View>
</View>
</Layout.Content>
)
}
+22 -8
View File
@@ -390,6 +390,7 @@ export function CompactVideoPostCard({
if (!AppBskyEmbedVideo.isView(embed)) return null
const likeCount = post?.likeCount ?? 0
const showLikeCount = false
const {thumbnail} = embed
const black = getBlackColor(t)
@@ -410,6 +411,7 @@ export function CompactVideoPostCard({
onPressOut={onPressOut}
style={[
a.flex_col,
t.atoms.shadow_sm,
{
alignItems: undefined,
justifyContent: undefined,
@@ -420,8 +422,10 @@ export function CompactVideoPostCard({
<View
style={[
a.justify_center,
a.rounded_md,
a.rounded_lg,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: black,
aspectRatio: 9 / 16,
@@ -442,6 +446,8 @@ export function CompactVideoPostCard({
a.inset_0,
a.justify_center,
a.align_center,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: 'black',
opacity: 0.2,
@@ -461,8 +467,10 @@ export function CompactVideoPostCard({
<View
style={[
a.justify_center,
a.rounded_md,
a.rounded_lg,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: black,
aspectRatio: 9 / 16,
@@ -475,18 +483,20 @@ export function CompactVideoPostCard({
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0]}>
<View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}>
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
<View
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
style={[a.relative, a.rounded_full, {width: 24, height: 24}]}>
<UserAvatar
type="user"
size={20}
size={24}
avatar={post.author.avatar}
/>
<MediaInsetBorder />
</View>
</View>
{showLikeCount && (
<View
style={[
a.absolute,
@@ -509,13 +519,15 @@ export function CompactVideoPostCard({
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
<Text
style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
</View>
</View>
)}
</View>
</View>
</Hider.Content>
@@ -529,11 +541,13 @@ export function CompactVideoPostCardPlaceholder() {
const black = getBlackColor(t)
return (
<View style={[a.flex_1]}>
<View style={[a.flex_1, t.atoms.shadow_sm]}>
<View
style={[
a.rounded_md,
a.rounded_lg,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{
backgroundColor: black,
aspectRatio: 9 / 16,
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -83,6 +84,7 @@ function Inner({style}: ViewStyleProp & {}) {
label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => {
appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
})}>
contact our moderation team
</InlineLinkText>{' '}
@@ -109,7 +111,12 @@ function Inner({style}: ViewStyleProp & {}) {
size="small"
variant="solid"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => control.open()}>
onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonText>
{hasInitiated ? (
<Trans>Verify again</Trans>
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
@@ -87,7 +88,10 @@ function Inner({
<InlineLinkText
label={_(msg`Go to account settings`)}
to={'/settings/account'}
style={[a.text_sm, a.leading_snug, a.font_bold]}>
style={[a.text_sm, a.leading_snug, a.font_bold]}
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
account settings.
</InlineLinkText>
</Trans>
@@ -5,7 +5,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {logger} from '#/logger'
import {logger} from '#/state/ageAssurance/util'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf'
@@ -45,6 +45,8 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
const {mutate, isPending} = useMutation({
mutationFn: async () => {
logger.metric('ageAssurance:appealDialogSubmit', {})
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
@@ -0,0 +1,141 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button} from '#/components/Button'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleFeedBanner,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleFeedBanner() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
const copy = useAgeAssuranceCopy()
if (!visible) return null
return (
<View
style={[
a.px_lg,
{
paddingVertical: 10,
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
},
]}>
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={() => {
close()
logger.metric('ageAssurance:navigateToSettings', {})
}}
style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 42,
height: 42,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="lg" />
</View>
<View
style={[
a.flex_1,
{
paddingRight: 40,
},
]}>
<View style={{maxWidth: 400}}>
<Text style={[a.leading_snug]}>{copy.banner}</Text>
</View>
</View>
</Link>
<Button
label={_(msg`Don't show again`)}
size="small"
onPress={() => {
close()
logger.metric('ageAssurance:dismissFeedBanner', {})
}}
style={[
a.absolute,
a.justify_center,
a.align_center,
{
top: 0,
bottom: 0,
right: 0,
paddingRight: a.px_md.paddingLeft,
},
]}>
<X
width={20}
fill={select(t.name, {
light: t.palette.primary_600,
dark: t.palette.primary_600,
dim: t.palette.primary_600,
})}
/>
</Button>
</View>
)
}
@@ -1,95 +0,0 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleHeaderButton,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleHeaderButton() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
if (!visible) return null
return (
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={close}>
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.px_sm,
a.pr_sm,
a.rounded_full,
{
paddingVertical: 6,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="sm" />
<Text
style={[
a.font_bold,
a.leading_snug,
{
color: select(t.name, {
light: t.palette.primary_800,
dark: t.palette.primary_800,
dim: t.palette.primary_800,
}),
},
]}>
<Trans>Age Assurance</Trans>
</Text>
</View>
</Link>
)
}
@@ -3,6 +3,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
@@ -37,13 +38,14 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() =>
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
}
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
@@ -1,16 +1,22 @@
import {useState} from 'react'
import {View} from 'react-native'
import {XRPCError} from '@atproto/xrpc'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {validate as validateEmail} from 'email-validator'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
@@ -66,6 +72,7 @@ function Inner() {
const {lastInitiatedAt} = useAgeAssuranceContext()
const getTimeAgo = useGetTimeAgo()
const tlds = useTLDs()
const createSupportLink = useCreateSupportLink()
const wasRecentlyInitiated =
lastInitiatedAt &&
@@ -79,7 +86,7 @@ function Inner() {
const [language, setLanguage] = useState<string | undefined>(
convertToKWSSupportedLanguage(langPrefs.appLanguage),
)
const [error, setError] = useState<string>('')
const [error, setError] = useState<React.ReactNode>(null)
const {mutateAsync: init, isPending} = useInitAgeAssurance()
@@ -109,6 +116,8 @@ function Inner() {
const onSubmit = async () => {
setLanguageError(false)
logger.metric('ageAssurance:initDialogSubmit', {})
try {
const {status} = runEmailValidation()
@@ -125,23 +134,42 @@ function Inner() {
setSuccess(true)
} catch (e) {
const {clean, raw} = cleanError(e)
let error: React.ReactNode = _(
msg`Something went wrong, please try again`,
)
if (clean) {
setError(clean || _(msg`Something went wrong, please try again`))
} else {
let message = _(msg`Something went wrong, please try again`)
if (raw) {
if (raw.startsWith('This email address is not supported')) {
message = _(
if (e instanceof XRPCError) {
if (e.error === 'InvalidEmail') {
error = _(
msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
)
logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
} else if (e.error === 'DidTooLong') {
error = (
<>
<Trans>
We're having issues initializing the age assurance process for
your account. Please{' '}
<InlineLinkText
to={createSupportLink({code: SupportCode.AA_DID, email})}
label={_(msg`Contact support`)}>
contact support
</InlineLinkText>{' '}
for assistance.
</Trans>
</>
)
logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
} else {
logger.metric('ageAssurance:initDialogError', {code: 'other'})
}
} else {
const {clean, raw} = cleanError(e)
error = clean || raw || error
logger.metric('ageAssurance:initDialogError', {code: 'other'})
}
setError(message)
}
setError(error)
}
}
@@ -166,7 +194,7 @@ function Inner() {
<>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
We use{' '}
We have partnered with{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
@@ -176,8 +204,11 @@ function Inner() {
KWS
</InlineLinkText>{' '}
to verify that you’re an adult. When you click "Begin" below,
KWS will email you instructions for verifying your age. When
you’re done, you'll be brought back to continue using Bluesky.
KWS will check if you have previously verified your age using
this email address for other games/services powered by KWS
technology. If not, KWS will email you instructions for
verifying your age. When you’re done, you'll be brought back
to continue using Bluesky.
</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug]}>
@@ -7,12 +7,14 @@ import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {isNative} from '#/platform/detection'
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -65,7 +67,7 @@ export function AgeAssuranceRedirectDialog() {
// Dialog.useAutoOpen(control.control, 3e3)
return (
<Dialog.Outer control={control.control}>
<Dialog.Outer control={control.control} onClose={() => control.clear()}>
<Dialog.Handle />
<Dialog.ScrollableInner
@@ -85,6 +87,7 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
const unmounted = useRef(false)
const control = useAgeAssuranceRedirectDialogControl()
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
useEffect(() => {
@@ -92,6 +95,8 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {})
wait(
3e3,
retry(
@@ -122,14 +127,16 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
// success! update state
await refreshAgeAssuranceState()
control.clear()
control.control.close()
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
setError(true)
// try a refetch anyway
refreshAgeAssuranceState()
logger.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
@@ -137,6 +144,55 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
}
}, [agent, control, refreshAgeAssuranceState])
if (success) {
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
<SuccessIcon size="sm" fill={t.palette.positive_600} />
<Text style={[a.text_xl, a.font_heavy]}>
<Trans>Success</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
We've confirmed your age assurance status. You can now close this
dialog.
</Trans>
</Text>
{isNative && (
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => control.control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
<Dialog.Close />
</>
)
}
return (
<>
<View style={[a.align_start, a.w_full]}>
@@ -169,8 +225,8 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
</Trans>
) : (
<Trans>
We're confirming your status with our servers. This dialog should
close in a few seconds.
We're confirming your age assurance status with our servers. This
should only take a few seconds.
</Trans>
)}
</Text>
@@ -181,7 +237,8 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary">
color="secondary"
onPress={() => control.control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -61,13 +62,11 @@ export function AgeRestrictedScreen({
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_heavy]}>
<Trans>
You must verify your age in order to access this screen.
You must complete age assurance in order to access this screen.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>{copy.notice}</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text>
</View>
<View
@@ -77,7 +76,10 @@ export function AgeRestrictedScreen({
to="/settings/account"
size="small"
variant="solid"
color="primary">
color="primary"
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
<ButtonText>
<Trans>Go to account settings</Trans>
</ButtonText>
@@ -8,10 +8,13 @@ export function useAgeAssuranceCopy() {
return useMemo(() => {
return {
notice: _(
msg`The laws in your location require that you verify your age before accessing certain features on Bluesky like adult content and direct messaging.`,
msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
),
chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you've been verified to be 18 or older.`,
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
}, [_])
@@ -1,7 +1,7 @@
import {useCallback, useEffect, useState} from 'react'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useMemo, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
import {useAgent, useSessionApi} from '#/state/session'
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
export type AccountEmailState = {
@@ -11,57 +11,36 @@ export type AccountEmailState = {
export const accountEmailStateQueryKey = ['accountEmailState'] as const
export function useInvalidateAccountEmailState() {
const qc = useQueryClient()
return useCallback(() => {
return qc.invalidateQueries({
queryKey: accountEmailStateQueryKey,
})
}, [qc])
}
export function useUpdateAccountEmailStateQueryCache() {
const qc = useQueryClient()
return useCallback(
(data: AccountEmailState) => {
return qc.setQueriesData(
{
queryKey: accountEmailStateQueryKey,
},
data,
)
},
[qc],
)
}
export function useAccountEmailState() {
const agent = useAgent()
const {partialRefreshSession} = useSessionApi()
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
!!agent.session?.emailConfirmed,
)
const fallbackData: AccountEmailState = {
const state: AccountEmailState = useMemo(
() => ({
isEmailVerified: !!agent.session?.emailConfirmed,
email2FAEnabled: !!agent.session?.emailAuthFactor,
}
const query = useQuery<AccountEmailState>({
}),
[agent.session],
)
/**
* Only here to refetch on focus, when necessary
*/
useQuery({
enabled: !!agent.session,
refetchOnWindowFocus: true,
/**
* Only refetch if the email verification s incomplete.
*/
refetchOnWindowFocus: !prevIsEmailVerified,
queryKey: accountEmailStateQueryKey,
queryFn: async () => {
// will also trigger updates to `#/state/session` data
const {data} = await agent.resumeSession(agent.session!)
return {
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
}
await partialRefreshSession()
return null
},
})
const state = query.data ?? fallbackData
/*
* This will emit `n` times for each instance of this hook. So the listeners
* all use `once` to prevent multiple handlers firing.
@@ -1,13 +1,10 @@
import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
export function useConfirmEmail() {
const agent = useAgent()
const {currentAccount} = useSession()
const updateAccountEmailStateQueryCache =
useUpdateAccountEmailStateQueryCache()
return useMutation({
mutationFn: async ({token}: {token: string}) => {
@@ -19,11 +16,8 @@ export function useConfirmEmail() {
email: currentAccount.email,
token: token.trim(),
})
const {data} = await agent.resumeSession(agent.session!)
updateAccountEmailStateQueryCache({
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
})
// will update session state at root of app
await agent.resumeSession(agent.session!)
},
})
}
@@ -1,13 +1,10 @@
import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
export function useManageEmail2FA() {
const agent = useAgent()
const {currentAccount} = useSession()
const updateAccountEmailStateQueryCache =
useUpdateAccountEmailStateQueryCache()
return useMutation({
mutationFn: async ({
@@ -25,11 +22,8 @@ export function useManageEmail2FA() {
emailAuthFactor: enabled,
token,
})
const {data} = await agent.resumeSession(agent.session!)
updateAccountEmailStateQueryCache({
isEmailVerified: !!data.emailConfirmed,
email2FAEnabled: !!data.emailAuthFactor,
})
// will update session state at root of app
await agent.resumeSession(agent.session!)
},
})
}
@@ -78,10 +78,12 @@ function MenuInner({
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}>
<div onWheel={evt => evt.stopPropagation()}>
<EmojiPicker
onEmojiSelect={handleEmojiPickerResponse}
autoFocus={true}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
) : (
+14 -13
View File
@@ -16,7 +16,6 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
@@ -25,7 +24,7 @@ import {
CompactVideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
const CARD_WIDTH = 100
const CARD_WIDTH = 108
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
const FEED_PARAMS: {
@@ -68,9 +67,10 @@ export function TrendingVideos() {
return (
<View
style={[
a.pt_lg,
a.pt_sm,
a.pb_lg,
a.border_t,
a.overflow_hidden,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
@@ -82,20 +82,17 @@ export function TrendingVideos() {
a.align_center,
a.justify_between,
]}>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
<Graph />
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
<Trans>Trending Videos</Trans>
</Text>
</View>
<Button
label={_(msg`Dismiss this section`)}
size="tiny"
variant="ghost"
variant="solid"
color="secondary"
shape="round"
shape="square"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
<ButtonIcon icon={X} size="sm" />
</Button>
</View>
@@ -104,11 +101,12 @@ export function TrendingVideos() {
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={CARD_WIDTH + a.gap_sm.gap}>
snapToInterval={CARD_WIDTH + a.gap_md.gap}
style={[a.overflow_visible]}>
<View
style={[
a.flex_row,
a.gap_sm,
a.gap_md,
{
paddingLeft: gutters.paddingLeft,
paddingRight: gutters.paddingRight,
@@ -193,8 +191,11 @@ function VideoCards({
a.justify_center,
a.align_center,
a.flex_1,
a.rounded_md,
a.rounded_lg,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.shadow_sm,
]}>
{({pressed}) => (
<View
@@ -1 +1,2 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const SUPPORT_PAGE = 'https://bsky.social/about/support'
@@ -30,7 +30,7 @@ import {createStaticClick, InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useSubmitReportMutation} from './action'
import {DMCA_LINK} from './const'
import {SUPPORT_PAGE} from './const'
import {useCopyForSubject} from './copy'
import {initialState, reducer} from './state'
import {type ReportDialogProps, type ReportSubject} from './types'
@@ -266,9 +266,9 @@ function Inner(props: ReportDialogProps) {
{['post', 'account'].includes(props.subject.type) && (
<Link
to={DMCA_LINK}
to={SUPPORT_PAGE}
label={_(
msg`View details for reporting a copyright violation`,
msg`Need to report a copyright violation, legal request, or regulatory compliance issue?`,
)}>
{({hovered, pressed}) => (
<View
@@ -285,7 +285,10 @@ function Inner(props: ReportDialogProps) {
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.flex_1, a.italic, a.leading_snug]}>
<Trans>Need to report a copyright violation?</Trans>
<Trans>
Need to report a copyright violation, legal
request, or regulatory compliance issue?
</Trans>
</Text>
<SquareArrowTopRight
size="sm"
+3 -3
View File
@@ -1,9 +1,9 @@
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {CustomFeedAPI} from './custom'
import {FollowingFeedAPI} from './following'
import {FeedAPI, FeedAPIResponse} from './types'
import {type FeedAPI, type FeedAPIResponse} from './types'
// HACK
// the feed API does not include any facilities for passing down
@@ -93,7 +93,7 @@ export class HomeFeedAPI implements FeedAPI {
}
}
if (this.usingDiscover) {
if (this.usingDiscover && !__DEV__) {
const res = await this.discover.fetch({cursor, limit})
returnCursor = res.cursor
posts = posts.concat(res.feed)
@@ -1,5 +1,5 @@
import React from 'react'
import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
import {type ColorValue, Dimensions, StyleSheet, View} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
clamp,
@@ -114,11 +114,16 @@ export function GestureActionView({
},
)
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10])
// NOTE(haileyok):
// Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
// reanimated doesn't offer great support for disabling y/x axes :/
.activeOffsetY([-200, 200])
const effectivelyDisabledOffset = 200
const panGesture = Gesture.Pan()
.activeOffsetX([
actions.leftFirst ? -10 : -effectivelyDisabledOffset,
actions.rightFirst ? 10 : effectivelyDisabledOffset,
])
.activeOffsetY([-effectivelyDisabledOffset, effectivelyDisabledOffset])
.onStart(() => {
'worklet'
isActive.set(true)
+39
View File
@@ -0,0 +1,39 @@
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
export const ZENDESK_SUPPORT_URL =
'https://blueskyweb.zendesk.com/hc/requests/new'
export enum SupportCode {
AA_DID = 'AA_DID',
}
/**
* {@link https://support.zendesk.com/hc/en-us/articles/4408839114522-Creating-pre-filled-ticket-forms}
*/
export function useCreateSupportLink() {
const {_} = useLingui()
const {currentAccount} = useSession()
return useCallback(
({code, email}: {code: SupportCode; email?: string}) => {
const url = new URL(ZENDESK_SUPPORT_URL)
if (currentAccount) {
url.search = new URLSearchParams({
tf_anonymous_requester_email: email || currentAccount.email || '', // email will be defined
tf_description:
`[Code: ${code}] — ` + _(msg`Please write your message below:`),
/**
* Custom field specific to {@link ZENDESK_SUPPORT_URL} form
*/
tf_17205412673421: currentAccount.handle + ` (${currentAccount.did})`,
}).toString()
}
return url.toString()
},
[_, currentAccount],
)
}
+15 -3
View File
@@ -1,8 +1,9 @@
import React from 'react'
import {Alert} from 'react-native'
import * as Linking from 'expo-linking'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {useCloseAllActiveElements} from '#/state/util'
@@ -12,8 +13,9 @@ import {
} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
type IntentType = 'compose' | 'verify-email' | 'age-assurance'
type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
@@ -27,12 +29,13 @@ export function useIntentHandler() {
const ageAssuranceRedirectDialogControl =
useAgeAssuranceRedirectDialogControl()
const {currentAccount} = useSession()
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
logger.metric('deepLink:referrerReceived', {
to: url,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
@@ -92,6 +95,14 @@ export function useIntentHandler() {
}
return
}
case 'apply-ota': {
const channel = params.get('channel')
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
} else {
tryApplyUpdate(channel)
}
}
default: {
return
}
@@ -111,6 +122,7 @@ export function useIntentHandler() {
verifyEmailIntent,
ageAssuranceRedirectDialogControl,
currentAccount,
tryApplyUpdate,
])
}
+107 -29
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {Alert, AppState, AppStateStatus} from 'react-native'
import {Alert, AppState, type AppStateStatus} from 'react-native'
import {nativeBuildVersion} from 'expo-application'
import {
checkForUpdateAsync,
@@ -29,6 +29,98 @@ async function setExtraParams() {
)
}
async function setExtraParamsPullRequest(channel: string) {
await setExtraParamAsync(
isIOS ? 'ios-build-number' : 'android-build-number',
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
// This just ensures it gets passed as a string
`${nativeBuildVersion}`,
)
await setExtraParamAsync('channel', channel)
}
async function updateTestflight() {
await setExtraParams()
const res = await checkForUpdateAsync()
if (res.isAvailable) {
await fetchUpdateAsync()
Alert.alert(
'Update Available',
'A new version of the app is available. Relaunch now?',
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
},
},
],
)
}
}
export function useApplyPullRequestOTAUpdate() {
const {currentlyRunning} = useUpdates()
const [pending, setPending] = React.useState(false)
const currentChannel = currentlyRunning?.channel
const isCurrentlyRunningPullRequestDeployment =
currentChannel?.startsWith('pull-request')
const tryApplyUpdate = async (channel: string) => {
setPending(true)
await setExtraParamsPullRequest(channel)
const res = await checkForUpdateAsync()
if (res.isAvailable) {
Alert.alert(
'Deployment Available',
`A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`,
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await fetchUpdateAsync()
await reloadAsync()
},
},
],
)
} else {
Alert.alert(
'No Deployment Available',
`No new deployments of ${channel} are currently available for your current native build.`,
)
}
setPending(false)
}
const revertToEmbedded = async () => {
try {
await updateTestflight()
} catch (e: any) {
logger.error('Internal OTA Update Error', {error: `${e}`})
}
}
return {
tryApplyUpdate,
revertToEmbedded,
isCurrentlyRunningPullRequestDeployment,
currentChannel,
pending,
}
}
export function useOTAUpdates() {
const shouldReceiveUpdates = isEnabled && !__DEV__
@@ -36,7 +128,8 @@ export function useOTAUpdates() {
const lastMinimize = React.useRef(0)
const ranInitialCheck = React.useRef(false)
const timeout = React.useRef<NodeJS.Timeout>()
const {isUpdatePending} = useUpdates()
const {currentlyRunning, isUpdatePending} = useUpdates()
const currentChannel = currentlyRunning?.channel
const setCheckTimeout = React.useCallback(() => {
timeout.current = setTimeout(async () => {
@@ -60,36 +153,18 @@ export function useOTAUpdates() {
const onIsTestFlight = React.useCallback(async () => {
try {
await setExtraParams()
const res = await checkForUpdateAsync()
if (res.isAvailable) {
await fetchUpdateAsync()
Alert.alert(
'Update Available',
'A new version of the app is available. Relaunch now?',
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
},
},
],
)
}
await updateTestflight()
} catch (e: any) {
logger.error('Internal OTA Update Error', {error: `${e}`})
}
}, [])
React.useEffect(() => {
// We don't need to check anything if the current update is a PR update
if (currentChannel?.startsWith('pull-request')) {
return
}
// We use this setTimeout to allow Statsig to initialize before we check for an update
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
@@ -103,12 +178,15 @@ export function useOTAUpdates() {
setCheckTimeout()
ranInitialCheck.current = true
}, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
}, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates])
// After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
// or B check for an update again.
React.useEffect(() => {
if (!isEnabled) return
// We also don't start this timeout if the user is on a pull request update
if (!isEnabled || currentChannel?.startsWith('pull-request')) {
return
}
const subscription = AppState.addEventListener(
'change',
@@ -138,5 +216,5 @@ export function useOTAUpdates() {
clearTimeout(timeout.current)
subscription.remove()
}
}, [isUpdatePending, setCheckTimeout])
}, [isUpdatePending, currentChannel, setCheckTimeout])
}
+9
View File
@@ -1 +1,10 @@
export function useOTAUpdates() {}
export function useApplyPullRequestOTAUpdate() {
return {
tryApplyUpdate: () => {},
revertToEmbedded: () => {},
isCurrentlyRunningPullRequestDeployment: false,
currentChannel: 'web-build',
pending: false,
}
}
+17 -11
View File
@@ -1,4 +1,4 @@
import {BskyAgent} from '@atproto/api'
import {type BskyAgent} from '@atproto/api'
import {LINK_META_PROXY} from '#/lib/constants'
import {getGiphyMetaUri} from '#/lib/strings/embed-player'
@@ -37,6 +37,7 @@ export async function getLinkMeta(
}
let urlp
let shouldFollowRedirect = false
try {
urlp = new URL(url)
@@ -46,6 +47,9 @@ export async function getLinkMeta(
url = giphyMetaUri
urlp = new URL(url)
}
// follow redirects for soundcloud shortlinks
// QUESTION - do we want to follow redirects in other cases? -sfn
shouldFollowRedirect = urlp.hostname === 'on.soundcloud.com'
} catch (e) {
return {
error: 'Invalid URL',
@@ -62,33 +66,35 @@ export async function getLinkMeta(
return meta
}
try {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), timeout || 5e3)
try {
const response = await fetch(
`${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
`${LINK_META_PROXY(agent.serviceUrl.toString() || '')}${encodeURIComponent(
url,
)}`,
{signal: controller.signal},
)
const body = await response.json()
clearTimeout(to)
const {description, error, image, title} = body
if (error !== '') {
throw new Error(error)
if (body.error !== '') {
throw new Error(body.error)
}
meta.description = description
meta.image = image
meta.title = title
meta.description = body.description
meta.image = body.image
meta.title = body.title
if (shouldFollowRedirect) {
meta.url = body.url
}
} catch (e) {
// failed
console.error(e)
meta.error = e instanceof Error ? e.toString() : 'Failed to fetch link'
} finally {
clearTimeout(to)
}
return meta
@@ -1,3 +1,5 @@
import {useCallback} from 'react'
export function useNotificationsRegistration() {}
export function useRequestNotificationsPermission() {
@@ -6,6 +8,10 @@ export function useRequestNotificationsPermission() {
) => {}
}
export function useGetAndRegisterPushToken() {
return useCallback(async ({}: {} = {}) => {}, [])
}
export async function decrementBadgeCount(_by: number) {}
export async function resetBadgeCount() {}
+3
View File
@@ -192,6 +192,9 @@ export function useNotificationsRegistration() {
* Register the push token with the Bluesky server, whenever it changes.
* This is also fired any time `getDevicePushTokenAsync` is called.
*
* Since this is registered immediately after `getAndRegisterPushToken`, it
* should also detect that getter and be fired almost immediately after this.
*
* 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.
-1
View File
@@ -1,6 +1,5 @@
export type Gate =
// Keep this alphabetic please.
| 'age_assurance'
| 'alt_share_icon'
| 'debug_show_feedcontext'
| 'debug_subscriptions'
+3
View File
@@ -8,6 +8,7 @@ import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import packageDotJson from '../../../package.json'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
@@ -25,6 +26,7 @@ type StatsigUser = {
// This is the place where we can add our own stuff.
// Fields here have to be non-optional to be visible in the UI.
platform: 'ios' | 'android' | 'web'
appVersion: string
bundleIdentifier: string
bundleDate: number
refSrc: string
@@ -210,6 +212,7 @@ function toStatsigUser(did: string | undefined): StatsigUser {
refSrc,
refUrl,
platform: Platform.OS as 'ios' | 'android' | 'web',
appVersion: packageDotJson.version,
bundleIdentifier: BUNDLE_IDENTIFIER,
bundleDate: BUNDLE_DATE,
appLanguage: languagePrefs.appLanguage,
+1 -1
View File
@@ -7,7 +7,7 @@ export function getMentionAt(
text: string,
cursorPos: number,
): FoundMention | undefined {
let re = /(^|\s)@([a-z0-9.]*)/gi
let re = /(^|\s)@([a-z0-9.-]*)/gi
let match
while ((match = re.exec(text))) {
const spaceOffset = match[1].length
+1 -2
View File
@@ -45,8 +45,7 @@ function getLocalizedLanguage(
const translatedName = allNames.of(langCode)
if (translatedName) {
// force simple title case (as languages do not always start with an uppercase in Unicode data)
return translatedName[0].toLocaleUpperCase() + translatedName.slice(1)
return translatedName
}
} catch (e) {
// ignore RangeError from Intl.DisplayNames APIs
+17 -17
View File
@@ -55,45 +55,45 @@ interface AppLanguageConfig {
export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.en, name: 'English'},
{code2: AppLanguage.an, name: 'Aragonés – Aragonese'},
{code2: AppLanguage.ast, name: 'Asturianu – Asturian'},
{code2: AppLanguage.ca, name: 'Català – Catalan'},
{code2: AppLanguage.an, name: 'aragonés – Aragonese'},
{code2: AppLanguage.ast, name: 'asturianu – Asturian'},
{code2: AppLanguage.ca, name: 'català – Catalan'},
{code2: AppLanguage.cy, name: 'Cymraeg – Welsh'},
{code2: AppLanguage.da, name: 'Dansk – Danish'},
{code2: AppLanguage.da, name: 'dansk – Danish'},
{code2: AppLanguage.de, name: 'Deutsch – German'},
{code2: AppLanguage.el, name: 'Ελληνικά – Greek'},
{code2: AppLanguage.en_GB, name: 'English (UK)'},
{code2: AppLanguage.en_GB, name: 'British English'},
{code2: AppLanguage.eo, name: 'Esperanto'},
{code2: AppLanguage.es, name: 'Español – Spanish'},
{code2: AppLanguage.eu, name: 'Euskera – Basque'},
{code2: AppLanguage.fi, name: 'Suomi – Finnish'},
{code2: AppLanguage.fr, name: 'Français – French'},
{code2: AppLanguage.fy, name: 'Frysk – Frisian'},
{code2: AppLanguage.es, name: 'español – Spanish'},
{code2: AppLanguage.eu, name: 'euskara – Basque'},
{code2: AppLanguage.fi, name: 'suomi – Finnish'},
{code2: AppLanguage.fr, name: 'français – French'},
{code2: AppLanguage.fy, name: 'Frysk – Western Frisian'},
{code2: AppLanguage.ga, name: 'Gaeilge – Irish'},
{code2: AppLanguage.gd, name: 'Gàidhlig – Scottish Gaelic'},
{code2: AppLanguage.gl, name: 'Galego – Galician'},
{code2: AppLanguage.gl, name: 'galego – Galician'},
{code2: AppLanguage.hi, name: 'हिंदी – Hindi'},
{code2: AppLanguage.hu, name: 'magyar – Hungarian'},
{code2: AppLanguage.ia, name: 'Interlingua'},
{code2: AppLanguage.id, name: 'Bahasa Indonesia – Indonesian'},
{code2: AppLanguage.it, name: 'Italiano – Italian'},
{code2: AppLanguage.it, name: 'italiano – Italian'},
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
{code2: AppLanguage.km, name: 'ភាសាខ្មែរ – Khmer'},
{code2: AppLanguage.ko, name: '한국어 – Korean'},
{code2: AppLanguage.ne, name: 'नेपाली – Nepali'},
{code2: AppLanguage.nl, name: 'Nederlands – Dutch'},
{code2: AppLanguage.pl, name: 'Polski – Polish'},
{code2: AppLanguage.pl, name: 'polski – Polish'},
{
code2: AppLanguage.pt_BR,
name: 'português do Brasil – Brazilian Portuguese',
},
{code2: AppLanguage.pt_PT, name: 'português europeu – European Portuguese'},
{code2: AppLanguage.ro, name: 'Română – Romanian'},
{code2: AppLanguage.ru, name: 'Русский – Russian'},
{code2: AppLanguage.sv, name: 'Svenska – Swedish'},
{code2: AppLanguage.ro, name: 'română – Romanian'},
{code2: AppLanguage.ru, name: 'русский – Russian'},
{code2: AppLanguage.sv, name: 'svenska – Swedish'},
{code2: AppLanguage.th, name: 'ภาษาไทย – Thai'},
{code2: AppLanguage.tr, name: 'Türkçe – Turkish'},
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
{code2: AppLanguage.uk, name: 'українська – Ukrainian'},
{code2: AppLanguage.vi, name: 'Tiếng Việt – Vietnamese'},
{code2: AppLanguage.zh_CN, name: '简体中文 – Simplified Chinese'},
{code2: AppLanguage.zh_TW, name: '繁體中文 – Traditional Chinese'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16
View File
@@ -457,4 +457,20 @@ export type MetricEvents = {
name: string
value: string
}
'ageAssurance:navigateToSettings': {}
'ageAssurance:dismissFeedBanner': {}
'ageAssurance:dismissSettingsNotice': {}
'ageAssurance:initDialogOpen': {
hasInitiatedPreviously: boolean
}
'ageAssurance:initDialogSubmit': {}
'ageAssurance:initDialogError': {
code: string
}
'ageAssurance:redirectDialogOpen': {}
'ageAssurance:redirectDialogSuccess': {}
'ageAssurance:redirectDialogFail': {}
'ageAssurance:appealDialogOpen': {}
'ageAssurance:appealDialogSubmit': {}
}
+17 -3
View File
@@ -164,12 +164,18 @@ export function MessagesScreenInner({navigation, route}: Props) {
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
const hasInboxRequests = inboxPreviewConvos?.length > 0
return [
...(hasInboxRequests
? [
{
type: 'INBOX',
type: 'INBOX' as const,
count: inboxPreviewConvos.length,
profiles: inboxPreviewConvos.slice(0, 3),
},
]
: []),
...conversations.map(
convo => ({type: 'CONVERSATION', conversation: convo}) as const,
),
@@ -223,16 +229,24 @@ export function MessagesScreenInner({navigation, route}: Props) {
return listenSoftReset(onSoftReset)
}, [onSoftReset, isScreenFocused])
// Will always have 1 item - the inbox button
if (conversations.length < 2) {
// NOTE(APiligrim)
// Show empty state only if there are no conversations at all
const actualConversations = conversations.filter(
item => item.type === 'CONVERSATION',
)
const hasInboxRequests = inboxPreviewConvos?.length > 0
if (actualConversations.length === 0) {
return (
<Layout.Screen>
<Header newChatControl={newChatControl} />
<Layout.Center>
{hasInboxRequests && (
<InboxPreview
count={inboxPreviewConvos.length}
profiles={inboxPreviewConvos}
/>
)}
{isLoading ? (
<ChatListLoadingPlaceholder />
) : (

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