Merge branch 'main' into feat/selectable-text
# Conflicts: # src/view/com/util/Link.tsx
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #26272D;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html.colorMode--system {
|
||||
@@ -62,6 +63,7 @@
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #26272D;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
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: [
|
||||
{
|
||||
path: '<rootDir>/src/locale/locales/{locale}/messages',
|
||||
|
||||
+21
-21
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.63.0",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -12,7 +12,7 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"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",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
@@ -60,7 +60,7 @@
|
||||
"@react-native-community/datetimepicker": "7.6.1",
|
||||
"@react-native-masked-view/masked-view": "0.3.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/drawer": "^6.6.2",
|
||||
"@react-navigation/native": "^6.1.6",
|
||||
@@ -94,25 +94,26 @@
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^50.0.0-preview.7",
|
||||
"expo-application": "~5.8.1",
|
||||
"expo": "^50.0.0-preview.10",
|
||||
"expo-application": "~5.8.2",
|
||||
"expo-build-properties": "^0.11.0",
|
||||
"expo-camera": "~14.0.1",
|
||||
"expo-constants": "~15.4.2",
|
||||
"expo-dev-client": "~3.3.4",
|
||||
"expo-device": "~5.9.1",
|
||||
"expo-image": "~1.10.1",
|
||||
"expo-constants": "~15.4.3",
|
||||
"expo-dev-client": "~3.3.5",
|
||||
"expo-device": "~5.9.2",
|
||||
"expo-image": "~1.10.3",
|
||||
"expo-image-manipulator": "^11.8.0",
|
||||
"expo-image-picker": "~14.7.1",
|
||||
"expo-localization": "~14.8.1",
|
||||
"expo-localization": "~14.8.2",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"expo-notifications": "~0.27.2",
|
||||
"expo-notifications": "~0.27.3",
|
||||
"expo-sharing": "^11.10.0",
|
||||
"expo-splash-screen": "~0.26.1",
|
||||
"expo-splash-screen": "~0.26.2",
|
||||
"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-updates": "~0.24.5",
|
||||
"expo-updates": "~0.24.7",
|
||||
"expo-web-browser": "~12.8.1",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
@@ -140,7 +141,7 @@
|
||||
"react-avatar-editor": "^13.0.0",
|
||||
"react-circular-progressbar": "^2.1.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-drawer-layout": "^4.0.0-alpha.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
@@ -148,24 +149,23 @@
|
||||
"react-native-get-random-values": "~1.8.0",
|
||||
"react-native-haptic-feedback": "^1.14.0",
|
||||
"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-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-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-reanimated": "^3.6.0",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
"react-native-safe-area-context": "4.7.4",
|
||||
"react-native-screens": "~3.27.0",
|
||||
"react-native-svg": "14.0.0",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"react-native-svg": "14.1.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.1",
|
||||
"react-native-version-number": "^0.3.6",
|
||||
"react-native-web": "~0.19.6",
|
||||
"react-native-web-linear-gradient": "^1.1.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",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
|
||||
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
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
|
||||
+++ b/node_modules/metro-transform-worker/src/index.js
|
||||
@@ -189,6 +189,10 @@ async function transformJS(file, { config, options, projectRoot }) {
|
||||
@@ -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
@@ -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({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we NEVER want to enable this
|
||||
// so we only selectively want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
// Structural sharing between responses makes it impossible to rely on
|
||||
|
||||
@@ -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
@@ -22,6 +22,14 @@ export function code3ToCode2(lang: string): string {
|
||||
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 {
|
||||
const lang2 = code3ToCode2(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
|
||||
// case 'de':
|
||||
// return AppLanguage.de
|
||||
case 'id':
|
||||
return AppLanguage.id
|
||||
case 'es':
|
||||
return AppLanguage.es
|
||||
case 'fr':
|
||||
return AppLanguage.fr
|
||||
case 'hi':
|
||||
return AppLanguage.hi
|
||||
case 'id':
|
||||
return AppLanguage.id
|
||||
case 'ja':
|
||||
return AppLanguage.ja
|
||||
case 'ko':
|
||||
|
||||
+4
-4
@@ -27,10 +27,6 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
// i18n.loadAndActivate({locale, messages: messagesDe})
|
||||
// break
|
||||
// }
|
||||
case AppLanguage.id: {
|
||||
i18n.loadAndActivate({locale, messages: messagesId})
|
||||
break
|
||||
}
|
||||
case AppLanguage.es: {
|
||||
i18n.loadAndActivate({locale, messages: messagesEs})
|
||||
break
|
||||
@@ -43,6 +39,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||
break
|
||||
}
|
||||
case AppLanguage.id: {
|
||||
i18n.loadAndActivate({locale, messages: messagesId})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||
break
|
||||
|
||||
@@ -17,10 +17,6 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
// mod = await import(`./locales/de/messages`)
|
||||
// break
|
||||
// }
|
||||
case AppLanguage.id: {
|
||||
mod = await import(`./locales/id/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.es: {
|
||||
mod = await import(`./locales/es/messages`)
|
||||
break
|
||||
@@ -33,6 +29,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/hi/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.id: {
|
||||
mod = await import(`./locales/id/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
mod = await import(`./locales/ja/messages`)
|
||||
break
|
||||
@@ -62,6 +62,9 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
export async function useLocaleLanguage() {
|
||||
const {appLanguage} = useLanguagePrefs()
|
||||
useEffect(() => {
|
||||
dynamicActivate(sanitizeAppLanguageSetting(appLanguage))
|
||||
const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
|
||||
|
||||
document.documentElement.lang = sanitizedLanguage
|
||||
dynamicActivate(sanitizedLanguage)
|
||||
}, [appLanguage])
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ export enum AppLanguage {
|
||||
en = 'en',
|
||||
// DISABLED until this translation is fixed -prf
|
||||
// de = 'de',
|
||||
id = 'id',
|
||||
es = 'es',
|
||||
fr = 'fr',
|
||||
hi = 'hi',
|
||||
id = 'id',
|
||||
ja = 'ja',
|
||||
ko = 'ko',
|
||||
pt_BR = 'pt-BR',
|
||||
@@ -27,10 +27,10 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
|
||||
{code2: AppLanguage.en, name: 'English'},
|
||||
// DISABLED until this translation is fixed -prf
|
||||
// {code2: AppLanguage.de, name: 'Deutsch'},
|
||||
{code2: AppLanguage.id, name: 'Indonesian'},
|
||||
{code2: AppLanguage.es, name: 'Español'},
|
||||
{code2: AppLanguage.fr, name: 'Français'},
|
||||
{code2: AppLanguage.hi, name: 'हिंदी'},
|
||||
{code2: AppLanguage.id, name: 'Bahasa Indonesia'},
|
||||
{code2: AppLanguage.ja, name: '日本語'},
|
||||
{code2: AppLanguage.ko, name: '한국어'},
|
||||
{code2: AppLanguage.pt_BR, name: 'Português (BR)'},
|
||||
|
||||
+1862
-367
File diff suppressed because it is too large
Load Diff
+1854
-375
File diff suppressed because it is too large
Load Diff
+1862
-367
File diff suppressed because it is too large
Load Diff
+1862
-367
File diff suppressed because it is too large
Load Diff
+1857
-378
File diff suppressed because it is too large
Load Diff
+2040
-437
File diff suppressed because it is too large
Load Diff
+1862
-367
File diff suppressed because it is too large
Load Diff
+1576
-558
File diff suppressed because it is too large
Load Diff
+1858
-379
File diff suppressed because it is too large
Load Diff
+1882
-404
File diff suppressed because it is too large
Load Diff
@@ -187,6 +187,11 @@ export interface EmbedConsentModal {
|
||||
onAccept: () => void
|
||||
}
|
||||
|
||||
export interface InAppBrowserConsentModal {
|
||||
name: 'in-app-browser-consent'
|
||||
href: string
|
||||
}
|
||||
|
||||
export type Modal =
|
||||
// Account
|
||||
| AddAppPasswordModal
|
||||
@@ -231,6 +236,7 @@ export type Modal =
|
||||
| ConfirmModal
|
||||
| LinkWarningModal
|
||||
| EmbedConsentModal
|
||||
| InAppBrowserConsentModal
|
||||
|
||||
const ModalContext = React.createContext<{
|
||||
isModalActive: boolean
|
||||
|
||||
@@ -53,6 +53,7 @@ export const schema = z.object({
|
||||
step: z.string(),
|
||||
}),
|
||||
hiddenPosts: z.array(z.string()).optional(), // should move to server
|
||||
useInAppBrowser: z.boolean().optional(),
|
||||
})
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
@@ -84,4 +85,5 @@ export const defaults: Schema = {
|
||||
step: 'Home',
|
||||
},
|
||||
hiddenPosts: [],
|
||||
useInAppBrowser: undefined,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {Provider as LanguagesProvider} from './languages'
|
||||
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
|
||||
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
|
||||
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
|
||||
import {Provider as InAppBrowserProvider} from './in-app-browser'
|
||||
|
||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||
export {
|
||||
@@ -20,7 +21,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<LanguagesProvider>
|
||||
<AltTextRequiredProvider>
|
||||
<ExternalEmbedsProvider>
|
||||
<HiddenPostsProvider>{children}</HiddenPostsProvider>
|
||||
<HiddenPostsProvider>
|
||||
<InAppBrowserProvider>{children}</InAppBrowserProvider>
|
||||
</HiddenPostsProvider>
|
||||
</ExternalEmbedsProvider>
|
||||
</AltTextRequiredProvider>
|
||||
</LanguagesProvider>
|
||||
|
||||
@@ -35,4 +35,5 @@ export interface CachedFeedPage {
|
||||
usableInFeed: boolean
|
||||
syncedAt: Date
|
||||
data: FeedPage | undefined
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ type StateContext = string
|
||||
|
||||
interface ApiContext {
|
||||
markAllRead: () => Promise<void>
|
||||
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
|
||||
checkUnread: (opts?: {
|
||||
invalidate?: boolean
|
||||
isPoll?: boolean
|
||||
}) => Promise<void>
|
||||
getCachedUnreadPage: () => FeedPage | undefined
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
usableInFeed: false,
|
||||
syncedAt: new Date(),
|
||||
data: undefined,
|
||||
unreadCount: 0,
|
||||
})
|
||||
|
||||
// periodic sync
|
||||
@@ -58,7 +62,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return
|
||||
}
|
||||
checkUnreadRef.current() // fire on init
|
||||
const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL)
|
||||
const interval = setInterval(
|
||||
() => checkUnreadRef.current?.({isPoll: true}),
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
return () => clearInterval(interval)
|
||||
}, [hasSession])
|
||||
|
||||
@@ -69,6 +76,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
usableInFeed: false,
|
||||
syncedAt: new Date(),
|
||||
data: undefined,
|
||||
unreadCount:
|
||||
data.event === '30+'
|
||||
? 30
|
||||
: data.event === ''
|
||||
? 0
|
||||
: parseInt(data.event, 10) || 1,
|
||||
}
|
||||
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 {
|
||||
if (!getAgent().session) return
|
||||
if (AppState.currentState !== 'active') {
|
||||
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
|
||||
const page = await fetchPage({
|
||||
cursor: undefined,
|
||||
@@ -133,6 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
usableInFeed: !!invalidate, // will be used immediately
|
||||
data: page,
|
||||
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
|
||||
unreadCount,
|
||||
}
|
||||
|
||||
// update & broadcast
|
||||
|
||||
@@ -31,7 +31,7 @@ export function usePreferencesQuery() {
|
||||
return useQuery({
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
structuralSharing: true,
|
||||
refetchInterval: STALE.SECONDS.FIFTEEN,
|
||||
refetchOnWindowFocus: true,
|
||||
queryKey: preferencesQueryKey,
|
||||
queryFn: async () => {
|
||||
const agent = getAgent()
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useProfileQuery({did}: {did: string | undefined}) {
|
||||
// if you remove it, the UI infinite-loops
|
||||
// -prf
|
||||
staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE,
|
||||
refetchInterval: STALE.MINUTES.FIVE,
|
||||
refetchOnWindowFocus: true,
|
||||
queryKey: RQKEY(did || ''),
|
||||
queryFn: async () => {
|
||||
const res = await getAgent().getProfile({actor: did || ''})
|
||||
|
||||
+45
-23
@@ -136,9 +136,11 @@ function createPersistSessionHandler(
|
||||
event,
|
||||
did: refreshedAccount.did,
|
||||
handle: refreshedAccount.handle,
|
||||
service: refreshedAccount.service,
|
||||
})
|
||||
|
||||
if (expired) {
|
||||
logger.warn(`session: expired`)
|
||||
emitSessionDropped()
|
||||
}
|
||||
|
||||
@@ -191,11 +193,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
const clearCurrentAccount = React.useCallback(() => {
|
||||
logger.debug(
|
||||
`session: clear current account`,
|
||||
{},
|
||||
logger.DebugContext.session,
|
||||
)
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
queryClient.clear()
|
||||
setStateAndPersist(s => ({
|
||||
@@ -225,6 +223,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
throw new Error(`session: createAccount failed to establish a session`)
|
||||
}
|
||||
|
||||
/*dont await*/ agent.upsertProfile(_existing => {
|
||||
return {
|
||||
displayName: handle,
|
||||
}
|
||||
})
|
||||
|
||||
const account: SessionAccount = {
|
||||
service: agent.service.toString(),
|
||||
did: agent.session.did,
|
||||
@@ -320,8 +324,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
const logout = React.useCallback<ApiContext['logout']>(async () => {
|
||||
logger.info(`session: logout`)
|
||||
clearCurrentAccount()
|
||||
logger.debug(`session: logout`, {}, logger.DebugContext.session)
|
||||
setStateAndPersist(s => {
|
||||
return {
|
||||
...s,
|
||||
@@ -549,30 +553,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return persisted.onUpdate(() => {
|
||||
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) {
|
||||
logger.debug(
|
||||
`session: switching account`,
|
||||
{
|
||||
from: {
|
||||
did: state.currentAccount?.did,
|
||||
handle: state.currentAccount?.handle,
|
||||
},
|
||||
to: {
|
||||
did: session.currentAccount.did,
|
||||
handle: session.currentAccount.handle,
|
||||
},
|
||||
logger.info(`session: persisted onUpdate, switching accounts`, {
|
||||
from: {
|
||||
did: state.currentAccount?.did,
|
||||
handle: state.currentAccount?.handle,
|
||||
},
|
||||
logger.DebugContext.session,
|
||||
)
|
||||
to: {
|
||||
did: session.currentAccount.did,
|
||||
handle: session.currentAccount.handle,
|
||||
},
|
||||
})
|
||||
|
||||
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) {
|
||||
logger.debug(
|
||||
`session: logging out`,
|
||||
`session: persisted onUpdate, logging out`,
|
||||
{
|
||||
did: state.currentAccount?.did,
|
||||
handle: state.currentAccount?.handle,
|
||||
@@ -580,10 +590,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
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()
|
||||
}
|
||||
|
||||
setState(s => ({
|
||||
...s,
|
||||
accounts: session.accounts,
|
||||
currentAccount: session.currentAccount,
|
||||
}))
|
||||
})
|
||||
}, [state, clearCurrentAccount, initSession])
|
||||
}, [state, setState, clearCurrentAccount, initSession])
|
||||
|
||||
const stateContext = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -45,6 +45,7 @@ import {Gallery} from './photos/Gallery'
|
||||
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
||||
import {LabelsBtn} from './labels/LabelsBtn'
|
||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
||||
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -454,6 +455,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
<View style={[pal.border, styles.bottomBar]}>
|
||||
{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,
|
||||
},
|
||||
})
|
||||
@@ -174,6 +174,7 @@ export function FeedPage({
|
||||
feed={feed}
|
||||
feedParams={feedParams}
|
||||
pollInterval={POLL_FREQ}
|
||||
disablePoll={hasNew}
|
||||
scrollElRef={scrollElRef}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
onHasNew={setHasNew}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Component(props: ReportComponentProps) {
|
||||
? 'com.atproto.repo.strongRef'
|
||||
: 'com.atproto.admin.defs#repoRef'
|
||||
await getAgent().createModerationReport({
|
||||
reasonType: ComAtprotoModerationDefs.REASONOTHER,
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...props,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
|
||||
import {SafeAreaView, StyleSheet, View} from 'react-native'
|
||||
import {ScrollView} from './util'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
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 {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
|
||||
export const snapPoints = ['50%']
|
||||
|
||||
@@ -21,10 +22,11 @@ export function Component({text, href}: {text: string; href: string}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
const potentiallyMisleading = isPossiblyAUrl(text)
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const onPressVisit = () => {
|
||||
closeModal()
|
||||
Linking.openURL(href)
|
||||
openLink(href)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ import * as ChangeEmailModal from './ChangeEmail'
|
||||
import * as SwitchAccountModal from './SwitchAccount'
|
||||
import * as LinkWarningModal from './LinkWarning'
|
||||
import * as EmbedConsentModal from './EmbedConsent'
|
||||
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
|
||||
|
||||
const DEFAULT_SNAPPOINTS = ['90%']
|
||||
const HANDLE_HEIGHT = 24
|
||||
@@ -180,6 +181,9 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'embed-consent') {
|
||||
snapPoints = EmbedConsentModal.snapPoints
|
||||
element = <EmbedConsentModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'in-app-browser-consent') {
|
||||
snapPoints = InAppBrowserConsentModal.snapPoints
|
||||
element = <InAppBrowserConsentModal.Component {...activeModal} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ let PostThreadItemLoaded = ({
|
||||
postCid={post.cid}
|
||||
postUri={post.uri}
|
||||
record={record}
|
||||
richText={richText}
|
||||
showAppealLabelItem={
|
||||
post.author.did === currentAccount?.did && isModeratedPost
|
||||
}
|
||||
@@ -440,6 +441,7 @@ let PostThreadItemLoaded = ({
|
||||
big
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
/>
|
||||
</View>
|
||||
@@ -588,6 +590,7 @@ let PostThreadItemLoaded = ({
|
||||
<PostCtrls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -213,7 +213,12 @@ function PostInner({
|
||||
</ContentHider>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostCtrls post={post} record={record} onPressReply={onPressReply} />
|
||||
<PostCtrls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
@@ -36,7 +36,8 @@ const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__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
|
||||
|
||||
let Feed = ({
|
||||
@@ -46,6 +47,7 @@ let Feed = ({
|
||||
style,
|
||||
enabled,
|
||||
pollInterval,
|
||||
disablePoll,
|
||||
scrollElRef,
|
||||
onScrolledDownChange,
|
||||
onHasNew,
|
||||
@@ -63,6 +65,7 @@ let Feed = ({
|
||||
style?: StyleProp<ViewStyle>
|
||||
enabled?: boolean
|
||||
pollInterval?: number
|
||||
disablePoll?: boolean
|
||||
scrollElRef?: ListRef
|
||||
onHasNew?: (v: boolean) => void
|
||||
onScrolledDownChange?: (isScrolledDown: boolean) => void
|
||||
@@ -107,7 +110,7 @@ let Feed = ({
|
||||
)
|
||||
|
||||
const checkForNew = React.useCallback(async () => {
|
||||
if (!data?.pages[0] || isFetching || !onHasNew || !enabled) {
|
||||
if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -117,7 +120,7 @@ let Feed = ({
|
||||
} catch (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 onPostCreated = React.useCallback(() => {
|
||||
@@ -146,11 +149,12 @@ let Feed = ({
|
||||
React.useEffect(() => {
|
||||
if (enabled) {
|
||||
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
|
||||
scrollElRef?.current?.scrollToOffset({offset: 0, animated: false})
|
||||
queryClient.resetQueries({queryKey: RQKEY(feed)})
|
||||
} else if (
|
||||
} else*/ if (
|
||||
timeSinceFirstLoad > CHECK_LATEST_AFTER &&
|
||||
checkForNewRef.current
|
||||
) {
|
||||
|
||||
@@ -304,6 +304,7 @@ let FeedItemInner = ({
|
||||
<PostCtrls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
showAppealLabelItem={
|
||||
post.author.did === currentAccount?.did && isModeratedPost
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {ComponentProps, memo, useMemo} from 'react'
|
||||
import {
|
||||
Linking,
|
||||
GestureResponderEvent,
|
||||
Platform,
|
||||
StyleProp,
|
||||
@@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {PressableWithHover} from './PressableWithHover'
|
||||
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
|
||||
type Event =
|
||||
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
@@ -65,6 +65,7 @@ export const Link = memo(function Link({
|
||||
const {closeModal} = useModalControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const onPress = React.useCallback(
|
||||
(e?: Event) => {
|
||||
@@ -74,11 +75,12 @@ export const Link = memo(function Link({
|
||||
navigation,
|
||||
href: sanitizeUrl(href),
|
||||
navigationAction,
|
||||
openLink,
|
||||
e,
|
||||
})
|
||||
}
|
||||
},
|
||||
[closeModal, navigation, navigationAction, href],
|
||||
[closeModal, navigation, navigationAction, href, openLink],
|
||||
)
|
||||
|
||||
if (noFeedback) {
|
||||
@@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({
|
||||
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
if (warnOnMismatchingLabel && typeof text !== 'string') {
|
||||
console.error('Unable to detect mismatching label')
|
||||
@@ -189,6 +192,7 @@ export const TextLink = memo(function TextLink({
|
||||
text,
|
||||
navigationAction,
|
||||
warnOnMismatchingLabel,
|
||||
openLink,
|
||||
})
|
||||
},
|
||||
[
|
||||
@@ -200,6 +204,7 @@ export const TextLink = memo(function TextLink({
|
||||
text,
|
||||
navigationAction,
|
||||
warnOnMismatchingLabel,
|
||||
openLink,
|
||||
],
|
||||
)
|
||||
const hrefAttrs = useMemo(() => {
|
||||
@@ -313,6 +318,7 @@ export const onTextLinkPress = ({
|
||||
text,
|
||||
navigationAction = 'push',
|
||||
warnOnMismatchingLabel,
|
||||
openLink,
|
||||
}: {
|
||||
onPress?: (e: GestureResponderEvent) => void
|
||||
e?: Event
|
||||
@@ -323,6 +329,7 @@ export const onTextLinkPress = ({
|
||||
text?: any
|
||||
navigationAction?: 'push' | 'replace' | 'navigate'
|
||||
warnOnMismatchingLabel?: boolean
|
||||
openLink: (href: string) => void
|
||||
}) => {
|
||||
const requiresWarning =
|
||||
warnOnMismatchingLabel &&
|
||||
@@ -368,7 +375,7 @@ export const onTextLinkPress = ({
|
||||
if (shouldHandle) {
|
||||
href = convertBskyAppUrlIfNeeded(href)
|
||||
if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
|
||||
Linking.openURL(href)
|
||||
openLink(href)
|
||||
} else {
|
||||
closeModal() // close any active modals
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {NativeScrollEvent} from 'react-native'
|
||||
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useSharedValue, interpolate} from 'react-native-reanimated'
|
||||
|
||||
const WEB_HIDE_SHELL_THRESHOLD = 200
|
||||
|
||||
function clamp(num: number, min: number, max: number) {
|
||||
'worklet'
|
||||
return Math.min(Math.max(num, min), max)
|
||||
@@ -21,8 +23,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const onBeginDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
if (isNative) {
|
||||
startDragOffset.value = e.contentOffset.y
|
||||
startMode.value = mode.value
|
||||
}
|
||||
},
|
||||
[mode, startDragOffset, startMode],
|
||||
)
|
||||
@@ -30,14 +34,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const onEndDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
if (e.contentOffset.y < headerHeight.value / 2) {
|
||||
// If we're close to the top, show the shell.
|
||||
setMode(false)
|
||||
} else {
|
||||
// Snap to whichever state is the closest.
|
||||
setMode(Math.round(mode.value) === 1)
|
||||
if (isNative) {
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
if (e.contentOffset.y < headerHeight.value / 2) {
|
||||
// If we're close to the top, show the shell.
|
||||
setMode(false)
|
||||
} else {
|
||||
// Snap to whichever state is the closest.
|
||||
setMode(Math.round(mode.value) === 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
[startDragOffset, startMode, setMode, mode, headerHeight],
|
||||
@@ -46,41 +52,40 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const onScroll = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
if (startDragOffset.value === null || startMode.value === null) {
|
||||
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
|
||||
// If we're close enough to the top, always show the shell.
|
||||
// Even if we're not dragging.
|
||||
setMode(false)
|
||||
if (isNative) {
|
||||
if (startDragOffset.value === null || startMode.value === null) {
|
||||
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
|
||||
// If we're close enough to the top, always show the shell.
|
||||
// Even if we're not dragging.
|
||||
setMode(false)
|
||||
}
|
||||
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.
|
||||
// Figure out how much to move it based on the current dragged distance.
|
||||
const dy = e.contentOffset.y - startDragOffset.value
|
||||
const dProgress = interpolate(
|
||||
dy,
|
||||
[-headerHeight.value, headerHeight.value],
|
||||
[-1, 1],
|
||||
)
|
||||
const newValue = clamp(startMode.value + dProgress, 0, 1)
|
||||
if (newValue !== mode.value) {
|
||||
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
||||
mode.value = newValue
|
||||
}
|
||||
if (isWeb) {
|
||||
// On the web, there is no concept of "starting" the drag,
|
||||
// so we don't have any specific anchor point to calculate the distance.
|
||||
// Instead, update it continuosly along the way and diff with the last event.
|
||||
// The "mode" value is always between 0 and 1.
|
||||
// Figure out how much to move it based on the current dragged distance.
|
||||
const dy = e.contentOffset.y - startDragOffset.value
|
||||
const dProgress = interpolate(
|
||||
dy,
|
||||
[-headerHeight.value, headerHeight.value],
|
||||
[-1, 1],
|
||||
)
|
||||
const newValue = clamp(startMode.value + dProgress, 0, 1)
|
||||
if (newValue !== mode.value) {
|
||||
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
||||
mode.value = newValue
|
||||
}
|
||||
} else {
|
||||
// On the web, we don't try to follow the drag because we don't know when it ends.
|
||||
// Instead, show/hide immediately based on whether we're scrolling up or down.
|
||||
const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
|
||||
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],
|
||||
|
||||
@@ -2,7 +2,12 @@ import React, {memo} from 'react'
|
||||
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
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 {useTheme} from 'lib/ThemeContext'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
@@ -24,6 +29,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
|
||||
let PostDropdownBtn = ({
|
||||
testID,
|
||||
@@ -31,6 +37,7 @@ let PostDropdownBtn = ({
|
||||
postCid,
|
||||
postUri,
|
||||
record,
|
||||
richText,
|
||||
style,
|
||||
showAppealLabelItem,
|
||||
}: {
|
||||
@@ -39,6 +46,7 @@ let PostDropdownBtn = ({
|
||||
postCid: string
|
||||
postUri: string
|
||||
record: AppBskyFeedPost.Record
|
||||
richText: RichTextAPI
|
||||
style?: StyleProp<ViewStyle>
|
||||
showAppealLabelItem?: boolean
|
||||
}): React.ReactNode => {
|
||||
@@ -96,9 +104,11 @@ let PostDropdownBtn = ({
|
||||
}, [rootUri, toggleThreadMute, _])
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
Clipboard.setString(record?.text || '')
|
||||
const str = richTextToString(richText)
|
||||
|
||||
Clipboard.setString(str)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [record, _])
|
||||
}, [_, richText])
|
||||
|
||||
const onOpenTranslate = React.useCallback(() => {
|
||||
Linking.openURL(translatorUrl)
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Text} from '../text/Text'
|
||||
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
||||
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
|
||||
@@ -33,6 +37,7 @@ let PostCtrls = ({
|
||||
big,
|
||||
post,
|
||||
record,
|
||||
richText,
|
||||
showAppealLabelItem,
|
||||
style,
|
||||
onPressReply,
|
||||
@@ -40,6 +45,7 @@ let PostCtrls = ({
|
||||
big?: boolean
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
record: AppBskyFeedPost.Record
|
||||
richText: RichTextAPI
|
||||
showAppealLabelItem?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
onPressReply: () => void
|
||||
@@ -212,6 +218,7 @@ let PostCtrls = ({
|
||||
postCid={post.cid}
|
||||
postUri={post.uri}
|
||||
record={record}
|
||||
richText={richText}
|
||||
showAppealLabelItem={showAppealLabelItem}
|
||||
style={styles.ctrlPad}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
ModerationUI,
|
||||
AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {PostMeta} from '../PostMeta'
|
||||
@@ -87,15 +88,19 @@ export function QuoteEmbed({
|
||||
() => quote.text.trim().length === 0,
|
||||
[quote.text],
|
||||
)
|
||||
const imagesEmbed = React.useMemo(
|
||||
() =>
|
||||
quote.embeds?.find(
|
||||
embed =>
|
||||
AppBskyEmbedImages.isView(embed) ||
|
||||
AppBskyEmbedRecordWithMedia.isView(embed),
|
||||
),
|
||||
[quote.embeds],
|
||||
)
|
||||
const embed = React.useMemo(() => {
|
||||
const e = quote.embeds?.[0]
|
||||
|
||||
if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
|
||||
return e
|
||||
} else if (
|
||||
AppBskyEmbedRecordWithMedia.isView(e) &&
|
||||
(AppBskyEmbedImages.isView(e.media) ||
|
||||
AppBskyEmbedExternal.isView(e.media))
|
||||
) {
|
||||
return e.media
|
||||
}
|
||||
}, [quote.embeds])
|
||||
return (
|
||||
<Link
|
||||
style={[styles.container, pal.borderDark, style]}
|
||||
@@ -113,16 +118,11 @@ export function QuoteEmbed({
|
||||
<PostAlerts moderation={moderation} style={styles.alert} />
|
||||
) : null}
|
||||
{!isEmpty ? (
|
||||
<Text type="post-text" style={pal.text} numberOfLines={6}>
|
||||
<Text type="post-text" style={pal.text} numberOfLines={20}>
|
||||
{quote.text}
|
||||
</Text>
|
||||
) : null}
|
||||
{AppBskyEmbedImages.isView(imagesEmbed) && (
|
||||
<PostEmbeds embed={imagesEmbed} moderation={{}} />
|
||||
)}
|
||||
{AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
|
||||
<PostEmbeds embed={imagesEmbed.media} moderation={{}} />
|
||||
)}
|
||||
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export function FeedsScreen(_props: Props) {
|
||||
data: preferences,
|
||||
isLoading: isPreferencesLoading,
|
||||
error: preferencesError,
|
||||
refetch: refetchPreferences,
|
||||
} = usePreferencesQuery()
|
||||
const {
|
||||
data: popularFeeds,
|
||||
@@ -151,9 +152,12 @@ export function FeedsScreen(_props: Props) {
|
||||
}, [query, debouncedSearch])
|
||||
const onPullToRefresh = React.useCallback(async () => {
|
||||
setIsPTR(true)
|
||||
await refetchPopularFeeds()
|
||||
await Promise.all([
|
||||
refetchPreferences().catch(_e => undefined),
|
||||
refetchPopularFeeds().catch(_e => undefined),
|
||||
])
|
||||
setIsPTR(false)
|
||||
}, [setIsPTR, refetchPopularFeeds])
|
||||
}, [setIsPTR, refetchPreferences, refetchPopularFeeds])
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (
|
||||
isPopularFeedsFetching ||
|
||||
|
||||
@@ -490,6 +490,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
||||
enabled={isFocused}
|
||||
feed={feed}
|
||||
pollInterval={60e3}
|
||||
disablePoll={hasNew}
|
||||
scrollElRef={scrollElRef}
|
||||
onHasNew={setHasNew}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
|
||||
@@ -646,6 +646,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
||||
enabled={isFocused}
|
||||
feed={feed}
|
||||
pollInterval={60e3}
|
||||
disablePoll={hasNew}
|
||||
scrollElRef={scrollElRef}
|
||||
onHasNew={setHasNew}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
|
||||
@@ -572,6 +572,7 @@ export function SearchScreen(
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
accessibilityHint=""
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{query ? (
|
||||
|
||||
@@ -70,6 +70,11 @@ import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {
|
||||
useInAppBrowser,
|
||||
useSetInAppBrowser,
|
||||
} from '#/state/preferences/in-app-browser'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
function SettingsAccountCard({account}: {account: SessionAccount}) {
|
||||
const pal = usePalette('default')
|
||||
@@ -146,6 +151,8 @@ export function SettingsScreen({}: Props) {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const requireAltTextEnabled = useRequireAltTextEnabled()
|
||||
const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
|
||||
const inAppBrowserPref = useInAppBrowser()
|
||||
const setUseInAppBrowser = useSetInAppBrowser()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -313,8 +320,14 @@ export function SettingsScreen({}: Props) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text type="lg" style={pal.text}>
|
||||
{currentAccount.email || '(no email)'}{' '}
|
||||
<Text
|
||||
type="lg"
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
pal.text,
|
||||
{overflow: 'hidden', marginRight: 4, flex: 1},
|
||||
]}>
|
||||
{currentAccount.email || '(no email)'}
|
||||
</Text>
|
||||
<Link onPress={() => openModal({name: 'change-email'})}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
@@ -658,6 +671,17 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Change handle</Trans>
|
||||
</Text>
|
||||
</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} />
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Danger Zone</Trans>
|
||||
|
||||
@@ -169,6 +169,9 @@ export function DesktopSearch() {
|
||||
accessibilityRole="search"
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
accessibilityHint=""
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{query ? (
|
||||
<View style={styles.cancelBtn}>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #26272D;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html.colorMode--system {
|
||||
@@ -66,6 +67,7 @@
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #26272D;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user