Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d942e7615 | |||
| 70dbc94766 | |||
| 118d385b10 | |||
| 54e5ea0aff | |||
| 555dd6a4db | |||
| 1fe94ba290 | |||
| e27134c7f1 | |||
| b1550f9c02 | |||
| dba0bd8379 | |||
| a770f5635b | |||
| 6e80b340c8 | |||
| 0a7734f9ed | |||
| 8a602bf9f6 | |||
| 3f47c8cf06 | |||
| aa3ee8db4e | |||
| 8fdefb7e94 | |||
| 4aaf81fedd | |||
| b3c3ee5541 | |||
| 13884b0a1c | |||
| 873ba16d2d | |||
| d5dc065a83 | |||
| 0278c7a0b4 | |||
| a091def512 | |||
| b800334ebc | |||
| 9363ff6f48 | |||
| 35daa5a343 | |||
| 03c50f07a1 | |||
| de7b63e67d | |||
| 0ac15920a4 | |||
| f1e44ee12e | |||
| cf6c8dc331 | |||
| 719d7b7a57 | |||
| 4f316538fb | |||
| faab49bd9e | |||
| fb8ab9f26d | |||
| fa0b1bc1bb | |||
| 16462b08a7 | |||
| c5a22ffd97 | |||
| f46336e341 | |||
| 32c2b69b84 | |||
| 9ae0cf60af | |||
| 2ff5f2fb9b | |||
| 62c4293adc | |||
| ff7407c8e2 | |||
| d899d09e60 | |||
| 2e73464fd1 | |||
| f8bd850465 | |||
| 238b00d193 | |||
| bf69672674 | |||
| 5371114609 | |||
| 4c0213b3e9 | |||
| 02a05d9948 | |||
| 4c142a86b4 | |||
| f3153b8acb | |||
| a59fb91a99 | |||
| c8568e3004 | |||
| 09111ef272 | |||
| b9ed94c7f4 | |||
| 872fcd1d20 | |||
| d496b5bdbf | |||
| 4879a07701 | |||
| 28876a4b23 | |||
| 1e8a96e297 | |||
| 815a1e4c44 |
@@ -34,6 +34,7 @@ module.exports = {
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Span',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
|
||||
- name: 🏗️ Build Production APK
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
|
||||
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
|
||||
|
||||
- name: 🚀 Upload Production APK Artifact
|
||||
id: upload-artifact-production-apk
|
||||
|
||||
@@ -17,7 +17,6 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
- tapOn: "Allow Full Access"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Porn"
|
||||
- tapOn:
|
||||
|
||||
@@ -28,6 +28,7 @@ appId: xyz.blueskyweb.app
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
- tapOn: "Not now"
|
||||
- inputText: "e2e-test"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
|
||||
@@ -23,4 +23,5 @@ appId: xyz.blueskyweb.app
|
||||
id: "loginPasswordInput"
|
||||
- inputText: "hunter2"
|
||||
- pressKey: Enter
|
||||
- tapOn: "Not now"
|
||||
- assertVisible: "Following"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const pkg = require('./package.json')
|
||||
|
||||
const DARK_SPLASH_ANDROID_BACKGROUND = '#0f141b'
|
||||
|
||||
module.exports = function (config) {
|
||||
module.exports = function (_config) {
|
||||
/**
|
||||
* App version number. Should be incremented as part of a release cycle.
|
||||
*/
|
||||
@@ -140,12 +138,10 @@ module.exports = function (config) {
|
||||
},
|
||||
androidStatusBar: {
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: '#00000000',
|
||||
},
|
||||
// Dark nav bar in light mode is better than light nav bar in dark mode
|
||||
androidNavigationBar: {
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: DARK_SPLASH_ANDROID_BACKGROUND,
|
||||
},
|
||||
android: {
|
||||
icon: './assets/app-icons/android_icon_default_light.png',
|
||||
@@ -197,6 +193,10 @@ module.exports = function (config) {
|
||||
plugins: [
|
||||
'expo-video',
|
||||
'expo-localization',
|
||||
[
|
||||
'react-native-edge-to-edge',
|
||||
{android: {enforceNavigationBarContrast: false}},
|
||||
],
|
||||
USE_SENTRY && [
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
@@ -240,7 +240,7 @@ module.exports = function (config) {
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
'./plugins/withAndroidDayNightThemePlugin.js',
|
||||
'./plugins/withAndroidNoJitpackPlugin.js',
|
||||
'./plugins/withNoBundleCompression.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
@@ -259,6 +259,8 @@ module.exports = function (config) {
|
||||
'./assets/fonts/inter/Inter-SemiBoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-ExtraBold.otf',
|
||||
'./assets/fonts/inter/Inter-ExtraBoldItalic.otf',
|
||||
// Verification
|
||||
'./assets/fonts/verification/Verification.ttf',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -357,16 +359,6 @@ module.exports = function (config) {
|
||||
},
|
||||
],
|
||||
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
|
||||
[
|
||||
'react-native-vision-camera',
|
||||
{
|
||||
enableLocation: false,
|
||||
cameraPermissionText: 'Bluesky needs access to your camera.',
|
||||
enableMicrophonePermission: true,
|
||||
microphonePermissionText:
|
||||
'Bluesky needs access to your microphone.',
|
||||
},
|
||||
],
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "Verification-verifiedCheck": 59905, "Verification-verifiedCheckGray": 59906, "Verification-verifierCheck": 59907, "Verification-verifierCheckGray": 59908}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M20.494 2.13a1 1 0 0 1 .375 1.364l-4.658 8.2.263.095c1.52.554 2.737 2.062 2.484 3.864-.336 2.393-1.358 4.12-3.245 6.047a1 1 0 0 1-1.102.222l-9.5-4a1 1 0 0 1-.166-1.754c1.458-.971 2.623-1.923 3.498-3.3 1.057-1.662 3.154-2.854 5.281-2.08l.58.212 4.827-8.494a1 1 0 0 1 1.363-.375ZM13.04 12.669c-.983-.358-2.19.142-2.91 1.273-.737 1.16-1.628 2.054-2.601 2.828l1.708.72a.2.2 0 0 0 .177-.011l2.13-1.218a.2.2 0 0 1 .29.237l-.551 1.653a.2.2 0 0 0 .112.247l3.353 1.413c1.359-1.494 1.994-2.76 2.23-4.435.094-.675-.359-1.405-1.188-1.707l-2.75-1ZM4.407 7.184a.5.5 0 0 1-.224.224l-1.29.645a.5.5 0 0 0 0 .894l1.29.645a.5.5 0 0 1 .224.224l.645 1.29a.5.5 0 0 0 .894 0l.645-1.29a.5.5 0 0 1 .224-.224l1.29-.645a.5.5 0 0 0 0-.894l-1.29-.645a.5.5 0 0 1-.224-.224l-.645-1.29a.5.5 0 0 0-.894 0l-.645 1.29ZM9.559 3.72a.36.36 0 0 0 .16-.16l.46-.921a.357.357 0 0 1 .64 0l.46.921q.054.106.16.16l.921.46a.357.357 0 0 1 0 .64l-.921.46a.36.36 0 0 0-.16.16l-.46.921a.357.357 0 0 1-.64 0l-.46-.921a.36.36 0 0 0-.16-.16l-.921-.46a.357.357 0 0 1 0-.64l.921-.46Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.633-3.274a1 1 0 0 1 .141 1.407l-4.5 5.5a1 1 0 0 1-1.481.074l-2-2a1 1 0 1 1 1.414-1.414l1.219 1.219 3.8-4.645a1 1 0 0 1 1.407-.141Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.325 2.054a1 1 0 0 0-.65 0l-8 2.75A1 1 0 0 0 3 5.75v6.162c0 2.807 1.149 4.83 2.813 6.405 1.572 1.488 3.632 2.6 5.555 3.636l.157.085a1 1 0 0 0 .95 0l.157-.085c1.923-1.037 3.983-2.148 5.556-3.636C19.85 16.742 21 14.719 21 11.912V5.75a1 1 0 0 0-.675-.946l-8-2.75ZM5 11.912V6.464l7-2.407 7 2.407v5.448c0 2.166-.851 3.687-2.188 4.952-1.276 1.209-2.964 2.158-4.812 3.157-1.848-1-3.536-1.948-4.813-3.157C5.851 15.6 5 14.078 5 11.912Zm10.207-1.205a1 1 0 0 0-1.414-1.414L11 12.086l-.793-.793a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.414 0l3.5-3.5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 685 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 2a1 1 0 0 1 1 1c0 3.188.669 5.256 1.882 6.536C16.084 10.805 18.01 11.5 21 11.5a1 1 0 1 1 0 2c-2.99 0-4.916.695-6.118 1.964C13.67 16.744 13 18.812 13 22a1 1 0 1 1-2 0c0-3.188-.669-5.256-1.882-6.536C7.916 14.195 5.99 13.5 3 13.5a1 1 0 1 1 0-2c2.99 0 4.916-.695 6.118-1.964C10.33 8.256 11 6.188 11 3a1 1 0 0 1 1-1Zm0 6.734a7.6 7.6 0 0 1-1.43 2.178A7.3 7.3 0 0 1 8.349 12.5a7.3 7.3 0 0 1 2.22 1.588A7.6 7.6 0 0 1 12 16.267a7.6 7.6 0 0 1 1.43-2.179 7.3 7.3 0 0 1 2.221-1.588 7.3 7.3 0 0 1-2.22-1.588A7.6 7.6 0 0 1 12 8.734Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 665 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#208BFE"/><path fill="#fff" fill-rule="evenodd" d="M18.311 7.421a1.437 1.437 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L6.42 12.74a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#208BFE" d="M8.792 1.54a4.11 4.11 0 0 1 6.416 0 4.13 4.13 0 0 0 3.146 1.54c2.616.04 4.544 2.5 4 5.1a4.28 4.28 0 0 0 .777 3.462c1.6 2.104.912 5.17-1.427 6.36a4.21 4.21 0 0 0-2.177 2.774c-.62 2.584-3.408 3.948-5.781 2.83a4.1 4.1 0 0 0-3.492 0c-2.373 1.118-5.16-.246-5.78-2.83a4.21 4.21 0 0 0-2.178-2.775c-2.34-1.19-3.028-4.256-1.427-6.36a4.28 4.28 0 0 0 .777-3.46c-.544-2.602 1.384-5.06 4-5.1a4.13 4.13 0 0 0 3.146-1.54Z"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.399a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.435a1.361 1.361 0 1 1 1.925-1.925l2.149 2.15 5.26-5.261a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"preact",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking"
|
||||
],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": "warn",
|
||||
"simple-import-sort/exports": "warn",
|
||||
'no-else-return': 'off'
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": "./bskyembed/tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'simple-import-sort'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'preact',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
],
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'warn',
|
||||
'simple-import-sort/exports': 'warn',
|
||||
'no-else-return': 'off',
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function Embed({
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
||||
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-lg p-2 gap-1.5 w-full flex flex-col">
|
||||
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
||||
<img
|
||||
@@ -207,7 +207,7 @@ export function Embed({
|
||||
|
||||
function Info({children}: {children: ComponentChildren}) {
|
||||
return (
|
||||
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
|
||||
<div className="w-full rounded-xl border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
|
||||
<img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-textLight dark:text-textDimmed">{children}</p>
|
||||
</div>
|
||||
@@ -231,12 +231,12 @@ function ImageEmbed({
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
@@ -249,19 +249,21 @@ function ImageEmbed({
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="flex-[3] object-cover rounded-sm"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 flex-[2]">
|
||||
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
|
||||
<div className="flex-1 aspect-square">
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{content.images.slice(1).map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
className="flex-1 object-cover rounded-sm min-h-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -269,13 +271,13 @@ function ImageEmbed({
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="aspect-square w-full object-cover rounded-sm"
|
||||
className="aspect-[3/2] w-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -308,7 +310,7 @@ function ExternalEmbed({
|
||||
return (
|
||||
<Link
|
||||
href={content.external.uri}
|
||||
className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
|
||||
className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
|
||||
disableTracking>
|
||||
{content.external.thumb && (
|
||||
<img
|
||||
@@ -345,7 +347,7 @@ function GenericWithImageEmbed({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="w-full rounded-lg border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
|
||||
className="w-full rounded-xl border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
{image ? (
|
||||
<img
|
||||
@@ -383,7 +385,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg aspect-square relative"
|
||||
className="w-full overflow-hidden rounded-xl aspect-square relative"
|
||||
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
||||
<img
|
||||
src={content.thumbnail}
|
||||
@@ -412,7 +414,7 @@ function StarterPackEmbed({
|
||||
return (
|
||||
<Link
|
||||
href={starterPackHref}
|
||||
className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
|
||||
className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
|
||||
<img src={imageUri} className="aspect-[1.91/1] object-cover" />
|
||||
<div className="py-3 px-4">
|
||||
<div className="flex space-x-2 items-center">
|
||||
|
||||
@@ -173,7 +173,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
|
||||
<p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
|
||||
{richText}
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -263,6 +263,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/moderation/modlists", server.WebGeneric)
|
||||
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||
e.GET("/moderation/verification-settings", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
e.GET("/settings/language", server.WebGeneric)
|
||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<!-- Hello Humans! API docs at https://atproto.com -->
|
||||
|
||||
<link rel="preload" as="font" type="font/woff2" href="{{ staticCDNHost }}/static/media/InterVariable.c504db5c06caaf7cdfba.woff2" crossorigin>
|
||||
<link rel="preload" as="font" type="font/ttf" href="/static/media/Verification.a57fd7e1809cc90127c3.ttf" crossorigin>
|
||||
|
||||
<style>
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Internationalization
|
||||
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
|
||||
|
||||
## Tools
|
||||
|
||||
- We use Crowdin to manage translations.
|
||||
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
|
||||
- Bluesky Crowdin: https://bluesky.crowdin.com/bluesky-social
|
||||
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
|
||||
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
|
||||
|
||||
### Using Crowdin
|
||||
|
||||
[Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
|
||||
[Crowdin](https://bluesky.crowdin.com/bluesky-social) is our primary tool for managing translations. There are two roles:
|
||||
|
||||
- **Proof-readers**. Can create new translations and approve submitted translations.
|
||||
- **Translators**. Can create new translations.
|
||||
@@ -32,7 +32,7 @@ Please treat everyone with respect. Proof-readers are given final say on transla
|
||||
|
||||
### Adding a new language
|
||||
|
||||
You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
|
||||
You can request a new language be added to the project by clicking **Request New Language** on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
|
||||
|
||||
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
|
||||
|
||||
|
||||
@@ -144,7 +144,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
|
||||
func updateLayout() {
|
||||
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier,
|
||||
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
|
||||
if (self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil),
|
||||
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
|
||||
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
|
||||
preventExpansion: self.preventExpansion)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
LayoutChangeEvent,
|
||||
NativeSyntheticEvent,
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Platform,
|
||||
StyleProp,
|
||||
type StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
|
||||
import {
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
} from './BottomSheet.types'
|
||||
import {BottomSheetPortalProvider} from './BottomSheetPortal'
|
||||
import {Context as PortalContext} from './BottomSheetPortal'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {BottomSheetViewProps} from './BottomSheet.types'
|
||||
import {type BottomSheetViewProps} from './BottomSheet.types'
|
||||
|
||||
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
|
||||
throw new Error('BottomSheetNativeComponent is not available on web')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.100.0",
|
||||
"version": "1.102.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -58,12 +58,13 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.14.20",
|
||||
"@atproto/api": "^0.15.3",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
@@ -86,7 +87,7 @@
|
||||
"@radix-ui/react-focus-scope": "^1.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-menu/menu": "^1.1.7",
|
||||
"@react-native-picker/picker": "2.9.0",
|
||||
"@react-native-picker/picker": "2.10.3",
|
||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
||||
"@react-navigation/drawer": "^6.6.15",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
@@ -141,12 +142,10 @@
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-localization": "~16.0.1",
|
||||
"expo-media-library": "~17.0.6",
|
||||
"expo-navigation-bar": "~4.0.9",
|
||||
"expo-notifications": "~0.29.14",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sharing": "~13.0.1",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.9",
|
||||
"expo-task-manager": "~12.0.6",
|
||||
"expo-updates": "~0.27.4",
|
||||
@@ -178,12 +177,13 @@
|
||||
"react-native-compressor": "1.10.3",
|
||||
"react-native-date-picker": "^5.0.7",
|
||||
"react-native-drawer-layout": "^4.1.1",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-emoji-popup": "^0.1.2",
|
||||
"react-native-gesture-handler": "2.20.2",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-image-crop-picker": "^0.41.6",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.14.5",
|
||||
"react-native-keyboard-controller": "^1.17.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-picker-select": "^9.3.1",
|
||||
@@ -197,8 +197,8 @@
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-vector-icons": "^10.2.0",
|
||||
"react-native-view-shot": "^4.0.3",
|
||||
"react-native-vision-camera": "^4.6.3",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "13.12.5",
|
||||
@@ -223,8 +223,8 @@
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/eslint-config": "^0.76.2",
|
||||
"@react-native/typescript-config": "^0.76.1",
|
||||
"@react-native/eslint-config": "^0.76.9",
|
||||
"@react-native/typescript-config": "^0.76.9",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^12.8.1",
|
||||
@@ -273,7 +273,7 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@expo/image-utils": "0.6.3",
|
||||
"@react-native/babel-preset": "0.76.1",
|
||||
"@react-native/babel-preset": "0.76.9",
|
||||
"@react-native/normalize-colors": "0.76.1",
|
||||
"@types/react": "^18",
|
||||
"**/expo-constants": "17.0.3",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
|
||||
index b928b8f..7175cf6 100644
|
||||
index 64e6efe..e61485e 100644
|
||||
--- a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
|
||||
+++ b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
|
||||
@@ -99,12 +99,12 @@ class MediaLibraryModule : Module() {
|
||||
@@ -111,12 +111,12 @@ class MediaLibraryModule : Module() {
|
||||
}
|
||||
|
||||
AsyncFunction("createAssetAsync") { localUri: String, promise: Promise ->
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
|
||||
index 5dd0c61..bf536dd 100644
|
||||
index 13bffbb..5ebbede 100644
|
||||
--- a/node_modules/expo-notifications/android/build.gradle
|
||||
+++ b/node_modules/expo-notifications/android/build.gradle
|
||||
@@ -46,6 +46,7 @@ dependencies {
|
||||
@@ -41,7 +41,7 @@ diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Scro
|
||||
index 93af874..106f8ec 100644
|
||||
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
@@ -66,28 +66,51 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
|
||||
@@ -66,28 +66,51 @@
|
||||
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
|
||||
* becomes larger than the ScrollView, there is no padding around the content but it
|
||||
* can still fill the whole view.
|
||||
@@ -103,7 +103,7 @@ index 93af874..106f8ec 100644
|
||||
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
|
||||
{
|
||||
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
|
||||
@@ -257,6 +280,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
|
||||
@@ -257,6 +280,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
|
||||
index 53bfd04..ff1b1ed 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
|
||||
@@ -23,6 +23,7 @@
|
||||
UIColor *_titleColor;
|
||||
CGFloat _progressViewOffset;
|
||||
BOOL _hasMovedToWindow;
|
||||
@@ -139,7 +139,7 @@ index 53bfd04..ff1b1ed 100644
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
@@ -58,6 +59,12 @@ - (void)layoutSubviews
|
||||
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
|
||||
_isInitialRender = false;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ index 53bfd04..ff1b1ed 100644
|
||||
- (void)didMoveToWindow
|
||||
{
|
||||
[super didMoveToWindow];
|
||||
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
|
||||
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
|
||||
index 40aaf9c..1c60164 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
|
||||
@@ -22,11 +22,12 @@ - (UIView *)view
|
||||
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
|
||||
@@ -222,10 +222,10 @@ index 40aaf9c..1c60164 100644
|
||||
{
|
||||
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
|
||||
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
|
||||
index e9ce48c..84a6fca 100644
|
||||
index 6f41b5c..9b4f77f 100644
|
||||
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
|
||||
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
|
||||
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
|
||||
@@ -159,26 +159,8 @@
|
||||
return !shouldDisableScrollInteraction;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ index e9ce48c..84a6fca 100644
|
||||
super.contentOffset = CGPointMake(
|
||||
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
|
||||
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
|
||||
@@ -427,6 +409,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
|
||||
@@ -433,6 +415,11 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
||||
// Does nothing
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ index e9ce48c..84a6fca 100644
|
||||
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
|
||||
{
|
||||
[super insertReactSubview:view atIndex:atIndex];
|
||||
@@ -443,6 +430,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
|
||||
@@ -449,6 +436,8 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
||||
_contentView = view;
|
||||
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
|
||||
[_scrollView addSubview:view];
|
||||
@@ -273,7 +273,7 @@ index e9ce48c..84a6fca 100644
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,9 +641,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
|
||||
@@ -658,9 +647,46 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
||||
}
|
||||
|
||||
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
|
||||
@@ -321,7 +321,7 @@ index e9ce48c..84a6fca 100644
|
||||
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
|
||||
{
|
||||
[_scrollListeners addObject:scrollListener];
|
||||
@@ -933,6 +959,7 @@ - (void)updateContentSizeIfNeeded
|
||||
@@ -939,6 +965,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
|
||||
CGSize contentSize = self.contentSize;
|
||||
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
|
||||
_scrollView.contentSize = contentSize;
|
||||
@@ -329,7 +329,7 @@ index e9ce48c..84a6fca 100644
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1082,22 @@ -(type)getter \
|
||||
@@ -1061,6 +1088,22 @@ RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalSc
|
||||
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
|
||||
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
|
||||
|
||||
@@ -356,7 +356,7 @@ diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManag
|
||||
index cd1e7eb..c1d0172 100644
|
||||
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
|
||||
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
|
||||
@@ -83,6 +83,7 @@ - (UIView *)view
|
||||
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
|
||||
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
|
||||
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
|
||||
@@ -0,0 +1,27 @@
|
||||
// Based on https://github.com/expo/expo/pull/33957
|
||||
// Could be removed once the app has been updated to Expo 53
|
||||
const {withAndroidStyles} = require('@expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidDayNightThemePlugin(appConfig) {
|
||||
const cleanupList = new Set([
|
||||
'colorPrimary',
|
||||
'android:editTextBackground',
|
||||
'android:textColor',
|
||||
'android:editTextStyle',
|
||||
])
|
||||
|
||||
return withAndroidStyles(appConfig, config => {
|
||||
config.modResults.resources.style = config.modResults.resources.style
|
||||
?.map(style => {
|
||||
if (style.$.name === 'AppTheme' && style.item != null) {
|
||||
style.item = style.item.filter(item => !cleanupList.has(item.$.name))
|
||||
}
|
||||
return style
|
||||
})
|
||||
.filter(style => {
|
||||
return style.$.name !== 'ResetEditText'
|
||||
})
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
const {withStringsXml, AndroidConfig} = require('@expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidSplashScreenStatusBarTranslucentPlugin(
|
||||
appConfig,
|
||||
) {
|
||||
return withStringsXml(appConfig, function (decoratedAppConfig) {
|
||||
try {
|
||||
decoratedAppConfig.modResults = AndroidConfig.Strings.setStringItem(
|
||||
[
|
||||
{
|
||||
_: 'true',
|
||||
$: {
|
||||
name: 'expo_splash_screen_status_bar_translucent',
|
||||
translatable: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
decoratedAppConfig.modResults,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`withAndroidSplashScreenStatusBarTranslucentPlugin failed`,
|
||||
e,
|
||||
)
|
||||
}
|
||||
return decoratedAppConfig
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ module.exports = function withAndroidStylesAccentColorPlugin(appConfig) {
|
||||
decoratedAppConfig.modResults,
|
||||
{
|
||||
add: true,
|
||||
parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
|
||||
parent: AndroidConfig.Styles.getAppThemeGroup(),
|
||||
name: 'colorAccent',
|
||||
value: '@color/colorPrimary',
|
||||
},
|
||||
|
||||
@@ -46,14 +46,13 @@ import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation
|
||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
type SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||
import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
@@ -219,9 +218,7 @@ function App() {
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<LightStatusBarProvider>
|
||||
<InnerApp />
|
||||
</LightStatusBarProvider>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
|
||||
@@ -35,14 +35,13 @@ import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation
|
||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
type SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||
import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
@@ -62,6 +61,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {loadVerificationCheckFonts} from '#/components/verification/VerificationCheck'
|
||||
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
@@ -75,6 +75,7 @@ function InnerApp() {
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
const [verificationChecksFontLoaded] = loadVerificationCheckFonts()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -104,6 +105,7 @@ function InnerApp() {
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady || !hasCheckedReferrer) return null
|
||||
if (!verificationChecksFontLoaded) return null
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
@@ -193,9 +195,7 @@ function App() {
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<LightStatusBarProvider>
|
||||
<InnerApp />
|
||||
</LightStatusBarProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
|
||||
@@ -33,7 +33,6 @@ import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
@@ -70,6 +69,7 @@ import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
|
||||
@@ -79,33 +79,35 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
|
||||
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch'
|
||||
import {SearchScreen} from '#/screens/Search'
|
||||
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
|
||||
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
|
||||
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
|
||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
|
||||
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
|
||||
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
|
||||
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
|
||||
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
|
||||
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
|
||||
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
|
||||
import {SettingsScreen} from '#/screens/Settings/Settings'
|
||||
import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
|
||||
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
|
||||
import {
|
||||
StarterPackScreen,
|
||||
StarterPackScreenShort,
|
||||
} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||
import TopicScreen from '#/screens/Topic'
|
||||
import {VideoFeed} from '#/screens/VideoFeed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {ProfileSearchScreen} from './screens/Profile/ProfileSearch'
|
||||
import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
|
||||
import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
|
||||
import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
|
||||
import {AppPasswordsScreen} from './screens/Settings/AppPasswords'
|
||||
import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings'
|
||||
import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences'
|
||||
import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences'
|
||||
import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings'
|
||||
import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings'
|
||||
import {SettingsScreen} from './screens/Settings/Settings'
|
||||
import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences'
|
||||
import TopicScreen from './screens/Topic'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -167,6 +169,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationVerificationSettings"
|
||||
getComponent={() => ModerationVerificationSettings}
|
||||
options={{
|
||||
title: title(msg`Verification Settings`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
getComponent={() => SettingsScreen}
|
||||
@@ -727,36 +737,39 @@ const LINKING = {
|
||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
|
||||
const {currentAccount} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
|
||||
const verifyEmailDialogControl = useDialogControl()
|
||||
|
||||
function onReady() {
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
openModal({name: 'verify-email', showReminder: true})
|
||||
verifyEmailDialogControl.open()
|
||||
snoozeEmailConfirmationPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logger.metric('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logger.metric('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
<>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logger.metric('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logger.metric('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
<VerifyEmailDialog control={verifyEmailDialogControl} reminder />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as NavigationBar from 'expo-navigation-bar'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {Theme} from '../types'
|
||||
|
||||
export function setNavigationBar(themeType: 'theme' | 'lightbox', t: Theme) {
|
||||
if (isAndroid) {
|
||||
if (themeType === 'theme') {
|
||||
NavigationBar.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
|
||||
NavigationBar.setBorderColorAsync(t.atoms.bg.backgroundColor)
|
||||
NavigationBar.setButtonStyleAsync(t.name !== 'light' ? 'light' : 'dark')
|
||||
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
|
||||
} else {
|
||||
NavigationBar.setBackgroundColorAsync('black')
|
||||
NavigationBar.setBorderColorAsync('black')
|
||||
NavigationBar.setButtonStyleAsync('light')
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {Theme} from '../types'
|
||||
|
||||
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
|
||||
if (isAndroid) {
|
||||
if (themeType === 'theme') {
|
||||
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
|
||||
} else {
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -12,6 +12,8 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {Button} from './Button'
|
||||
import {Text} from './Typography'
|
||||
|
||||
@@ -74,11 +76,13 @@ export function AccountList({
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.align_baseline,
|
||||
a.font_bold,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
{paddingLeft: 48},
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingLeft: 56},
|
||||
]}>
|
||||
{otherLabel ?? <Trans>Other account</Trans>}
|
||||
</Text>
|
||||
@@ -105,6 +109,7 @@ function AccountItem({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
onSelect(account)
|
||||
@@ -114,7 +119,7 @@ function AccountItem({
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[a.flex_1]}
|
||||
style={[a.w_full]}
|
||||
onPress={onPress}
|
||||
label={
|
||||
isCurrentAccount
|
||||
@@ -127,33 +132,45 @@ function AccountItem({
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
a.px_md,
|
||||
a.gap_sm,
|
||||
{height: 56},
|
||||
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={24}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text emoji style={[a.font_bold]}>
|
||||
{sanitizeDisplayName(
|
||||
profile?.displayName || profile?.handle || account.handle,
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={36}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
|
||||
<View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.font_bold, a.leading_tight]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile?.displayName || profile?.handle || account.handle,
|
||||
)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View>
|
||||
<VerificationCheck
|
||||
width={12}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Text>{' '}
|
||||
<Text emoji style={[t.atoms.text_contrast_medium]}>
|
||||
{sanitizeHandle(account.handle)}
|
||||
</View>
|
||||
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
|
||||
{sanitizeHandle(account.handle, '@')}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isCurrentAccount ? (
|
||||
<Check
|
||||
size="sm"
|
||||
style={[{color: t.palette.positive_600}, a.mr_md]}
|
||||
/>
|
||||
<Check size="sm" style={[{color: t.palette.positive_600}]} />
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
<Chevron size="sm" style={[t.atoms.text]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -556,7 +556,7 @@ export function Outer({
|
||||
// pure vibes based
|
||||
const TOP_INSET = insets.top + 80
|
||||
const BOTTOM_INSET_IOS = insets.bottom + 20
|
||||
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
|
||||
const BOTTOM_INSET_ANDROID = insets.bottom + 12
|
||||
|
||||
const {height} = evt.nativeEvent.layout
|
||||
const topPosition =
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleProp,
|
||||
type ScrollView,
|
||||
type StyleProp,
|
||||
TextInput,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
useKeyboardHandler,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
||||
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -25,22 +25,22 @@ import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
type DialogControlProps,
|
||||
type DialogInnerProps,
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
BottomSheetSnapPointChangeEvent,
|
||||
BottomSheetStateChangeEvent,
|
||||
type BottomSheetSnapPointChangeEvent,
|
||||
type BottomSheetStateChangeEvent,
|
||||
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
|
||||
import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/shared'
|
||||
@@ -299,7 +299,7 @@ export const InnerFlatList = React.forwardRef<
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
|
||||
}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import {useCallback} from 'react'
|
||||
import {SystemBars} from 'react-native-edge-to-edge'
|
||||
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
|
||||
/**
|
||||
* If we're calling a system API like the image picker that opens a sheet
|
||||
* wrap it in this function to make sure the status bar is the correct color.
|
||||
*/
|
||||
export function useSheetWrapper() {
|
||||
const {setFullyExpandedCount} = useDialogStateControlContext()
|
||||
return useCallback(
|
||||
async <T>(promise: Promise<T>): Promise<T> => {
|
||||
setFullyExpandedCount(c => c + 1)
|
||||
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
|
||||
if (isIOS) {
|
||||
const entry = SystemBars.pushStackEntry({
|
||||
style: {
|
||||
statusBar: 'light',
|
||||
},
|
||||
})
|
||||
const res = await promise
|
||||
setFullyExpandedCount(c => c - 1)
|
||||
SystemBars.popStackEntry(entry)
|
||||
return res
|
||||
},
|
||||
[setFullyExpandedCount],
|
||||
)
|
||||
} else {
|
||||
return await promise
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, {useContext, useMemo} from 'react'
|
||||
import {StyleSheet, View, ViewProps, ViewStyle} from 'react-native'
|
||||
import {StyleProp} from 'react-native'
|
||||
import {forwardRef, memo, useContext, useMemo} from 'react'
|
||||
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
|
||||
import {type StyleProp} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardAwareScrollViewProps,
|
||||
type KeyboardAwareScrollViewProps,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
AnimatedScrollViewProps,
|
||||
type AnimatedScrollViewProps,
|
||||
useAnimatedProps,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
@@ -35,7 +35,7 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
|
||||
/**
|
||||
* Outermost component of every screen
|
||||
*/
|
||||
export const Screen = React.memo(function Screen({
|
||||
export const Screen = memo(function Screen({
|
||||
style,
|
||||
noInsetTop,
|
||||
...props
|
||||
@@ -61,49 +61,55 @@ export type ContentProps = AnimatedScrollViewProps & {
|
||||
/**
|
||||
* Default scroll view for simple pages
|
||||
*/
|
||||
export const Content = React.memo(function Content({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const animatedProps = useAnimatedProps(() => {
|
||||
return {
|
||||
scrollIndicatorInsets: {
|
||||
bottom: footerHeight.get(),
|
||||
top: 0,
|
||||
right: 1,
|
||||
},
|
||||
} satisfies AnimatedScrollViewProps
|
||||
})
|
||||
export const Content = memo(
|
||||
forwardRef<Animated.ScrollView, ContentProps>(function Content(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const animatedProps = useAnimatedProps(() => {
|
||||
return {
|
||||
scrollIndicatorInsets: {
|
||||
bottom: footerHeight.get(),
|
||||
top: 0,
|
||||
right: 1,
|
||||
},
|
||||
} satisfies AnimatedScrollViewProps
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
// sets the scroll inset to the height of the footer
|
||||
animatedProps={animatedProps}
|
||||
style={[scrollViewStyles.common, style]}
|
||||
contentContainerStyle={[
|
||||
scrollViewStyles.contentContainer,
|
||||
contentContainerStyle,
|
||||
]}
|
||||
{...props}>
|
||||
{isWeb ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
// sets the scroll inset to the height of the footer
|
||||
animatedProps={animatedProps}
|
||||
style={[scrollViewStyles.common, style]}
|
||||
contentContainerStyle={[
|
||||
scrollViewStyles.contentContainer,
|
||||
contentContainerStyle,
|
||||
]}
|
||||
{...props}>
|
||||
{isWeb ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const scrollViewStyles = StyleSheet.create({
|
||||
common: {
|
||||
@@ -124,7 +130,7 @@ export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
|
||||
*
|
||||
* BE SURE TO TEST THIS WHEN USING, it's untested as of writing this comment.
|
||||
*/
|
||||
export const KeyboardAwareContent = React.memo(function LayoutScrollView({
|
||||
export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
@@ -147,7 +153,7 @@ export const KeyboardAwareContent = React.memo(function LayoutScrollView({
|
||||
/**
|
||||
* Utility component to center content within the screen
|
||||
*/
|
||||
export const Center = React.memo(function LayoutContent({
|
||||
export const Center = memo(function LayoutCenter({
|
||||
children,
|
||||
style,
|
||||
ignoreTabletLayoutOffset,
|
||||
@@ -192,7 +198,7 @@ export const Center = React.memo(function LayoutContent({
|
||||
/**
|
||||
* Only used within `Layout.Screen`, not for reuse
|
||||
*/
|
||||
const WebCenterBorders = React.memo(function LayoutContent() {
|
||||
const WebCenterBorders = memo(function LayoutWebCenterBorders() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {ActivityIndicator, FlatList, View} from 'react-native'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface LikesDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
uri: string
|
||||
}
|
||||
|
||||
export function LikesDialog(props: LikesDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<LikesDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function LikesDialogInner({control, uri}: LikesDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
data: resolvedUri,
|
||||
error: resolveError,
|
||||
isFetched: hasFetchedResolvedUri,
|
||||
} = useResolveUriQuery(uri)
|
||||
const {
|
||||
data,
|
||||
isFetching: isFetchingLikedBy,
|
||||
isFetched: hasFetchedLikedBy,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error: likedByError,
|
||||
} = useLikedByQuery(resolvedUri?.uri)
|
||||
|
||||
const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
|
||||
const likes = useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingLikedBy || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more likes', {message: err})
|
||||
}
|
||||
}, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: GetLikes.Like}) => {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.actor.did}
|
||||
profile={item.actor}
|
||||
onPress={() => control.close()}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[control],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
|
||||
<Trans>Liked by</Trans>
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={{minHeight: 300}}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : resolveError || likedByError || !data ? (
|
||||
<ErrorMessage message={cleanError(resolveError || likedByError)} />
|
||||
) : likes.length === 0 ? (
|
||||
<View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
|
||||
<Text style={[a.text_center]}>
|
||||
<Trans>
|
||||
Nobody has liked this yet. Maybe you should be the first!
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
ListFooterComponent={
|
||||
<ListFooterComponent isFetching={isFetchingNextPage} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
function ListFooterComponent({isFetching}: {isFetching: boolean}) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View style={a.pt_lg}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {type GestureResponderEvent} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {type AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
/**
|
||||
@@ -210,7 +210,9 @@ export function useLink({
|
||||
}
|
||||
|
||||
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
||||
Omit<ButtonProps, 'onPress' | 'disabled'>
|
||||
Omit<ButtonProps, 'onPress' | 'disabled'> & {
|
||||
overridePresentation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
||||
@@ -228,6 +230,7 @@ export function Link({
|
||||
onLongPress: outerOnLongPress,
|
||||
download,
|
||||
shouldProxy,
|
||||
overridePresentation,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
@@ -237,6 +240,7 @@ export function Link({
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
shouldProxy: shouldProxy,
|
||||
overridePresentation,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -267,7 +271,7 @@ export function Link({
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
BaseLinkProps &
|
||||
TextStyleProp &
|
||||
Pick<TextProps, 'selectable' | 'numberOfLines'> &
|
||||
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
|
||||
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
|
||||
disableUnderline?: boolean
|
||||
title?: TextProps['title']
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {type LogEvents} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
@@ -27,21 +27,26 @@ import {
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import * as Pills from '#/components/Pills'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function Default({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
testID,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
testID?: string
|
||||
}) {
|
||||
return (
|
||||
<Link profile={profile}>
|
||||
<Link testID={testID} profile={profile}>
|
||||
<Card
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
@@ -60,8 +65,6 @@ export function Card({
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Header>
|
||||
@@ -74,10 +77,7 @@ export function Card({
|
||||
/>
|
||||
</Header>
|
||||
|
||||
<ProfileCardPills
|
||||
followedBy={Boolean(profile.viewer?.followedBy)}
|
||||
moderation={moderation}
|
||||
/>
|
||||
<Labels profile={profile} moderationOpts={moderationOpts} />
|
||||
|
||||
<Description profile={profile} />
|
||||
</Outer>
|
||||
@@ -87,7 +87,7 @@ export function Card({
|
||||
export function Outer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
children: React.ReactNode | React.ReactNode[]
|
||||
}) {
|
||||
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function Outer({
|
||||
export function Header({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
children: React.ReactNode | React.ReactNode[]
|
||||
}) {
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
||||
}
|
||||
@@ -130,19 +130,30 @@ export function Link({
|
||||
export function Avatar({
|
||||
profile,
|
||||
moderationOpts,
|
||||
onPress,
|
||||
disabledPreview,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onPress?: () => void
|
||||
disabledPreview?: boolean
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
return disabledPreview ? (
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
) : (
|
||||
<PreviewableUserAvatar
|
||||
size={40}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
onBeforePress={onPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,32 +180,61 @@ export function NameAndHandle({
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Name profile={profile} moderationOpts={moderationOpts} />
|
||||
<Handle profile={profile} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Name({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View style={[a.pl_xs]}>
|
||||
<VerificationCheck
|
||||
width={14}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
const t = useTheme()
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function NameAndHandlePlaceholder() {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -415,3 +455,31 @@ export function FollowButtonInner({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Labels({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const modui = moderation.ui('profileList')
|
||||
const followedBy = profile.viewer?.followedBy
|
||||
|
||||
if (!followedBy && !modui.inform && !modui.alert) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Pills.Row style={[a.pt_xs]}>
|
||||
{followedBy && <Pills.FollowsYou />}
|
||||
{modui.alerts.map(alert => (
|
||||
<Pills.Label key={getModerationCauseKey(alert)} cause={alert} />
|
||||
))}
|
||||
{modui.informs.map(inform => (
|
||||
<Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
|
||||
))}
|
||||
</Pills.Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -33,7 +37,9 @@ import * as Pills from '#/components/Pills'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {type ProfileHoverCardProps} from './types'
|
||||
|
||||
const floatingMiddlewares = [
|
||||
offset(4),
|
||||
@@ -412,6 +418,7 @@ function Inner({
|
||||
[currentAccount, profile],
|
||||
)
|
||||
const isLabeler = profile.associated?.labeler
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -465,13 +472,30 @@ function Inner({
|
||||
|
||||
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
|
||||
<View style={[a.pb_sm, a.flex_1]}>
|
||||
<Text
|
||||
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
<View style={[a.flex_row, a.align_center, a.pt_md, a.pb_xs]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[a.text_lg, a.font_bold, a.self_start]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: -2,
|
||||
},
|
||||
]}>
|
||||
<VerificationCheck
|
||||
width={16}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ProfileHeaderHandle profile={profileShadow} disableTaps />
|
||||
</View>
|
||||
|
||||
@@ -7,24 +7,17 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
ZoomInEasyDown,
|
||||
} from 'react-native-reanimated'
|
||||
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
@@ -49,15 +42,14 @@ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/componen
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ListFooter} from '../Lists'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ProgressGuideTask} from './Task'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
isSuggestion: boolean
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
@@ -128,94 +120,39 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const {currentAccount} = useSession()
|
||||
const [suggestedAccounts, setSuggestedAccounts] = useState<
|
||||
Map<string, AppBskyActorDefs.ProfileView[]>
|
||||
>(() => new Map())
|
||||
|
||||
useEffect(() => {
|
||||
lastSearchText = searchText
|
||||
lastSelectedInterest = selectedInterest
|
||||
}, [searchText, selectedInterest])
|
||||
|
||||
const query = searchText || selectedInterest
|
||||
const {
|
||||
data: suggestions,
|
||||
isFetching: isFetchingSuggestions,
|
||||
error: suggestionsError,
|
||||
} = useGetSuggestedUsersQuery({
|
||||
category: selectedInterest,
|
||||
limit: 50,
|
||||
})
|
||||
const {
|
||||
data: searchResults,
|
||||
isFetching,
|
||||
error,
|
||||
isError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
isFetching: isFetchingSearchResults,
|
||||
error: searchResultsError,
|
||||
isError: isSearchResultsError,
|
||||
} = useActorSearchPaginated({
|
||||
query,
|
||||
enabled: !!searchText,
|
||||
query: searchText,
|
||||
})
|
||||
|
||||
const hasSearchText = !!searchText
|
||||
|
||||
const resultsKey = searchText || selectedInterest
|
||||
const items = useMemo(() => {
|
||||
const results = searchResults?.pages.flatMap(r => r.actors)
|
||||
const results = hasSearchText
|
||||
? searchResults?.pages.flatMap(p => p.actors)
|
||||
: suggestions?.actors
|
||||
let _items: Item[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else if (results) {
|
||||
// First pass: search results
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
if (profile.viewer?.following) continue
|
||||
// my sincere apologies to Jake Gold - your bio is too keyword-filled and
|
||||
// your page-rank too high, so you're at the top of half the categories -sfn
|
||||
if (
|
||||
!hasSearchText &&
|
||||
profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' &&
|
||||
// constrain to 'tech'
|
||||
selectedInterest !== 'tech'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
seen.add(profile.did)
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
// Don't share identity across tabs or typing attempts
|
||||
key: query + ':' + profile.did,
|
||||
profile,
|
||||
isSuggestion: false,
|
||||
})
|
||||
}
|
||||
// Second pass: suggestions
|
||||
_items = _items.flatMap(item => {
|
||||
if (item.type !== 'profile') {
|
||||
return item
|
||||
}
|
||||
const suggestions = suggestedAccounts.get(item.profile.did)
|
||||
if (!suggestions) {
|
||||
return item
|
||||
}
|
||||
const itemWithSuggestions = [item]
|
||||
for (const suggested of suggestions) {
|
||||
if (seen.has(suggested.did)) {
|
||||
// Skip search results from previous step or already seen suggestions
|
||||
continue
|
||||
}
|
||||
seen.add(suggested.did)
|
||||
itemWithSuggestions.push({
|
||||
type: 'profile',
|
||||
key: suggested.did,
|
||||
profile: suggested,
|
||||
isSuggestion: true,
|
||||
})
|
||||
if (itemWithSuggestions.length === 1 + 3) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return itemWithSuggestions
|
||||
})
|
||||
} else {
|
||||
if (isFetchingSuggestions || isFetchingSearchResults) {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((__, i) => ({
|
||||
@@ -224,21 +161,54 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
}))
|
||||
|
||||
_items.push(...placeholders)
|
||||
} else if (
|
||||
(hasSearchText && searchResultsError) ||
|
||||
(!hasSearchText && suggestionsError) ||
|
||||
!results?.length
|
||||
) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for (const profile of results) {
|
||||
if (seen.has(profile.did)) continue
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
if (profile.viewer?.following) continue
|
||||
|
||||
seen.add(profile.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
// Don't share identity across tabs or typing attempts
|
||||
key: resultsKey + ':' + profile.did,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
suggestions,
|
||||
suggestionsError,
|
||||
isFetchingSuggestions,
|
||||
searchResults,
|
||||
isError,
|
||||
searchResultsError,
|
||||
isFetchingSearchResults,
|
||||
currentAccount?.did,
|
||||
hasSearchText,
|
||||
selectedInterest,
|
||||
suggestedAccounts,
|
||||
query,
|
||||
resultsKey,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
if (
|
||||
searchText &&
|
||||
!isFetchingSearchResults &&
|
||||
!items.length &&
|
||||
!isSearchResultsError
|
||||
) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
@@ -249,9 +219,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
return (
|
||||
<FollowProfileCard
|
||||
profile={item.profile}
|
||||
isSuggestion={item.isSuggestion}
|
||||
moderationOpts={moderationOpts!}
|
||||
setSuggestedAccounts={setSuggestedAccounts}
|
||||
noBorder={index === 0}
|
||||
/>
|
||||
)
|
||||
@@ -297,15 +265,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
/>
|
||||
)
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more people to follow', {message: err})
|
||||
}
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
@@ -325,15 +284,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
|
||||
scrollIndicatorInsets={{top: headerHeight}}
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
onEndReached={onEndReached}
|
||||
itemLayoutAnimation={LinearTransition}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -471,7 +421,6 @@ let Tabs = ({
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
}): React.ReactNode => {
|
||||
const listRef = useRef<ScrollView>(null)
|
||||
const [scrollX, setScrollX] = useState(0)
|
||||
const [totalWidth, setTotalWidth] = useState(0)
|
||||
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
||||
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
|
||||
@@ -490,24 +439,11 @@ let Tabs = ({
|
||||
function scrollIntoViewIfNeeded(index: number) {
|
||||
const btnLayout = tabOffsets[index]
|
||||
if (!btnLayout) return
|
||||
|
||||
const viewportLeftEdge = scrollX
|
||||
const viewportRightEdge = scrollX + totalWidth
|
||||
const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x
|
||||
const shouldScrollToRightEdge =
|
||||
viewportRightEdge < btnLayout.x + btnLayout.width
|
||||
|
||||
if (shouldScrollToLeftEdge) {
|
||||
listRef.current?.scrollTo({
|
||||
x: btnLayout.x - tokens.space.lg,
|
||||
animated: true,
|
||||
})
|
||||
} else if (shouldScrollToRightEdge) {
|
||||
listRef.current?.scrollTo({
|
||||
x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg,
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
listRef.current?.scrollTo({
|
||||
// centered
|
||||
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectTab(index: number) {
|
||||
@@ -539,7 +475,7 @@ let Tabs = ({
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
scrollEventThrottle={200} // big throttle
|
||||
onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}>
|
||||
>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !hasSearchText
|
||||
return (
|
||||
@@ -575,6 +511,7 @@ let Tab = ({
|
||||
interestsDisplayName: string
|
||||
onLayout: (index: number, x: number, width: number) => void
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const activeText = active ? _(msg` (active)`) : ''
|
||||
return (
|
||||
@@ -585,12 +522,32 @@ let Tab = ({
|
||||
}>
|
||||
<Button
|
||||
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
color={active ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
onPress={() => onSelectTab(index)}>
|
||||
<ButtonIcon icon={SearchIcon} />
|
||||
<ButtonText>{interestsDisplayName}</ButtonText>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.px_lg,
|
||||
a.py_sm,
|
||||
a.border,
|
||||
active || hovered || pressed || focused
|
||||
? [
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
||||
]
|
||||
: [t.atoms.bg, t.atoms.border_contrast_low],
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
/* TODO: medium weight */
|
||||
active || hovered || pressed || focused
|
||||
? t.atoms.text
|
||||
: t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{interestsDisplayName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
@@ -600,49 +557,18 @@ Tab = memo(Tab)
|
||||
let FollowProfileCard = ({
|
||||
profile,
|
||||
moderationOpts,
|
||||
isSuggestion,
|
||||
setSuggestedAccounts,
|
||||
noBorder,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
isSuggestion: boolean
|
||||
setSuggestedAccounts: (
|
||||
updater: (
|
||||
v: Map<string, AppBskyActorDefs.ProfileView[]>,
|
||||
) => Map<string, AppBskyActorDefs.ProfileView[]>,
|
||||
) => void
|
||||
noBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
const [hasFollowed, setHasFollowed] = useState(false)
|
||||
const followupSuggestion = useSuggestedFollowsByActorQuery({
|
||||
did: profile.did,
|
||||
enabled: hasFollowed,
|
||||
})
|
||||
const candidates = followupSuggestion.data?.suggestions
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Move out of effect.
|
||||
if (hasFollowed && candidates && candidates.length > 0) {
|
||||
setSuggestedAccounts(suggestions => {
|
||||
const newSuggestions = new Map(suggestions)
|
||||
newSuggestions.set(profile.did, candidates)
|
||||
return newSuggestions
|
||||
})
|
||||
}
|
||||
}, [hasFollowed, profile.did, candidates, setSuggestedAccounts])
|
||||
|
||||
return (
|
||||
<LayoutAnimationConfig skipEntering={!isSuggestion}>
|
||||
<Animated.View entering={native(ZoomInEasyDown)}>
|
||||
<FollowProfileCardInner
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
onFollow={() => setHasFollowed(true)}
|
||||
noBorder={noBorder}
|
||||
/>
|
||||
</Animated.View>
|
||||
</LayoutAnimationConfig>
|
||||
<FollowProfileCardInner
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
noBorder={noBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
FollowProfileCard = memo(FollowProfileCard)
|
||||
@@ -653,7 +579,7 @@ function FollowProfileCardInner({
|
||||
onFollow,
|
||||
noBorder,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onFollow?: () => void
|
||||
noBorder?: boolean
|
||||
@@ -670,7 +596,7 @@ function FollowProfileCardInner({
|
||||
style={[
|
||||
a.flex_1,
|
||||
noBorder && a.border_t_0,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Button, type ButtonColor, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetViewProps} from '../../modules/bottom-sheet'
|
||||
import {type BottomSheetViewProps} from '../../modules/bottom-sheet'
|
||||
|
||||
export {
|
||||
type DialogControlProps as PromptControlProps,
|
||||
@@ -62,12 +62,22 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleText({children}: React.PropsWithChildren<{}>) {
|
||||
export function TitleText({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<ViewStyleProp>) {
|
||||
const {titleId} = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
nativeID={titleId}
|
||||
style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}>
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_2xl,
|
||||
a.font_bold,
|
||||
a.pb_sm,
|
||||
a.leading_snug,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
@@ -190,7 +200,7 @@ export function Basic({
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
title: string
|
||||
description: string
|
||||
description?: string
|
||||
cancelButtonCta?: string
|
||||
confirmButtonCta?: string
|
||||
/**
|
||||
@@ -207,7 +217,7 @@ export function Basic({
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
<TitleText>{title}</TitleText>
|
||||
<DescriptionText>{description}</DescriptionText>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
<Actions>
|
||||
<Action
|
||||
cta={confirmButtonCta}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
childHasEmoji,
|
||||
normalizeTextStyles,
|
||||
renderChildrenWithEmoji,
|
||||
TextProps,
|
||||
type TextProps,
|
||||
} from '#/alf/typography'
|
||||
|
||||
export type {TextProps}
|
||||
export {Text as Span} from 'react-native'
|
||||
|
||||
/**
|
||||
* Our main text component. Use this most of the time.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useBreakpoints, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ChangeEmailDialog({
|
||||
control,
|
||||
verifyEmailControl,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
verifyEmailControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Inner verifyEmailControl={verifyEmailControl} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({
|
||||
verifyEmailControl,
|
||||
}: {
|
||||
verifyEmailControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const control = Dialog.useDialogContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
'StepOne' | 'StepTwo' | 'StepThree'
|
||||
>('StepOne')
|
||||
const [email, setEmail] = useState('')
|
||||
const [confirmationCode, setConfirmationCode] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const currentEmail = currentAccount?.email || '(no email)'
|
||||
const uiStrings = {
|
||||
StepOne: {
|
||||
title: _(msg`Change Your Email`),
|
||||
message: '',
|
||||
},
|
||||
StepTwo: {
|
||||
title: _(msg`Security Step Required`),
|
||||
message: _(
|
||||
msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`,
|
||||
),
|
||||
},
|
||||
StepThree: {
|
||||
title: _(msg`Email Updated!`),
|
||||
message: _(
|
||||
msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const onRequestChange = async () => {
|
||||
if (email === currentAccount?.email) {
|
||||
setError(
|
||||
_(
|
||||
msg`The email address you entered is the same as your current email address.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const res = await agent.com.atproto.server.requestEmailUpdate()
|
||||
if (res.data.tokenRequired) {
|
||||
setCurrentStep('StepTwo')
|
||||
} else {
|
||||
await agent.com.atproto.server.updateEmail({email: email.trim()})
|
||||
await agent.resumeSession(agent.session!)
|
||||
setCurrentStep('StepThree')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirm = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await agent.com.atproto.server.updateEmail({
|
||||
email: email.trim(),
|
||||
token: confirmationCode.trim(),
|
||||
})
|
||||
await agent.resumeSession(agent.session!)
|
||||
setCurrentStep('StepThree')
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onVerify = async () => {
|
||||
control.close(() => {
|
||||
verifyEmailControl.open()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={web({maxWidth: 450})}>
|
||||
<Dialog.Close />
|
||||
<View style={[a.gap_xl]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
{uiStrings[currentStep].title}
|
||||
</Text>
|
||||
{error ? (
|
||||
<View style={[a.rounded_sm, a.overflow_hidden]}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
) : null}
|
||||
{currentStep === 'StepOne' ? (
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Enter your new email address below.</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(msg`New email address`)}
|
||||
placeholder={_(msg`alice@example.com`)}
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
{uiStrings[currentStep].message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{currentStep === 'StepTwo' ? (
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Confirmation code</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(msg`Confirmation code`)}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
onChangeText={setConfirmationCode}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
|
||||
{currentStep === 'StepOne' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Request change`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={onRequestChange}>
|
||||
<ButtonText>
|
||||
<Trans>Request change</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`I have a code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => setCurrentStep('StepTwo')}>
|
||||
<ButtonText>
|
||||
<Trans>I have a code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepTwo' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Confirm`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={onConfirm}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Resend email`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => {
|
||||
setConfirmationCode('')
|
||||
setCurrentStep('StepOne')
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Resend email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepThree' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Verify email`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onVerify}>
|
||||
<ButtonText>
|
||||
<Trans>Verify email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -6,7 +7,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -18,48 +19,62 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ListMethods} from '#/view/com/util/List'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
enabled: boolean
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: 'placeholder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
key: string
|
||||
}
|
||||
export type ProfileItem = {
|
||||
type: 'profile'
|
||||
key: string
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
|
||||
type EmptyItem = {
|
||||
type: 'empty'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type PlaceholderItem = {
|
||||
type: 'placeholder'
|
||||
key: string
|
||||
}
|
||||
|
||||
type ErrorItem = {
|
||||
type: 'error'
|
||||
key: string
|
||||
}
|
||||
|
||||
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
||||
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
onSelectChat,
|
||||
showRecentConvos,
|
||||
sortByMessageDeclaration,
|
||||
onSelectChat,
|
||||
renderProfileCard,
|
||||
}: {
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
showRecentConvos?: boolean
|
||||
}) {
|
||||
sortByMessageDeclaration?: boolean
|
||||
} & (
|
||||
| {
|
||||
renderProfileCard: (item: ProfileItem) => React.ReactNode
|
||||
onSelectChat?: undefined
|
||||
}
|
||||
| {
|
||||
onSelectChat: (did: string) => void
|
||||
renderProfileCard?: undefined
|
||||
}
|
||||
)) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -98,15 +113,17 @@ export function SearchablePeopleList({
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
// @ts-ignore
|
||||
return item.enabled ? -1 : 1
|
||||
})
|
||||
if (sortByMessageDeclaration) {
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && canBeMessaged(item.profile)
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
@@ -134,14 +151,13 @@ export function SearchablePeopleList({
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let followsItems: typeof _items = []
|
||||
let followsItems: ProfileItem[] = []
|
||||
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
@@ -150,17 +166,17 @@ export function SearchablePeopleList({
|
||||
followsItems.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(item => {
|
||||
// @ts-ignore
|
||||
return item.enabled ? -1 : 1
|
||||
})
|
||||
if (sortByMessageDeclaration) {
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(item => {
|
||||
return canBeMessaged(item.profile) ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
// then append
|
||||
_items.push(...followsItems)
|
||||
@@ -173,16 +189,18 @@ export function SearchablePeopleList({
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
// @ts-ignore
|
||||
return item.enabled ? -1 : 1
|
||||
})
|
||||
if (sortByMessageDeclaration) {
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && canBeMessaged(item.profile)
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
@@ -198,6 +216,7 @@ export function SearchablePeopleList({
|
||||
follows,
|
||||
convos,
|
||||
showRecentConvos,
|
||||
sortByMessageDeclaration,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
@@ -208,15 +227,18 @@ export function SearchablePeopleList({
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'profile': {
|
||||
return (
|
||||
<ProfileCard
|
||||
key={item.key}
|
||||
enabled={item.enabled}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
/>
|
||||
)
|
||||
if (renderProfileCard) {
|
||||
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
|
||||
} else {
|
||||
return (
|
||||
<DefaultProfileCard
|
||||
key={item.key}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'placeholder': {
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
@@ -228,7 +250,7 @@ export function SearchablePeopleList({
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, onSelectChat],
|
||||
[moderationOpts, onSelectChat, renderProfileCard],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -247,6 +269,10 @@ export function SearchablePeopleList({
|
||||
a.relative,
|
||||
web(a.pt_lg),
|
||||
native(a.pt_4xl),
|
||||
android({
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
}),
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
@@ -327,19 +353,18 @@ export function SearchablePeopleList({
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
enabled,
|
||||
function DefaultProfileCard({
|
||||
profile,
|
||||
moderationOpts,
|
||||
onPress,
|
||||
}: {
|
||||
enabled: boolean
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onPress: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const enabled = canBeMessaged(profile)
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
const displayName = sanitizeDisplayName(
|
||||
@@ -360,38 +385,35 @@ function ProfileCard({
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.py_sm,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
!enabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused
|
||||
: pressed || focused || hovered
|
||||
? t.atoms.bg_contrast_25
|
||||
: hovered
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={42}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_tight, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
|
||||
</Text>
|
||||
</View>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
{enabled ? (
|
||||
<ProfileCard.Handle profile={profile} />
|
||||
) : (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
<Trans>{handle} can't be messaged</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
@@ -1,86 +1,115 @@
|
||||
import React from 'react'
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChangeEmailDialog} from './ChangeEmailDialog'
|
||||
|
||||
export function VerifyEmailDialog({
|
||||
control,
|
||||
onCloseWithoutVerifying,
|
||||
onCloseAfterVerifying,
|
||||
reasonText,
|
||||
changeEmailControl,
|
||||
reminder,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onCloseWithoutVerifying?: () => void
|
||||
onCloseAfterVerifying?: () => void
|
||||
reasonText?: string
|
||||
/**
|
||||
* if a changeEmailControl for a ChangeEmailDialog is not provided,
|
||||
* this component will create one for you. Using this prop
|
||||
* helps reduce duplication, since these dialogs are often used together.
|
||||
*/
|
||||
changeEmailControl?: Dialog.DialogControlProps
|
||||
reminder?: boolean
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const fallbackChangeEmailControl = Dialog.useDialogControl()
|
||||
|
||||
const [didVerify, setDidVerify] = React.useState(false)
|
||||
const [didVerify, setDidVerify] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={async () => {
|
||||
if (!didVerify) {
|
||||
onCloseWithoutVerifying?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.resumeSession(agent.session!)
|
||||
onCloseAfterVerifying?.()
|
||||
} catch (e: unknown) {
|
||||
logger.error(String(e))
|
||||
return
|
||||
}
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
<>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
setDidVerify={setDidVerify}
|
||||
reasonText={reasonText}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
onClose={async () => {
|
||||
if (!didVerify) {
|
||||
onCloseWithoutVerifying?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.resumeSession(agent.session!)
|
||||
onCloseAfterVerifying?.()
|
||||
} catch (e: unknown) {
|
||||
logger.error(String(e))
|
||||
return
|
||||
}
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
setDidVerify={setDidVerify}
|
||||
reasonText={reasonText}
|
||||
changeEmailControl={changeEmailControl ?? fallbackChangeEmailControl}
|
||||
reminder={reminder}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
{!changeEmailControl && (
|
||||
<ChangeEmailDialog
|
||||
control={fallbackChangeEmailControl}
|
||||
verifyEmailControl={control}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({
|
||||
control,
|
||||
setDidVerify,
|
||||
reasonText,
|
||||
changeEmailControl,
|
||||
reminder,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
setDidVerify: (value: boolean) => void
|
||||
reasonText?: string
|
||||
changeEmailControl: Dialog.DialogControlProps
|
||||
reminder?: boolean
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {openModal} = useModalControls()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
|
||||
const [currentStep, setCurrentStep] = React.useState<
|
||||
'StepOne' | 'StepTwo' | 'StepThree'
|
||||
>('StepOne')
|
||||
const [confirmationCode, setConfirmationCode] = React.useState('')
|
||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree'
|
||||
>(reminder ? 'Reminder' : 'StepOne')
|
||||
const [confirmationCode, setConfirmationCode] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const uiStrings = {
|
||||
Reminder: {
|
||||
title: _(msg`Please Verify Your Email`),
|
||||
message: _(
|
||||
msg`Your email has not yet been verified. This is an important security step which we recommend.`,
|
||||
),
|
||||
},
|
||||
StepOne: {
|
||||
title: _(msg`Verify Your Email`),
|
||||
message: '',
|
||||
@@ -132,11 +161,20 @@ export function Inner({
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<Dialog.Close />
|
||||
style={web({maxWidth: 450})}>
|
||||
<View style={[a.gap_xl]}>
|
||||
{currentStep === 'Reminder' && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 150},
|
||||
t.atoms.bg_contrast_100,
|
||||
]}>
|
||||
<EnvelopeIcon width={64} fill="white" />
|
||||
</View>
|
||||
)}
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
{uiStrings[currentStep].title}
|
||||
@@ -164,7 +202,7 @@ export function Inner({
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
control.close(() => {
|
||||
openModal({name: 'change-email'})
|
||||
changeEmailControl.open()
|
||||
})
|
||||
return false
|
||||
}}>
|
||||
@@ -189,7 +227,7 @@ export function Inner({
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
control.close(() => {
|
||||
openModal({name: 'change-email'})
|
||||
changeEmailControl.open()
|
||||
})
|
||||
return false
|
||||
}}>
|
||||
@@ -219,7 +257,32 @@ export function Inner({
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
|
||||
{currentStep === 'StepOne' ? (
|
||||
{currentStep === 'Reminder' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Get started`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={() => setCurrentStep('StepOne')}>
|
||||
<ButtonText>
|
||||
<Trans>Get started</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Maybe later`)}
|
||||
accessibilityHint={_(msg`Snoozes the reminder`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Maybe later</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepOne' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Send confirmation email`)}
|
||||
@@ -229,21 +292,21 @@ export function Inner({
|
||||
disabled={isProcessing}
|
||||
onPress={onSendEmail}>
|
||||
<ButtonText>
|
||||
<Trans>Send Confirmation</Trans>
|
||||
<Trans>Send confirmation</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`I Have a Code`)}
|
||||
label={_(msg`I have a code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => setCurrentStep('StepTwo')}>
|
||||
<ButtonText>
|
||||
<Trans>I Have a Code</Trans>
|
||||
<Trans>I have a code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
@@ -264,7 +327,7 @@ export function Inner({
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Resend Email`)}
|
||||
label={_(msg`Resend email`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
@@ -274,13 +337,13 @@ export function Inner({
|
||||
setCurrentStep('StepOne')
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Resend Email</Trans>
|
||||
<Trans>Resend email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepThree' ? (
|
||||
<Button
|
||||
label={_(msg`Confirm`)}
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {
|
||||
getMembership,
|
||||
type ListMembersip,
|
||||
useDangerousListMembershipsQuery,
|
||||
useListMembershipAddMutation,
|
||||
useListMembershipRemoveMutation,
|
||||
} from '#/state/queries/list-memberships'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {
|
||||
type ProfileItem,
|
||||
SearchablePeopleList,
|
||||
} from '#/components/dialogs/SearchablePeopleList'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function ListAddRemoveUsersDialog({
|
||||
control,
|
||||
list,
|
||||
onChange,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
list: AppBskyGraphDefs.ListView
|
||||
onChange?: (
|
||||
type: 'add' | 'remove',
|
||||
profile: bsky.profile.AnyProfileView,
|
||||
) => void | undefined
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} testID="listAddRemoveUsersDialog">
|
||||
<Dialog.Handle />
|
||||
<DialogInner list={list} onChange={onChange} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
list,
|
||||
onChange,
|
||||
}: {
|
||||
list: AppBskyGraphDefs.ListView
|
||||
onChange?: (
|
||||
type: 'add' | 'remove',
|
||||
profile: bsky.profile.AnyProfileView,
|
||||
) => void | undefined
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {data: memberships} = useDangerousListMembershipsQuery()
|
||||
|
||||
const renderProfileCard = useCallback(
|
||||
(item: ProfileItem) => {
|
||||
return (
|
||||
<UserResult
|
||||
profile={item.profile}
|
||||
onChange={onChange}
|
||||
memberships={memberships}
|
||||
list={list}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[onChange, memberships, list, moderationOpts],
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Add people to list`)}
|
||||
renderProfileCard={renderProfileCard}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function UserResult({
|
||||
profile,
|
||||
list,
|
||||
memberships,
|
||||
onChange,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
list: AppBskyGraphDefs.ListView
|
||||
memberships: ListMembersip[] | undefined
|
||||
onChange?: (
|
||||
type: 'add' | 'remove',
|
||||
profile: bsky.profile.AnyProfileView,
|
||||
) => void | undefined
|
||||
moderationOpts?: ModerationOpts
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const membership = useMemo(
|
||||
() => getMembership(memberships, list.uri, profile.did),
|
||||
[memberships, list.uri, profile.did],
|
||||
)
|
||||
const {mutate: listMembershipAdd, isPending: isAddingPending} =
|
||||
useListMembershipAddMutation({
|
||||
onSuccess: () => {
|
||||
Toast.show(_(msg`Added to list`))
|
||||
onChange?.('add', profile)
|
||||
},
|
||||
onError: e => Toast.show(cleanError(e), 'xmark'),
|
||||
})
|
||||
const {mutate: listMembershipRemove, isPending: isRemovingPending} =
|
||||
useListMembershipRemoveMutation({
|
||||
onSuccess: () => {
|
||||
Toast.show(_(msg`Removed from list`))
|
||||
onChange?.('remove', profile)
|
||||
},
|
||||
onError: e => Toast.show(cleanError(e), 'xmark'),
|
||||
})
|
||||
const isMutating = isAddingPending || isRemovingPending
|
||||
|
||||
const onToggleMembership = useCallback(() => {
|
||||
if (typeof membership === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (membership === false) {
|
||||
listMembershipAdd({
|
||||
listUri: list.uri,
|
||||
actorDid: profile.did,
|
||||
})
|
||||
} else {
|
||||
listMembershipRemove({
|
||||
listUri: list.uri,
|
||||
actorDid: profile.did,
|
||||
membershipUri: membership,
|
||||
})
|
||||
}
|
||||
}, [list, profile, membership, listMembershipAdd, listMembershipRemove])
|
||||
|
||||
if (!moderationOpts) return null
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.py_sm, a.px_lg]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} />
|
||||
<View style={[a.flex_1]}>
|
||||
<ProfileCard.Name profile={profile} moderationOpts={moderationOpts} />
|
||||
<ProfileCard.Handle profile={profile} />
|
||||
</View>
|
||||
{membership !== undefined && (
|
||||
<Button
|
||||
label={
|
||||
membership === false
|
||||
? _(msg`Add user to list`)
|
||||
: _(msg`Remove user from list`)
|
||||
}
|
||||
onPress={onToggleMembership}
|
||||
disabled={isMutating}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
{isMutating ? (
|
||||
<ButtonIcon icon={Loader} />
|
||||
) : (
|
||||
<ButtonText>
|
||||
{membership === false ? (
|
||||
<Trans>Add</Trans>
|
||||
) : (
|
||||
<Trans>Remove</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {urls} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {VerifierCheck} from '#/components/icons/VerifierCheck'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
|
||||
export function InitialVerificationAnnouncement() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Announcing verification on Bluesky`)}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<View style={[a.align_start, a.gap_xl]}>
|
||||
<View
|
||||
style={[
|
||||
a.pl_sm,
|
||||
a.pr_md,
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<SparkleIcon fill={t.palette.primary_700} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.primary_700,
|
||||
},
|
||||
]}>
|
||||
<Trans>New Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{minHeight: 100},
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/initial_verification_announcement_1.png')}
|
||||
style={[
|
||||
{
|
||||
aspectRatio: 353 / 160,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_snug]}>
|
||||
<Trans>A new form of verification</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
We’re introducing a new layer of verification on Bluesky — an
|
||||
easy-to-see checkmark.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{minHeight: 100},
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/initial_verification_announcement_2.png')}
|
||||
style={[
|
||||
{
|
||||
aspectRatio: 353 / 196,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<VerifierCheck width={14} />
|
||||
<Text style={[a.text_lg, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Who can verify?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
Bluesky will proactively verify notable and authentic
|
||||
accounts.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
Trust emerges from relationships, communities, and shared
|
||||
context, so we’re also enabling{' '}
|
||||
<Span style={[a.font_bold]}>trusted verifiers</Span>:
|
||||
organizations that can directly issue verification.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
When you tap on a check, you’ll see which organizations have
|
||||
granted verification.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.w_full, a.gap_md]}>
|
||||
<Link
|
||||
overridePresentation
|
||||
to={urls.website.blog.initialVerificationAnnouncement}
|
||||
label={_(msg`Read blog post`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
style={[a.justify_center, a.w_full]}
|
||||
onPress={() => {
|
||||
logger.metric('verification:learn-more', {
|
||||
location: 'initialAnnouncementeNux',
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Read blog post</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
{isNative && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[a.justify_center, a.w_full]}
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
UsePreferencesQueryResponse,
|
||||
type UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {SessionAccount, useSession} from '#/state/session'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
|
||||
/*
|
||||
* NUXs
|
||||
*/
|
||||
@@ -29,7 +30,12 @@ const queuedNuxs: {
|
||||
currentProfile: AppBskyActorDefs.ProfileViewDetailed
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) => boolean
|
||||
}[] = []
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.InitialVerificationAnnouncement,
|
||||
enabled: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
const Context = React.createContext<Context>({
|
||||
activeNux: undefined,
|
||||
@@ -163,6 +169,9 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.InitialVerificationAnnouncement && (
|
||||
<InitialVerificationAnnouncement />
|
||||
)}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
@@ -61,6 +62,12 @@ export let MessageContextMenu = ({
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
openLink(translatorUrl, true)
|
||||
|
||||
logger.metric('translate', {
|
||||
sourceLanguages: [],
|
||||
targetLanguage: langPrefs.primaryLanguage,
|
||||
textLength: message.text.length,
|
||||
})
|
||||
}, [langPrefs.primaryLanguage, message.text, openLink])
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
type AppBskyActorDefs,
|
||||
type ModerationCause,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
@@ -12,12 +12,12 @@ import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {BACK_HITSLOP} from '#/lib/constants'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Shadow} from '#/state/cache/profile-shadow'
|
||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {ConvoItem} from '#/state/messages/convo/types'
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
@@ -25,6 +25,8 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/
|
||||
import {Link} from '#/components/Link'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
|
||||
const PFP_SIZE = isWeb ? 40 : 34
|
||||
|
||||
@@ -149,6 +151,9 @@ function HeaderReady({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const convoState = useConvo()
|
||||
const verification = useSimpleVerificationState({
|
||||
profile,
|
||||
})
|
||||
|
||||
const isDeletedAccount = profile?.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
@@ -185,17 +190,27 @@ function HeaderReady({
|
||||
/>
|
||||
</View>
|
||||
<View style={a.flex_1}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.self_start,
|
||||
web(a.leading_normal),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.self_start,
|
||||
web(a.leading_normal),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View style={[a.pl_xs]}>
|
||||
<VerificationCheck
|
||||
width={14}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -11,9 +11,9 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
@@ -71,6 +71,7 @@ export function NewChat({
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Start a new chat`)}
|
||||
onSelectChat={onCreateChat}
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {logger} from '#/logger'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
|
||||
|
||||
export function SendViaChatDialog({
|
||||
control,
|
||||
@@ -62,6 +62,7 @@ function SendViaChatDialogInner({
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {ServerInputDialog} from '#/view/com/auth/server-input'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -41,25 +40,27 @@ export function HostingProvider({
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
{minimal ? (
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_xs]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
You are creating an account on{' '}
|
||||
<Button
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Changes hosting provider`)}
|
||||
onPress={onPressSelectService}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="tiny"
|
||||
style={[a.px_xs, {margin: tokens.space.xs * -1}]}>
|
||||
<ButtonText style={[a.text_sm]}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={PencilIcon} />
|
||||
</Button>
|
||||
</Trans>
|
||||
<Trans>You are creating an account on</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Changes hosting provider`)}
|
||||
onPress={onPressSelectService}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="tiny"
|
||||
style={[
|
||||
a.px_xs,
|
||||
{marginHorizontal: tokens.space.xs * -1},
|
||||
{paddingVertical: 0},
|
||||
]}>
|
||||
<ButtonText style={[a.text_sm]}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={PencilIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
@@ -73,10 +74,10 @@ export function HostingProvider({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
a.py_sm,
|
||||
a.pl_md,
|
||||
a.pr_sm,
|
||||
a.gap_xs,
|
||||
{paddingVertical: isAndroid ? 14 : 8},
|
||||
]}
|
||||
onPress={onPressSelectService}>
|
||||
{({hovered, pressed}) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const BroomSparkle_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M20.494 2.13a1 1 0 0 1 .375 1.364l-4.658 8.2.263.095c1.52.554 2.737 2.062 2.484 3.864-.336 2.393-1.358 4.12-3.245 6.047a1 1 0 0 1-1.102.222l-9.5-4a1 1 0 0 1-.166-1.754c1.458-.971 2.623-1.923 3.498-3.3 1.057-1.662 3.154-2.854 5.281-2.08l.58.212 4.827-8.494a1 1 0 0 1 1.363-.375ZM13.04 12.669c-.983-.358-2.19.142-2.91 1.273-.737 1.16-1.628 2.054-2.601 2.828l1.708.72a.2.2 0 0 0 .177-.011l2.13-1.218a.2.2 0 0 1 .29.237l-.551 1.653a.2.2 0 0 0 .112.247l3.353 1.413c1.359-1.494 1.994-2.76 2.23-4.435.094-.675-.359-1.405-1.188-1.707l-2.75-1ZM4.407 7.184a.5.5 0 0 1-.224.224l-1.29.645a.5.5 0 0 0 0 .894l1.29.645a.5.5 0 0 1 .224.224l.645 1.29a.5.5 0 0 0 .894 0l.645-1.29a.5.5 0 0 1 .224-.224l1.29-.645a.5.5 0 0 0 0-.894l-1.29-.645a.5.5 0 0 1-.224-.224l-.645-1.29a.5.5 0 0 0-.894 0l-.645 1.29ZM9.559 3.72a.36.36 0 0 0 .16-.16l.46-.921a.357.357 0 0 1 .64 0l.46.921q.054.106.16.16l.921.46a.357.357 0 0 1 0 .64l-.921.46a.36.36 0 0 0-.16.16l-.46.921a.357.357 0 0 1-.64 0l-.46-.921a.36.36 0 0 0-.16-.16l-.921-.46a.357.357 0 0 1 0-.64l.921-.46Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CircleCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.633-3.274a1 1 0 0 1 .141 1.407l-4.5 5.5a1 1 0 0 1-1.481.074l-2-2a1 1 0 1 1 1.414-1.414l1.219 1.219 3.8-4.645a1 1 0 0 1 1.407-.141Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
|
||||
})
|
||||
|
||||
export const ShieldCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12.325 2.054a1 1 0 0 0-.65 0l-8 2.75A1 1 0 0 0 3 5.75v6.162c0 2.807 1.149 4.83 2.813 6.405 1.572 1.488 3.632 2.6 5.555 3.636l.157.085a1 1 0 0 0 .95 0l.157-.085c1.923-1.037 3.983-2.148 5.556-3.636C19.85 16.742 21 14.719 21 11.912V5.75a1 1 0 0 0-.675-.946l-8-2.75ZM5 11.912V6.464l7-2.407 7 2.407v5.448c0 2.166-.851 3.687-2.188 4.952-1.276 1.209-2.964 2.158-4.812 3.157-1.848-1-3.536-1.948-4.813-3.157C5.851 15.6 5 14.078 5 11.912Zm10.207-1.205a1 1 0 0 0-1.414-1.414L11 12.086l-.793-.793a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.414 0l3.5-3.5Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Sparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a1 1 0 0 1 1 1c0 3.188.669 5.256 1.882 6.536C16.084 10.805 18.01 11.5 21 11.5a1 1 0 1 1 0 2c-2.99 0-4.916.695-6.118 1.964C13.67 16.744 13 18.812 13 22a1 1 0 1 1-2 0c0-3.188-.669-5.256-1.882-6.536C7.916 14.195 5.99 13.5 3 13.5a1 1 0 1 1 0-2c2.99 0 4.916-.695 6.118-1.964C10.33 8.256 11 6.188 11 3a1 1 0 0 1 1-1Zm0 6.734a7.608 7.608 0 0 1-1.43 2.178A7.285 7.285 0 0 1 8.349 12.5c.846.397 1.589.921 2.22 1.588A7.607 7.607 0 0 1 12 16.267a7.607 7.607 0 0 1 1.43-2.179 7.284 7.284 0 0 1 2.221-1.588 7.284 7.284 0 0 1-2.22-1.588A7.608 7.608 0 0 1 12 8.734Z',
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import Svg, {Circle, Path} from 'react-native-svg'
|
||||
|
||||
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
export const VerifiedCheck = React.forwardRef<Svg, Props>(function LogoImpl(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Circle cx="12" cy="12" r="12" fill={fill} />
|
||||
<Path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.311 7.421a1.437 1.437 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L6.42 12.74a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
export const VerifierCheck = React.forwardRef<Svg, Props>(function LogoImpl(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.792 1.54a4.11 4.11 0 0 1 6.416 0 4.128 4.128 0 0 0 3.146 1.54c2.616.04 4.544 2.5 4 5.1a4.277 4.277 0 0 0 .777 3.462c1.6 2.104.912 5.17-1.427 6.36a4.21 4.21 0 0 0-2.177 2.774c-.62 2.584-3.408 3.948-5.781 2.83a4.092 4.092 0 0 0-3.492 0c-2.373 1.118-5.16-.246-5.78-2.83a4.21 4.21 0 0 0-2.178-2.775c-2.34-1.19-3.028-4.256-1.427-6.36a4.277 4.277 0 0 0 .776-3.46c-.543-2.602 1.385-5.06 4.001-5.1a4.128 4.128 0 0 0 3.146-1.54Z"
|
||||
/>
|
||||
<Path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.659 8.399a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.435a1.361 1.361 0 1 1 1.925-1.925l2.149 2.15 5.26-5.261a1.361 1.361 0 0 1 1.925 0Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import {createIconSet} from '@expo/vector-icons'
|
||||
import {useFonts} from 'expo-font'
|
||||
|
||||
import {type Props} from '#/components/icons/common'
|
||||
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
|
||||
import {VerifierCheck} from '#/components/icons/VerifierCheck'
|
||||
|
||||
export function VerificationCheck({
|
||||
verifier,
|
||||
...rest
|
||||
}: Props & {
|
||||
verifier?: boolean
|
||||
}) {
|
||||
return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
|
||||
}
|
||||
|
||||
export function loadVerificationCheckFonts() {
|
||||
return useFonts({
|
||||
Verification: require('../../../assets/fonts/verification/Verification.ttf'),
|
||||
})
|
||||
}
|
||||
|
||||
export const VerificationCheckIcon = createIconSet(
|
||||
{
|
||||
'Verification-verifiedCheck': 59905,
|
||||
'Verification-verifiedCheckGray': 59906,
|
||||
'Verification-verifierCheck': 59907,
|
||||
'Verification-verifierCheckGray': 59908,
|
||||
},
|
||||
'Verification',
|
||||
'Verification.ttf',
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {useFullVerificationState} from '#/components/verification'
|
||||
import {type FullVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {VerificationsDialog} from '#/components/verification/VerificationsDialog'
|
||||
import {VerifierDialog} from '#/components/verification/VerifierDialog'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function shouldShowVerificationCheckButton(
|
||||
state: FullVerificationState,
|
||||
) {
|
||||
let ok = false
|
||||
|
||||
if (state.profile.role === 'default') {
|
||||
if (state.profile.isVerified) {
|
||||
ok = true
|
||||
} else if (state.profile.isViewer && state.profile.wasVerified) {
|
||||
ok = true
|
||||
} else if (
|
||||
state.viewer.role === 'verifier' &&
|
||||
state.viewer.hasIssuedVerification
|
||||
) {
|
||||
ok = true
|
||||
}
|
||||
} else if (state.profile.role === 'verifier') {
|
||||
if (state.profile.isViewer) {
|
||||
ok = true
|
||||
} else if (state.profile.isVerified) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!state.profile.showBadge &&
|
||||
!state.profile.isViewer &&
|
||||
!(state.viewer.role === 'verifier' && state.viewer.hasIssuedVerification)
|
||||
) {
|
||||
ok = false
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
export function VerificationCheckButton({
|
||||
profile,
|
||||
size,
|
||||
}: {
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
size: 'lg' | 'md' | 'sm'
|
||||
}) {
|
||||
const state = useFullVerificationState({
|
||||
profile,
|
||||
})
|
||||
|
||||
if (shouldShowVerificationCheckButton(state)) {
|
||||
return <Badge profile={profile} verificationState={state} size={size} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
profile,
|
||||
verificationState: state,
|
||||
size,
|
||||
}: {
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
verificationState: FullVerificationState
|
||||
size: 'lg' | 'md' | 'sm'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const verificationsDialogControl = useDialogControl()
|
||||
const verifierDialogControl = useDialogControl()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
let dimensions = 12
|
||||
if (size === 'lg') {
|
||||
dimensions = gtPhone ? 20 : 18
|
||||
} else if (size === 'md') {
|
||||
dimensions = 16
|
||||
}
|
||||
|
||||
const verifiedByHidden = !state.profile.showBadge && state.profile.isViewer
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={
|
||||
state.profile.isViewer
|
||||
? _(msg`View your verifications`)
|
||||
: _(msg`View this user's verifications`)
|
||||
}
|
||||
hitSlop={20}
|
||||
onPress={() => {
|
||||
logger.metric('verification:badge:click', {})
|
||||
if (state.profile.role === 'verifier') {
|
||||
verifierDialogControl.open()
|
||||
} else {
|
||||
verificationsDialogControl.open()
|
||||
}
|
||||
}}
|
||||
style={[]}>
|
||||
{({hovered}) => (
|
||||
<View
|
||||
style={[
|
||||
a.justify_end,
|
||||
a.align_end,
|
||||
a.transition_transform,
|
||||
{
|
||||
width: dimensions,
|
||||
height: dimensions,
|
||||
transform: [
|
||||
{
|
||||
scale: hovered ? 1.1 : 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
<VerificationCheck
|
||||
width={dimensions}
|
||||
fill={
|
||||
verifiedByHidden
|
||||
? t.atoms.bg_contrast_100.backgroundColor
|
||||
: state.profile.isVerified
|
||||
? t.palette.primary_500
|
||||
: t.atoms.bg_contrast_100.backgroundColor
|
||||
}
|
||||
verifier={state.profile.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<VerificationsDialog
|
||||
control={verificationsDialogControl}
|
||||
profile={profile}
|
||||
verificationState={state}
|
||||
/>
|
||||
|
||||
<VerifierDialog
|
||||
control={verifierDialogControl}
|
||||
profile={profile}
|
||||
verificationState={state}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useVerificationCreateMutation} from '#/state/queries/verification/useVerificationCreateMutation'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function VerificationCreatePrompt({
|
||||
control,
|
||||
profile,
|
||||
}: {
|
||||
control: DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {mutateAsync: create} = useVerificationCreateMutation()
|
||||
const onConfirm = useCallback(async () => {
|
||||
try {
|
||||
await create({profile})
|
||||
Toast.show(_(msg`Successfully verified`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`Failed to create a verification`), 'xmark')
|
||||
logger.error('Failed to create a verification', {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
}, [_, profile, create])
|
||||
|
||||
return (
|
||||
<Prompt.Outer control={control}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.pb_sm]}>
|
||||
<VerifiedCheck width={18} />
|
||||
<Prompt.TitleText style={[a.pb_0]}>
|
||||
{_(msg`Verify this account?`)}
|
||||
</Prompt.TitleText>
|
||||
</View>
|
||||
<Prompt.DescriptionText>
|
||||
{_(msg`This action can be undone at any time.`)}
|
||||
</Prompt.DescriptionText>
|
||||
<View style={[a.pb_xl]}>
|
||||
{moderationOpts ? (
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
) : null}
|
||||
</View>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action cta={_(msg`Verify account`)} onPress={onConfirm} />
|
||||
<Prompt.Cancel />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {useCallback} from 'react'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useVerificationsRemoveMutation} from '#/state/queries/verification/useVerificationsRemoveMutation'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
||||
|
||||
export function VerificationRemovePrompt({
|
||||
control,
|
||||
profile,
|
||||
verifications,
|
||||
onConfirm: onConfirmInner,
|
||||
}: {
|
||||
control: DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
verifications: AppBskyActorDefs.VerificationView[]
|
||||
onConfirm?: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {mutateAsync: remove} = useVerificationsRemoveMutation()
|
||||
const onConfirm = useCallback(async () => {
|
||||
onConfirmInner?.()
|
||||
try {
|
||||
await remove({profile, verifications})
|
||||
Toast.show(_(msg`Removed verification`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`Failed to remove verification`), 'xmark')
|
||||
logger.error('Failed to remove verification', {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
}, [_, profile, verifications, remove, onConfirmInner])
|
||||
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Remove your verification for this account?`)}
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonCta={_(msg`Remove verification`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {urls} from '#/lib/constants'
|
||||
import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
||||
import {logger} from '#/logger'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type FullVerificationState} from '#/components/verification'
|
||||
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
export function VerificationsDialog({
|
||||
control,
|
||||
profile,
|
||||
verificationState,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
verificationState: FullVerificationState
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
control={control}
|
||||
profile={profile}
|
||||
verificationState={verificationState}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({
|
||||
profile,
|
||||
control,
|
||||
verificationState: state,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
verificationState: FullVerificationState
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const userName = getUserDisplayName(profile)
|
||||
const label = state.profile.isViewer
|
||||
? state.profile.isVerified
|
||||
? _(msg`You are verified`)
|
||||
: _(msg`Your verifications`)
|
||||
: state.profile.isVerified
|
||||
? _(msg`${userName} is verified`)
|
||||
: _(
|
||||
msg({
|
||||
message: `${userName}'s verifications`,
|
||||
comment: `Possessive, meaning "the verifications of {userName}"`,
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={label}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
{state.profile.isVerified ? (
|
||||
<Trans>
|
||||
This account has a checkmark because it's been verified by trusted
|
||||
sources.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This account has one or more attempted verifications, but it is
|
||||
not currently verified.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{profile.verification ? (
|
||||
<View style={[a.pb_xl, a.gap_md]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Verified by:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_lg]}>
|
||||
{profile.verification.verifications.map(v => (
|
||||
<VerifierCard
|
||||
key={v.uri}
|
||||
verification={v}
|
||||
subject={profile}
|
||||
outerDialogControl={control}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{profile.verification.verifications.some(v => !v.isValid) &&
|
||||
state.profile.isViewer && (
|
||||
<Admonition type="warning" style={[a.mt_xs]}>
|
||||
<Trans>Some of your verifications are invalid.</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.gap_sm,
|
||||
a.justify_end,
|
||||
gtMobile
|
||||
? [a.flex_row, a.flex_row_reverse, a.justify_start]
|
||||
: [a.flex_col],
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Link
|
||||
overridePresentation
|
||||
to={urls.website.blog.initialVerificationAnnouncement}
|
||||
label={_(msg`Learn more about verification on Bluesky`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[a.justify_center]}
|
||||
onPress={() => {
|
||||
logger.metric('verification:learn-more', {
|
||||
location: 'verificationsDialog',
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Learn more</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifierCard({
|
||||
verification,
|
||||
subject,
|
||||
outerDialogControl,
|
||||
}: {
|
||||
verification: AppBskyActorDefs.VerificationView
|
||||
subject: bsky.profile.AnyProfileView
|
||||
outerDialogControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {data: profile, error} = useProfileQuery({did: verification.issuer})
|
||||
const verificationRemovePromptControl = useDialogControl()
|
||||
const canAdminister = verification.issuer === currentAccount?.did
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
opacity: verification.isValid ? 1 : 0.5,
|
||||
}}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
{error ? (
|
||||
<>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Unknown verifier</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{verification.issuer}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : profile && moderationOpts ? (
|
||||
<>
|
||||
<ProfileCard.Link
|
||||
profile={profile}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_1]}
|
||||
onPress={() => {
|
||||
outerDialogControl.close()
|
||||
}}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{i18n.date(new Date(verification.createdAt), {
|
||||
dateStyle: 'long',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</ProfileCard.Link>
|
||||
{canAdminister && (
|
||||
<View>
|
||||
<Button
|
||||
label={_(msg`Remove verification`)}
|
||||
size="small"
|
||||
variant="outline"
|
||||
color="negative"
|
||||
shape="round"
|
||||
onPress={() => {
|
||||
verificationRemovePromptControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={TrashIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</>
|
||||
)}
|
||||
</ProfileCard.Header>
|
||||
</ProfileCard.Outer>
|
||||
|
||||
<VerificationRemovePrompt
|
||||
control={verificationRemovePromptControl}
|
||||
profile={subject}
|
||||
verifications={[verification]}
|
||||
onConfirm={() => outerDialogControl.close()}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {Text as RNText, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {urls} from '#/lib/constants'
|
||||
import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
||||
import {logger} from '#/logger'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {VerifierCheck} from '#/components/icons/VerifierCheck'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type FullVerificationState} from '#/components/verification'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
export function VerifierDialog({
|
||||
control,
|
||||
profile,
|
||||
verificationState,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
verificationState: FullVerificationState
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
control={control}
|
||||
profile={profile}
|
||||
verificationState={verificationState}
|
||||
/>
|
||||
<Dialog.Close />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({
|
||||
profile,
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
verificationState: FullVerificationState
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const isSelf = profile.did === currentAccount?.did
|
||||
const userName = getUserDisplayName(profile)
|
||||
const label = isSelf
|
||||
? _(msg`You are a trusted verifier`)
|
||||
: _(msg`${userName} is a trusted verifier`)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={label}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{minHeight: 100},
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../assets/images/initial_verification_announcement_1.png')}
|
||||
style={[
|
||||
{
|
||||
aspectRatio: 353 / 160,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Accounts with a scalloped blue check mark{' '}
|
||||
<RNText>
|
||||
<VerifierCheck width={14} />
|
||||
</RNText>{' '}
|
||||
can verify others. These trusted verifiers are selected by
|
||||
Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.gap_sm,
|
||||
a.justify_end,
|
||||
gtMobile ? [a.flex_row, a.justify_end] : [a.flex_col],
|
||||
]}>
|
||||
<Link
|
||||
overridePresentation
|
||||
to={urls.website.blog.initialVerificationAnnouncement}
|
||||
label={_(msg`Learn more about verification on Bluesky`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
style={[a.justify_center]}
|
||||
onPress={() => {
|
||||
logger.metric('verification:learn-more', {
|
||||
location: 'verifierDialog',
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Learn more</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||
import {useSession} from '#/state/session'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type FullVerificationState = {
|
||||
profile: {
|
||||
role: 'default' | 'verifier'
|
||||
isVerified: boolean
|
||||
wasVerified: boolean
|
||||
isViewer: boolean
|
||||
showBadge: boolean
|
||||
}
|
||||
viewer:
|
||||
| {
|
||||
role: 'default'
|
||||
isVerified: boolean
|
||||
}
|
||||
| {
|
||||
role: 'verifier'
|
||||
isVerified: boolean
|
||||
hasIssuedVerification: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function useFullVerificationState({
|
||||
profile,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}): FullVerificationState {
|
||||
const {currentAccount} = useSession()
|
||||
const currentAccountProfile = useCurrentAccountProfile()
|
||||
const profileState = useSimpleVerificationState({profile})
|
||||
const viewerState = useSimpleVerificationState({
|
||||
profile: currentAccountProfile,
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const verifications = profile.verification?.verifications || []
|
||||
const wasVerified =
|
||||
profileState.role === 'default' &&
|
||||
!profileState.isVerified &&
|
||||
verifications.length > 0
|
||||
const hasIssuedVerification = Boolean(
|
||||
viewerState &&
|
||||
viewerState.role === 'verifier' &&
|
||||
profileState.role === 'default' &&
|
||||
verifications.find(v => v.issuer === currentAccount?.did),
|
||||
)
|
||||
|
||||
return {
|
||||
profile: {
|
||||
...profileState,
|
||||
wasVerified,
|
||||
isViewer: profile.did === currentAccount?.did,
|
||||
showBadge: profileState.showBadge,
|
||||
},
|
||||
viewer:
|
||||
viewerState.role === 'verifier'
|
||||
? {
|
||||
role: 'verifier',
|
||||
isVerified: viewerState.isVerified,
|
||||
hasIssuedVerification,
|
||||
}
|
||||
: {
|
||||
role: 'default',
|
||||
isVerified: viewerState.isVerified,
|
||||
},
|
||||
}
|
||||
}, [profile, currentAccount, profileState, viewerState])
|
||||
}
|
||||
|
||||
export type SimpleVerificationState = {
|
||||
role: 'default' | 'verifier'
|
||||
isVerified: boolean
|
||||
showBadge: boolean
|
||||
}
|
||||
|
||||
export function useSimpleVerificationState({
|
||||
profile,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
}): SimpleVerificationState {
|
||||
const preferences = usePreferencesQuery()
|
||||
const prefs = useMemo(
|
||||
() => preferences.data?.verificationPrefs || {hideBadges: false},
|
||||
[preferences.data?.verificationPrefs],
|
||||
)
|
||||
return useMemo(() => {
|
||||
if (!profile || !profile.verification) {
|
||||
return {
|
||||
role: 'default',
|
||||
isVerified: false,
|
||||
showBadge: false,
|
||||
}
|
||||
}
|
||||
|
||||
const {verifiedStatus, trustedVerifierStatus} = profile.verification
|
||||
const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
|
||||
const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
|
||||
const isVerified =
|
||||
(isVerifiedUser && verifiedStatus === 'valid') ||
|
||||
(isVerifierUser && trustedVerifierStatus === 'valid')
|
||||
|
||||
return {
|
||||
role: isVerifierUser ? 'verifier' : 'default',
|
||||
isVerified,
|
||||
showBadge: prefs.hideBadges ? false : isVerified,
|
||||
}
|
||||
}, [profile, prefs])
|
||||
}
|
||||
@@ -192,3 +192,11 @@ export const SUPPORTED_MIME_TYPES = [
|
||||
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
||||
|
||||
export const EMOJI_REACTION_LIMIT = 5
|
||||
|
||||
export const urls = {
|
||||
website: {
|
||||
blog: {
|
||||
initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
|
||||
export function getUserDisplayName<
|
||||
T extends {displayName?: string; handle: string; [key: string]: any},
|
||||
>(props: T): string {
|
||||
return sanitizeDisplayName(
|
||||
props.displayName || sanitizeHandle(props.handle, '@'),
|
||||
)
|
||||
}
|
||||
@@ -26,10 +26,7 @@ export function KeyboardControllerProvider({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<KeyboardProvider
|
||||
enabled={false}
|
||||
// I don't think this is necessary, but Chesterton's fence and all that -sfn
|
||||
statusBarTranslucent={true}>
|
||||
<KeyboardProvider enabled={false}>
|
||||
<KeyboardControllerProviderInner>
|
||||
{children}
|
||||
</KeyboardControllerProviderInner>
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CommonNavigatorParams = {
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
ModerationInteractionSettings: undefined
|
||||
ModerationVerificationSettings: undefined
|
||||
Settings: undefined
|
||||
Profile: {name: string; hideBackButton?: boolean}
|
||||
ProfileFollowers: {name: string}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function isInvalidHandle(handle: string): boolean {
|
||||
export function sanitizeHandle(handle: string, prefix = ''): string {
|
||||
return isInvalidHandle(handle)
|
||||
? '⚠Invalid Handle'
|
||||
: forceLTR(`${prefix}${handle}`)
|
||||
: forceLTR(`${prefix}${handle.toLocaleLowerCase()}`)
|
||||
}
|
||||
|
||||
export interface IsValidHandle {
|
||||
|
||||