Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc6a90b0b7 | |||
| fffd258a68 | |||
| 5c552be275 | |||
| d7a644e759 | |||
| bff64b78d1 | |||
| ee2a579d1a | |||
| 4bea63faf7 | |||
| ec4cdf1189 | |||
| a2d8d3b90e | |||
| 981409fbcc | |||
| 42ec49c81a | |||
| 2a69ee8fac | |||
| ca4e7f9cc0 | |||
| af70a8a9e0 | |||
| dbe86dde43 | |||
| 2b23499a15 | |||
| 996e550c0a | |||
| 5c1f21623d | |||
| 19ec70b8f0 | |||
| c894dda0bf | |||
| c634cd9071 | |||
| 3c92714e4e | |||
| 25baf1f661 | |||
| dcbcd1bb80 | |||
| 92ee6260c1 | |||
| 9c7b330f92 | |||
| bc95c0e50c | |||
| aeafb14fb4 | |||
| 4c75b568df | |||
| 9702b210cc | |||
| d53fdb0dda | |||
| 9be3b0794c | |||
| 77db98de65 | |||
| 51babe0164 | |||
| 04a2a227fc | |||
| 42510c0515 | |||
| f36776a91e | |||
| eb18b6a49a | |||
| 3eaa2d9ddf | |||
| 066fd8cbf9 | |||
| 06ebd2964a | |||
| 3304cd0424 | |||
| 32ba17a4f1 | |||
| f83335f110 | |||
| 4da86e5864 | |||
| dd86402763 | |||
| 13d21692bf | |||
| e672f43ec8 | |||
| afd3d2829f | |||
| 619fa0d0bb | |||
| 9cf457acf8 | |||
| f1f9ca9606 | |||
| 438ec7c3e1 | |||
| 21989b558b | |||
| 7dc6bb57a6 | |||
| c16d5ce821 | |||
| c2b71a6a9e | |||
| 585dbebb69 | |||
| 5c50e10205 |
+3
-3
@@ -29,8 +29,8 @@ module.exports = function (_config) {
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||
? 'testflight'
|
||||
: IS_PRODUCTION
|
||||
? 'production'
|
||||
: undefined
|
||||
? 'production'
|
||||
: undefined
|
||||
const UPDATES_ENABLED = !!UPDATES_CHANNEL
|
||||
|
||||
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
|
||||
@@ -219,7 +219,7 @@ module.exports = function (_config) {
|
||||
compileSdkVersion: 35,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
newArchEnabled: true,
|
||||
newArchEnabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.5 9.5 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z"/></svg>
|
||||
|
After Width: | Height: | Size: 809 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3.92 19v-4.153a1 1 0 0 1 1-1H9l.103.005a1 1 0 0 1 0 1.99L9 15.847H7.285c.854.737 1.784 1.38 2.631 1.9.702.431 1.329.769 1.78.997l.291.143a25.6 25.6 0 0 0 3.67-2.326c2.144-1.642 4.073-3.756 4.315-6.023a1 1 0 0 1 1.988.212c-.336 3.154-2.89 5.717-5.086 7.398a27.6 27.6 0 0 1-4.34 2.704l-.078.038-.021.01-.007.003-.002.001-.001.001a1 1 0 0 1-.827.01v0h-.002l-.004-.002-.013-.006q-.016-.006-.045-.02l-.162-.075a27 27 0 0 1-2.503-1.361 22 22 0 0 1-2.95-2.143V19a1 1 0 0 1-2 0ZM2 10c0-2.214.696-3.971 1.833-5.184A5.7 5.7 0 0 1 8 3a7.1 7.1 0 0 1 4 1.228A7.1 7.1 0 0 1 16 3a5.68 5.68 0 0 1 3.469 1.185l.031-1.702a1 1 0 0 1 2 .035l-.081 4.5a1 1 0 0 1-1 .983H16.5a1 1 0 1 1 0-2h2.02A3.68 3.68 0 0 0 16 5a5.12 5.12 0 0 0-3.11 1.053 3 3 0 0 0-.155.129l-.029.025v.002l-.003.002-.072.064a1 1 0 0 1-1.338-.068l-.028-.025a3 3 0 0 0-.155-.13A5.12 5.12 0 0 0 8 5c-.982 0-1.965.392-2.708 1.185C4.554 6.97 4 8.214 4 10q0 .507.099 1.002l.075.328.02.1a1 1 0 0 1-1.925.5l-.03-.097-.102-.446A7 7 0 0 1 2 10Z"/></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" d="M16 6a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V6ZM2.87 7.225a1 1 0 0 1 1.337 1.482L3.155 9.759a.546.546 0 0 0-.05.714l.119.173c.52.827.52 1.88 0 2.707l-.12.174a.546.546 0 0 0 .051.714l1.052 1.052.069.076a1 1 0 0 1-1.407 1.406l-.076-.068-1.052-1.052a2.55 2.55 0 0 1-.237-3.328l.048-.075a.55.55 0 0 0 0-.504l-.048-.075a2.55 2.55 0 0 1 .237-3.328l1.052-1.052.076-.068Zm16.923.068a1 1 0 0 1 1.338-.068l.076.068 1.052 1.052.16.174c.696.837.78 2.03.209 2.958l-.133.196a.55.55 0 0 0 0 .654l.133.196a2.55 2.55 0 0 1-.21 2.958l-.16.174-1.05 1.052a1 1 0 1 1-1.415-1.414l1.052-1.052.064-.077a.55.55 0 0 0 .04-.552l-.053-.085a2.545 2.545 0 0 1 0-3.054l.052-.085a.55.55 0 0 0-.039-.552l-.064-.077-1.052-1.052-.068-.076a1 1 0 0 1 .068-1.338ZM13 6l.103.005a1 1 0 0 1 0 1.99L13 8h-2a1 1 0 1 1 0-2h2Zm5 12a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12Z"/></svg>
|
||||
|
After Width: | Height: | Size: 979 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M6.043 14.293a1 1 0 1 1 1.414 1.414L5.164 18l2.293 2.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47Zm6.22 0a1 1 0 0 1 1.414 1.414L12.384 17H18a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3h-5.616l1.293 1.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47ZM3 11V8a3 3 0 0 1 3-3h5.586l-1.293-1.293-.068-.076a1 1 0 0 1 1.406-1.406l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L11.586 7H6a1 1 0 0 0-1 1v3a1 1 0 1 1-2 0Zm13.543-8.707a1 1 0 0 1 1.338-.068l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L18.836 6l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z"/></svg>
|
||||
|
After Width: | Height: | Size: 823 B |
@@ -1,9 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: ['variant', [
|
||||
'&:is(.dark *):not(:is(.dark .light *))',
|
||||
]],
|
||||
darkMode: ['variant', ['&:is(.dark *):not(:is(.dark .light *))']],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import assert from 'assert'
|
||||
import {
|
||||
Kysely,
|
||||
KyselyPlugin,
|
||||
type KyselyPlugin,
|
||||
Migrator,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
type PluginTransformQueryArgs,
|
||||
type PluginTransformResultArgs,
|
||||
PostgresDialect,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
type QueryResult,
|
||||
type RootOperationNode,
|
||||
type UnknownRow,
|
||||
} from 'kysely'
|
||||
import {default as Pg} from 'pg'
|
||||
|
||||
import {dbLogger as log} from '../logger.js'
|
||||
import {default as migrations} from './migrations/index.js'
|
||||
import {DbMigrationProvider} from './migrations/provider.js'
|
||||
import {DbSchema} from './schema.js'
|
||||
import {type DbSchema} from './schema.js'
|
||||
|
||||
export class Database {
|
||||
migrator: Migrator
|
||||
destroyed = false
|
||||
|
||||
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
|
||||
constructor(
|
||||
public db: Kysely<DbSchema>,
|
||||
public cfg: PgConfig,
|
||||
) {
|
||||
this.migrator = new Migrator({
|
||||
db,
|
||||
migrationTableSchema: cfg.schema,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
import type http from 'node:http'
|
||||
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
|
||||
|
||||
import {Config} from './config.js'
|
||||
import {type Config} from './config.js'
|
||||
import {AppContext} from './context.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
|
||||
@@ -17,7 +17,10 @@ export class LinkService {
|
||||
public server?: http.Server
|
||||
private terminator?: HttpTerminator
|
||||
|
||||
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||
constructor(
|
||||
public app: express.Application,
|
||||
public ctx: AppContext,
|
||||
) {}
|
||||
|
||||
static async create(cfg: Config): Promise<LinkService> {
|
||||
let app = express()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
import type http from 'node:http'
|
||||
|
||||
import express from 'express'
|
||||
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
|
||||
|
||||
import {Config} from './config.js'
|
||||
import {type Config} from './config.js'
|
||||
import {AppContext} from './context.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
|
||||
@@ -15,7 +15,10 @@ export class CardService {
|
||||
public server?: http.Server
|
||||
private terminator?: HttpTerminator
|
||||
|
||||
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||
constructor(
|
||||
public app: express.Application,
|
||||
public ctx: AppContext,
|
||||
) {}
|
||||
|
||||
static async create(cfg: Config): Promise<CardService> {
|
||||
let app = express()
|
||||
|
||||
@@ -278,6 +278,17 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
e.GET("/settings/interests", server.WebGeneric)
|
||||
e.GET("/settings/about", server.WebGeneric)
|
||||
e.GET("/settings/notifications", server.WebGeneric)
|
||||
e.GET("/settings/notifications/replies", server.WebGeneric)
|
||||
e.GET("/settings/notifications/mentions", server.WebGeneric)
|
||||
e.GET("/settings/notifications/quotes", server.WebGeneric)
|
||||
e.GET("/settings/notifications/likes", server.WebGeneric)
|
||||
e.GET("/settings/notifications/reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/new-followers", server.WebGeneric)
|
||||
e.GET("/settings/notifications/likes-on-reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/activity", server.WebGeneric)
|
||||
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
|
||||
e.GET("/settings/app-icon", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||
|
||||
+15
@@ -15,6 +15,8 @@ class BackgroundNotificationHandler(
|
||||
|
||||
if (remoteMessage.data["reason"] == "chat-message") {
|
||||
mutateWithChatMessage(remoteMessage)
|
||||
} else {
|
||||
mutateWithOtherReason(remoteMessage)
|
||||
}
|
||||
|
||||
notifInterface.showMessage(remoteMessage)
|
||||
@@ -39,4 +41,17 @@ class BackgroundNotificationHandler(
|
||||
// TODO - Remove this once we have more backend capability
|
||||
remoteMessage.data["badge"] = null
|
||||
}
|
||||
|
||||
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
|
||||
// If oreo or higher
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost"
|
||||
// assign to it's eponymous channel. otherwise do nothing, let expo handle it
|
||||
when (remoteMessage.data["reason"]) {
|
||||
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> {
|
||||
remoteMessage.data["channelId"] = remoteMessage.data["reason"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const EmojiPicker = ({onEmojiSelected}: EmojiPickerViewProps) => {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
backgroundColor: scheme === 'dark' ? '#000' : '#fff',
|
||||
} as const),
|
||||
}) as const,
|
||||
[scheme],
|
||||
)
|
||||
|
||||
|
||||
+4
-4
@@ -69,7 +69,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.15",
|
||||
"@atproto/api": "^0.15.16",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -190,7 +190,7 @@
|
||||
"react-native-gesture-handler": "2.25.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.17.1",
|
||||
"react-native-keyboard-controller": "^1.17.5",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "^6.7.1",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
@@ -218,7 +218,7 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.133",
|
||||
"@atproto/dev-env": "^0.3.144",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -265,7 +265,7 @@
|
||||
"lint-staged": "^13.2.3",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"prettier": "^2.8.3",
|
||||
"prettier": "^3.6.0",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^3.3.2",
|
||||
|
||||
@@ -9,10 +9,9 @@ const templateFile = path.join(
|
||||
'scripts.html',
|
||||
)
|
||||
|
||||
const {entrypoints} = require(path.join(
|
||||
projectRoot,
|
||||
'web-build/asset-manifest.json',
|
||||
))
|
||||
const {entrypoints} = require(
|
||||
path.join(projectRoot, 'web-build/asset-manifest.json'),
|
||||
)
|
||||
|
||||
console.log(`Found ${entrypoints.length} entrypoints`)
|
||||
console.log(`Writing ${templateFile}`)
|
||||
|
||||
+8
-5
@@ -33,6 +33,7 @@ import {
|
||||
ensureGeolocationResolved,
|
||||
Provider as GeolocationProvider,
|
||||
} from '#/state/geolocation'
|
||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||
@@ -154,11 +155,13 @@ function InnerApp() {
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceAccountManager>
|
||||
|
||||
+93
-6
@@ -90,11 +90,10 @@ 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 {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
|
||||
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,
|
||||
@@ -110,6 +109,17 @@ import {
|
||||
} from '#/components/dialogs/EmailDialog'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
|
||||
import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
|
||||
import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
|
||||
import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
|
||||
import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
|
||||
import {MiscellaneousNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
|
||||
import {NewFollowerNotificationSettingsScreen} from './screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
|
||||
import {QuoteNotificationSettingsScreen} from './screens/Settings/NotificationSettings/QuoteNotificationSettings'
|
||||
import {ReplyNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ReplyNotificationSettings'
|
||||
import {RepostNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostNotificationSettings'
|
||||
import {RepostsOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -380,6 +390,83 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
options={{title: title(msg`Notification settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ReplyNotificationSettings"
|
||||
getComponent={() => ReplyNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Reply notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MentionNotificationSettings"
|
||||
getComponent={() => MentionNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Mention notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="QuoteNotificationSettings"
|
||||
getComponent={() => QuoteNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Quote notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="LikeNotificationSettings"
|
||||
getComponent={() => LikeNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Like notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="RepostNotificationSettings"
|
||||
getComponent={() => RepostNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Repost notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NewFollowerNotificationSettings"
|
||||
getComponent={() => NewFollowerNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`New follower notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="LikesOnRepostsNotificationSettings"
|
||||
getComponent={() => LikesOnRepostsNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Likes of your reposts notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="RepostsOnRepostsNotificationSettings"
|
||||
getComponent={() => RepostsOnRepostsNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Reposts of your reposts notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MiscellaneousNotificationSettings"
|
||||
getComponent={() => MiscellaneousNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Miscellaneous notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ContentAndMediaSettings"
|
||||
getComponent={() => ContentAndMediaSettingsScreen}
|
||||
@@ -389,8 +476,8 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SettingsInterests"
|
||||
getComponent={() => SettingsInterests}
|
||||
name="InterestsSettings"
|
||||
getComponent={() => InterestsSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Your interests`),
|
||||
requireAuth: true,
|
||||
@@ -438,8 +525,8 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
name="LegacyNotificationSettings"
|
||||
getComponent={() => LegacyNotificationSettingsScreen}
|
||||
options={{title: title(msg`Notification settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
|
||||
@@ -983,6 +983,10 @@ export const atoms = {
|
||||
transition_none: web({
|
||||
transitionProperty: 'none',
|
||||
}),
|
||||
transition_timing_default: web({
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||
transitionDuration: '100ms',
|
||||
}),
|
||||
transition_all: web({
|
||||
transitionProperty: 'all',
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||
|
||||
@@ -190,7 +190,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
if (item) playHaptic('Light')
|
||||
setHoveredMenuItem(item)
|
||||
},
|
||||
} satisfies ContextType),
|
||||
}) satisfies ContextType,
|
||||
[
|
||||
measurement,
|
||||
setMeasurement,
|
||||
@@ -710,8 +710,8 @@ export function Item({
|
||||
const xOffset = position
|
||||
? position.x
|
||||
: align === 'left'
|
||||
? measurement.x
|
||||
: measurement.x + measurement.width - layout.width
|
||||
? measurement.x
|
||||
: measurement.x + measurement.width - layout.width
|
||||
|
||||
registerHoverable(
|
||||
id,
|
||||
|
||||
+57
-22
@@ -9,7 +9,7 @@ import {
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {type AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
@@ -24,6 +24,7 @@ import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -98,10 +99,10 @@ export function useLink({
|
||||
return typeof to === 'string'
|
||||
? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
|
||||
: to.screen
|
||||
? router.matchName(to.screen)?.build(to.params)
|
||||
: to.href
|
||||
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
|
||||
: undefined
|
||||
? router.matchName(to.screen)?.build(to.params)
|
||||
: to.href
|
||||
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
|
||||
: undefined
|
||||
}, [to])
|
||||
|
||||
if (!href) {
|
||||
@@ -111,7 +112,8 @@ export function useLink({
|
||||
}
|
||||
|
||||
const isExternal = isExternalUrl(href)
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const {closeModal} = useModalControls()
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const onPress = React.useCallback(
|
||||
@@ -132,10 +134,9 @@ export function useLink({
|
||||
}
|
||||
|
||||
if (requiresWarning) {
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: displayText,
|
||||
href: href,
|
||||
linkWarningDialogControl.open({
|
||||
displayText,
|
||||
href,
|
||||
})
|
||||
} else {
|
||||
if (isExternal) {
|
||||
@@ -154,15 +155,44 @@ export function useLink({
|
||||
} else {
|
||||
closeModal() // close any active modals
|
||||
|
||||
const [screen, params] = router.matchPath(href) as [
|
||||
screen: keyof AllNavigatorParams,
|
||||
params?: RouteParams,
|
||||
]
|
||||
|
||||
// does not apply to web's flat navigator
|
||||
if (isNative && screen !== 'NotFound') {
|
||||
const state = navigation.getState()
|
||||
// if screen is not in the current navigator, it means it's
|
||||
// most likely a tab screen
|
||||
if (!state.routeNames.includes(screen)) {
|
||||
const parent = navigation.getParent()
|
||||
if (
|
||||
parent &&
|
||||
parent.getState().routeNames.includes(`${screen}Tab`)
|
||||
) {
|
||||
// yep, it's a tab screen. i.e. SearchTab
|
||||
// thus we need to navigate to the child screen
|
||||
// via the parent navigator
|
||||
// see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action
|
||||
// TODO: can we support the other kinds of actions? push/replace -sfn
|
||||
|
||||
// @ts-expect-error include does not narrow the type unfortunately
|
||||
parent.navigate(`${screen}Tab`, {screen, params})
|
||||
return
|
||||
} else {
|
||||
// will probably fail, but let's try anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'push') {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath(href)))
|
||||
navigation.dispatch(StackActions.push(screen, params))
|
||||
} else if (action === 'replace') {
|
||||
navigation.dispatch(
|
||||
StackActions.replace(...router.matchPath(href)),
|
||||
)
|
||||
navigation.dispatch(StackActions.replace(screen, params))
|
||||
} else if (action === 'navigate') {
|
||||
// @ts-ignore
|
||||
navigation.navigate(...router.matchPath(href))
|
||||
// @ts-expect-error not typed
|
||||
navigation.navigate(screen, params)
|
||||
} else {
|
||||
throw Error('Unsupported navigator action.')
|
||||
}
|
||||
@@ -176,13 +206,13 @@ export function useLink({
|
||||
displayText,
|
||||
isExternal,
|
||||
href,
|
||||
openModal,
|
||||
openLink,
|
||||
closeModal,
|
||||
action,
|
||||
navigation,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
linkWarningDialogControl,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -195,16 +225,21 @@ export function useLink({
|
||||
)
|
||||
|
||||
if (requiresWarning) {
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: displayText,
|
||||
href: href,
|
||||
linkWarningDialogControl.open({
|
||||
displayText,
|
||||
href,
|
||||
share: true,
|
||||
})
|
||||
} else {
|
||||
shareUrl(href)
|
||||
}
|
||||
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
|
||||
}, [
|
||||
disableMismatchWarning,
|
||||
displayText,
|
||||
href,
|
||||
isExternal,
|
||||
linkWarningDialogControl,
|
||||
])
|
||||
|
||||
const onLongPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
|
||||
@@ -77,9 +77,9 @@ export function ImageEmbed({
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
|
||||
|
||||
@@ -317,8 +317,8 @@ export function Controls({
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
accessibilityHint=""
|
||||
style={[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
|
||||
|
||||
import {isSafari} from '#/lib/browser'
|
||||
import {logger} from '#/logger'
|
||||
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
|
||||
export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
|
||||
@@ -79,7 +80,12 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
|
||||
await ref.current.play()
|
||||
} catch (e: any) {
|
||||
if (
|
||||
!e.message?.includes(`The request is not allowed by the user agent`)
|
||||
!e.message?.includes(
|
||||
`The request is not allowed by the user agent`,
|
||||
) &&
|
||||
!e.message?.includes(
|
||||
`The play() request was interrupted by a call to pause()`,
|
||||
)
|
||||
) {
|
||||
throw e
|
||||
}
|
||||
@@ -176,8 +182,15 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
promise.catch((err: any) => {
|
||||
if (
|
||||
// ignore this common error. it's fine
|
||||
!err.message?.includes(
|
||||
`The play() request was interrupted by a call to pause()`,
|
||||
)
|
||||
) {
|
||||
logger.error('Error playing video:', {message: err})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,8 +600,8 @@ let PostMenuItems = ({
|
||||
isDetachPending
|
||||
? Loader
|
||||
: quoteEmbed.isDetached
|
||||
? Eye
|
||||
: EyeSlash
|
||||
? Eye
|
||||
: EyeSlash
|
||||
}
|
||||
position="right"
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,17 @@ let RepostButton = ({
|
||||
const requireAuth = useRequireAuth()
|
||||
const dialogControl = Dialog.useDialogControl()
|
||||
|
||||
const onPress = () => requireAuth(() => dialogControl.open())
|
||||
|
||||
const onLongPress = () =>
|
||||
requireAuth(() => {
|
||||
if (embeddingDisabled) {
|
||||
dialogControl.open()
|
||||
} else {
|
||||
onQuote()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostControlButton
|
||||
@@ -47,8 +58,8 @@ let RepostButton = ({
|
||||
active={isReposted}
|
||||
activeColor={t.palette.positive_600}
|
||||
big={big}
|
||||
onPress={() => requireAuth(() => dialogControl.open())}
|
||||
onLongPress={() => requireAuth(() => onQuote())}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
label={
|
||||
isReposted
|
||||
? _(
|
||||
|
||||
@@ -74,7 +74,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||
return props.children
|
||||
} else {
|
||||
return (
|
||||
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
|
||||
<View onPointerMove={onPointerMove} style={[a.flex_shrink, props.style]}>
|
||||
<ProfileHoverCardInner {...props} />
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type React from 'react'
|
||||
|
||||
export type ProfileHoverCardProps = {
|
||||
children: React.ReactElement
|
||||
import {type ViewStyleProp} from '#/alf'
|
||||
|
||||
export type ProfileHoverCardProps = ViewStyleProp & {
|
||||
children: React.ReactNode
|
||||
did: string
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
borderColor: focused
|
||||
? t.palette.primary_500
|
||||
: hovered
|
||||
? t.palette.contrast_100
|
||||
: t.palette.contrast_25,
|
||||
? t.palette.contrast_100
|
||||
: t.palette.contrast_25,
|
||||
},
|
||||
])}>
|
||||
{children}
|
||||
@@ -244,6 +244,7 @@ export function Item({ref, value, style, children}: ItemProps) {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={flatten([
|
||||
t.atoms.text,
|
||||
a.relative,
|
||||
a.flex,
|
||||
{minHeight: 25, paddingLeft: 30, paddingRight: 35},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export const BUBBLE_MAX_WIDTH = 240
|
||||
export const ARROW_SIZE = 12
|
||||
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
|
||||
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
|
||||
@@ -0,0 +1,411 @@
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {useOnGesture} from '#/components/hooks/useOnGesture'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {
|
||||
ARROW_HALF_SIZE,
|
||||
ARROW_SIZE,
|
||||
BUBBLE_MAX_WIDTH,
|
||||
MIN_EDGE_SPACE,
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/**
|
||||
* These are native specific values, not shared with web
|
||||
*/
|
||||
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
|
||||
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
ready: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}
|
||||
|
||||
type TargetContextType = {
|
||||
targetMeasurements:
|
||||
| {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
| undefined
|
||||
targetRef: React.RefObject<View>
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
position: 'bottom',
|
||||
ready: false,
|
||||
onVisibleChange: () => {},
|
||||
})
|
||||
|
||||
const TargetContext = createContext<TargetContextType>({
|
||||
targetMeasurements: undefined,
|
||||
targetRef: {current: null},
|
||||
})
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
position = 'bottom',
|
||||
visible: requestVisible,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom'
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
/**
|
||||
* Whether we have measured the target and are ready to show the tooltip.
|
||||
*/
|
||||
const [ready, setReady] = useState(false)
|
||||
/**
|
||||
* Lagging state to track the externally-controlled visibility of the
|
||||
* tooltip.
|
||||
*/
|
||||
const [prevRequestVisible, setPrevRequestVisible] = useState<
|
||||
boolean | undefined
|
||||
>()
|
||||
/**
|
||||
* Needs to reference the element this Tooltip is attached to.
|
||||
*/
|
||||
const targetRef = useRef<View>(null)
|
||||
const [targetMeasurements, setTargetMeasurements] = useState<
|
||||
| {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
if (requestVisible && !prevRequestVisible) {
|
||||
setPrevRequestVisible(true)
|
||||
|
||||
if (targetRef.current) {
|
||||
/*
|
||||
* Once opened, measure the dimensions and position of the target
|
||||
*/
|
||||
targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
|
||||
if (pageX !== undefined && pageY !== undefined && width && height) {
|
||||
setTargetMeasurements({x: pageX, y: pageY, width, height})
|
||||
setReady(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (!requestVisible && prevRequestVisible) {
|
||||
setPrevRequestVisible(false)
|
||||
setTargetMeasurements(undefined)
|
||||
setReady(false)
|
||||
}
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({position, ready, onVisibleChange}),
|
||||
[position, ready, onVisibleChange],
|
||||
)
|
||||
const targetCtx = useMemo(
|
||||
() => ({targetMeasurements, targetRef}),
|
||||
[targetMeasurements, targetRef],
|
||||
)
|
||||
|
||||
return (
|
||||
<TooltipContext.Provider value={ctx}>
|
||||
<TargetContext.Provider value={targetCtx}>
|
||||
{children}
|
||||
</TargetContext.Provider>
|
||||
</TooltipContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
const {targetRef} = useContext(TargetContext)
|
||||
|
||||
return (
|
||||
<View collapsable={false} ref={targetRef}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const {position, ready, onVisibleChange} = useContext(TooltipContext)
|
||||
const {targetMeasurements} = useContext(TargetContext)
|
||||
const requestClose = useCallback(() => {
|
||||
onVisibleChange(false)
|
||||
}, [onVisibleChange])
|
||||
|
||||
if (!ready || !targetMeasurements) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Bubble
|
||||
label={label}
|
||||
position={position}
|
||||
/*
|
||||
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
|
||||
* the context providers.
|
||||
*/
|
||||
targetMeasurements={targetMeasurements}
|
||||
requestClose={requestClose}>
|
||||
{children}
|
||||
</Bubble>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({
|
||||
children,
|
||||
label,
|
||||
position,
|
||||
requestClose,
|
||||
targetMeasurements,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
position: TooltipContextType['position']
|
||||
requestClose: () => void
|
||||
targetMeasurements: Exclude<
|
||||
TargetContextType['targetMeasurements'],
|
||||
undefined
|
||||
>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const dimensions = useWindowDimensions()
|
||||
const [bubbleMeasurements, setBubbleMeasurements] = useState<
|
||||
| {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
const coords = useMemo(() => {
|
||||
if (!bubbleMeasurements)
|
||||
return {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
tipTop: 0,
|
||||
tipLeft: 0,
|
||||
}
|
||||
|
||||
const {width: ww, height: wh} = dimensions
|
||||
const maxTop = insets.top
|
||||
const maxBottom = wh - insets.bottom
|
||||
const {width: cw, height: ch} = bubbleMeasurements
|
||||
const minLeft = MIN_EDGE_SPACE
|
||||
const maxLeft = ww - minLeft
|
||||
|
||||
let computedPosition: 'top' | 'bottom' = position
|
||||
let top = targetMeasurements.y + targetMeasurements.height
|
||||
let left = Math.max(
|
||||
minLeft,
|
||||
targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
|
||||
)
|
||||
const tipTranslate = ARROW_HALF_SIZE * -1
|
||||
let tipTop = tipTranslate
|
||||
|
||||
if (left + cw > maxLeft) {
|
||||
left -= left + cw - maxLeft
|
||||
}
|
||||
|
||||
let tipLeft =
|
||||
targetMeasurements.x -
|
||||
left +
|
||||
targetMeasurements.width / 2 -
|
||||
ARROW_HALF_SIZE
|
||||
|
||||
let bottom = top + ch
|
||||
|
||||
function positionTop() {
|
||||
top = top - ch - targetMeasurements.height
|
||||
bottom = top + ch
|
||||
tipTop = tipTop + ch
|
||||
computedPosition = 'top'
|
||||
}
|
||||
|
||||
function positionBottom() {
|
||||
top = targetMeasurements.y + targetMeasurements.height
|
||||
bottom = top + ch
|
||||
tipTop = tipTranslate
|
||||
computedPosition = 'bottom'
|
||||
}
|
||||
|
||||
if (position === 'top') {
|
||||
positionTop()
|
||||
if (top < maxTop) {
|
||||
positionBottom()
|
||||
}
|
||||
} else {
|
||||
if (bottom > maxBottom) {
|
||||
positionTop()
|
||||
}
|
||||
}
|
||||
|
||||
if (computedPosition === 'bottom') {
|
||||
top += ARROW_VISUAL_OFFSET
|
||||
bottom += ARROW_VISUAL_OFFSET
|
||||
} else {
|
||||
top -= ARROW_VISUAL_OFFSET
|
||||
bottom -= ARROW_VISUAL_OFFSET
|
||||
}
|
||||
|
||||
return {
|
||||
computedPosition,
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right: left + cw,
|
||||
tipTop,
|
||||
tipLeft,
|
||||
}
|
||||
}, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
|
||||
|
||||
const requestCloseWrapped = useCallback(() => {
|
||||
setBubbleMeasurements(undefined)
|
||||
requestClose()
|
||||
}, [requestClose])
|
||||
|
||||
useOnGesture(
|
||||
useCallback(
|
||||
e => {
|
||||
const {x, y} = e
|
||||
const isInside =
|
||||
x > coords.left &&
|
||||
x < coords.right &&
|
||||
y > coords.top &&
|
||||
y < coords.bottom
|
||||
|
||||
if (!isInside) {
|
||||
requestCloseWrapped()
|
||||
}
|
||||
},
|
||||
[coords, requestCloseWrapped],
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
accessible
|
||||
role="alert"
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
// android
|
||||
importantForAccessibility="yes"
|
||||
// ios
|
||||
accessibilityViewIsModal
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_start,
|
||||
{
|
||||
width: BUBBLE_MAX_WIDTH,
|
||||
opacity: bubbleMeasurements ? 1 : 0,
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
},
|
||||
]}>
|
||||
<Animated.View
|
||||
entering={ZoomIn.easing(Easing.out(Easing.exp))}
|
||||
style={{transformOrigin: oppposite(position)}}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.top_0,
|
||||
a.z_10,
|
||||
t.atoms.bg,
|
||||
select(t.name, {
|
||||
light: t.atoms.bg,
|
||||
dark: t.atoms.bg_contrast_100,
|
||||
dim: t.atoms.bg_contrast_100,
|
||||
}),
|
||||
{
|
||||
borderTopLeftRadius: a.rounded_2xs.borderRadius,
|
||||
borderBottomRightRadius: a.rounded_2xs.borderRadius,
|
||||
width: ARROW_SIZE,
|
||||
height: ARROW_SIZE,
|
||||
transform: [{rotate: '45deg'}],
|
||||
top: coords.tipTop,
|
||||
left: coords.tipLeft,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.rounded_sm,
|
||||
select(t.name, {
|
||||
light: t.atoms.bg,
|
||||
dark: t.atoms.bg_contrast_100,
|
||||
dim: t.atoms.bg_contrast_100,
|
||||
}),
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowOpacity: 0.2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height:
|
||||
BUBBLE_SHADOW_OFFSET *
|
||||
(coords.computedPosition === 'bottom' ? -1 : 1),
|
||||
},
|
||||
},
|
||||
]}
|
||||
onLayout={e => {
|
||||
setBubbleMeasurements({
|
||||
width: e.nativeEvent.layout.width,
|
||||
height: e.nativeEvent.layout.height,
|
||||
})
|
||||
}}>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function oppposite(position: 'top' | 'bottom') {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'center bottom'
|
||||
case 'bottom':
|
||||
return 'center top'
|
||||
default:
|
||||
return 'center'
|
||||
}
|
||||
}
|
||||
|
||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
||||
const c = Children.toArray(children)
|
||||
return (
|
||||
<Content label={c.join(' ')}>
|
||||
<View style={[a.gap_xs]}>
|
||||
{c.map((child, i) => (
|
||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
||||
{child}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</Content>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {Children, createContext, useContext, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Popover} from 'radix-ui'
|
||||
|
||||
import {atoms as a, flatten, select, useTheme} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {
|
||||
ARROW_SIZE,
|
||||
BUBBLE_MAX_WIDTH,
|
||||
MIN_EDGE_SPACE,
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
position: 'bottom',
|
||||
})
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
position = 'bottom',
|
||||
visible,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position?: 'top' | 'bottom'
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
const ctx = useMemo(() => ({position}), [position])
|
||||
return (
|
||||
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<View collapsable={false}>{children}</View>
|
||||
</Popover.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {position} = useContext(TooltipContext)
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="radix-popover-content"
|
||||
aria-label={label}
|
||||
side={position}
|
||||
sideOffset={4}
|
||||
collisionPadding={MIN_EDGE_SPACE}
|
||||
style={flatten([
|
||||
a.rounded_sm,
|
||||
select(t.name, {
|
||||
light: t.atoms.bg,
|
||||
dark: t.atoms.bg_contrast_100,
|
||||
dim: t.atoms.bg_contrast_100,
|
||||
}),
|
||||
{
|
||||
minWidth: 'max-content',
|
||||
boxShadow: select(t.name, {
|
||||
light: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||
dark: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||
dim: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||
}),
|
||||
},
|
||||
])}>
|
||||
<Popover.Arrow
|
||||
width={ARROW_SIZE}
|
||||
height={ARROW_SIZE / 2}
|
||||
fill={select(t.name, {
|
||||
light: t.atoms.bg.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||
})}
|
||||
/>
|
||||
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
|
||||
{children}
|
||||
</View>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextBubble({children}: {children: React.ReactNode}) {
|
||||
const c = Children.toArray(children)
|
||||
return (
|
||||
<Content label={c.join(' ')}>
|
||||
<View style={[a.gap_xs]}>
|
||||
{c.map((child, i) => (
|
||||
<Text key={i} style={[a.text_sm, a.leading_snug]}>
|
||||
{child}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</Content>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
Keyboard,
|
||||
Platform,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
type AppBskyGraphDefs,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -13,7 +19,7 @@ import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
ThreadgateAllowUISetting,
|
||||
type ThreadgateAllowUISetting,
|
||||
threadgateViewToAllowUISetting,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -70,8 +76,8 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
const description = anyoneCanReply
|
||||
? _(msg`Everybody can reply`)
|
||||
: noOneCanReply
|
||||
? _(msg`Replies disabled`)
|
||||
: _(msg`Some people can reply`)
|
||||
? _(msg`Replies disabled`)
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
const onPressOpen = () => {
|
||||
if (isNative && Keyboard.isVisible()) {
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useBreakpoints, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ChangeEmailDialog({
|
||||
control,
|
||||
verifyEmailControl,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
verifyEmailControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Inner verifyEmailControl={verifyEmailControl} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({
|
||||
verifyEmailControl,
|
||||
}: {
|
||||
verifyEmailControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const control = Dialog.useDialogContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
'StepOne' | 'StepTwo' | 'StepThree'
|
||||
>('StepOne')
|
||||
const [email, setEmail] = useState('')
|
||||
const [confirmationCode, setConfirmationCode] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const currentEmail = currentAccount?.email || '(no email)'
|
||||
const uiStrings = {
|
||||
StepOne: {
|
||||
title: _(msg`Change Your Email`),
|
||||
message: '',
|
||||
},
|
||||
StepTwo: {
|
||||
title: _(msg`Security Step Required`),
|
||||
message: _(
|
||||
msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`,
|
||||
),
|
||||
},
|
||||
StepThree: {
|
||||
title: _(msg`Email Updated!`),
|
||||
message: _(
|
||||
msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const onRequestChange = async () => {
|
||||
if (email === currentAccount?.email) {
|
||||
setError(
|
||||
_(
|
||||
msg`The email address you entered is the same as your current email address.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const res = await agent.com.atproto.server.requestEmailUpdate()
|
||||
if (res.data.tokenRequired) {
|
||||
setCurrentStep('StepTwo')
|
||||
} else {
|
||||
await agent.com.atproto.server.updateEmail({email: email.trim()})
|
||||
await agent.resumeSession(agent.session!)
|
||||
setCurrentStep('StepThree')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirm = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await agent.com.atproto.server.updateEmail({
|
||||
email: email.trim(),
|
||||
token: confirmationCode.trim(),
|
||||
})
|
||||
await agent.resumeSession(agent.session!)
|
||||
setCurrentStep('StepThree')
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onVerify = async () => {
|
||||
control.close(() => {
|
||||
verifyEmailControl.open()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={web({maxWidth: 450})}>
|
||||
<Dialog.Close />
|
||||
<View style={[a.gap_xl]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
{uiStrings[currentStep].title}
|
||||
</Text>
|
||||
{error ? (
|
||||
<View style={[a.rounded_sm, a.overflow_hidden]}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
) : null}
|
||||
{currentStep === 'StepOne' ? (
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Enter your new email address below.</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(msg`New email address`)}
|
||||
placeholder={_(msg`alice@example.com`)}
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
{uiStrings[currentStep].message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{currentStep === 'StepTwo' ? (
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Confirmation code</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(msg`Confirmation code`)}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
onChangeText={setConfirmationCode}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
|
||||
{currentStep === 'StepOne' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Request change`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={onRequestChange}>
|
||||
<ButtonText>
|
||||
<Trans>Request change</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`I have a code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => setCurrentStep('StepTwo')}>
|
||||
<ButtonText>
|
||||
<Trans>I have a code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepTwo' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Confirm`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={onConfirm}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Resend email`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => {
|
||||
setConfirmationCode('')
|
||||
setCurrentStep('StepOne')
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Resend email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepThree' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Verify email`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onVerify}>
|
||||
<ButtonText>
|
||||
<Trans>Verify email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,11 @@ type ControlsContext = {
|
||||
signinDialogControl: Control
|
||||
inAppBrowserConsentControl: StatefulControl<string>
|
||||
emailDialogControl: StatefulControl<Screen>
|
||||
linkWarningDialogControl: StatefulControl<{
|
||||
href: string
|
||||
displayText: string
|
||||
share?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
const ControlsContext = createContext<ControlsContext | null>(null)
|
||||
@@ -36,6 +41,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const signinDialogControl = Dialog.useDialogControl()
|
||||
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
|
||||
const emailDialogControl = useStatefulDialogControl<Screen>()
|
||||
const linkWarningDialogControl = useStatefulDialogControl<{
|
||||
href: string
|
||||
displayText: string
|
||||
share?: boolean
|
||||
}>()
|
||||
|
||||
const ctx = useMemo<ControlsContext>(
|
||||
() => ({
|
||||
@@ -43,12 +53,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
signinDialogControl,
|
||||
inAppBrowserConsentControl,
|
||||
emailDialogControl,
|
||||
linkWarningDialogControl,
|
||||
}),
|
||||
[
|
||||
mutedWordsDialogControl,
|
||||
signinDialogControl,
|
||||
inAppBrowserConsentControl,
|
||||
emailDialogControl,
|
||||
linkWarningDialogControl,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ export function Disable() {
|
||||
state.emailStatus === 'pending'
|
||||
? Loader
|
||||
: state.emailStatus === 'success'
|
||||
? Check
|
||||
: Envelope
|
||||
? Check
|
||||
: Envelope
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -116,8 +116,8 @@ export function Enable() {
|
||||
state.status === 'pending'
|
||||
? Loader
|
||||
: state.status === 'success'
|
||||
? Check
|
||||
: ShieldIcon
|
||||
? Check
|
||||
: ShieldIcon
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useGlobalDialogsControlContext} from './Context'
|
||||
|
||||
export function LinkWarningDialog() {
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={linkWarningDialogControl.control}
|
||||
nativeOptions={{preventExpansion: true}}
|
||||
webOptions={{alignCenter: true}}
|
||||
onClose={linkWarningDialogControl.clear}>
|
||||
<Dialog.Handle />
|
||||
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function InAppBrowserConsentInner({
|
||||
link,
|
||||
}: {
|
||||
link?: {href: string; displayText: string; share?: boolean}
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const openLink = useOpenLink()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const potentiallyMisleading = useMemo(
|
||||
() => link && isPossiblyAUrl(link.displayText),
|
||||
[link],
|
||||
)
|
||||
|
||||
const onPressVisit = useCallback(() => {
|
||||
control.close(() => {
|
||||
if (!link) return
|
||||
if (link.share) {
|
||||
shareUrl(link.href)
|
||||
} else {
|
||||
openLink(link.href, undefined, true)
|
||||
}
|
||||
})
|
||||
}, [control, link, openLink])
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
control.close()
|
||||
}, [control])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
style={web({maxWidth: 450})}
|
||||
label={
|
||||
potentiallyMisleading
|
||||
? _(msg`Potentially misleading link warning`)
|
||||
: _(msg`Leaving Bluesky`)
|
||||
}>
|
||||
<View style={[a.gap_2xl]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
{potentiallyMisleading ? (
|
||||
<Trans>Potentially misleading link</Trans>
|
||||
) : (
|
||||
<Trans>Leaving Bluesky</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
|
||||
<Trans>This link is taking you to the following website:</Trans>
|
||||
</Text>
|
||||
{link && <LinkBox href={link.href} />}
|
||||
{potentiallyMisleading && (
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
|
||||
<Trans>Make sure this is where you intend to go!</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.gap_sm,
|
||||
gtMobile && [a.flex_row_reverse, a.justify_start],
|
||||
]}>
|
||||
<Button
|
||||
label={link?.share ? _(msg`Share link`) : _(msg`Visit site`)}
|
||||
accessibilityHint={_(msg`Opens link ${link?.href ?? ''}`)}
|
||||
onPress={onPressVisit}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color={potentiallyMisleading ? 'secondary_inverted' : 'primary'}>
|
||||
<ButtonText>
|
||||
{link?.share ? (
|
||||
<Trans>Share link</Trans>
|
||||
) : (
|
||||
<Trans>Visit site</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Go back`)}
|
||||
onPress={onCancel}
|
||||
size="large"
|
||||
variant="ghost"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Go back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkBox({href}: {href: string}) {
|
||||
const t = useTheme()
|
||||
const [scheme, hostname, rest] = useMemo(() => {
|
||||
try {
|
||||
const urlp = new URL(href)
|
||||
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
|
||||
return [
|
||||
urlp.protocol + '//' + subdomain,
|
||||
apexdomain,
|
||||
urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash,
|
||||
]
|
||||
} catch {
|
||||
return ['', href, '']
|
||||
}
|
||||
}, [href])
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.px_md,
|
||||
{paddingVertical: 10},
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
]}>
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
{scheme}
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text, a.font_bold]}>
|
||||
{hostname}
|
||||
</Text>
|
||||
{rest}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -22,7 +26,7 @@ import {
|
||||
import {
|
||||
createThreadgateViewQueryKey,
|
||||
getThreadgateView,
|
||||
ThreadgateAllowUISetting,
|
||||
type ThreadgateAllowUISetting,
|
||||
threadgateViewToAllowUISetting,
|
||||
useSetThreadgateAllowMutation,
|
||||
useThreadgateViewQuery,
|
||||
@@ -558,7 +562,8 @@ export function usePrefetchPostInteractionSettings({
|
||||
await Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: createPostgateQueryKey(postUri),
|
||||
queryFn: () => getPostgateRecord({agent, postUri}),
|
||||
queryFn: () =>
|
||||
getPostgateRecord({agent, postUri}).then(res => res ?? null),
|
||||
staleTime: STALE.SECONDS.THIRTY,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
|
||||
@@ -390,8 +390,8 @@ function DefaultProfileCard({
|
||||
!enabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused || hovered
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg,
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChangeEmailDialog} from './ChangeEmailDialog'
|
||||
|
||||
export function VerifyEmailDialog({
|
||||
control,
|
||||
onCloseWithoutVerifying,
|
||||
onCloseAfterVerifying,
|
||||
reasonText,
|
||||
changeEmailControl,
|
||||
reminder,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onCloseWithoutVerifying?: () => void
|
||||
onCloseAfterVerifying?: () => void
|
||||
reasonText?: string
|
||||
/**
|
||||
* if a changeEmailControl for a ChangeEmailDialog is not provided,
|
||||
* this component will create one for you. Using this prop
|
||||
* helps reduce duplication, since these dialogs are often used together.
|
||||
*/
|
||||
changeEmailControl?: Dialog.DialogControlProps
|
||||
reminder?: boolean
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const fallbackChangeEmailControl = Dialog.useDialogControl()
|
||||
|
||||
const [didVerify, setDidVerify] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={async () => {
|
||||
if (!didVerify) {
|
||||
onCloseWithoutVerifying?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.resumeSession(agent.session!)
|
||||
onCloseAfterVerifying?.()
|
||||
} catch (e: unknown) {
|
||||
logger.error(String(e))
|
||||
return
|
||||
}
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
setDidVerify={setDidVerify}
|
||||
reasonText={reasonText}
|
||||
changeEmailControl={changeEmailControl ?? fallbackChangeEmailControl}
|
||||
reminder={reminder}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
{!changeEmailControl && (
|
||||
<ChangeEmailDialog
|
||||
control={fallbackChangeEmailControl}
|
||||
verifyEmailControl={control}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({
|
||||
setDidVerify,
|
||||
reasonText,
|
||||
changeEmailControl,
|
||||
reminder,
|
||||
}: {
|
||||
setDidVerify: (value: boolean) => void
|
||||
reasonText?: string
|
||||
changeEmailControl: Dialog.DialogControlProps
|
||||
reminder?: boolean
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree'
|
||||
>(reminder ? 'Reminder' : 'StepOne')
|
||||
const [confirmationCode, setConfirmationCode] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const uiStrings = {
|
||||
Reminder: {
|
||||
title: _(msg`Please Verify Your Email`),
|
||||
message: _(
|
||||
msg`Your email has not yet been verified. This is an important security step which we recommend.`,
|
||||
),
|
||||
},
|
||||
StepOne: {
|
||||
title: _(msg`Verify Your Email`),
|
||||
message: '',
|
||||
},
|
||||
StepTwo: {
|
||||
title: _(msg`Enter Code`),
|
||||
message: _(
|
||||
msg`An email has been sent! Please enter the confirmation code included in the email below.`,
|
||||
),
|
||||
},
|
||||
StepThree: {
|
||||
title: _(msg`Success!`),
|
||||
message: _(msg`Thank you! Your email has been successfully verified.`),
|
||||
},
|
||||
}
|
||||
|
||||
const onSendEmail = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await agent.com.atproto.server.requestEmailConfirmation()
|
||||
setCurrentStep('StepTwo')
|
||||
} catch (e: unknown) {
|
||||
setError(cleanError(e))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onVerifyEmail = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await agent.com.atproto.server.confirmEmail({
|
||||
email: (currentAccount?.email || '').trim(),
|
||||
token: confirmationCode.trim(),
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
setError(cleanError(String(e)))
|
||||
setIsProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessing(false)
|
||||
setDidVerify(true)
|
||||
setCurrentStep('StepThree')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={web({maxWidth: 450})}>
|
||||
<View style={[a.gap_xl]}>
|
||||
{currentStep === 'Reminder' && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 150},
|
||||
t.atoms.bg_contrast_100,
|
||||
]}>
|
||||
<EnvelopeIcon width={64} fill="white" />
|
||||
</View>
|
||||
)}
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
{uiStrings[currentStep].title}
|
||||
</Text>
|
||||
{error ? (
|
||||
<View style={[a.rounded_sm, a.overflow_hidden]}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
) : null}
|
||||
{currentStep === 'StepOne' ? (
|
||||
<View>
|
||||
{reasonText ? (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>{reasonText}</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
Don't have access to{' '}
|
||||
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
|
||||
{currentAccount?.email}
|
||||
</Text>
|
||||
?{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={_(msg`Change email address`)}
|
||||
style={[a.text_md, a.leading_snug]}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
control.close(() => {
|
||||
changeEmailControl.open()
|
||||
})
|
||||
return false
|
||||
}}>
|
||||
<Trans>Change your email address</Trans>
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
You'll receive an email at{' '}
|
||||
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
|
||||
{currentAccount?.email}
|
||||
</Text>{' '}
|
||||
to verify it's you.
|
||||
</Trans>{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={_(msg`Change email address`)}
|
||||
style={[a.text_md, a.leading_snug]}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
control.close(() => {
|
||||
changeEmailControl.open()
|
||||
})
|
||||
return false
|
||||
}}>
|
||||
<Trans>Need to change it?</Trans>
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
{uiStrings[currentStep].message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{currentStep === 'StepTwo' ? (
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Confirmation Code</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(msg`Confirmation code`)}
|
||||
placeholder="XXXXX-XXXXX"
|
||||
onChangeText={setConfirmationCode}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
|
||||
{currentStep === 'Reminder' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Get started`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={() => setCurrentStep('StepOne')}>
|
||||
<ButtonText>
|
||||
<Trans>Get started</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Maybe later`)}
|
||||
accessibilityHint={_(msg`Snoozes the reminder`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Maybe later</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepOne' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Send confirmation email`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={onSendEmail}>
|
||||
<ButtonText>
|
||||
<Trans>Send confirmation</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`I have a code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => setCurrentStep('StepTwo')}>
|
||||
<ButtonText>
|
||||
<Trans>I have a code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepTwo' ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Confirm`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={onVerifyEmail}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<Loader size="sm" style={[{color: 'white'}]} />
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Resend email`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
disabled={isProcessing}
|
||||
onPress={() => {
|
||||
setConfirmationCode('')
|
||||
setCurrentStep('StepOne')
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Resend email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 'StepThree' ? (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -98,8 +98,8 @@ export function EmojiReactionPicker({
|
||||
: t.palette.primary_500,
|
||||
}
|
||||
: alreadyReacted
|
||||
? {backgroundColor: t.palette.primary_200}
|
||||
: bgColor,
|
||||
? {backgroundColor: t.palette.primary_200}
|
||||
: bgColor,
|
||||
{height: 40, width: 40},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
|
||||
@@ -128,8 +128,7 @@ export let MessageContextMenu = ({
|
||||
label={_(msg`Message options`)}
|
||||
contentLabel={_(
|
||||
msg`Message from @${
|
||||
sender?.handle ?? // should always be defined
|
||||
'unknown'
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`,
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {useEffect} from 'react'
|
||||
|
||||
import {
|
||||
type GlobalGestureEvents,
|
||||
useGlobalGestureEvents,
|
||||
} from '#/state/global-gesture-events'
|
||||
|
||||
/**
|
||||
* Listen for global gesture events. Callback should be wrapped with
|
||||
* `useCallback` or otherwise memoized to avoid unnecessary re-renders.
|
||||
*/
|
||||
export function useOnGesture(
|
||||
onGestureCallback: (e: GlobalGestureEvents['begin']) => void,
|
||||
) {
|
||||
const ctx = useGlobalGestureEvents()
|
||||
useEffect(() => {
|
||||
ctx.register()
|
||||
ctx.events.on('begin', onGestureCallback)
|
||||
return () => {
|
||||
ctx.unregister()
|
||||
ctx.events.off('begin', onGestureCallback)
|
||||
}
|
||||
}, [ctx, onGestureCallback])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function useOnGesture() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.47 9.47 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z',
|
||||
})
|
||||
@@ -7,3 +7,7 @@ export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const Heart2_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z',
|
||||
})
|
||||
|
||||
export const LikeRepost_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M3.92 19v-4.153a1 1 0 0 1 1-1H9l.103.005a1 1 0 0 1 0 1.99L9 15.847H7.285c.854.737 1.784 1.38 2.631 1.9.702.431 1.329.769 1.78.997q.162.08.291.143a25.561 25.561 0 0 0 3.67-2.326c2.144-1.642 4.073-3.756 4.315-6.023a1 1 0 0 1 1.988.212c-.336 3.154-2.89 5.717-5.086 7.398a27.6 27.6 0 0 1-4.34 2.704l-.078.038-.021.01-.007.003-.002.001-.001.001a1 1 0 0 1-.827.01v0h-.002l-.004-.002-.013-.006q-.016-.006-.045-.02l-.162-.075a27.39 27.39 0 0 1-2.503-1.361 22 22 0 0 1-2.95-2.143V19a1 1 0 0 1-2 0ZM2 10c0-2.214.696-3.971 1.833-5.184A5.7 5.7 0 0 1 8 3a7.1 7.1 0 0 1 4 1.228A7.117 7.117 0 0 1 16 3c1.231 0 2.452.402 3.469 1.185l.031-1.702a1 1 0 0 1 2 .035l-.081 4.5a1 1 0 0 1-1 .983H16.5a1 1 0 1 1 0-2h2.02A3.68 3.68 0 0 0 16 5a5.12 5.12 0 0 0-3.11 1.053 3 3 0 0 0-.155.129l-.029.025v.002l-.003.002-.072.064a1 1 0 0 1-1.338-.068l-.028-.025a3 3 0 0 0-.155-.13A5.119 5.119 0 0 0 8 5c-.982 0-1.965.392-2.708 1.185C4.554 6.97 4 8.214 4 10q0 .507.099 1.002l.075.328.02.1a1 1 0 0 1-1.925.5l-.03-.097-.102-.446A7 7 0 0 1 2 10Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const PhoneHaptic_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M16 6a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V6ZM2.87 7.225a1 1 0 0 1 1.337 1.482L3.155 9.759a.546.546 0 0 0-.05.714l.119.173c.52.827.52 1.88 0 2.707l-.12.174a.546.546 0 0 0 .051.714l1.052 1.052.069.076a1 1 0 0 1-1.407 1.406l-.076-.068-1.052-1.052a2.55 2.55 0 0 1-.237-3.328l.048-.075a.55.55 0 0 0 0-.504l-.048-.075a2.55 2.55 0 0 1 .237-3.328l1.052-1.052.076-.068Zm16.923.068a1 1 0 0 1 1.338-.068l.076.068 1.052 1.052.16.174c.696.837.78 2.03.209 2.958l-.133.196a.55.55 0 0 0 0 .654l.133.196a2.55 2.55 0 0 1-.21 2.958l-.16.174-1.05 1.052a1 1 0 1 1-1.415-1.414l1.052-1.052.064-.077a.55.55 0 0 0 .04-.552l-.053-.085a2.545 2.545 0 0 1 0-3.054l.052-.085a.55.55 0 0 0-.039-.552l-.064-.077-1.052-1.052-.068-.076a1 1 0 0 1 .068-1.338ZM13 6l.103.005a1 1 0 0 1 0 1.99L13 8h-2a1 1 0 1 1 0-2h2Zm5 12a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12Z',
|
||||
})
|
||||
|
||||
@@ -11,3 +11,7 @@ export const Repost_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
export const Repost_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: 'M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const RepostRepost_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M6.043 14.293a1 1 0 1 1 1.414 1.414L5.164 18l2.293 2.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47Zm6.22 0a1 1 0 0 1 1.414 1.414L12.384 17H18a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3h-5.616l1.293 1.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47ZM3 11V8a3 3 0 0 1 3-3h5.586l-1.293-1.293-.068-.076a1 1 0 0 1 1.406-1.406l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L11.586 7H6a1 1 0 0 0-1 1v3a1 1 0 1 1-2 0Zm13.543-8.707a1 1 0 0 1 1.338-.068l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L18.836 6l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z',
|
||||
})
|
||||
|
||||
@@ -3,28 +3,27 @@ import Svg, {Circle, Path} from 'react-native-svg'
|
||||
|
||||
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
export const VerifiedCheck = React.forwardRef<Svg, Props>(function LogoImpl(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
export const VerifiedCheck = React.forwardRef<Svg, Props>(
|
||||
function LogoImpl(props, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Circle cx="12" cy="12" r="11.5" fill={fill} />
|
||||
<Path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Circle cx="12" cy="12" r="11.5" fill={fill} />
|
||||
<Path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,33 +3,32 @@ import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
export const VerifierCheck = React.forwardRef<Svg, Props>(function LogoImpl(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
export const VerifierCheck = React.forwardRef<Svg, Props>(
|
||||
function LogoImpl(props, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
|
||||
/>
|
||||
<Path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
|
||||
/>
|
||||
<Path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -148,8 +148,8 @@ function ContentHiderActive({
|
||||
modui.noOverride
|
||||
? _(msg`Learn more about the moderation applied to this content`)
|
||||
: override
|
||||
? _(msg`Hides the content`)
|
||||
: _(msg`Shows the content`)
|
||||
? _(msg`Hides the content`)
|
||||
: _(msg`Shows the content`)
|
||||
}>
|
||||
{state => (
|
||||
<View
|
||||
|
||||
@@ -512,13 +512,13 @@ function StepTitle({
|
||||
backgroundColor: active
|
||||
? t.palette.primary_500
|
||||
: completed
|
||||
? t.palette.primary_100
|
||||
: t.atoms.bg_contrast_25.backgroundColor,
|
||||
? t.palette.primary_100
|
||||
: t.atoms.bg_contrast_25.backgroundColor,
|
||||
borderColor: active
|
||||
? t.palette.primary_500
|
||||
: completed
|
||||
? t.palette.primary_400
|
||||
: t.atoms.border_contrast_low.borderColor,
|
||||
? t.palette.primary_400
|
||||
: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}>
|
||||
{completed ? (
|
||||
@@ -533,8 +533,8 @@ function StepTitle({
|
||||
color: active
|
||||
? 'white'
|
||||
: completed
|
||||
? t.palette.primary_700
|
||||
: t.atoms.text_contrast_medium.color,
|
||||
? t.palette.primary_700
|
||||
: t.atoms.text_contrast_medium.color,
|
||||
fontVariant: ['tabular-nums'],
|
||||
width: 24,
|
||||
height: 24,
|
||||
|
||||
@@ -130,8 +130,8 @@ export function Badge({
|
||||
verifiedByHidden
|
||||
? t.atoms.bg_contrast_100.backgroundColor
|
||||
: state.profile.isVerified
|
||||
? t.palette.primary_500
|
||||
: t.atoms.bg_contrast_100.backgroundColor
|
||||
? t.palette.primary_500
|
||||
: t.atoms.bg_contrast_100.backgroundColor
|
||||
}
|
||||
verifier={state.profile.role === 'verifier'}
|
||||
/>
|
||||
|
||||
@@ -64,13 +64,13 @@ function Inner({
|
||||
? _(msg`You are verified`)
|
||||
: _(msg`Your verifications`)
|
||||
: state.profile.isVerified
|
||||
? _(msg`${userName} is verified`)
|
||||
: _(
|
||||
msg({
|
||||
message: `${userName}'s verifications`,
|
||||
comment: `Possessive, meaning "the verifications of {userName}"`,
|
||||
}),
|
||||
)
|
||||
? _(msg`${userName} is verified`)
|
||||
: _(
|
||||
msg({
|
||||
message: `${userName}'s verifications`,
|
||||
comment: `Possessive, meaning "the verifications of {userName}"`,
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
|
||||
@@ -14,6 +14,7 @@ export type DebouncedNavigationProp = Pick<
|
||||
| 'dispatch'
|
||||
| 'goBack'
|
||||
| 'getState'
|
||||
| 'getParent'
|
||||
>
|
||||
|
||||
export function useNavigationDeduped() {
|
||||
@@ -46,6 +47,9 @@ export function useNavigationDeduped() {
|
||||
getState: () => {
|
||||
return navigation.getState()
|
||||
},
|
||||
getParent: (...args: Parameters<typeof navigation.getParent>) => {
|
||||
return navigation.getParent(...args)
|
||||
},
|
||||
}),
|
||||
[dedupe, navigation],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react'
|
||||
import {useEffect} from 'react'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {type AppBskyNotificationListNotifications} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {CommonActions, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -25,6 +28,10 @@ export type NotificationReason =
|
||||
| 'quote'
|
||||
| 'chat-message'
|
||||
| 'starterpack-joined'
|
||||
| 'like-via-repost'
|
||||
| 'repost-via-repost'
|
||||
| 'verified'
|
||||
| 'unverified'
|
||||
|
||||
/**
|
||||
* Manually overridden type, but retains the possibility of
|
||||
@@ -66,34 +73,103 @@ export function useNotificationsHandler() {
|
||||
const {currentConvoId} = useCurrentConvoId()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {_} = useLingui()
|
||||
|
||||
// On Android, we cannot control which sound is used for a notification on Android
|
||||
// 28 or higher. Instead, we have to configure a notification channel ahead of time
|
||||
// which has the sounds we want in the configuration for that channel. These two
|
||||
// channels allow for the mute/unmute functionality we want for the background
|
||||
// handler.
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!isAndroid) return
|
||||
// assign both chat notifications to a group
|
||||
// NOTE: I don't think that it will retroactively move them into the group
|
||||
// if the channels already exist. no big deal imo -sfn
|
||||
const CHAT_GROUP = 'chat'
|
||||
Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
|
||||
name: _(msg`Chat`),
|
||||
description: _(
|
||||
msg`You can choose whether chat notifications have sound in the chat settings within the app`,
|
||||
),
|
||||
})
|
||||
Notifications.setNotificationChannelAsync('chat-messages', {
|
||||
name: 'Chat',
|
||||
name: _(msg`Chat messages - sound`),
|
||||
groupId: CHAT_GROUP,
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
sound: 'dm.mp3',
|
||||
showBadge: true,
|
||||
vibrationPattern: [250],
|
||||
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
|
||||
})
|
||||
|
||||
Notifications.setNotificationChannelAsync('chat-messages-muted', {
|
||||
name: 'Chat - Muted',
|
||||
name: _(msg`Chat messages - silent`),
|
||||
groupId: CHAT_GROUP,
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
sound: null,
|
||||
showBadge: true,
|
||||
vibrationPattern: [250],
|
||||
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'like' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Likes`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Reposts`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Replies`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Mentions`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Quotes`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`New followers`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Likes of your reposts`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
Notifications.setNotificationChannelAsync(
|
||||
'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
|
||||
{
|
||||
name: _(msg`Reposts of your reposts`),
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
},
|
||||
)
|
||||
}, [_])
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotification = (payload?: NotificationPayload) => {
|
||||
if (!payload) return
|
||||
|
||||
@@ -151,6 +227,10 @@ export function useNotificationsHandler() {
|
||||
case 'quote':
|
||||
case 'reply':
|
||||
case 'starterpack-joined':
|
||||
case 'like-via-repost':
|
||||
case 'repost-via-repost':
|
||||
case 'verified':
|
||||
case 'unverified':
|
||||
resetToTab('NotificationsTab')
|
||||
break
|
||||
// TODO implement these after we have an idea of how to handle each individual case
|
||||
@@ -242,7 +322,19 @@ export function useNotificationsHandler() {
|
||||
const payload = e.notification.request.trigger
|
||||
.payload as NotificationPayload
|
||||
|
||||
if (!payload) return
|
||||
if (!payload) {
|
||||
logger.error('useNotificationsHandler: received no payload', {
|
||||
identifier: e.notification.request.identifier,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!payload.reason) {
|
||||
logger.error('useNotificationsHandler: received unknown payload', {
|
||||
payload,
|
||||
identifier: e.notification.request.identifier,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isRelativeUrl,
|
||||
toNiceDomain,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
|
||||
import {useTheme} from '#/alf'
|
||||
@@ -64,6 +65,10 @@ export function useOpenLink() {
|
||||
toolbarColor: t.atoms.bg.backgroundColor,
|
||||
controlsColor: t.palette.primary_500,
|
||||
createTask: false,
|
||||
}).catch(err => {
|
||||
if (__DEV__)
|
||||
logger.error('Could not open web browser', {message: err})
|
||||
Linking.openURL(url)
|
||||
}),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyLabelerDefs,
|
||||
type AppBskyLabelerDefs,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
InterpretedLabelValueDefinition,
|
||||
type ComAtprotoLabelDefs,
|
||||
type InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
ModerationCause,
|
||||
ModerationOpts,
|
||||
ModerationUI,
|
||||
type ModerationCause,
|
||||
type ModerationOpts,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
|
||||
export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
|
||||
export const OTHER_SELF_LABELS = ['graphic-media']
|
||||
@@ -29,8 +29,8 @@ export function getModerationCauseKey(
|
||||
cause.source.type === 'labeler'
|
||||
? cause.source.did
|
||||
: cause.source.type === 'list'
|
||||
? cause.source.list.uri
|
||||
: 'user'
|
||||
? cause.source.list.uri
|
||||
: 'user'
|
||||
if (cause.type === 'label') {
|
||||
return `label:${cause.label.val}:${source}`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BSKY_LABELER_DID,
|
||||
ModerationCause,
|
||||
ModerationCauseSource,
|
||||
type ModerationCause,
|
||||
type ModerationCauseSource,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -12,10 +12,10 @@ import {useLabelDefinitions} from '#/state/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||
|
||||
@@ -153,8 +153,8 @@ export function useModerationCauseDescription(
|
||||
def.identifier === '!no-unauthenticated'
|
||||
? EyeSlash
|
||||
: def.severity === 'alert'
|
||||
? Warning
|
||||
: CircleInfo,
|
||||
? Warning
|
||||
: CircleInfo,
|
||||
name: strings.name,
|
||||
description: strings.description,
|
||||
source,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {type Route, type RouteParams} from './types'
|
||||
|
||||
export class Router {
|
||||
export class Router<T extends Record<string, any>> {
|
||||
routes: [string, Route][] = []
|
||||
constructor(description: Record<string, string | string[]>) {
|
||||
constructor(description: Record<keyof T, string | string[]>) {
|
||||
for (const [screen, pattern] of Object.entries(description)) {
|
||||
if (typeof pattern === 'string') {
|
||||
this.routes.push([screen, createRoute(pattern)])
|
||||
@@ -14,7 +14,7 @@ export class Router {
|
||||
}
|
||||
}
|
||||
|
||||
matchName(name: string): Route | undefined {
|
||||
matchName(name: keyof T | (string & {})): Route | undefined {
|
||||
for (const [screenName, route] of this.routes) {
|
||||
if (screenName === name) {
|
||||
return route
|
||||
|
||||
+13
-11
@@ -52,7 +52,18 @@ export type CommonNavigatorParams = {
|
||||
AccountSettings: undefined
|
||||
PrivacyAndSecuritySettings: undefined
|
||||
ContentAndMediaSettings: undefined
|
||||
SettingsInterests: undefined
|
||||
NotificationSettings: undefined
|
||||
ReplyNotificationSettings: undefined
|
||||
MentionNotificationSettings: undefined
|
||||
QuoteNotificationSettings: undefined
|
||||
LikeNotificationSettings: undefined
|
||||
RepostNotificationSettings: undefined
|
||||
NewFollowerNotificationSettings: undefined
|
||||
LikesOnRepostsNotificationSettings: undefined
|
||||
RepostsOnRepostsNotificationSettings: undefined
|
||||
ActivityNotificationSettings: undefined
|
||||
MiscellaneousNotificationSettings: undefined
|
||||
InterestsSettings: undefined
|
||||
AboutSettings: undefined
|
||||
AppIconSettings: undefined
|
||||
Search: {q?: string}
|
||||
@@ -61,7 +72,7 @@ export type CommonNavigatorParams = {
|
||||
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
||||
MessagesSettings: undefined
|
||||
MessagesInbox: undefined
|
||||
NotificationSettings: undefined
|
||||
LegacyNotificationSettings: undefined
|
||||
Feeds: undefined
|
||||
Start: {name: string; rkey: string}
|
||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||
@@ -104,8 +115,6 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Search: {q?: string}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Topic: {topic: string}
|
||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||
}
|
||||
|
||||
@@ -118,15 +127,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
NotificationsTab: undefined
|
||||
Notifications: undefined
|
||||
MyProfileTab: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Topic: {topic: string}
|
||||
MessagesTab: undefined
|
||||
Messages: {animation?: 'push' | 'pop'}
|
||||
Start: {name: string; rkey: string}
|
||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||
StarterPackShort: {code: string}
|
||||
StarterPackWizard: undefined
|
||||
StarterPackEdit: {rkey?: string}
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Gate =
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'post_threads_v2_unspecced'
|
||||
| 'reengagement_features'
|
||||
| 'remove_show_latest_button'
|
||||
| 'test_gate_1'
|
||||
| 'test_gate_2'
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
|
||||
|
||||
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
|
||||
import {logger} from '#/logger'
|
||||
import {MetricEvents} from '#/logger/metrics'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {useSession} from '../../state/session'
|
||||
import {timeout} from '../async/timeout'
|
||||
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
|
||||
import {Gate} from './gates'
|
||||
import {type Gate} from './gates'
|
||||
|
||||
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
|
||||
|
||||
@@ -51,8 +51,8 @@ function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'development'
|
||||
: IS_TESTFLIGHT
|
||||
? 'staging'
|
||||
: 'production',
|
||||
? 'staging'
|
||||
: 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
|
||||
@@ -11,8 +11,8 @@ const IFRAME_HOST = isWeb
|
||||
? 'http://localhost:8100'
|
||||
: 'https://bsky.app'
|
||||
: __DEV__ && !process.env.JEST_WORKER_ID
|
||||
? 'http://localhost:8100'
|
||||
: 'https://bsky.app'
|
||||
? 'http://localhost:8100'
|
||||
: 'https://bsky.app'
|
||||
|
||||
export const embedPlayerSources = [
|
||||
'youtube',
|
||||
|
||||
@@ -193,6 +193,11 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
return startUriToStarterPackUri(urlp.pathname)
|
||||
}
|
||||
|
||||
// special-case search links
|
||||
if (urlp.pathname === '/search') {
|
||||
return `/search?q=${urlp.searchParams.get('q')}`
|
||||
}
|
||||
|
||||
return urlp.pathname
|
||||
} catch (e) {
|
||||
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
|
||||
|
||||
+474
-399
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,7 @@ describe('general functionality', () => {
|
||||
const timestamp = Date.now()
|
||||
const sentryTimestamp = timestamp / 1000
|
||||
|
||||
/*
|
||||
sentryTransport(
|
||||
LogLevel.Debug,
|
||||
Logger.Context.Default,
|
||||
@@ -125,6 +126,7 @@ describe('general functionality', () => {
|
||||
level: LogLevel.Debug,
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
*/
|
||||
|
||||
sentryTransport(
|
||||
LogLevel.Info,
|
||||
@@ -154,7 +156,7 @@ describe('general functionality', () => {
|
||||
message,
|
||||
data: {__context__: 'logger'},
|
||||
type: 'default',
|
||||
level: 'debug', // Sentry bug, log becomes debug
|
||||
level: 'log',
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
jest.runAllTimers()
|
||||
@@ -220,7 +222,7 @@ describe('general functionality', () => {
|
||||
const sentryTimestamp = timestamp / 1000
|
||||
|
||||
sentryTransport(
|
||||
LogLevel.Debug,
|
||||
LogLevel.Info,
|
||||
undefined,
|
||||
message,
|
||||
{error: new Error('foo')},
|
||||
@@ -230,7 +232,7 @@ describe('general functionality', () => {
|
||||
message,
|
||||
data: {error: 'Error: foo'},
|
||||
type: 'default',
|
||||
level: LogLevel.Debug,
|
||||
level: LogLevel.Info,
|
||||
timestamp: sentryTimestamp,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {Sentry} from '#/logger/sentry/lib'
|
||||
import {LogLevel, Transport} from '#/logger/types'
|
||||
import {LogLevel, type Transport} from '#/logger/types'
|
||||
import {prepareMetadata} from '#/logger/util'
|
||||
|
||||
export const sentryTransport: Transport = (
|
||||
@@ -10,6 +10,9 @@ export const sentryTransport: Transport = (
|
||||
{type, tags, ...metadata},
|
||||
timestamp,
|
||||
) => {
|
||||
// Skip debug messages entirely for now - esb
|
||||
if (level === LogLevel.Debug) return
|
||||
|
||||
const meta = {
|
||||
__context__: context,
|
||||
...prepareMetadata(metadata),
|
||||
|
||||
@@ -41,12 +41,12 @@ globalThis.atob = (str: string): string => {
|
||||
r1 === 64
|
||||
? String.fromCharCode((bitmap >> 16) & 255)
|
||||
: r2 === 64
|
||||
? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
|
||||
: String.fromCharCode(
|
||||
(bitmap >> 16) & 255,
|
||||
(bitmap >> 8) & 255,
|
||||
bitmap & 255,
|
||||
)
|
||||
? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
|
||||
: String.fromCharCode(
|
||||
(bitmap >> 16) & 255,
|
||||
(bitmap >> 8) & 255,
|
||||
bitmap & 255,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
+22
-4
@@ -1,11 +1,17 @@
|
||||
import {Router} from '#/lib/routes/router'
|
||||
import {type FlatNavigatorParams} from './lib/routes/types'
|
||||
|
||||
export const router = new Router({
|
||||
type AllNavigatableRoutes = Omit<
|
||||
FlatNavigatorParams,
|
||||
'NotFound' | 'SharedPreferencesTester'
|
||||
>
|
||||
|
||||
export const router = new Router<AllNavigatableRoutes>({
|
||||
Home: '/',
|
||||
Search: '/search',
|
||||
Feeds: '/feeds',
|
||||
Notifications: '/notifications',
|
||||
NotificationSettings: '/notifications/settings',
|
||||
LegacyNotificationSettings: '/notifications/settings',
|
||||
Settings: '/settings',
|
||||
Lists: '/lists',
|
||||
// moderation
|
||||
@@ -42,13 +48,25 @@ export const router = new Router({
|
||||
AccessibilitySettings: '/settings/accessibility',
|
||||
AppearanceSettings: '/settings/appearance',
|
||||
SavedFeeds: '/settings/saved-feeds',
|
||||
// new settings
|
||||
AccountSettings: '/settings/account',
|
||||
PrivacyAndSecuritySettings: '/settings/privacy-and-security',
|
||||
ContentAndMediaSettings: '/settings/content-and-media',
|
||||
SettingsInterests: '/settings/interests',
|
||||
InterestsSettings: '/settings/interests',
|
||||
AboutSettings: '/settings/about',
|
||||
AppIconSettings: '/settings/app-icon',
|
||||
NotificationSettings: '/settings/notifications',
|
||||
ReplyNotificationSettings: '/settings/notifications/replies',
|
||||
MentionNotificationSettings: '/settings/notifications/mentions',
|
||||
QuoteNotificationSettings: '/settings/notifications/quotes',
|
||||
LikeNotificationSettings: '/settings/notifications/likes',
|
||||
RepostNotificationSettings: '/settings/notifications/reposts',
|
||||
NewFollowerNotificationSettings: '/settings/notifications/new-followers',
|
||||
LikesOnRepostsNotificationSettings:
|
||||
'/settings/notifications/likes-on-reposts',
|
||||
RepostsOnRepostsNotificationSettings:
|
||||
'/settings/notifications/reposts-on-reposts',
|
||||
ActivityNotificationSettings: '/settings/notifications/activity',
|
||||
MiscellaneousNotificationSettings: '/settings/notifications/miscellaneous',
|
||||
// support
|
||||
Support: '/support',
|
||||
PrivacyPolicy: '/support/privacy',
|
||||
|
||||
@@ -49,8 +49,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
requestedAccount
|
||||
? Forms.Login
|
||||
: accounts.length
|
||||
? Forms.ChooseAccount
|
||||
: Forms.Login,
|
||||
? Forms.ChooseAccount
|
||||
: Forms.Login,
|
||||
)
|
||||
|
||||
const {
|
||||
|
||||
@@ -155,7 +155,7 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
profiles: inboxPreviewConvos.slice(0, 3),
|
||||
},
|
||||
...conversations.map(
|
||||
convo => ({type: 'CONVERSATION', conversation: convo} as const),
|
||||
convo => ({type: 'CONVERSATION', conversation: convo}) as const,
|
||||
),
|
||||
] satisfies ListItem[]
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
|
||||
@@ -176,8 +176,8 @@ export function StepFinished() {
|
||||
avatarResult: profileStepResults.isCreatedAvatar
|
||||
? 'created'
|
||||
: profileStepResults.image
|
||||
? 'uploaded'
|
||||
: 'default',
|
||||
? 'uploaded'
|
||||
: 'default',
|
||||
})
|
||||
})(),
|
||||
requestNotificationsPermission('AfterOnboarding'),
|
||||
|
||||
@@ -311,33 +311,34 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
isRoot && [a.pt_lg],
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
onBeforePress={onOpenAuthor}
|
||||
/>
|
||||
<ProfileHoverCard did={post.author.did}>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Link
|
||||
to={authorHref}
|
||||
style={[a.flex_shrink]}
|
||||
label={sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
onPress={onOpenAuthor}>
|
||||
<View collapsable={false}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
onBeforePress={onOpenAuthor}
|
||||
/>
|
||||
</View>
|
||||
<Link
|
||||
to={authorHref}
|
||||
style={[a.flex_1]}
|
||||
label={sanitizeDisplayName(
|
||||
post.author.displayName || sanitizeHandle(post.author.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
onPress={onOpenAuthor}>
|
||||
<View style={[a.flex_1, a.align_start]}>
|
||||
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.flex_shrink,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
a.self_start,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
@@ -346,32 +347,25 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<View style={[{paddingLeft: 3, top: -1}]}>
|
||||
<VerificationCheckButton profile={authorShadow} size="md" />
|
||||
<View style={[{paddingLeft: 3, top: -1}]}>
|
||||
<VerificationCheckButton profile={authorShadow} size="md" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.align_start]}>
|
||||
<Link
|
||||
style={[a.flex_shrink]}
|
||||
to={authorHref}
|
||||
label={sanitizeHandle(post.author.handle, '@')}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
</Text>
|
||||
</ProfileHoverCard>
|
||||
</View>
|
||||
</ProfileHoverCard>
|
||||
</Link>
|
||||
{showFollowButton && (
|
||||
<View>
|
||||
<View collapsable={false}>
|
||||
<PostThreadFollowBtn did={post.author.did} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {OUTER_SPACE} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
|
||||
/*
|
||||
* Wacky padding here is just replicating what we have in the actual
|
||||
* `PostThreadComposePrompt` component
|
||||
*/
|
||||
export function ThreadItemReplyComposerSkeleton() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
if (!gtMobile) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}>
|
||||
<Skele.Circle size={gtMobile ? 24 : 22} />
|
||||
<Skele.Text style={[a.text_md]} />
|
||||
<View style={[a.px_sm, a.py_xs, a.border_t, t.atoms.border_contrast_low]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.px_sm, a.py_sm]}>
|
||||
<Skele.Circle size={24} />
|
||||
<Skele.Text style={[a.text_md, {maxWidth: 119}]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -54,13 +54,16 @@ export function PostThread({uri}: {uri: string}) {
|
||||
* One query to rule them all
|
||||
*/
|
||||
const thread = usePostThread({anchor: uri})
|
||||
const anchor = useMemo(() => {
|
||||
const {anchor, hasParents} = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
let hasParents = false
|
||||
for (const item of thread.data.items) {
|
||||
if (item.type === 'threadPost' && item.depth === 0) {
|
||||
return item
|
||||
return {anchor: item, hasParents}
|
||||
}
|
||||
hasParents = true
|
||||
}
|
||||
return
|
||||
return {hasParents}
|
||||
}, [thread.data.items])
|
||||
|
||||
const {openComposer} = useOpenComposer()
|
||||
@@ -481,6 +484,8 @@ export function PostThread({uri}: {uri: string}) {
|
||||
],
|
||||
)
|
||||
|
||||
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Header.Outer headerRef={headerRef}>
|
||||
@@ -537,8 +542,10 @@ export function PostThread({uri}: {uri: string}) {
|
||||
* back to the top of the screen when handling scroll.
|
||||
*/
|
||||
height={platform({
|
||||
web: windowHeight - 200,
|
||||
default: deferParents ? windowHeight * 2 : windowHeight - 200,
|
||||
web: defaultListFooterHeight,
|
||||
default: deferParents
|
||||
? windowHeight * 2
|
||||
: defaultListFooterHeight,
|
||||
})}
|
||||
style={isTombstoneView ? {borderTopWidth: 0} : undefined}
|
||||
/>
|
||||
|
||||
@@ -214,8 +214,8 @@ let ProfileHeaderLabeler = ({
|
||||
? t.palette.contrast_50
|
||||
: t.palette.contrast_25
|
||||
: state.hovered || state.pressed
|
||||
? tokens.color.temp_purple_dark
|
||||
: tokens.color.temp_purple,
|
||||
? tokens.color.temp_purple_dark
|
||||
: tokens.color.temp_purple,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
|
||||
@@ -301,19 +301,19 @@ export function Explore({
|
||||
const onPTR = useCallback(async () => {
|
||||
setIsPTR(true)
|
||||
await Promise.all([
|
||||
await qc.resetQueries({
|
||||
qc.resetQueries({
|
||||
queryKey: createGetTrendsQueryKey(),
|
||||
}),
|
||||
await qc.resetQueries({
|
||||
qc.resetQueries({
|
||||
queryKey: createSuggestedStarterPacksQueryKey(),
|
||||
}),
|
||||
await qc.resetQueries({
|
||||
qc.resetQueries({
|
||||
queryKey: [getSuggestedUsersQueryKeyRoot],
|
||||
}),
|
||||
await qc.resetQueries({
|
||||
qc.resetQueries({
|
||||
queryKey: [useActorSearchPaginatedQueryKeyRoot],
|
||||
}),
|
||||
await qc.resetQueries({
|
||||
qc.resetQueries({
|
||||
queryKey: createGetSuggestedFeedsQueryKey(),
|
||||
}),
|
||||
])
|
||||
@@ -342,11 +342,11 @@ export function Explore({
|
||||
])
|
||||
|
||||
const topBorder = useMemo(
|
||||
() => ({type: 'topBorder', key: 'top-border'} as const),
|
||||
() => ({type: 'topBorder', key: 'top-border'}) as const,
|
||||
[],
|
||||
)
|
||||
const trendingTopicsModule = useMemo(
|
||||
() => ({type: 'trendingTopics', key: 'trending-topics'} as const),
|
||||
() => ({type: 'trendingTopics', key: 'trending-topics'}) as const,
|
||||
[],
|
||||
)
|
||||
const suggestedFollowsModule = useMemo(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {useMemo} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {AppIconSet} from '#/screens/Settings/AppIconSettings/types'
|
||||
import {type AppIconSet} from '#/screens/Settings/AppIconSettings/types'
|
||||
|
||||
export function useAppIconSets() {
|
||||
const {_} = useLingui()
|
||||
@@ -13,20 +13,28 @@ export function useAppIconSets() {
|
||||
id: 'default_light',
|
||||
name: _(msg({context: 'Name of app icon variant', message: 'Light'})),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_default_light.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_default_light.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_default_light.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_default_light.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default_dark',
|
||||
name: _(msg({context: 'Name of app icon variant', message: 'Dark'})),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_default_dark.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_default_dark.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_default_dark.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_default_dark.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
] satisfies AppIconSet[]
|
||||
@@ -39,10 +47,14 @@ export function useAppIconSets() {
|
||||
id: 'core_aurora',
|
||||
name: _(msg({context: 'Name of app icon variant', message: 'Aurora'})),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_aurora.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_aurora.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_aurora.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_aurora.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
// {
|
||||
@@ -59,20 +71,28 @@ export function useAppIconSets() {
|
||||
id: 'core_sunrise',
|
||||
name: _(msg({context: 'Name of app icon variant', message: 'Sunrise'})),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_sunrise.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_sunrise.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_sunrise.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_sunrise.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core_sunset',
|
||||
name: _(msg({context: 'Name of app icon variant', message: 'Sunset'})),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_sunset.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_sunset.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_sunset.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_sunset.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -81,10 +101,14 @@ export function useAppIconSets() {
|
||||
msg({context: 'Name of app icon variant', message: 'Midnight'}),
|
||||
),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_midnight.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_midnight.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_midnight.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_midnight.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -93,10 +117,14 @@ export function useAppIconSets() {
|
||||
msg({context: 'Name of app icon variant', message: 'Flat Blue'}),
|
||||
),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_flat_blue.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_flat_blue.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_flat_blue.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_flat_blue.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -105,10 +133,14 @@ export function useAppIconSets() {
|
||||
msg({context: 'Name of app icon variant', message: 'Flat White'}),
|
||||
),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_flat_white.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_flat_white.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_flat_white.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_flat_white.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,10 +149,14 @@ export function useAppIconSets() {
|
||||
msg({context: 'Name of app icon variant', message: 'Flat Black'}),
|
||||
),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_flat_black.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_flat_black.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_flat_black.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_flat_black.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -132,10 +168,14 @@ export function useAppIconSets() {
|
||||
}),
|
||||
),
|
||||
iosImage: () => {
|
||||
return require(`../../../../assets/app-icons/ios_icon_core_classic.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/ios_icon_core_classic.png`,
|
||||
)
|
||||
},
|
||||
androidImage: () => {
|
||||
return require(`../../../../assets/app-icons/android_icon_core_classic.png`)
|
||||
return require(
|
||||
`../../../../assets/app-icons/android_icon_core_classic.png`,
|
||||
)
|
||||
},
|
||||
},
|
||||
] satisfies AppIconSet[]
|
||||
|
||||
+7
-8
@@ -2,9 +2,11 @@ import {useMemo, useState} from 'react'
|
||||
import {type TextStyle, View, type ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {
|
||||
preferencesQueryKey,
|
||||
usePreferencesQuery,
|
||||
@@ -24,7 +26,8 @@ import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SettingsInterests() {
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'InterestsSettings'>
|
||||
export function InterestsSettingsScreen({}: Props) {
|
||||
const t = useTheme()
|
||||
const gutters = useGutters(['base'])
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
@@ -110,13 +113,9 @@ function Inner({
|
||||
},
|
||||
)
|
||||
await Promise.all([
|
||||
await qc.resetQueries({
|
||||
queryKey: createSuggestedStarterPacksQueryKey(),
|
||||
}),
|
||||
await qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}),
|
||||
await qc.resetQueries({
|
||||
queryKey: createGetSuggestedUsersQueryKey({}),
|
||||
}),
|
||||
qc.resetQueries({queryKey: createSuggestedStarterPacksQueryKey()}),
|
||||
qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}),
|
||||
qc.resetQueries({queryKey: createGetSuggestedUsersQueryKey({})}),
|
||||
])
|
||||
|
||||
Toast.show(
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'LegacyNotificationSettings'
|
||||
>
|
||||
export function LegacyNotificationSettingsScreen({navigation}: Props) {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
navigation.replace('NotificationSettings')
|
||||
}, [navigation]),
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import {Text} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
|
||||
import {useNotificationSettingsMutation} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Error} from '#/components/Error'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as SettingsList from './components/SettingsList'
|
||||
|
||||
type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
|
||||
export function NotificationSettingsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {
|
||||
data,
|
||||
isError: isQueryError,
|
||||
refetch,
|
||||
} = useNotificationFeedQuery({
|
||||
filter: 'all',
|
||||
})
|
||||
const serverPriority = data?.pages.at(0)?.priority
|
||||
|
||||
const {
|
||||
mutate: onChangePriority,
|
||||
isPending: isMutationPending,
|
||||
variables,
|
||||
} = useNotificationSettingsMutation()
|
||||
|
||||
const priority = isMutationPending
|
||||
? variables[0] === 'enabled'
|
||||
: serverPriority
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notification Settings</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
{isQueryError ? (
|
||||
<Error
|
||||
title={_(msg`Oops!`)}
|
||||
message={_(msg`Something went wrong!`)}
|
||||
onRetry={refetch}
|
||||
sideBorders={false}
|
||||
/>
|
||||
) : (
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Group>
|
||||
<SettingsList.ItemIcon icon={BeakerIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Notification filters</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Group
|
||||
label={_(msg`Priority notifications`)}
|
||||
type="checkbox"
|
||||
values={priority ? ['enabled'] : []}
|
||||
onChange={onChangePriority}
|
||||
disabled={typeof priority !== 'boolean' || isMutationPending}>
|
||||
<Toggle.Item
|
||||
name="enabled"
|
||||
label={_(msg`Enable priority notifications`)}
|
||||
style={[a.flex_1, a.justify_between]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Enable priority notifications</Trans>
|
||||
</Toggle.LabelText>
|
||||
{!data ? <Loader size="md" /> : <Toggle.Platform />}
|
||||
</Toggle.Item>
|
||||
</Toggle.Group>
|
||||
</SettingsList.Group>
|
||||
<SettingsList.Item>
|
||||
<Admonition type="warning" style={[a.flex_1]}>
|
||||
<Trans>
|
||||
<Text style={[a.font_bold]}>Experimental:</Text> When this
|
||||
preference is enabled, you'll only receive reply and quote
|
||||
notifications from users you follow. We'll continue to add
|
||||
more controls here over time.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
</SettingsList.Item>
|
||||
</SettingsList.Container>
|
||||
)}
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'LikeNotificationSettings'
|
||||
>
|
||||
export function LikeNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={HeartIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Likes</Trans>}
|
||||
subtitleText={
|
||||
<Trans>Get notifications when people like your posts.</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls name="like" preference={preferences?.like} />
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon} from '#/components/icons/Heart2'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'LikesOnRepostsNotificationSettings'
|
||||
>
|
||||
export function LikesOnRepostsNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={LikeRepostIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Likes of your reposts</Trans>}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Get notifications when people like posts that you've reposted.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls
|
||||
name="likeViaRepost"
|
||||
preference={preferences?.likeViaRepost}
|
||||
/>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'MentionNotificationSettings'
|
||||
>
|
||||
export function MentionNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={AtIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Mentions</Trans>}
|
||||
subtitleText={
|
||||
<Trans>Get notifications when people mention you.</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls
|
||||
name="mention"
|
||||
preference={preferences?.mention}
|
||||
/>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'MiscellaneousNotificationSettings'
|
||||
>
|
||||
export function MiscellaneousNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={ShapesIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Everything else</Trans>}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Notifications for everything else, such as when someone joins
|
||||
via one of your starter packs.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls
|
||||
name="starterpackJoined"
|
||||
preference={preferences?.starterpackJoined}
|
||||
syncOthers={['verified', 'unverified']}
|
||||
allowDisableInApp={false}
|
||||
/>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'NewFollowerNotificationSettings'
|
||||
>
|
||||
export function NewFollowerNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={PersonPlusIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>New followers</Trans>}
|
||||
subtitleText={
|
||||
<Trans>Get notifications when people follow you.</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls
|
||||
name="follow"
|
||||
preference={preferences?.follow}
|
||||
/>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'QuoteNotificationSettings'
|
||||
>
|
||||
export function QuoteNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={CloseQuoteIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Quotes</Trans>}
|
||||
subtitleText={
|
||||
<Trans>Get notifications when people quote your posts.</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls name="quote" preference={preferences?.quote} />
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'ReplyNotificationSettings'
|
||||
>
|
||||
export function ReplyNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={BubbleIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Replies</Trans>}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Get notifications when people reply to your posts.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls name="reply" preference={preferences?.reply} />
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'RepostNotificationSettings'
|
||||
>
|
||||
export function RepostNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={RepostIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Reposts</Trans>}
|
||||
subtitleText={
|
||||
<Trans>Get notifications when people repost your posts.</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls
|
||||
name="repost"
|
||||
preference={preferences?.repost}
|
||||
/>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon} from '#/components/icons/Repost'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'RepostsOnRepostsNotificationSettings'
|
||||
>
|
||||
export function RepostsOnRepostsNotificationSettingsScreen({}: Props) {
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={RepostRepostIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={<Trans>Reposts of your reposts</Trans>}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Get notifications when people repost posts that you've
|
||||
reposted.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isError ? (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<PreferenceControls
|
||||
name="repostViaRepost"
|
||||
preference={preferences?.repostViaRepost}
|
||||
/>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as SettingsList from '../../components/SettingsList'
|
||||
|
||||
export function ItemTextWithSubtitle({
|
||||
titleText,
|
||||
subtitleText,
|
||||
bold = false,
|
||||
showSkeleton = false,
|
||||
}: {
|
||||
titleText: React.ReactNode
|
||||
subtitleText: React.ReactNode
|
||||
bold?: boolean
|
||||
showSkeleton?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.flex_1, bold ? a.gap_xs : a.gap_2xs]}>
|
||||
<SettingsList.ItemText style={bold && [a.font_bold, a.text_lg]}>
|
||||
{titleText}
|
||||
</SettingsList.ItemText>
|
||||
{showSkeleton ? (
|
||||
<Skele.Text style={[a.text_sm, {width: 120}]} />
|
||||
) : (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{subtitleText}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyNotificationDefs} from '@atproto/api'
|
||||
import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky/notification/defs'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Divider} from '../../components/SettingsList'
|
||||
|
||||
export function PreferenceControls({
|
||||
name,
|
||||
syncOthers,
|
||||
preference,
|
||||
allowDisableInApp = true,
|
||||
}: {
|
||||
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
|
||||
/**
|
||||
* Keep other prefs in sync with `name`. For use in the "everything else" category
|
||||
* which groups starterpack joins + verified + unverified notifications into a single toggle.
|
||||
*/
|
||||
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
|
||||
preference?: AppBskyNotificationDefs.Preference | FilterablePreference
|
||||
allowDisableInApp?: boolean
|
||||
}) {
|
||||
const gate = useGate()
|
||||
|
||||
if (!gate('reengagement_features')) return null
|
||||
|
||||
if (!preference)
|
||||
return (
|
||||
<View style={[a.w_full, a.pt_5xl, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<Inner
|
||||
name={name}
|
||||
syncOthers={syncOthers}
|
||||
preference={preference}
|
||||
allowDisableInApp={allowDisableInApp}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({
|
||||
name,
|
||||
syncOthers = [],
|
||||
preference,
|
||||
allowDisableInApp,
|
||||
}: {
|
||||
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
|
||||
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
|
||||
preference: AppBskyNotificationDefs.Preference | FilterablePreference
|
||||
allowDisableInApp: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mutate} = useNotificationSettingsUpdateMutation()
|
||||
|
||||
const channels = useMemo(() => {
|
||||
const arr = []
|
||||
if (preference.list) arr.push('list')
|
||||
if (preference.push) arr.push('push')
|
||||
return arr
|
||||
}, [preference])
|
||||
|
||||
const onChangeChannels = (change: string[]) => {
|
||||
const newPreference = {
|
||||
...preference,
|
||||
list: change.includes('list'),
|
||||
push: change.includes('push'),
|
||||
} satisfies typeof preference
|
||||
|
||||
mutate({
|
||||
[name]: newPreference,
|
||||
...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
|
||||
})
|
||||
}
|
||||
|
||||
const onChangeFilter = ([change]: string[]) => {
|
||||
if (change !== 'all' && change !== 'follows')
|
||||
throw new Error('Invalid filter')
|
||||
|
||||
const newPreference = {
|
||||
...preference,
|
||||
include: change,
|
||||
} satisfies typeof preference
|
||||
|
||||
mutate({
|
||||
[name]: newPreference,
|
||||
...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.px_xl, a.pt_md, a.gap_sm]}>
|
||||
<Toggle.Group
|
||||
type="checkbox"
|
||||
label={_(msg`Select your preferred notification channels`)}
|
||||
values={channels}
|
||||
onChange={onChangeChannels}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Receive push notifications`)}
|
||||
name="push"
|
||||
style={[
|
||||
a.py_xs,
|
||||
platform({
|
||||
native: [a.justify_between],
|
||||
web: [a.flex_row_reverse, a.gap_md],
|
||||
}),
|
||||
]}>
|
||||
<Toggle.LabelText
|
||||
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
|
||||
<Trans>Push notifications</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Platform />
|
||||
</Toggle.Item>
|
||||
{allowDisableInApp && (
|
||||
<Toggle.Item
|
||||
label={_(msg`Receive in-app notifications`)}
|
||||
name="list"
|
||||
style={[
|
||||
a.py_xs,
|
||||
platform({
|
||||
native: [a.justify_between],
|
||||
web: [a.flex_row_reverse, a.gap_md],
|
||||
}),
|
||||
]}>
|
||||
<Toggle.LabelText
|
||||
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
|
||||
<Trans>In-app notifications</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Platform />
|
||||
</Toggle.Item>
|
||||
)}
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
{'include' in preference && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text style={[a.font_bold, a.text_md]}>
|
||||
<Trans>From</Trans>
|
||||
</Text>
|
||||
<Toggle.Group
|
||||
type="radio"
|
||||
label={_(msg`Filter who you receive notifications from`)}
|
||||
values={[preference.include]}
|
||||
onChange={onChangeFilter}
|
||||
disabled={channels.length === 0}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Everyone`)}
|
||||
name="all"
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_xs,
|
||||
platform({native: [a.gap_sm], web: [a.gap_md]}),
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText
|
||||
style={[
|
||||
channels.length > 0 && t.atoms.text,
|
||||
a.font_normal,
|
||||
a.text_md,
|
||||
]}>
|
||||
<Trans>Everyone</Trans>
|
||||
</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
label={_(msg`People I follow`)}
|
||||
name="follows"
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_xs,
|
||||
platform({native: [a.gap_sm], web: [a.gap_md]}),
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText
|
||||
style={[
|
||||
channels.length > 0 && t.atoms.text,
|
||||
a.font_normal,
|
||||
a.text_md,
|
||||
]}>
|
||||
<Trans>People I follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {useEffect} from 'react'
|
||||
import {Linking, View} from 'react-native'
|
||||
import * as Notification from 'expo-notifications'
|
||||
import {type AppBskyNotificationDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useAppState} from '#/lib/hooks/useAppState'
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {isAndroid, isIOS, isWeb} from '#/platform/detection'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
|
||||
// import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
|
||||
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
|
||||
import {
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
|
||||
LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||
import {
|
||||
Repost_Stroke2_Corner2_Rounded as RepostIcon,
|
||||
RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon,
|
||||
} from '#/components/icons/Repost'
|
||||
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
|
||||
const RQKEY = ['notification-permissions']
|
||||
|
||||
type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
|
||||
export function NotificationSettingsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {data: settings, isError} = useNotificationSettingsQuery()
|
||||
|
||||
const {data: permissions, refetch} = useQuery({
|
||||
queryKey: RQKEY,
|
||||
queryFn: async () => {
|
||||
if (isWeb) return null
|
||||
return await Notification.getPermissionsAsync()
|
||||
},
|
||||
})
|
||||
|
||||
const appState = useAppState()
|
||||
useEffect(() => {
|
||||
if (appState === 'active') {
|
||||
refetch()
|
||||
}
|
||||
}, [appState, refetch])
|
||||
|
||||
const onRequestPermissions = async () => {
|
||||
if (isWeb) return
|
||||
if (permissions?.canAskAgain) {
|
||||
const response = await Notification.requestPermissionsAsync()
|
||||
queryClient.setQueryData(RQKEY, response)
|
||||
} else {
|
||||
if (isAndroid) {
|
||||
try {
|
||||
await Linking.sendIntent(
|
||||
'android.settings.APP_NOTIFICATION_SETTINGS',
|
||||
[
|
||||
{
|
||||
key: 'android.provider.extra.APP_PACKAGE',
|
||||
value: 'xyz.blueskyweb.app',
|
||||
},
|
||||
],
|
||||
)
|
||||
} catch {
|
||||
Linking.openSettings()
|
||||
}
|
||||
} else if (isIOS) {
|
||||
Linking.openSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
{permissions && !permissions.granted && (
|
||||
<>
|
||||
<SettingsList.PressableItem
|
||||
label={_(msg`Enable push notifications`)}
|
||||
onPress={onRequestPermissions}>
|
||||
<SettingsList.ItemIcon icon={HapticIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Enable push notifications</Trans>
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.PressableItem>
|
||||
<SettingsList.Divider />
|
||||
</>
|
||||
)}
|
||||
{isError && (
|
||||
<View style={[a.px_lg, a.pb_md]}>
|
||||
<Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
)}
|
||||
<View style={[a.gap_sm]}>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for like notifications`)}
|
||||
to={{screen: 'LikeNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={HeartIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Likes</Trans>}
|
||||
subtitleText={<SettingPreview preference={settings?.like} />}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for new follower notifications`)}
|
||||
to={{screen: 'NewFollowerNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={PersonPlusIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>New followers</Trans>}
|
||||
subtitleText={<SettingPreview preference={settings?.follow} />}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for reply notifications`)}
|
||||
to={{screen: 'ReplyNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={BubbleIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Replies</Trans>}
|
||||
subtitleText={<SettingPreview preference={settings?.reply} />}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for mention notifications`)}
|
||||
to={{screen: 'MentionNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={AtIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Mentions</Trans>}
|
||||
subtitleText={<SettingPreview preference={settings?.mention} />}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for quote notifications`)}
|
||||
to={{screen: 'QuoteNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={CloseQuoteIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Quotes</Trans>}
|
||||
subtitleText={<SettingPreview preference={settings?.quote} />}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for repost notifications`)}
|
||||
to={{screen: 'RepostNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={RepostIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Reposts</Trans>}
|
||||
subtitleText={<SettingPreview preference={settings?.repost} />}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
{/* <SettingsList.LinkItem
|
||||
label={_(msg`Settings for activity alerts`)}
|
||||
to={{screen: 'ActivityNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={BellRingingIcon} />
|
||||
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Activity alerts</Trans>}
|
||||
subtitleText={
|
||||
<SettingPreview preference={settings?.subscribedPost} />
|
||||
}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem> */}
|
||||
<SettingsList.LinkItem
|
||||
label={_(
|
||||
msg`Settings for notifications for likes of your reposts`,
|
||||
)}
|
||||
to={{screen: 'LikesOnRepostsNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={LikeRepostIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Likes of your reposts</Trans>}
|
||||
subtitleText={
|
||||
<SettingPreview preference={settings?.likeViaRepost} />
|
||||
}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(
|
||||
msg`Settings for notifications for reposts of your reposts`,
|
||||
)}
|
||||
to={{screen: 'RepostsOnRepostsNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={RepostRepostIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Reposts of your reposts</Trans>}
|
||||
subtitleText={
|
||||
<SettingPreview preference={settings?.repostViaRepost} />
|
||||
}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.LinkItem
|
||||
label={_(msg`Settings for notifications for everything else`)}
|
||||
to={{screen: 'MiscellaneousNotificationSettings'}}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={ShapesIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>Everything else</Trans>}
|
||||
// technically a bundle of several settings, but since they're set together
|
||||
// and are most likely in sync we'll just show the state of one of them
|
||||
subtitleText={
|
||||
<SettingPreview preference={settings?.starterpackJoined} />
|
||||
}
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.LinkItem>
|
||||
</View>
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingPreview({
|
||||
preference,
|
||||
}: {
|
||||
preference?:
|
||||
| AppBskyNotificationDefs.Preference
|
||||
| AppBskyNotificationDefs.FilterablePreference
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
if (!preference) {
|
||||
return null
|
||||
} else {
|
||||
if ('include' in preference) {
|
||||
if (preference.include === 'all') {
|
||||
if (preference.list && preference.push) {
|
||||
return _(msg`In-app, Push, Everyone`)
|
||||
} else if (preference.list) {
|
||||
return _(msg`In-app, Everyone`)
|
||||
} else if (preference.push) {
|
||||
return _(msg`Push, Everyone`)
|
||||
}
|
||||
} else if (preference.include === 'follows') {
|
||||
if (preference.list && preference.push) {
|
||||
return _(msg`In-app, Push, People you follow`)
|
||||
} else if (preference.list) {
|
||||
return _(msg`In-app, People you follow`)
|
||||
} else if (preference.push) {
|
||||
return _(msg`Push, People you follow`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (preference.list && preference.push) {
|
||||
return _(msg`In-app, Push`)
|
||||
} else if (preference.list) {
|
||||
return _(msg`In-app`)
|
||||
} else if (preference.push) {
|
||||
return _(msg`Push`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _(msg`Off`)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type CommonNavigatorParams,
|
||||
type NavigationProp,
|
||||
} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -36,6 +37,7 @@ import {AvatarStackWithFetch} from '#/components/AvatarStack'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
|
||||
import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell'
|
||||
import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
|
||||
import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
|
||||
import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
|
||||
@@ -80,6 +82,7 @@ export function SettingsScreen({}: Props) {
|
||||
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
|
||||
const [showAccounts, setShowAccounts] = useState(false)
|
||||
const [showDevOptions, setShowDevOptions] = useState(false)
|
||||
const gate = useGate()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
@@ -180,6 +183,16 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Moderation</Trans>
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.LinkItem>
|
||||
{gate('reengagement_features') && (
|
||||
<SettingsList.LinkItem
|
||||
to="/settings/notifications"
|
||||
label={_(msg`Notifications`)}>
|
||||
<SettingsList.ItemIcon icon={NotificationIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Notifications</Trans>
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.LinkItem>
|
||||
)}
|
||||
<SettingsList.LinkItem
|
||||
to="/settings/content-and-media"
|
||||
label={_(msg`Content and media`)}>
|
||||
|
||||
@@ -525,8 +525,8 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
|
||||
isVerified
|
||||
? _(msg`Update to ${domain}`)
|
||||
: dnsPanel
|
||||
? _(msg`Verify DNS Record`)
|
||||
: _(msg`Verify Text File`)
|
||||
? _(msg`Verify DNS Record`)
|
||||
: _(msg`Verify Text File`)
|
||||
}
|
||||
variant="solid"
|
||||
size="large"
|
||||
|
||||
Vendored
+2
-2
@@ -144,8 +144,8 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
|
||||
'status' in shadow
|
||||
? shadow.status
|
||||
: 'status' in profile
|
||||
? profile.status
|
||||
: undefined,
|
||||
? profile.status
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {createContext, useContext, useMemo, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
type GestureStateChangeEvent,
|
||||
type GestureUpdateEvent,
|
||||
type PanGestureHandlerEventPayload,
|
||||
} from 'react-native-gesture-handler'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
export type GlobalGestureEvents = {
|
||||
begin: GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||
update: GestureUpdateEvent<PanGestureHandlerEventPayload>
|
||||
end: GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||
finalize: GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
events: EventEmitter<GlobalGestureEvents>
|
||||
register: () => void
|
||||
unregister: () => void
|
||||
}>({
|
||||
events: new EventEmitter<GlobalGestureEvents>(),
|
||||
register: () => {},
|
||||
unregister: () => {},
|
||||
})
|
||||
|
||||
export function GlobalGestureEventsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const refCount = useRef(0)
|
||||
const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), [])
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
events,
|
||||
register() {
|
||||
refCount.current += 1
|
||||
if (refCount.current === 1) {
|
||||
setEnabled(true)
|
||||
}
|
||||
},
|
||||
unregister() {
|
||||
refCount.current -= 1
|
||||
if (refCount.current === 0) {
|
||||
setEnabled(false)
|
||||
}
|
||||
},
|
||||
}),
|
||||
[events, setEnabled],
|
||||
)
|
||||
const gesture = Gesture.Pan()
|
||||
.runOnJS(true)
|
||||
.enabled(enabled)
|
||||
.simultaneousWithExternalGesture()
|
||||
.onBegin(e => {
|
||||
events.emit('begin', e)
|
||||
})
|
||||
.onUpdate(e => {
|
||||
events.emit('update', e)
|
||||
})
|
||||
.onEnd(e => {
|
||||
events.emit('end', e)
|
||||
})
|
||||
.onFinalize(e => {
|
||||
events.emit('finalize', e)
|
||||
})
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<View collapsable={false}>{children}</View>
|
||||
</GestureDetector>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGlobalGestureEvents() {
|
||||
return useContext(Context)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function GlobalGestureEventsProvider(_props: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
throw new Error('GlobalGestureEventsProvider is not supported on web.')
|
||||
}
|
||||
|
||||
export function useGlobalGestureEvents() {
|
||||
throw new Error('useGlobalGestureEvents is not supported on web.')
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user