Merge branch 'kindgracekind/third-party-feed-feedback' of github.com:kindgracekind/social-app into kindgracekind/third-party-feed-feedback
This commit is contained in:
@@ -13,6 +13,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) 🤖*
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -67,6 +67,9 @@ export const atoms = {
|
||||
zIndex: 50,
|
||||
},
|
||||
|
||||
overflow_visible: {
|
||||
overflow: 'visible',
|
||||
},
|
||||
overflow_hidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,47 +483,51 @@ 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>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.pt_2xl,
|
||||
{
|
||||
top: 'auto',
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[black, 'rgba(0, 0, 0, 0)']}
|
||||
locations={[0.02, 1]}
|
||||
start={{x: 0, y: 1}}
|
||||
end={{x: 0, y: 0}}
|
||||
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
|
||||
/>
|
||||
|
||||
{showLikeCount && (
|
||||
<View
|
||||
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
|
||||
{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'}]}>
|
||||
{formatCount(i18n, likeCount)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.pt_2xl,
|
||||
{
|
||||
top: 'auto',
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[black, 'rgba(0, 0, 0, 0)']}
|
||||
locations={[0.02, 1]}
|
||||
start={{x: 0, y: 1}}
|
||||
end={{x: 0, y: 0}}
|
||||
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
|
||||
{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'}]}>
|
||||
{formatCount(i18n, likeCount)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</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 = _(
|
||||
msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
|
||||
)
|
||||
}
|
||||
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'})
|
||||
}
|
||||
|
||||
setError(message)
|
||||
} else {
|
||||
const {clean, raw} = cleanError(e)
|
||||
error = clean || raw || error
|
||||
logger.metric('ageAssurance:initDialogError', {code: 'other'})
|
||||
}
|
||||
|
||||
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 = {
|
||||
isEmailVerified: !!agent.session?.emailConfirmed,
|
||||
email2FAEnabled: !!agent.session?.emailAuthFactor,
|
||||
}
|
||||
const query = useQuery<AccountEmailState>({
|
||||
const state: AccountEmailState = useMemo(
|
||||
() => ({
|
||||
isEmailVerified: !!agent.session?.emailConfirmed,
|
||||
email2FAEnabled: !!agent.session?.emailAuthFactor,
|
||||
}),
|
||||
[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}}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiPickerResponse}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<div onWheel={evt => evt.stopPropagation()}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiPickerResponse}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
) : (
|
||||
|
||||
@@ -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]}>
|
||||
<Trans>Trending Videos</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Trending Videos</Trans>
|
||||
</Text>
|
||||
<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"
|
||||
|
||||
@@ -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({
|
||||
},
|
||||
)
|
||||
|
||||
// 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 :/
|
||||
const effectivelyDisabledOffset = 200
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-10, 10])
|
||||
// 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])
|
||||
.activeOffsetX([
|
||||
actions.leftFirst ? -10 : -effectivelyDisabledOffset,
|
||||
actions.rightFirst ? 10 : effectivelyDisabledOffset,
|
||||
])
|
||||
.activeOffsetY([-effectivelyDisabledOffset, effectivelyDisabledOffset])
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
isActive.set(true)
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export function useOTAUpdates() {}
|
||||
export function useApplyPullRequestOTAUpdate() {
|
||||
return {
|
||||
tryApplyUpdate: () => {},
|
||||
revertToEmbedded: () => {},
|
||||
isCurrentlyRunningPullRequestDeployment: false,
|
||||
currentChannel: 'web-build',
|
||||
pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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() {}
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'age_assurance'
|
||||
| 'alt_share_icon'
|
||||
| 'debug_show_feedcontext'
|
||||
| 'debug_subscriptions'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'},
|
||||
|
||||
+712
-427
File diff suppressed because it is too large
Load Diff
+710
-425
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+710
-425
File diff suppressed because it is too large
Load Diff
+710
-425
File diff suppressed because it is too large
Load Diff
+713
-428
File diff suppressed because it is too large
Load Diff
+750
-465
File diff suppressed because it is too large
Load Diff
+718
-433
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+425
-377
File diff suppressed because it is too large
Load Diff
+728
-443
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+711
-426
File diff suppressed because it is too large
Load Diff
+713
-428
File diff suppressed because it is too large
Load Diff
+718
-433
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+710
-425
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+717
-432
File diff suppressed because it is too large
Load Diff
+710
-425
File diff suppressed because it is too large
Load Diff
+711
-426
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
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
+804
-519
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+723
-438
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+727
-444
File diff suppressed because it is too large
Load Diff
+712
-427
File diff suppressed because it is too large
Load Diff
+712
-427
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
@@ -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': {}
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
type: 'INBOX',
|
||||
count: inboxPreviewConvos.length,
|
||||
profiles: inboxPreviewConvos.slice(0, 3),
|
||||
},
|
||||
...(hasInboxRequests
|
||||
? [
|
||||
{
|
||||
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>
|
||||
<InboxPreview
|
||||
count={inboxPreviewConvos.length}
|
||||
profiles={inboxPreviewConvos}
|
||||
/>
|
||||
{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
Reference in New Issue
Block a user