Merge branch 'main' into feat/selectable-text

# Conflicts:
#	src/view/com/util/Link.tsx
This commit is contained in:
Hailey
2024-01-12 23:41:10 -08:00
58 changed files with 19550 additions and 4517 deletions
+2
View File
@@ -49,6 +49,7 @@
--text: white; --text: white;
--background: black; --background: black;
--backgroundLight: #26272D; --backgroundLight: #26272D;
color-scheme: dark;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html.colorMode--system { html.colorMode--system {
@@ -62,6 +63,7 @@
--text: white; --text: white;
--background: black; --background: black;
--backgroundLight: #26272D; --backgroundLight: #26272D;
color-scheme: dark;
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/** @type {import('@lingui/conf').LinguiConfig} */ /** @type {import('@lingui/conf').LinguiConfig} */
module.exports = { module.exports = {
locales: ['en', 'hi', 'ja', 'fr', 'de', 'es', 'ko', 'es', 'pt-BR', 'uk', 'id'], locales: ['en', 'de', 'es', 'fr', 'hi', 'id', 'ja', 'ko', 'pt-BR', 'uk'],
catalogs: [ catalogs: [
{ {
path: '<rootDir>/src/locale/locales/{locale}/messages', path: '<rootDir>/src/locale/locales/{locale}/messages',
+21 -21
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.63.0", "version": "1.64.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -12,7 +12,7 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/", "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && eas build --platform all", "build-all": "yarn intl:build && eas build --platform all",
"start": "expo start --dev-client", "start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify", "start:prod": "expo start --dev-client --no-dev --minify",
@@ -60,7 +60,7 @@
"@react-native-community/datetimepicker": "7.6.1", "@react-native-community/datetimepicker": "7.6.1",
"@react-native-masked-view/masked-view": "0.3.0", "@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^0.8.0", "@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.5.1", "@react-native-picker/picker": "2.6.1",
"@react-navigation/bottom-tabs": "^6.5.7", "@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2", "@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6", "@react-navigation/native": "^6.1.6",
@@ -94,25 +94,26 @@
"email-validator": "^2.0.4", "email-validator": "^2.0.4",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^50.0.0-preview.7", "expo": "^50.0.0-preview.10",
"expo-application": "~5.8.1", "expo-application": "~5.8.2",
"expo-build-properties": "^0.11.0", "expo-build-properties": "^0.11.0",
"expo-camera": "~14.0.1", "expo-camera": "~14.0.1",
"expo-constants": "~15.4.2", "expo-constants": "~15.4.3",
"expo-dev-client": "~3.3.4", "expo-dev-client": "~3.3.5",
"expo-device": "~5.9.1", "expo-device": "~5.9.2",
"expo-image": "~1.10.1", "expo-image": "~1.10.3",
"expo-image-manipulator": "^11.8.0", "expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.7.1", "expo-image-picker": "~14.7.1",
"expo-localization": "~14.8.1", "expo-localization": "~14.8.2",
"expo-media-library": "~15.9.1", "expo-media-library": "~15.9.1",
"expo-notifications": "~0.27.2", "expo-notifications": "~0.27.3",
"expo-sharing": "^11.10.0", "expo-sharing": "^11.10.0",
"expo-splash-screen": "~0.26.1", "expo-splash-screen": "~0.26.2",
"expo-status-bar": "~1.11.1", "expo-status-bar": "~1.11.1",
"expo-system-ui": "~2.9.2", "expo-system-ui": "~2.9.3",
"expo-task-manager": "~11.7.0", "expo-task-manager": "~11.7.0",
"expo-updates": "~0.24.5", "expo-updates": "~0.24.7",
"expo-web-browser": "~12.8.1",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
@@ -140,7 +141,7 @@
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-native": "0.73.1", "react-native": "0.73.2",
"react-native-appstate-hook": "^1.0.6", "react-native-appstate-hook": "^1.0.6",
"react-native-drawer-layout": "^4.0.0-alpha.3", "react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
@@ -148,24 +149,23 @@
"react-native-get-random-values": "~1.8.0", "react-native-get-random-values": "~1.8.0",
"react-native-haptic-feedback": "^1.14.0", "react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1", "react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-ios-context-menu": "^1.15.3", "react-native-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2", "react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.2.2", "react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0", "react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress", "react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.6.0", "react-native-reanimated": "^3.6.0",
"react-native-root-siblings": "^4.1.1", "react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.7.4", "react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.27.0", "react-native-screens": "~3.29.0",
"react-native-svg": "14.0.0", "react-native-svg": "14.1.0",
"react-native-url-polyfill": "^1.3.0", "react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1", "react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6", "react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6", "react-native-web": "~0.19.6",
"react-native-web-linear-gradient": "^1.1.2", "react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2", "react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.6.3", "react-native-webview": "13.6.4",
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0", "rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1", "sentry-expo": "~7.0.1",
-12
View File
@@ -1,12 +0,0 @@
diff --git a/node_modules/babel-preset-fbjs/plugins/inline-requires.js b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
index b11fc83..e18661a 100644
--- a/node_modules/babel-preset-fbjs/plugins/inline-requires.js
+++ b/node_modules/babel-preset-fbjs/plugins/inline-requires.js
@@ -256,6 +256,7 @@ function getInlineableModule(path, state) {
return moduleName == null ||
state.ignoredRequires.has(moduleName) ||
+ moduleName.startsWith('@babel/runtime/') ||
isRequireInScope
? null
: { moduleName, requireFnName: fnName };
@@ -1,5 +1,5 @@
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
index cae11e7..42f251b 100644 index 9f2e3d2..5222c8e 100644
--- a/node_modules/metro-transform-worker/src/index.js --- a/node_modules/metro-transform-worker/src/index.js
+++ b/node_modules/metro-transform-worker/src/index.js +++ b/node_modules/metro-transform-worker/src/index.js
@@ -189,6 +189,10 @@ async function transformJS(file, { config, options, projectRoot }) { @@ -189,6 +189,10 @@ async function transformJS(file, { config, options, projectRoot }) {
-69
View File
@@ -1,69 +0,0 @@
/**
* This is a temporary off-spec search endpoint
* TODO removeme when we land this in proto!
*/
import {AppBskyFeedPost} from '@atproto/api'
const PROFILES_ENDPOINT = 'https://search.bsky.social/search/profiles'
const POSTS_ENDPOINT = 'https://search.bsky.social/search/posts'
export interface ProfileSearchItem {
$type: string
avatar: {
cid: string
mimeType: string
}
banner: {
cid: string
mimeType: string
}
description: string | undefined
displayName: string | undefined
did: string
}
export interface PostSearchItem {
tid: string
cid: string
user: {
did: string
handle: string
}
post: AppBskyFeedPost.Record
}
export async function searchProfiles(
query: string,
): Promise<ProfileSearchItem[]> {
return await doFetch<ProfileSearchItem[]>(PROFILES_ENDPOINT, query)
}
export async function searchPosts(query: string): Promise<PostSearchItem[]> {
return await doFetch<PostSearchItem[]>(POSTS_ENDPOINT, query)
}
async function doFetch<T>(endpoint: string, query: string): Promise<T> {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 15e3)
const uri = new URL(endpoint)
uri.searchParams.set('q', query)
const res = await fetch(String(uri), {
method: 'get',
headers: {
accept: 'application/json',
},
signal: controller.signal,
})
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
let resBody = await res.json()
clearTimeout(to)
return resBody as unknown as T
}
+30 -2
View File
@@ -1,11 +1,39 @@
import {QueryClient} from '@tanstack/react-query' import {AppState, AppStateStatus} from 'react-native'
import {QueryClient, focusManager} from '@tanstack/react-query'
import {isNative} from '#/platform/detection'
focusManager.setEventListener(onFocus => {
if (isNative) {
const subscription = AppState.addEventListener(
'change',
(status: AppStateStatus) => {
focusManager.setFocused(status === 'active')
},
)
return () => subscription.remove()
} else if (typeof window !== 'undefined' && window.addEventListener) {
// these handlers are a bit redundant but focus catches when the browser window
// is blurred/focused while visibilitychange seems to only handle when the
// window minimizes (both of them catch tab changes)
// there's no harm to redundant fires because refetchOnWindowFocus is only
// used with queries that employ stale data times
const handler = () => onFocus()
window.addEventListener('focus', handler, false)
window.addEventListener('visibilitychange', handler, false)
return () => {
window.removeEventListener('visibilitychange', handler)
window.removeEventListener('focus', handler)
}
}
})
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
// NOTE // NOTE
// refetchOnWindowFocus breaks some UIs (like feeds) // refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this // so we only selectively want to enable this
// -prf // -prf
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
// Structural sharing between responses makes it impossible to rely on // Structural sharing between responses makes it impossible to rely on
+29
View File
@@ -0,0 +1,29 @@
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {linkRequiresWarning} from './url-helpers'
export function richTextToString(rt: RichText): string {
const {text, facets} = rt
if (!facets?.length) {
return text
}
let result = ''
for (const segment of rt.segments()) {
const link = segment.link
if (link && AppBskyRichtextFacet.validateLink(link).success) {
const href = link.uri
const text = segment.text
const requiresWarning = linkRequiresWarning(href, text)
result += !requiresWarning ? href : `[${text}](${href})`
} else {
result += segment.text
}
}
return result
}
+10 -2
View File
@@ -22,6 +22,14 @@ export function code3ToCode2(lang: string): string {
return lang return lang
} }
export function code3ToCode2Strict(lang: string): string | undefined {
if (lang.length === 3) {
return LANGUAGES_MAP_CODE3[lang]?.code2
}
return undefined
}
export function codeToLanguageName(lang: string): string { export function codeToLanguageName(lang: string): string {
const lang2 = code3ToCode2(lang) const lang2 = code3ToCode2(lang)
return LANGUAGES_MAP_CODE2[lang2]?.name || lang return LANGUAGES_MAP_CODE2[lang2]?.name || lang
@@ -113,14 +121,14 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
// DISABLED until this translation is fixed -prf // DISABLED until this translation is fixed -prf
// case 'de': // case 'de':
// return AppLanguage.de // return AppLanguage.de
case 'id':
return AppLanguage.id
case 'es': case 'es':
return AppLanguage.es return AppLanguage.es
case 'fr': case 'fr':
return AppLanguage.fr return AppLanguage.fr
case 'hi': case 'hi':
return AppLanguage.hi return AppLanguage.hi
case 'id':
return AppLanguage.id
case 'ja': case 'ja':
return AppLanguage.ja return AppLanguage.ja
case 'ko': case 'ko':
+4 -4
View File
@@ -27,10 +27,6 @@ export async function dynamicActivate(locale: AppLanguage) {
// i18n.loadAndActivate({locale, messages: messagesDe}) // i18n.loadAndActivate({locale, messages: messagesDe})
// break // break
// } // }
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
break
}
case AppLanguage.es: { case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs}) i18n.loadAndActivate({locale, messages: messagesEs})
break break
@@ -43,6 +39,10 @@ export async function dynamicActivate(locale: AppLanguage) {
i18n.loadAndActivate({locale, messages: messagesHi}) i18n.loadAndActivate({locale, messages: messagesHi})
break break
} }
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
break
}
case AppLanguage.ja: { case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa}) i18n.loadAndActivate({locale, messages: messagesJa})
break break
+8 -5
View File
@@ -17,10 +17,6 @@ export async function dynamicActivate(locale: AppLanguage) {
// mod = await import(`./locales/de/messages`) // mod = await import(`./locales/de/messages`)
// break // break
// } // }
case AppLanguage.id: {
mod = await import(`./locales/id/messages`)
break
}
case AppLanguage.es: { case AppLanguage.es: {
mod = await import(`./locales/es/messages`) mod = await import(`./locales/es/messages`)
break break
@@ -33,6 +29,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/hi/messages`) mod = await import(`./locales/hi/messages`)
break break
} }
case AppLanguage.id: {
mod = await import(`./locales/id/messages`)
break
}
case AppLanguage.ja: { case AppLanguage.ja: {
mod = await import(`./locales/ja/messages`) mod = await import(`./locales/ja/messages`)
break break
@@ -62,6 +62,9 @@ export async function dynamicActivate(locale: AppLanguage) {
export async function useLocaleLanguage() { export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs() const {appLanguage} = useLanguagePrefs()
useEffect(() => { useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage)) const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage)
}, [appLanguage]) }, [appLanguage])
} }
+2 -2
View File
@@ -8,10 +8,10 @@ export enum AppLanguage {
en = 'en', en = 'en',
// DISABLED until this translation is fixed -prf // DISABLED until this translation is fixed -prf
// de = 'de', // de = 'de',
id = 'id',
es = 'es', es = 'es',
fr = 'fr', fr = 'fr',
hi = 'hi', hi = 'hi',
id = 'id',
ja = 'ja', ja = 'ja',
ko = 'ko', ko = 'ko',
pt_BR = 'pt-BR', pt_BR = 'pt-BR',
@@ -27,10 +27,10 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.en, name: 'English'}, {code2: AppLanguage.en, name: 'English'},
// DISABLED until this translation is fixed -prf // DISABLED until this translation is fixed -prf
// {code2: AppLanguage.de, name: 'Deutsch'}, // {code2: AppLanguage.de, name: 'Deutsch'},
{code2: AppLanguage.id, name: 'Indonesian'},
{code2: AppLanguage.es, name: 'Español'}, {code2: AppLanguage.es, name: 'Español'},
{code2: AppLanguage.fr, name: 'Français'}, {code2: AppLanguage.fr, name: 'Français'},
{code2: AppLanguage.hi, name: 'हिंदी'}, {code2: AppLanguage.hi, name: 'हिंदी'},
{code2: AppLanguage.id, name: 'Bahasa Indonesia'},
{code2: AppLanguage.ja, name: '日本語'}, {code2: AppLanguage.ja, name: '日本語'},
{code2: AppLanguage.ko, name: '한국어'}, {code2: AppLanguage.ko, name: '한국어'},
{code2: AppLanguage.pt_BR, name: 'Português (BR)'}, {code2: AppLanguage.pt_BR, name: 'Português (BR)'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -187,6 +187,11 @@ export interface EmbedConsentModal {
onAccept: () => void onAccept: () => void
} }
export interface InAppBrowserConsentModal {
name: 'in-app-browser-consent'
href: string
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@@ -231,6 +236,7 @@ export type Modal =
| ConfirmModal | ConfirmModal
| LinkWarningModal | LinkWarningModal
| EmbedConsentModal | EmbedConsentModal
| InAppBrowserConsentModal
const ModalContext = React.createContext<{ const ModalContext = React.createContext<{
isModalActive: boolean isModalActive: boolean
+2
View File
@@ -53,6 +53,7 @@ export const schema = z.object({
step: z.string(), step: z.string(),
}), }),
hiddenPosts: z.array(z.string()).optional(), // should move to server hiddenPosts: z.array(z.string()).optional(), // should move to server
useInAppBrowser: z.boolean().optional(),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
@@ -84,4 +85,5 @@ export const defaults: Schema = {
step: 'Home', step: 'Home',
}, },
hiddenPosts: [], hiddenPosts: [],
useInAppBrowser: undefined,
} }
+79
View File
@@ -0,0 +1,79 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection'
import {useModalControls} from '../modals'
type StateContext = persisted.Schema['useInAppBrowser']
type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
const stateContext = React.createContext<StateContext>(
persisted.defaults.useInAppBrowser,
)
const setContext = React.createContext<SetContext>(
(_: persisted.Schema['useInAppBrowser']) => {},
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('useInAppBrowser'))
const setStateWrapped = React.useCallback(
(inAppBrowser: persisted.Schema['useInAppBrowser']) => {
setState(inAppBrowser)
persisted.write('useInAppBrowser', inAppBrowser)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('useInAppBrowser'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useInAppBrowser() {
return React.useContext(stateContext)
}
export function useSetInAppBrowser() {
return React.useContext(setContext)
}
export function useOpenLink() {
const {openModal} = useModalControls()
const enabled = useInAppBrowser()
const openLink = React.useCallback(
(url: string, override?: boolean) => {
if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) {
openModal({
name: 'in-app-browser-consent',
href: url,
})
return
} else if (override ?? enabled) {
WebBrowser.openBrowserAsync(url, {
presentationStyle:
WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
})
return
}
}
Linking.openURL(url)
},
[enabled, openModal],
)
return openLink
}
+4 -1
View File
@@ -3,6 +3,7 @@ import {Provider as LanguagesProvider} from './languages'
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts' import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as InAppBrowserProvider} from './in-app-browser'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export { export {
@@ -20,7 +21,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<LanguagesProvider> <LanguagesProvider>
<AltTextRequiredProvider> <AltTextRequiredProvider>
<ExternalEmbedsProvider> <ExternalEmbedsProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider> <HiddenPostsProvider>
<InAppBrowserProvider>{children}</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider> </ExternalEmbedsProvider>
</AltTextRequiredProvider> </AltTextRequiredProvider>
</LanguagesProvider> </LanguagesProvider>
+1
View File
@@ -35,4 +35,5 @@ export interface CachedFeedPage {
usableInFeed: boolean usableInFeed: boolean
syncedAt: Date syncedAt: Date
data: FeedPage | undefined data: FeedPage | undefined
unreadCount: number
} }
+28 -3
View File
@@ -25,7 +25,10 @@ type StateContext = string
interface ApiContext { interface ApiContext {
markAllRead: () => Promise<void> markAllRead: () => Promise<void>
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void> checkUnread: (opts?: {
invalidate?: boolean
isPoll?: boolean
}) => Promise<void>
getCachedUnreadPage: () => FeedPage | undefined getCachedUnreadPage: () => FeedPage | undefined
} }
@@ -50,6 +53,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: false, usableInFeed: false,
syncedAt: new Date(), syncedAt: new Date(),
data: undefined, data: undefined,
unreadCount: 0,
}) })
// periodic sync // periodic sync
@@ -58,7 +62,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return return
} }
checkUnreadRef.current() // fire on init checkUnreadRef.current() // fire on init
const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL) const interval = setInterval(
() => checkUnreadRef.current?.({isPoll: true}),
UPDATE_INTERVAL,
)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [hasSession]) }, [hasSession])
@@ -69,6 +76,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: false, usableInFeed: false,
syncedAt: new Date(), syncedAt: new Date(),
data: undefined, data: undefined,
unreadCount:
data.event === '30+'
? 30
: data.event === ''
? 0
: parseInt(data.event, 10) || 1,
} }
setNumUnread(data.event) setNumUnread(data.event)
} }
@@ -95,13 +108,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, },
async checkUnread({invalidate}: {invalidate?: boolean} = {}) { async checkUnread({
invalidate,
isPoll,
}: {invalidate?: boolean; isPoll?: boolean} = {}) {
try { try {
if (!getAgent().session) return if (!getAgent().session) return
if (AppState.currentState !== 'active') { if (AppState.currentState !== 'active') {
return return
} }
// reduce polling if unread count is set
if (isPoll && cacheRef.current?.unreadCount !== 0) {
// if hit 30+ then don't poll, otherwise reduce polling by 50%
if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) {
return
}
}
// count // count
const page = await fetchPage({ const page = await fetchPage({
cursor: undefined, cursor: undefined,
@@ -133,6 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: !!invalidate, // will be used immediately usableInFeed: !!invalidate, // will be used immediately
data: page, data: page,
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed, syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
unreadCount,
} }
// update & broadcast // update & broadcast
+1 -1
View File
@@ -31,7 +31,7 @@ export function usePreferencesQuery() {
return useQuery({ return useQuery({
staleTime: STALE.SECONDS.FIFTEEN, staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: true, structuralSharing: true,
refetchInterval: STALE.SECONDS.FIFTEEN, refetchOnWindowFocus: true,
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
queryFn: async () => { queryFn: async () => {
const agent = getAgent() const agent = getAgent()
+1 -1
View File
@@ -35,7 +35,7 @@ export function useProfileQuery({did}: {did: string | undefined}) {
// if you remove it, the UI infinite-loops // if you remove it, the UI infinite-loops
// -prf // -prf
staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE, staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.FIVE, refetchOnWindowFocus: true,
queryKey: RQKEY(did || ''), queryKey: RQKEY(did || ''),
queryFn: async () => { queryFn: async () => {
const res = await getAgent().getProfile({actor: did || ''}) const res = await getAgent().getProfile({actor: did || ''})
+45 -23
View File
@@ -136,9 +136,11 @@ function createPersistSessionHandler(
event, event,
did: refreshedAccount.did, did: refreshedAccount.did,
handle: refreshedAccount.handle, handle: refreshedAccount.handle,
service: refreshedAccount.service,
}) })
if (expired) { if (expired) {
logger.warn(`session: expired`)
emitSessionDropped() emitSessionDropped()
} }
@@ -191,11 +193,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const clearCurrentAccount = React.useCallback(() => { const clearCurrentAccount = React.useCallback(() => {
logger.debug( logger.warn(`session: clear current account`)
`session: clear current account`,
{},
logger.DebugContext.session,
)
__globalAgent = PUBLIC_BSKY_AGENT __globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear() queryClient.clear()
setStateAndPersist(s => ({ setStateAndPersist(s => ({
@@ -225,6 +223,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
throw new Error(`session: createAccount failed to establish a session`) throw new Error(`session: createAccount failed to establish a session`)
} }
/*dont await*/ agent.upsertProfile(_existing => {
return {
displayName: handle,
}
})
const account: SessionAccount = { const account: SessionAccount = {
service: agent.service.toString(), service: agent.service.toString(),
did: agent.session.did, did: agent.session.did,
@@ -320,8 +324,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const logout = React.useCallback<ApiContext['logout']>(async () => { const logout = React.useCallback<ApiContext['logout']>(async () => {
logger.info(`session: logout`)
clearCurrentAccount() clearCurrentAccount()
logger.debug(`session: logout`, {}, logger.DebugContext.session)
setStateAndPersist(s => { setStateAndPersist(s => {
return { return {
...s, ...s,
@@ -549,30 +553,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return persisted.onUpdate(() => { return persisted.onUpdate(() => {
const session = persisted.get('session') const session = persisted.get('session')
logger.debug(`session: onUpdate`, {}, logger.DebugContext.session) logger.info(`session: persisted onUpdate`, {})
if (session.currentAccount) { if (session.currentAccount && session.currentAccount.refreshJwt) {
if (session.currentAccount?.did !== state.currentAccount?.did) { if (session.currentAccount?.did !== state.currentAccount?.did) {
logger.debug( logger.info(`session: persisted onUpdate, switching accounts`, {
`session: switching account`, from: {
{ did: state.currentAccount?.did,
from: { handle: state.currentAccount?.handle,
did: state.currentAccount?.did,
handle: state.currentAccount?.handle,
},
to: {
did: session.currentAccount.did,
handle: session.currentAccount.handle,
},
}, },
logger.DebugContext.session, to: {
) did: session.currentAccount.did,
handle: session.currentAccount.handle,
},
})
initSession(session.currentAccount) initSession(session.currentAccount)
} else {
logger.info(`session: persisted onUpdate, updating session`, {})
/*
* Use updated session in this tab's agent. Do not call
* upsertAccount, since that will only persist the session that's
* already persisted, and we'll get a loop between tabs.
*/
// @ts-ignore we checked for `refreshJwt` above
__globalAgent.session = session.currentAccount
} }
} else if (!session.currentAccount && state.currentAccount) { } else if (!session.currentAccount && state.currentAccount) {
logger.debug( logger.debug(
`session: logging out`, `session: persisted onUpdate, logging out`,
{ {
did: state.currentAccount?.did, did: state.currentAccount?.did,
handle: state.currentAccount?.handle, handle: state.currentAccount?.handle,
@@ -580,10 +590,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.DebugContext.session, logger.DebugContext.session,
) )
/*
* No need to do a hard logout here. If we reach this, tokens for this
* account have already been cleared either by an `expired` event
* handled by `persistSession` (which nukes this accounts tokens only),
* or by a `logout` call which nukes all accounts tokens)
*/
clearCurrentAccount() clearCurrentAccount()
} }
setState(s => ({
...s,
accounts: session.accounts,
currentAccount: session.currentAccount,
}))
}) })
}, [state, clearCurrentAccount, initSession]) }, [state, setState, clearCurrentAccount, initSession])
const stateContext = React.useMemo( const stateContext = React.useMemo(
() => ({ () => ({
+2
View File
@@ -45,6 +45,7 @@ import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants' import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {LabelsBtn} from './labels/LabelsBtn' import {LabelsBtn} from './labels/LabelsBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn' import {SelectLangBtn} from './select-language/SelectLangBtn'
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {insertMentionAt} from 'lib/strings/mention-manip' import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -454,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
))} ))}
</View> </View>
) : null} ) : null}
<SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? ( {canSelectImages ? (
<> <>
@@ -0,0 +1,101 @@
import React, {useEffect, useState} from 'react'
import {StyleSheet, View} from 'react-native'
import lande from 'lande'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Text} from '../../util/text/Text'
import {Button} from '../../util/forms/Button'
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
import {
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {usePalette} from '#/lib/hooks/usePalette'
import {s} from '#/lib/styles'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
// fallbacks for safari
const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1))
const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
export function SuggestedLanguage({text}: {text: string}) {
const [suggestedLanguage, setSuggestedLanguage] = useState<string>()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const pal = usePalette('default')
const {_} = useLingui()
useEffect(() => {
const textTrimmed = text.trim()
// Don't run the language model on small posts, the results are likely
// to be inaccurate anyway.
if (textTrimmed.length < 40) {
setSuggestedLanguage(undefined)
return
}
const idle = onIdle(() => {
// Only select languages that have a high confidence and convert to code2
const result = lande(textTrimmed).filter(
([lang, value]) => value >= 0.97 && code3ToCode2Strict(lang),
)
setSuggestedLanguage(
result.length > 0 ? code3ToCode2Strict(result[0][0]) : undefined,
)
})
return () => cancelIdle(idle)
}, [text])
return suggestedLanguage &&
!toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) ? (
<View style={[pal.border, styles.infoBar]}>
<FontAwesomeIcon
icon="language"
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
<Text style={[pal.text, s.flex1]}>
<Trans>
Are you writing in{' '}
<Text type="sm-bold" style={pal.text}>
{codeToLanguageName(suggestedLanguage)}
</Text>
?
</Trans>
</Text>
<Button
type="default"
onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
accessibilityLabel={_(
msg`Change post language to ${codeToLanguageName(suggestedLanguage)}`,
)}
accessibilityHint="">
<Text type="button" style={[pal.link, s.fw600]}>
<Trans>Yes</Trans>
</Text>
</Button>
</View>
) : null
}
const styles = StyleSheet.create({
infoBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 10,
marginBottom: 10,
},
})
+1
View File
@@ -174,6 +174,7 @@ export function FeedPage({
feed={feed} feed={feed}
feedParams={feedParams} feedParams={feedParams}
pollInterval={POLL_FREQ} pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew} onHasNew={setHasNew}
+1 -1
View File
@@ -38,7 +38,7 @@ export function Component(props: ReportComponentProps) {
? 'com.atproto.repo.strongRef' ? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef' : 'com.atproto.admin.defs#repoRef'
await getAgent().createModerationReport({ await getAgent().createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONOTHER, reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: { subject: {
$type, $type,
...props, ...props,
+102
View File
@@ -0,0 +1,102 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {s} from 'lib/styles'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ScrollView} from './util'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {
useOpenLink,
useSetInAppBrowser,
} from '#/state/preferences/in-app-browser'
export const snapPoints = [350]
export function Component({href}: {href: string}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
const {_} = useLingui()
const setInAppBrowser = useSetInAppBrowser()
const openLink = useOpenLink()
const onUseIAB = React.useCallback(() => {
setInAppBrowser(true)
closeModal()
openLink(href, true)
}, [closeModal, setInAppBrowser, href, openLink])
const onUseLinking = React.useCallback(() => {
setInAppBrowser(false)
closeModal()
openLink(href, false)
}, [closeModal, setInAppBrowser, href, openLink])
return (
<ScrollView
testID="inAppBrowserConsentModal"
style={[s.flex1, pal.view, {paddingHorizontal: 20, paddingTop: 10}]}>
<Text style={[pal.text, styles.title]}>
<Trans>How should we open this link?</Trans>
</Text>
<Text style={pal.text}>
<Trans>
Your choice will be saved, but can be changed later in settings.
</Trans>
</Text>
<View style={[styles.btnContainer]}>
<Button
testID="confirmBtn"
type="inverted"
onPress={onUseIAB}
accessibilityLabel={_(msg`Use in-app browser`)}
accessibilityHint=""
label={_(msg`Use in-app browser`)}
labelContainerStyle={{justifyContent: 'center', padding: 8}}
labelStyle={[s.f18]}
/>
<Button
testID="confirmBtn"
type="inverted"
onPress={onUseLinking}
accessibilityLabel={_(msg`Use my default browser`)}
accessibilityHint=""
label={_(msg`Use my default browser`)}
labelContainerStyle={{justifyContent: 'center', padding: 8}}
labelStyle={[s.f18]}
/>
<Button
testID="cancelBtn"
type="default"
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint=""
label="Cancel"
labelContainerStyle={{justifyContent: 'center', padding: 8}}
labelStyle={[s.f18]}
/>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
btnContainer: {
marginTop: 20,
flexDirection: 'column',
justifyContent: 'center',
rowGap: 10,
},
})
+4 -2
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {Linking, SafeAreaView, StyleSheet, View} from 'react-native' import {SafeAreaView, StyleSheet, View} from 'react-native'
import {ScrollView} from './util' import {ScrollView} from './util'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@@ -12,6 +12,7 @@ import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
export const snapPoints = ['50%'] export const snapPoints = ['50%']
@@ -21,10 +22,11 @@ export function Component({text, href}: {text: string; href: string}) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {_} = useLingui() const {_} = useLingui()
const potentiallyMisleading = isPossiblyAUrl(text) const potentiallyMisleading = isPossiblyAUrl(text)
const openLink = useOpenLink()
const onPressVisit = () => { const onPressVisit = () => {
closeModal() closeModal()
Linking.openURL(href) openLink(href)
} }
return ( return (
+4
View File
@@ -39,6 +39,7 @@ import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount' import * as SwitchAccountModal from './SwitchAccount'
import * as LinkWarningModal from './LinkWarning' import * as LinkWarningModal from './LinkWarning'
import * as EmbedConsentModal from './EmbedConsent' import * as EmbedConsentModal from './EmbedConsent'
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
const DEFAULT_SNAPPOINTS = ['90%'] const DEFAULT_SNAPPOINTS = ['90%']
const HANDLE_HEIGHT = 24 const HANDLE_HEIGHT = 24
@@ -180,6 +181,9 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'embed-consent') { } else if (activeModal?.name === 'embed-consent') {
snapPoints = EmbedConsentModal.snapPoints snapPoints = EmbedConsentModal.snapPoints
element = <EmbedConsentModal.Component {...activeModal} /> element = <EmbedConsentModal.Component {...activeModal} />
} else if (activeModal?.name === 'in-app-browser-consent') {
snapPoints = InAppBrowserConsentModal.snapPoints
element = <InAppBrowserConsentModal.Component {...activeModal} />
} else { } else {
return null return null
} }
@@ -336,6 +336,7 @@ let PostThreadItemLoaded = ({
postCid={post.cid} postCid={post.cid}
postUri={post.uri} postUri={post.uri}
record={record} record={record}
richText={richText}
showAppealLabelItem={ showAppealLabelItem={
post.author.did === currentAccount?.did && isModeratedPost post.author.did === currentAccount?.did && isModeratedPost
} }
@@ -440,6 +441,7 @@ let PostThreadItemLoaded = ({
big big
post={post} post={post}
record={record} record={record}
richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
/> />
</View> </View>
@@ -588,6 +590,7 @@ let PostThreadItemLoaded = ({
<PostCtrls <PostCtrls
post={post} post={post}
record={record} record={record}
richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
/> />
</View> </View>
+6 -1
View File
@@ -213,7 +213,12 @@ function PostInner({
</ContentHider> </ContentHider>
) : null} ) : null}
</ContentHider> </ContentHider>
<PostCtrls post={post} record={record} onPressReply={onPressReply} /> <PostCtrls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
/>
</View> </View>
</View> </View>
</Link> </Link>
+9 -5
View File
@@ -36,7 +36,8 @@ const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
const REFRESH_AFTER = STALE.HOURS.ONE // DISABLED need to check if this is causing random feed refreshes -prf
// const REFRESH_AFTER = STALE.HOURS.ONE
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
let Feed = ({ let Feed = ({
@@ -46,6 +47,7 @@ let Feed = ({
style, style,
enabled, enabled,
pollInterval, pollInterval,
disablePoll,
scrollElRef, scrollElRef,
onScrolledDownChange, onScrolledDownChange,
onHasNew, onHasNew,
@@ -63,6 +65,7 @@ let Feed = ({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
enabled?: boolean enabled?: boolean
pollInterval?: number pollInterval?: number
disablePoll?: boolean
scrollElRef?: ListRef scrollElRef?: ListRef
onHasNew?: (v: boolean) => void onHasNew?: (v: boolean) => void
onScrolledDownChange?: (isScrolledDown: boolean) => void onScrolledDownChange?: (isScrolledDown: boolean) => void
@@ -107,7 +110,7 @@ let Feed = ({
) )
const checkForNew = React.useCallback(async () => { const checkForNew = React.useCallback(async () => {
if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
return return
} }
try { try {
@@ -117,7 +120,7 @@ let Feed = ({
} catch (e) { } catch (e) {
logger.error('Poll latest failed', {feed, error: String(e)}) logger.error('Poll latest failed', {feed, error: String(e)})
} }
}, [feed, data, isFetching, onHasNew, enabled]) }, [feed, data, isFetching, onHasNew, enabled, disablePoll])
const myDid = currentAccount?.did || '' const myDid = currentAccount?.did || ''
const onPostCreated = React.useCallback(() => { const onPostCreated = React.useCallback(() => {
@@ -146,11 +149,12 @@ let Feed = ({
React.useEffect(() => { React.useEffect(() => {
if (enabled) { if (enabled) {
const timeSinceFirstLoad = Date.now() - lastFetchRef.current const timeSinceFirstLoad = Date.now() - lastFetchRef.current
if (timeSinceFirstLoad > REFRESH_AFTER) { // DISABLED need to check if this is causing random feed refreshes -prf
/*if (timeSinceFirstLoad > REFRESH_AFTER) {
// do a full refresh // do a full refresh
scrollElRef?.current?.scrollToOffset({offset: 0, animated: false}) scrollElRef?.current?.scrollToOffset({offset: 0, animated: false})
queryClient.resetQueries({queryKey: RQKEY(feed)}) queryClient.resetQueries({queryKey: RQKEY(feed)})
} else if ( } else*/ if (
timeSinceFirstLoad > CHECK_LATEST_AFTER && timeSinceFirstLoad > CHECK_LATEST_AFTER &&
checkForNewRef.current checkForNewRef.current
) { ) {
+1
View File
@@ -304,6 +304,7 @@ let FeedItemInner = ({
<PostCtrls <PostCtrls
post={post} post={post}
record={record} record={record}
richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
showAppealLabelItem={ showAppealLabelItem={
post.author.did === currentAccount?.did && isModeratedPost post.author.did === currentAccount?.did && isModeratedPost
+10 -3
View File
@@ -1,6 +1,5 @@
import React, {ComponentProps, memo, useMemo} from 'react' import React, {ComponentProps, memo, useMemo} from 'react'
import { import {
Linking,
GestureResponderEvent, GestureResponderEvent,
Platform, Platform,
StyleProp, StyleProp,
@@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
import {PressableWithHover} from './PressableWithHover' import {PressableWithHover} from './PressableWithHover'
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
type Event = type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -65,6 +65,7 @@ export const Link = memo(function Link({
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
const openLink = useOpenLink()
const onPress = React.useCallback( const onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
@@ -74,11 +75,12 @@ export const Link = memo(function Link({
navigation, navigation,
href: sanitizeUrl(href), href: sanitizeUrl(href),
navigationAction, navigationAction,
openLink,
e, e,
}) })
} }
}, },
[closeModal, navigation, navigationAction, href], [closeModal, navigation, navigationAction, href, openLink],
) )
if (noFeedback) { if (noFeedback) {
@@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({
const {...props} = useLinkProps({to: sanitizeUrl(href)}) const {...props} = useLinkProps({to: sanitizeUrl(href)})
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink()
if (warnOnMismatchingLabel && typeof text !== 'string') { if (warnOnMismatchingLabel && typeof text !== 'string') {
console.error('Unable to detect mismatching label') console.error('Unable to detect mismatching label')
@@ -189,6 +192,7 @@ export const TextLink = memo(function TextLink({
text, text,
navigationAction, navigationAction,
warnOnMismatchingLabel, warnOnMismatchingLabel,
openLink,
}) })
}, },
[ [
@@ -200,6 +204,7 @@ export const TextLink = memo(function TextLink({
text, text,
navigationAction, navigationAction,
warnOnMismatchingLabel, warnOnMismatchingLabel,
openLink,
], ],
) )
const hrefAttrs = useMemo(() => { const hrefAttrs = useMemo(() => {
@@ -313,6 +318,7 @@ export const onTextLinkPress = ({
text, text,
navigationAction = 'push', navigationAction = 'push',
warnOnMismatchingLabel, warnOnMismatchingLabel,
openLink,
}: { }: {
onPress?: (e: GestureResponderEvent) => void onPress?: (e: GestureResponderEvent) => void
e?: Event e?: Event
@@ -323,6 +329,7 @@ export const onTextLinkPress = ({
text?: any text?: any
navigationAction?: 'push' | 'replace' | 'navigate' navigationAction?: 'push' | 'replace' | 'navigate'
warnOnMismatchingLabel?: boolean warnOnMismatchingLabel?: boolean
openLink: (href: string) => void
}) => { }) => {
const requiresWarning = const requiresWarning =
warnOnMismatchingLabel && warnOnMismatchingLabel &&
@@ -368,7 +375,7 @@ export const onTextLinkPress = ({
if (shouldHandle) { if (shouldHandle) {
href = convertBskyAppUrlIfNeeded(href) href = convertBskyAppUrlIfNeeded(href)
if (newTab || href.startsWith('http') || href.startsWith('mailto')) { if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
Linking.openURL(href) openLink(href)
} else { } else {
closeModal() // close any active modals closeModal() // close any active modals
+47 -42
View File
@@ -3,9 +3,11 @@ import {ScrollProvider} from '#/lib/ScrollContext'
import {NativeScrollEvent} from 'react-native' import {NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {isWeb} from 'platform/detection' import {isNative} from 'platform/detection'
import {useSharedValue, interpolate} from 'react-native-reanimated' import {useSharedValue, interpolate} from 'react-native-reanimated'
const WEB_HIDE_SHELL_THRESHOLD = 200
function clamp(num: number, min: number, max: number) { function clamp(num: number, min: number, max: number) {
'worklet' 'worklet'
return Math.min(Math.max(num, min), max) return Math.min(Math.max(num, min), max)
@@ -21,8 +23,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const onBeginDrag = useCallback( const onBeginDrag = useCallback(
(e: NativeScrollEvent) => { (e: NativeScrollEvent) => {
'worklet' 'worklet'
startDragOffset.value = e.contentOffset.y if (isNative) {
startMode.value = mode.value startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
}, },
[mode, startDragOffset, startMode], [mode, startDragOffset, startMode],
) )
@@ -30,14 +34,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const onEndDrag = useCallback( const onEndDrag = useCallback(
(e: NativeScrollEvent) => { (e: NativeScrollEvent) => {
'worklet' 'worklet'
startDragOffset.value = null if (isNative) {
startMode.value = null startDragOffset.value = null
if (e.contentOffset.y < headerHeight.value / 2) { startMode.value = null
// If we're close to the top, show the shell. if (e.contentOffset.y < headerHeight.value / 2) {
setMode(false) // If we're close to the top, show the shell.
} else { setMode(false)
// Snap to whichever state is the closest. } else {
setMode(Math.round(mode.value) === 1) // Snap to whichever state is the closest.
setMode(Math.round(mode.value) === 1)
}
} }
}, },
[startDragOffset, startMode, setMode, mode, headerHeight], [startDragOffset, startMode, setMode, mode, headerHeight],
@@ -46,41 +52,40 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const onScroll = useCallback( const onScroll = useCallback(
(e: NativeScrollEvent) => { (e: NativeScrollEvent) => {
'worklet' 'worklet'
if (startDragOffset.value === null || startMode.value === null) { if (isNative) {
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { if (startDragOffset.value === null || startMode.value === null) {
// If we're close enough to the top, always show the shell. if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
// Even if we're not dragging. // If we're close enough to the top, always show the shell.
setMode(false) // Even if we're not dragging.
setMode(false)
}
return return
} }
if (isWeb) {
// On the web, there is no concept of "starting" the drag.
// When we get the first scroll event, we consider that the start.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
return
}
// The "mode" value is always between 0 and 1. // The "mode" value is always between 0 and 1.
// Figure out how much to move it based on the current dragged distance. // Figure out how much to move it based on the current dragged distance.
const dy = e.contentOffset.y - startDragOffset.value const dy = e.contentOffset.y - startDragOffset.value
const dProgress = interpolate( const dProgress = interpolate(
dy, dy,
[-headerHeight.value, headerHeight.value], [-headerHeight.value, headerHeight.value],
[-1, 1], [-1, 1],
) )
const newValue = clamp(startMode.value + dProgress, 0, 1) const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== mode.value) { if (newValue !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated. // Manually adjust the value. This won't be (and shouldn't be) animated.
mode.value = newValue mode.value = newValue
} }
if (isWeb) { } else {
// On the web, there is no concept of "starting" the drag, // On the web, we don't try to follow the drag because we don't know when it ends.
// so we don't have any specific anchor point to calculate the distance. // Instead, show/hide immediately based on whether we're scrolling up or down.
// Instead, update it continuosly along the way and diff with the last event. const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
startDragOffset.value = e.contentOffset.y startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
setMode(false)
} else if (dy > 0) {
setMode(true)
}
} }
}, },
[headerHeight, mode, setMode, startDragOffset, startMode], [headerHeight, mode, setMode, startDragOffset, startMode],
+13 -3
View File
@@ -2,7 +2,12 @@ import React, {memo} from 'react'
import {Linking, StyleProp, View, ViewStyle} from 'react-native' import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api' import {
AppBskyActorDefs,
AppBskyFeedPost,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
@@ -24,6 +29,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
let PostDropdownBtn = ({ let PostDropdownBtn = ({
testID, testID,
@@ -31,6 +37,7 @@ let PostDropdownBtn = ({
postCid, postCid,
postUri, postUri,
record, record,
richText,
style, style,
showAppealLabelItem, showAppealLabelItem,
}: { }: {
@@ -39,6 +46,7 @@ let PostDropdownBtn = ({
postCid: string postCid: string
postUri: string postUri: string
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showAppealLabelItem?: boolean showAppealLabelItem?: boolean
}): React.ReactNode => { }): React.ReactNode => {
@@ -96,9 +104,11 @@ let PostDropdownBtn = ({
}, [rootUri, toggleThreadMute, _]) }, [rootUri, toggleThreadMute, _])
const onCopyPostText = React.useCallback(() => { const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '') const str = richTextToString(richText)
Clipboard.setString(str)
Toast.show(_(msg`Copied to clipboard`)) Toast.show(_(msg`Copied to clipboard`))
}, [record, _]) }, [_, richText])
const onOpenTranslate = React.useCallback(() => { const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl) Linking.openURL(translatorUrl)
+8 -1
View File
@@ -6,7 +6,11 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import {
AppBskyFeedDefs,
AppBskyFeedPost,
RichText as RichTextAPI,
} from '@atproto/api'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@@ -33,6 +37,7 @@ let PostCtrls = ({
big, big,
post, post,
record, record,
richText,
showAppealLabelItem, showAppealLabelItem,
style, style,
onPressReply, onPressReply,
@@ -40,6 +45,7 @@ let PostCtrls = ({
big?: boolean big?: boolean
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI
showAppealLabelItem?: boolean showAppealLabelItem?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onPressReply: () => void onPressReply: () => void
@@ -212,6 +218,7 @@ let PostCtrls = ({
postCid={post.cid} postCid={post.cid}
postUri={post.uri} postUri={post.uri}
record={record} record={record}
richText={richText}
showAppealLabelItem={showAppealLabelItem} showAppealLabelItem={showAppealLabelItem}
style={styles.ctrlPad} style={styles.ctrlPad}
/> />
+16 -16
View File
@@ -6,6 +6,7 @@ import {
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
ModerationUI, ModerationUI,
AppBskyEmbedExternal,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {PostMeta} from '../PostMeta' import {PostMeta} from '../PostMeta'
@@ -87,15 +88,19 @@ export function QuoteEmbed({
() => quote.text.trim().length === 0, () => quote.text.trim().length === 0,
[quote.text], [quote.text],
) )
const imagesEmbed = React.useMemo( const embed = React.useMemo(() => {
() => const e = quote.embeds?.[0]
quote.embeds?.find(
embed => if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
AppBskyEmbedImages.isView(embed) || return e
AppBskyEmbedRecordWithMedia.isView(embed), } else if (
), AppBskyEmbedRecordWithMedia.isView(e) &&
[quote.embeds], (AppBskyEmbedImages.isView(e.media) ||
) AppBskyEmbedExternal.isView(e.media))
) {
return e.media
}
}, [quote.embeds])
return ( return (
<Link <Link
style={[styles.container, pal.borderDark, style]} style={[styles.container, pal.borderDark, style]}
@@ -113,16 +118,11 @@ export function QuoteEmbed({
<PostAlerts moderation={moderation} style={styles.alert} /> <PostAlerts moderation={moderation} style={styles.alert} />
) : null} ) : null}
{!isEmpty ? ( {!isEmpty ? (
<Text type="post-text" style={pal.text} numberOfLines={6}> <Text type="post-text" style={pal.text} numberOfLines={20}>
{quote.text} {quote.text}
</Text> </Text>
) : null} ) : null}
{AppBskyEmbedImages.isView(imagesEmbed) && ( {embed && <PostEmbeds embed={embed} moderation={{}} />}
<PostEmbeds embed={imagesEmbed} moderation={{}} />
)}
{AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
<PostEmbeds embed={imagesEmbed.media} moderation={{}} />
)}
</Link> </Link>
) )
} }
+6 -2
View File
@@ -97,6 +97,7 @@ export function FeedsScreen(_props: Props) {
data: preferences, data: preferences,
isLoading: isPreferencesLoading, isLoading: isPreferencesLoading,
error: preferencesError, error: preferencesError,
refetch: refetchPreferences,
} = usePreferencesQuery() } = usePreferencesQuery()
const { const {
data: popularFeeds, data: popularFeeds,
@@ -151,9 +152,12 @@ export function FeedsScreen(_props: Props) {
}, [query, debouncedSearch]) }, [query, debouncedSearch])
const onPullToRefresh = React.useCallback(async () => { const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true) setIsPTR(true)
await refetchPopularFeeds() await Promise.all([
refetchPreferences().catch(_e => undefined),
refetchPopularFeeds().catch(_e => undefined),
])
setIsPTR(false) setIsPTR(false)
}, [setIsPTR, refetchPopularFeeds]) }, [setIsPTR, refetchPreferences, refetchPopularFeeds])
const onEndReached = React.useCallback(() => { const onEndReached = React.useCallback(() => {
if ( if (
isPopularFeedsFetching || isPopularFeedsFetching ||
+1
View File
@@ -490,6 +490,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
enabled={isFocused} enabled={isFocused}
feed={feed} feed={feed}
pollInterval={60e3} pollInterval={60e3}
disablePoll={hasNew}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onHasNew={setHasNew} onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}
+1
View File
@@ -646,6 +646,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
enabled={isFocused} enabled={isFocused}
feed={feed} feed={feed}
pollInterval={60e3} pollInterval={60e3}
disablePoll={hasNew}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onHasNew={setHasNew} onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}
+1
View File
@@ -572,6 +572,7 @@ export function SearchScreen(
accessibilityLabel={_(msg`Search`)} accessibilityLabel={_(msg`Search`)}
accessibilityHint="" accessibilityHint=""
autoCorrect={false} autoCorrect={false}
autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
/> />
{query ? ( {query ? (
+26 -2
View File
@@ -70,6 +70,11 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {
useInAppBrowser,
useSetInAppBrowser,
} from '#/state/preferences/in-app-browser'
import {isNative} from '#/platform/detection'
function SettingsAccountCard({account}: {account: SessionAccount}) { function SettingsAccountCard({account}: {account: SessionAccount}) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -146,6 +151,8 @@ export function SettingsScreen({}: Props) {
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const requireAltTextEnabled = useRequireAltTextEnabled() const requireAltTextEnabled = useRequireAltTextEnabled()
const setRequireAltTextEnabled = useSetRequireAltTextEnabled() const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
const inAppBrowserPref = useInAppBrowser()
const setUseInAppBrowser = useSetInAppBrowser()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
@@ -313,8 +320,14 @@ export function SettingsScreen({}: Props) {
/> />
</> </>
)} )}
<Text type="lg" style={pal.text}> <Text
{currentAccount.email || '(no email)'}{' '} type="lg"
numberOfLines={1}
style={[
pal.text,
{overflow: 'hidden', marginRight: 4, flex: 1},
]}>
{currentAccount.email || '(no email)'}
</Text> </Text>
<Link onPress={() => openModal({name: 'change-email'})}> <Link onPress={() => openModal({name: 'change-email'})}>
<Text type="lg" style={pal.link}> <Text type="lg" style={pal.link}>
@@ -658,6 +671,17 @@ export function SettingsScreen({}: Props) {
<Trans>Change handle</Trans> <Trans>Change handle</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{isNative && (
<View style={[pal.view, styles.toggleCard]}>
<ToggleButton
type="default-light"
label={_(msg`Open links with in-app browser`)}
labelType="lg"
isSelected={inAppBrowserPref ?? false}
onPress={() => setUseInAppBrowser(!inAppBrowserPref)}
/>
</View>
)}
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}> <Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Danger Zone</Trans> <Trans>Danger Zone</Trans>
+3
View File
@@ -169,6 +169,9 @@ export function DesktopSearch() {
accessibilityRole="search" accessibilityRole="search"
accessibilityLabel={_(msg`Search`)} accessibilityLabel={_(msg`Search`)}
accessibilityHint="" accessibilityHint=""
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
/> />
{query ? ( {query ? (
<View style={styles.cancelBtn}> <View style={styles.cancelBtn}>
+2
View File
@@ -53,6 +53,7 @@
--text: white; --text: white;
--background: black; --background: black;
--backgroundLight: #26272D; --backgroundLight: #26272D;
color-scheme: dark;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html.colorMode--system { html.colorMode--system {
@@ -66,6 +67,7 @@
--text: white; --text: white;
--background: black; --background: black;
--backgroundLight: #26272D; --backgroundLight: #26272D;
color-scheme: dark;
} }
} }
+392 -292
View File
File diff suppressed because it is too large Load Diff