Compare commits

..

2 Commits

Author SHA1 Message Date
Eric Bailey df5ff1b72e Override scrollRef types on profile 2023-11-17 10:46:59 -06:00
Eric Bailey 54bf784faf Fix some low-hanging type errors 2023-11-17 10:42:14 -06:00
138 changed files with 5042 additions and 9893 deletions
@@ -1,5 +1,3 @@
import {it, describe, expect} from '@jest/globals'
import {
linkRequiresWarning,
isPossiblyAUrl,
@@ -8,7 +6,6 @@ import {
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
@@ -67,10 +64,6 @@ describe('linkRequiresWarning', () => {
['http://bsky.app/', 'https://google.com', true],
['https://bsky.app/', 'https://google.com', true],
// case insensitive
['https://Example.com', 'example.com', false],
['https://example.com', 'Example.com', false],
// bad uri inputs, default to true
['', '', true],
['example.com', 'example.com', true],
+5 -39
View File
@@ -1,41 +1,12 @@
const pkg = require('./package.json')
module.exports = function () {
/**
* App version number. Should be incremented as part of a release cycle.
*/
const VERSION = pkg.version
/**
* iOS build number. Must be incremented for each TestFlight version.
*/
const IOS_BUILD_NUMBER = '4'
/**
* Android build number. Must be incremented for each release.
*/
const ANDROID_VERSION_CODE = 46
/**
* Uses built-in Expo env vars
*
* @see https://docs.expo.dev/build-reference/variables/#built-in-environment-variables
*/
const PLATFORM = process.env.EAS_BUILD_PLATFORM
/**
* Additional granularity for the `dist` field
*/
const DIST_BUILD_NUMBER =
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN
return {
expo: {
version: VERSION,
name: 'Bluesky',
slug: 'bluesky',
scheme: 'bluesky',
owner: 'blueskysocial',
version: '1.56.0',
runtimeVersion: {
policy: 'appVersion',
},
@@ -48,7 +19,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
ios: {
buildNumber: IOS_BUILD_NUMBER,
buildNumber: '3',
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -72,7 +43,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
android: {
versionCode: ANDROID_VERSION_CODE,
versionCode: 46,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
@@ -103,7 +74,7 @@ module.exports = function () {
},
plugins: [
'expo-localization',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
hasSentryToken && 'sentry-expo',
[
'expo-build-properties',
{
@@ -129,16 +100,11 @@ module.exports = function () {
},
hooks: {
postPublish: [
/*
* @see https://docs.expo.dev/guides/using-sentry/#app-configuration
*/
{
file: 'sentry-expo/upload-sourcemaps',
config: {
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: `${PLATFORM}.${VERSION}.${DIST_BUILD_NUMBER}`,
},
},
],
+22 -13
View File
@@ -4,33 +4,42 @@
"promptToConfigurePushNotifications": false
},
"build": {
"base": {
"node": "18.18.2"
},
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"ios": {
"simulator": true,
"resourceClass": "large"
}
"resourceClass": "m-large"
},
"channel": "development"
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-large"
},
"channel": "development"
},
"preview": {
"extends": "base",
"distribution": "internal",
"channel": "preview",
"ios": {
"resourceClass": "large"
}
"resourceClass": "m-large"
},
"channel": "preview"
},
"production": {
"extends": "base",
"ios": {
"resourceClass": "large"
"resourceClass": "m-large"
},
"channel": "production"
},
"dev-android-apk": {
"developmentClient": true,
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},
"submit": {
-3
View File
@@ -2,9 +2,6 @@
import {configure} from '@testing-library/react-native'
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
configure({asyncUtilTimeout: 20000})
jest.mock('@react-native-async-storage/async-storage', () =>
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.57.0",
"version": "1.56.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
@@ -28,6 +28,7 @@
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure",
"build:apk": "eas build -p android --profile dev-android-apk",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
},
+22 -25
View File
@@ -28,8 +28,6 @@ import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
@@ -37,13 +35,18 @@ import {
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {messages} from './locale/locales/en/messages'
i18n.load('en', messages)
i18n.activate('en')
enableFreeze(true)
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
// init
@@ -70,25 +73,21 @@ function InnerApp() {
*/
return (
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
@@ -116,9 +115,7 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+22 -25
View File
@@ -16,14 +16,15 @@ import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {defaultLocale, dynamicActivate} from './locale/i18n'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
useSession,
@@ -35,7 +36,7 @@ import * as persisted from '#/state/persisted'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
@@ -43,6 +44,8 @@ function InnerApp() {
useEffect(() => {
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
@@ -58,25 +61,21 @@ function InnerApp() {
*/
return (
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
@@ -104,9 +103,7 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<I18nProvider>
<InnerApp />
</I18nProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+23 -25
View File
@@ -9,6 +9,7 @@ import {
DefaultTheme,
DarkTheme,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {
BottomTabBarProps,
createBottomTabNavigator,
@@ -68,18 +69,16 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
createNativeStackNavigator<NotificationsTabNavigatorParams>()
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigator<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
@@ -98,37 +97,37 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists'), requireAuth: true}}
options={{title: title('Lists')}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation'), requireAuth: true}}
options={{title: title('Moderation')}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists'), requireAuth: true}}
options={{title: title('Moderation Lists')}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts'), requireAuth: true}}
options={{title: title('Muted Accounts')}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts'), requireAuth: true}}
options={{title: title('Blocked Accounts')}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
options={{title: title('Settings'), requireAuth: true}}
options={{title: title('Settings')}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings'), requireAuth: true}}
options={{title: title('Language Settings')}}
/>
<Stack.Screen
name="Profile"
@@ -155,7 +154,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title('List'), requireAuth: true}}
options={{title: title('List')}}
/>
<Stack.Screen
name="PostThread"
@@ -185,12 +184,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title('Debug'), requireAuth: true}}
options={{title: title('Debug')}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
options={{title: title('Log'), requireAuth: true}}
options={{title: title('Log')}}
/>
<Stack.Screen
name="Support"
@@ -220,22 +219,22 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswords}
options={{title: title('App Passwords'), requireAuth: true}}
options={{title: title('App Passwords')}}
/>
<Stack.Screen
name="SavedFeeds"
getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds'), requireAuth: true}}
options={{title: title('Edit My Feeds')}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences'), requireAuth: true}}
options={{title: title('Home Feed Preferences')}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences'), requireAuth: true}}
options={{title: title('Threads Preferences')}}
/>
</>
)
@@ -340,7 +339,6 @@ function NotificationsTabNavigator() {
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{requireAuth: true}}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
@@ -359,8 +357,8 @@ function MyProfileTabNavigator() {
contentStyle,
}}>
<MyProfileTab.Screen
// @ts-ignore // TODO: fix this broken type in ProfileScreen
name="MyProfile"
// @ts-ignore // TODO: fix this broken type in ProfileScreen
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
@@ -407,7 +405,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title('Notifications'), requireAuth: true}}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
+2 -3
View File
@@ -19,9 +19,8 @@ export class FeedViewPostsSlice {
constructor(public items: FeedViewPost[] = []) {}
get _reactKey() {
const rootItem = this.isFlattenedReply ? this.items[1] : this.items[0]
return `slice-${rootItem.post.uri}-${
rootItem.reason?.indexedAt || rootItem.post.indexedAt
return `slice-${this.items[0].post.uri}-${
this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
}`
}
-1
View File
@@ -1 +0,0 @@
export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
-2
View File
@@ -1,2 +0,0 @@
// @ts-ignore
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
+2 -14
View File
@@ -1,8 +1,4 @@
import {useCallback} from 'react'
import {useNavigation} from '@react-navigation/native'
import {isWeb} from '#/platform/detection'
import {NavigationProp} from '#/lib/routes/types'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useSessionApi, SessionAccount} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -12,29 +8,21 @@ export function useAccountSwitcher() {
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const navigation = useNavigation<NavigationProp>()
const onPressSwitchAccount = useCallback(
async (acct: SessionAccount) => {
track('Settings:SwitchAccountButtonClicked')
try {
closeAllActiveElements()
navigation.navigate(isWeb ? 'Home' : 'HomeTab')
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
}
},
[
track,
clearCurrentAccount,
selectAccount,
closeAllActiveElements,
navigation,
],
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
)
return {onPressSwitchAccount}
+3 -3
View File
@@ -3,7 +3,6 @@ import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {t} from '@lingui/macro'
export function useOTAUpdate() {
const {openModal} = useModalControls()
@@ -12,8 +11,9 @@ export function useOTAUpdate() {
const showUpdatePopup = useCallback(() => {
openModal({
name: 'confirm',
title: t`Update Available`,
message: t`A new version of the app is available. Please update to continue using the app.`,
title: 'Update Available',
message:
'A new version of the app is available. Please update to continue using the app.',
onPressConfirm: async () => {
Updates.reloadAsync().catch(err => {
throw err
+2 -40
View File
@@ -1,46 +1,8 @@
/**
* Importing these separately from `platform/detection` and `lib/app-info` to
* avoid future conflicts and/or circular deps
*/
import {Platform} from 'react-native'
import app from 'react-native-version-number'
import * as info from 'expo-updates'
import {init} from 'sentry-expo'
/**
* Matches the build profile `channel` props in `eas.json`
*/
const buildChannel = (info.channel || 'development') as
| 'development'
| 'preview'
| 'production'
/**
* Examples:
* - `dev`
* - `1.57.0`
*/
const release = app.appVersion ?? 'dev'
/**
* Examples:
* - `web.dev`
* - `ios.dev`
* - `android.dev`
* - `web.1.57.0`
* - `ios.1.57.0.3`
* - `android.1.57.0.46`
*/
const dist = `${Platform.OS}.${release}${
app.buildVersion ? `.${app.buildVersion}` : ''
}`
init({
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
enableInExpoDevelopment: true,
environment: buildChannel,
dist,
release,
environment: __DEV__ ? 'development' : 'production', // Set the environment
})
+5 -20
View File
@@ -168,15 +168,8 @@ export function getYoutubeVideoId(link: string): string | undefined {
return videoId
}
/**
* Checks if the label in the post text matches the host of the link facet.
*
* Hosts are case-insensitive, so should be lowercase for comparison.
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
*/
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
let urip
try {
urip = new URL(uri)
@@ -184,9 +177,7 @@ export function linkRequiresWarning(uri: string, label: string) {
return true
}
const host = urip.hostname.toLowerCase()
if (host === 'bsky.app') {
if (urip.hostname === 'bsky.app') {
// if this is a link to internal content,
// warn if it represents itself as a URL to another app
if (
@@ -203,26 +194,20 @@ export function linkRequiresWarning(uri: string, label: string) {
if (!labelDomain) {
return true
}
return labelDomain !== host
return labelDomain !== urip.hostname
}
}
/**
* Returns a lowercase domain hostname if the label is a valid URL.
*
* Hosts are case-insensitive, so should be lowercase for comparison.
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
*/
export function labelToDomain(label: string): string | undefined {
function labelToDomain(label: string): string | undefined {
// any spaces just immediately consider the label a non-url
if (/\s/.test(label)) {
return undefined
}
try {
return new URL(label).hostname.toLowerCase()
return new URL(label).hostname
} catch {}
try {
return new URL('https://' + label).hostname.toLowerCase()
return new URL('https://' + label).hostname
} catch {}
return undefined
}
+3 -21
View File
@@ -1,8 +1,4 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
import {messages as messagesEn} from './locales/en/messages'
import {messages as messagesHi} from './locales/hi/messages'
export const locales = {
en: 'English',
@@ -18,21 +14,7 @@ export const defaultLocale = 'en'
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
if (locale === 'en') {
i18n.loadAndActivate({locale, messages: messagesEn})
return
} else if (locale === 'hi') {
i18n.loadAndActivate({locale, messages: messagesHi})
return
} else {
i18n.loadAndActivate({locale, messages: messagesEn})
return
}
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
const {messages} = await import(`./locales/${locale}/messages`)
i18n.load(locale, messages)
i18n.activate(locale)
}
-29
View File
@@ -1,29 +0,0 @@
import {useLanguagePrefs} from '#/state/preferences'
import {i18n} from '@lingui/core'
import {useEffect} from 'react'
export const locales = {
en: 'English',
cs: 'Česky',
fr: 'Français',
hi: 'हिंदी',
es: 'Español',
}
export const defaultLocale = 'en'
/**
* We do a dynamic import of just the catalog that we need
* @param locale any locale string
*/
export async function dynamicActivate(locale: string) {
const {messages} = await import(`./locales/${locale}/messages`)
i18n.load(locale, messages)
i18n.activate(locale)
}
export async function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
useEffect(() => {
dynamicActivate(appLanguage)
}, [appLanguage])
}
-9
View File
@@ -1,9 +0,0 @@
import React from 'react'
import {I18nProvider as DefaultI18nProvider} from '@lingui/react'
import {i18n} from '@lingui/core'
import {useLocaleLanguage} from './i18n'
export default function I18nProvider({children}: {children: React.ReactNode}) {
useLocaleLanguage()
return <DefaultI18nProvider i18n={i18n}>{children}</DefaultI18nProvider>
}
-10
View File
@@ -4,16 +4,6 @@ interface Language {
name: string
}
interface AppLanguage {
code2: string
name: string
}
export const APP_LANGUAGES: AppLanguage[] = [
{code2: 'en', name: 'English'},
{code2: 'hi', name: 'हिंदी'},
]
export const LANGUAGES: Language[] = [
{code3: 'aar', code2: 'aa', name: 'Afar'},
{code3: 'abk', code2: 'ab', name: 'Abkhazian'},
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-20
View File
@@ -222,26 +222,6 @@ describe('general functionality', () => {
})
})
test('sentryTransport serializes errors', () => {
const message = 'message'
const timestamp = Date.now()
const sentryTimestamp = timestamp / 1000
sentryTransport(
LogLevel.Debug,
message,
{error: new Error('foo')},
timestamp,
)
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
message,
data: {error: 'Error: foo'},
type: 'default',
level: LogLevel.Debug,
timestamp: sentryTimestamp,
})
})
test('add/remove transport', () => {
const timestamp = Date.now()
const logger = new Logger({enabled: true})
+10 -20
View File
@@ -90,16 +90,6 @@ const enabledLogLevels: {
[LogLevel.Error]: [LogLevel.Error],
}
export function prepareMetadata(metadata: Metadata): Metadata {
return Object.keys(metadata).reduce((acc, key) => {
let value = metadata[key]
if (value instanceof Error) {
value = value.toString()
}
return {...acc, [key]: value}
}, {})
}
/**
* Used in dev mode to nicely log to the console
*/
@@ -110,8 +100,7 @@ export const consoleTransport: Transport = (
timestamp,
) => {
const extra = Object.keys(metadata).length
? // don't prepareMetadata here, in dev we want the stack trace
' ' + JSON.stringify(metadata, null, ' ')
? ' ' + JSON.stringify(metadata, null, ' ')
: ''
const log = {
[LogLevel.Debug]: console.debug,
@@ -130,8 +119,6 @@ export const sentryTransport: Transport = (
{type, tags, ...metadata},
timestamp,
) => {
const meta = prepareMetadata(metadata)
/**
* If a string, report a breadcrumb
*/
@@ -148,7 +135,7 @@ export const sentryTransport: Transport = (
Sentry.addBreadcrumb({
message,
data: meta,
data: metadata,
type: type || 'default',
level: severity,
timestamp: timestamp / 1000, // Sentry expects seconds
@@ -168,7 +155,7 @@ export const sentryTransport: Transport = (
Sentry.captureMessage(message, {
level: messageLevel,
tags,
extra: meta,
extra: metadata,
})
}
} else {
@@ -177,7 +164,7 @@ export const sentryTransport: Transport = (
*/
Sentry.captureException(message, {
tags,
extra: meta,
extra: metadata,
})
}
}
@@ -288,13 +275,16 @@ export class Logger {
*/
export const logger = new Logger()
/**
* Report to console in dev, Sentry in prod, nothing in test.
*/
if (env.IS_DEV && !env.IS_TEST) {
logger.addTransport(consoleTransport)
/**
* Comment this out to disable Sentry transport in dev
* Uncomment this to test Sentry in dev
*/
logger.addTransport(sentryTransport)
// logger.addTransport(sentryTransport);
} else if (env.IS_PROD) {
logger.addTransport(sentryTransport)
// logger.addTransport(sentryTransport)
}
+29 -39
View File
@@ -1,8 +1,7 @@
import {useEffect, useState, useMemo, useCallback} from 'react'
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
@@ -22,36 +21,15 @@ interface CacheEntry {
value: PostShadow
}
const firstSeenMap = new WeakMap<AppBskyFeedDefs.PostView, number>()
function getFirstSeenTS(post: AppBskyFeedDefs.PostView): number {
let timeStamp = firstSeenMap.get(post)
if (timeStamp !== undefined) {
return timeStamp
}
timeStamp = Date.now()
firstSeenMap.set(post, timeStamp)
return timeStamp
}
export function usePostShadow(
post: AppBskyFeedDefs.PostView,
ifAfterTS: number,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
const postSeenTS = getFirstSeenTS(post)
const [state, setState] = useState<CacheEntry>(() => ({
ts: postSeenTS,
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromPost(post),
}))
const [prevPost, setPrevPost] = useState(post)
if (post !== prevPost) {
// if we got a new prop, assume it's fresher
// than whatever shadow state we accumulated
setPrevPost(post)
setState({
ts: postSeenTS,
value: fromPost(post),
})
}
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<PostShadow>) => {
@@ -68,17 +46,28 @@ export function usePostShadow(
}
}, [post.uri, onUpdate])
return useMemo(() => {
return state.ts > postSeenTS
? mergeShadow(post, state.value)
: castAsShadow(post)
}, [post, state, postSeenTS])
// react to post updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromPost(post)})
}
firstRun.current = false
}, [post])
return state.ts > ifAfterTS
? mergeShadow(post, state.value)
: {...post, isShadowed: true}
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
batchedUpdates(() => {
emitter.emit(uri, value)
})
emitter.emit(uri, value)
}
export function isPostShadowed(
v: AppBskyFeedDefs.PostView | Shadow<AppBskyFeedDefs.PostView>,
): v is Shadow<AppBskyFeedDefs.PostView> {
return 'isShadowed' in v && !!v.isShadowed
}
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
@@ -98,7 +87,7 @@ function mergeShadow(
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return castAsShadow({
return {
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
@@ -107,5 +96,6 @@ function mergeShadow(
like: shadow.likeUri,
repost: shadow.repostUri,
},
})
isShadowed: true,
}
}
+32 -40
View File
@@ -1,8 +1,7 @@
import {useEffect, useState, useMemo, useCallback} from 'react'
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
@@ -23,34 +22,15 @@ type ProfileView =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed
const firstSeenMap = new WeakMap<ProfileView, number>()
function getFirstSeenTS(profile: ProfileView): number {
let timeStamp = firstSeenMap.get(profile)
if (timeStamp !== undefined) {
return timeStamp
}
timeStamp = Date.now()
firstSeenMap.set(profile, timeStamp)
return timeStamp
}
export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
const profileSeenTS = getFirstSeenTS(profile)
const [state, setState] = useState<CacheEntry>(() => ({
ts: profileSeenTS,
export function useProfileShadow(
profile: ProfileView,
ifAfterTS: number,
): Shadow<ProfileView> {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromProfile(profile),
}))
const [prevProfile, setPrevProfile] = useState(profile)
if (profile !== prevProfile) {
// if we got a new prop, assume it's fresher
// than whatever shadow state we accumulated
setPrevProfile(profile)
setState({
ts: profileSeenTS,
value: fromProfile(profile),
})
}
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
@@ -67,20 +47,31 @@ export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
}
}, [profile.did, onUpdate])
return useMemo(() => {
return state.ts > profileSeenTS
? mergeShadow(profile, state.value)
: castAsShadow(profile)
}, [profile, state, profileSeenTS])
// react to profile updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromProfile(profile)})
}
firstRun.current = false
}, [profile])
return state.ts > ifAfterTS
? mergeShadow(profile, state.value)
: {...profile, isShadowed: true}
}
export function updateProfileShadow(
uri: string,
value: Partial<ProfileShadow>,
) {
batchedUpdates(() => {
emitter.emit(uri, value)
})
emitter.emit(uri, value)
}
export function isProfileShadowed<T extends ProfileView>(
v: T | Shadow<T>,
): v is Shadow<T> {
return 'isShadowed' in v && !!v.isShadowed
}
function fromProfile(profile: ProfileView): ProfileShadow {
@@ -95,7 +86,7 @@ function mergeShadow(
profile: ProfileView,
shadow: ProfileShadow,
): Shadow<ProfileView> {
return castAsShadow({
return {
...profile,
viewer: {
...(profile.viewer || {}),
@@ -103,5 +94,6 @@ function mergeShadow(
muted: shadow.muted,
blocking: shadow.blockingUri,
},
})
isShadowed: true,
}
}
+1 -7
View File
@@ -1,7 +1 @@
// This isn't a real property, but it prevents T being compatible with Shadow<T>.
declare const shadowTag: unique symbol
export type Shadow<T> = T & {[shadowTag]: true}
export function castAsShadow<T>(value: T): Shadow<T> {
return value as any as Shadow<T>
}
export type Shadow<T> = T & {isShadowed: true}
+2 -3
View File
@@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
* the Provider.
*/
export async function init() {
logger.info('persisted state: initializing')
logger.debug('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage
@@ -28,12 +28,11 @@ export async function init() {
const stored = await store.read() // check for new store
if (!stored) await store.write(defaults) // opt: init new store
_state = stored || defaults // return new store
logger.log('persisted state: initialized')
} catch (e) {
logger.error('persisted state: failed to load root state from storage', {
error: e,
})
// AsyncStorage failure, but we can still continue in memory
// AsyncStorage failured, but we can still continue in memory
return defaults
}
}
+4 -48
View File
@@ -94,8 +94,6 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
postLanguageHistory:
legacy.preferences?.postLanguageHistory ||
defaults.languagePrefs.postLanguageHistory,
appLanguage:
legacy.preferences?.postLanguage || defaults.languagePrefs.appLanguage,
},
requireAltTextEnabled:
legacy.preferences?.requireAltTextEnabled ||
@@ -116,52 +114,20 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists.
*/
export async function migrate() {
logger.info('persisted state: migrate')
logger.debug('persisted state: migrate')
try {
const rawLegacyData = await AsyncStorage.getItem(
DEPRECATED_ROOT_STATE_STORAGE_KEY,
)
const newData = await read()
const alreadyMigrated = Boolean(newData)
try {
if (rawLegacyData) {
const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema>
logger.info(`persisted state: debug legacy data`, {
hasExistingLoggedInAccount: Boolean(legacy?.session?.data),
numberOfExistingAccounts: legacy?.session?.accounts?.length,
foundExistingCurrentAccount: Boolean(
legacy.session?.accounts?.find(
a => a.did === legacy.session?.data?.did,
),
),
})
logger.info(`persisted state: debug new data`, {
hasExistingLoggedInAccount: Boolean(newData?.session?.currentAccount),
numberOfExistingAccounts: newData?.session?.accounts?.length,
existingAccountMatchesLegacy: Boolean(
newData?.session?.currentAccount?.did ===
legacy?.session?.data?.did,
),
})
} else {
logger.info(`persisted state: no legacy to debug, fresh install`)
}
} catch (e) {
logger.error(`persisted state: legacy debugging failed`, {error: e})
}
const alreadyMigrated = Boolean(await read())
if (!alreadyMigrated && rawLegacyData) {
logger.info('persisted state: migrating legacy storage')
logger.debug('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
await write(newData)
// track successful migrations
logger.log('persisted state: migrated legacy storage')
} else {
// track successful migrations
logger.log('persisted state: no migration needed')
logger.debug('persisted state: migrated legacy storage')
}
} catch (e) {
logger.error('persisted state: error migrating legacy storage', {
@@ -169,13 +135,3 @@ export async function migrate() {
})
}
}
export async function clearLegacyStorage() {
try {
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
} catch (e: any) {
logger.error(`persisted legacy store: failed to clear`, {
error: e.toString(),
})
}
}
-2
View File
@@ -30,7 +30,6 @@ export const schema = z.object({
contentLanguages: z.array(z.string()), // should move to server
postLanguage: z.string(), // should move to server
postLanguageHistory: z.array(z.string()),
appLanguage: z.string(),
}),
requireAltTextEnabled: z.boolean(), // should move to server
mutedThreads: z.array(z.string()), // should move to server
@@ -59,7 +58,6 @@ export const defaults: Schema = {
postLanguageHistory: (deviceLocales || [])
.concat(['en', 'ja', 'pt', 'de'])
.slice(0, 6),
appLanguage: deviceLocales[0] || 'en',
},
requireAltTextEnabled: false,
mutedThreads: [],
-9
View File
@@ -1,7 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {Schema, schema} from '#/state/persisted/schema'
import {logger} from '#/logger'
const BSKY_STORAGE = 'BSKY_STORAGE'
@@ -17,11 +16,3 @@ export async function read(): Promise<Schema | undefined> {
return objData
}
}
export async function clear() {
try {
await AsyncStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
logger.error(`persisted store: failed to clear`, {error: e.toString()})
}
}
-5
View File
@@ -11,7 +11,6 @@ type ApiContext = {
toggleContentLanguage: (code2: string) => void
togglePostLanguage: (code2: string) => void
savePostLanguageToHistory: () => void
setAppLanguage: (code2: string) => void
}
const stateContext = React.createContext<StateContext>(
@@ -23,7 +22,6 @@ const apiContext = React.createContext<ApiContext>({
toggleContentLanguage: (_: string) => {},
togglePostLanguage: (_: string) => {},
savePostLanguageToHistory: () => {},
setAppLanguage: (_: string) => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -106,9 +104,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.slice(0, 6),
}))
},
setAppLanguage(code2: string) {
setStateWrapped(s => ({...s, appLanguage: code2}))
},
}),
[state, setStateWrapped],
)
+8 -33
View File
@@ -160,38 +160,6 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
})
}
export const isFeedPublicQueryKey = ({uri}: {uri: string}) => [
'isFeedPublic',
uri,
]
export function useIsFeedPublicQuery({uri}: {uri: string}) {
return useQuery({
queryKey: isFeedPublicQueryKey({uri}),
queryFn: async ({queryKey}) => {
const [, uri] = queryKey
try {
const res = await getAgent().app.bsky.feed.getFeed({
feed: uri,
limit: 1,
})
return Boolean(res.data.feed)
} catch (e: any) {
const msg = e.toString() as string
if (msg.includes('missing jwt')) {
return false
} else if (msg.includes('This feed requires being logged-in')) {
// e.g. https://github.com/bluesky-social/atproto/blob/99ab1ae55c463e8d5321a1eaad07a175bdd56fea/packages/bsky/src/feed-gen/best-of-follows.ts#L13
return false
}
return true
}
},
})
}
export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() {
@@ -252,6 +220,7 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] {
FOLLOWING_FEED_STUB,
])
const {data: preferences} = usePreferencesQuery()
const pinnedFeedsKey = JSON.stringify(preferences?.feeds?.pinned)
React.useEffect(() => {
if (!preferences?.feeds?.pinned) return
@@ -298,7 +267,13 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] {
}
fetchFeedInfo()
}, [queryClient, setTabs, preferences?.feeds?.pinned])
}, [
queryClient,
setTabs,
preferences?.feeds?.pinned,
// ensure we react to re-ordering
pinnedFeedsKey,
])
return tabs
}
+1 -38
View File
@@ -7,18 +7,11 @@ import {
BskyAgent,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
useQueryClient,
QueryClient,
} from '@tanstack/react-query'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '../../session'
import {useModerationOpts} from '../preferences'
import {shouldFilterNotif} from './util'
import {useMutedThreads} from '#/state/muted-threads'
import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const PAGE_SIZE = 30
@@ -55,7 +48,6 @@ export interface FeedPage {
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const enabled = opts?.enabled !== false
@@ -88,9 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
if (notif.subject) {
precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
}
}
}
@@ -110,32 +99,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
})
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: ['notification-feed'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.subject?.uri === uri) {
return item.subject
}
}
}
}
return undefined
}
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
+1 -5
View File
@@ -70,12 +70,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
async checkUnread() {
const agent = getAgent()
if (!agent.session) return
// count
const res = await agent.listNotifications({limit: 40})
const res = await getAgent().listNotifications({limit: 40})
const filtered = res.data.notifications.filter(
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
)
+3 -39
View File
@@ -1,12 +1,6 @@
import {useCallback, useMemo} from 'react'
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
QueryClient,
useQueryClient,
} from '@tanstack/react-query'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {getAgent} from '../session'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
@@ -20,7 +14,6 @@ import {MergeFeedAPI} from 'lib/api/feed/merge'
import {useModerationOpts} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
type ActorDid = string
type AuthorFilter =
@@ -73,7 +66,6 @@ export function usePostFeedQuery(
params?: FeedParams,
opts?: {enabled?: boolean},
) {
const queryClient = useQueryClient()
const feedTuners = useFeedTuners(feedDesc)
const enabled = opts?.enabled !== false
const moderationOpts = useModerationOpts()
@@ -144,12 +136,11 @@ export function usePostFeedQuery(
staleTime: STALE.INFINITY,
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
logger.debug('usePostFeedQuery', {feedDesc, pageParam})
console.log('fetch', feedDesc, pageParam)
if (!pageParam) {
tuner.reset()
}
const res = await api.fetch({cursor: pageParam, limit: 30})
precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
const slices = tuner.tune(res.feed, feedTuners)
return {
cursor: res.cursor,
@@ -161,6 +152,7 @@ export function usePostFeedQuery(
slice.items.every(
item => item.post.author.did === slice.items[0].post.author.did,
),
source: undefined, // TODO
items: slice.items
.map((item, i) => {
if (
@@ -188,31 +180,3 @@ export function usePostFeedQuery(
return {...out, pollLatest}
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): FeedPostSliceItem | undefined {
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: ['post-feed'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const slice of page.slices) {
for (const item of slice.items) {
if (item.uri === uri) {
return item
}
}
}
}
}
return undefined
}
+5 -148
View File
@@ -3,17 +3,11 @@ import {
AppBskyFeedPost,
AppBskyFeedGetPostThread,
} from '@atproto/api'
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
import {useQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {STALE} from '#/state/queries'
import {
findPostInQueryData as findPostInFeedQueryData,
FeedPostSliceItem,
} from './post-feed'
import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
export const RQKEY = (uri: string) => ['post-thread', uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -24,8 +18,6 @@ export interface ThreadCtx {
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
isParentLoading?: boolean
isChildLoading?: boolean
}
export type ThreadPost = {
@@ -66,44 +58,17 @@ export type ThreadNode =
| ThreadUnknown
export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient()
return useQuery<ThreadNode, Error>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await getAgent().getPostThread({uri: uri!})
if (res.success) {
const nodes = responseToThreadNodes(res.data.thread)
precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
return nodes
return responseToThreadNodes(res.data.thread)
}
return {type: 'unknown', uri: uri!}
},
enabled: !!uri,
placeholderData: () => {
if (!uri) {
return undefined
}
{
const item = findPostInQueryData(queryClient, uri)
if (item) {
return threadNodeToPlaceholderThread(item)
}
}
{
const item = findPostInFeedQueryData(queryClient, uri)
if (item) {
return feedItemToPlaceholderThread(item)
}
}
{
const item = findPostInNotifsQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
return undefined
},
})
}
@@ -186,10 +151,9 @@ function responseToThreadNodes(
: undefined,
replies:
node.replies?.length && direction !== 'up'
? node.replies
.map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
// do not show blocked posts in replies
.filter(node => node.type !== 'blocked')
? node.replies.map(reply =>
responseToThreadNodes(reply, depth + 1, 'down'),
)
: undefined,
viewer: node.viewer,
ctx: {
@@ -213,110 +177,3 @@ function responseToThreadNodes(
return {type: 'unknown', uri: ''}
}
}
function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): ThreadNode | undefined {
const queryDatas = queryClient.getQueriesData<ThreadNode>({
queryKey: ['post-thread'],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const item of traverseThread(queryData)) {
if (item.uri === uri) {
return item
}
}
}
return undefined
}
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
if (node.type === 'post') {
if (node.parent) {
yield* traverseThread(node.parent)
}
yield node
if (node.replies?.length) {
for (const reply of node.replies) {
yield* traverseThread(reply)
}
}
}
}
function threadNodeToPlaceholderThread(
node: ThreadNode,
): ThreadNode | undefined {
if (node.type !== 'post') {
return undefined
}
return {
type: node.type,
_reactKey: node._reactKey,
uri: node.uri,
post: node.post,
record: node.record,
parent: undefined,
replies: undefined,
viewer: node.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!node.record.reply,
isChildLoading: !!node.post.replyCount,
},
}
}
function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode {
return {
type: 'post',
_reactKey: item.post.uri,
uri: item.post.uri,
post: item.post,
record: item.record,
parent: undefined,
replies: undefined,
viewer: item.post.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!item.record.reply,
isChildLoading: !!item.post.replyCount,
},
}
}
function postViewToPlaceholderThread(
post: AppBskyFeedDefs.PostView,
): ThreadNode {
return {
type: 'post',
_reactKey: post.uri,
uri: post.uri,
post: post,
record: post.record as AppBskyFeedPost.Record, // validate in notifs
parent: undefined,
replies: undefined,
viewer: post.viewer,
ctx: {
depth: 0,
isHighlightedPost: true,
hasMore: false,
showChildReplyLine: false,
showParentReplyLine: false,
isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
isChildLoading: !!post.replyCount,
},
}
}
-24
View File
@@ -2,7 +2,6 @@ import {
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
@@ -26,26 +25,3 @@ export const DEFAULT_PROD_FEEDS = {
pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
}
export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
birthDate: new Date('2022-11-17'), // TODO(pwi)
adultContentEnabled: false,
feeds: {
saved: [],
pinned: [],
unpinned: [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
},
feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
}
+77 -72
View File
@@ -1,6 +1,11 @@
import {useMemo} from 'react'
import {useEffect, useState} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
import {
LabelPreference,
BskyFeedViewPreference,
ModerationOpts,
} from '@atproto/api'
import isEqual from 'lodash.isequal'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
@@ -15,7 +20,6 @@ import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries'
@@ -24,83 +28,84 @@ export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/const'
export const preferencesQueryKey = ['getPreferences']
export const usePreferencesQueryKey = ['getPreferences']
export function usePreferencesQuery() {
const {hasSession} = useSession()
return useQuery({
enabled: hasSession,
staleTime: STALE.MINUTES.ONE,
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
queryFn: async () => {
const agent = getAgent()
if (agent.session?.did === undefined) {
return DEFAULT_LOGGED_OUT_PREFERENCES
} else {
const res = await agent.getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
}
return preferences
const res = await getAgent().getPreferences()
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
saved: res.feeds?.saved || [],
pinned: res.feeds?.pinned || [],
unpinned:
res.feeds.saved?.filter(f => {
return !res.feeds.pinned?.includes(f)
}) || [],
},
// labels are undefined until set by user
contentLabels: {
nsfw: temp__migrateLabelPref(
res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
),
nudity: temp__migrateLabelPref(
res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
),
suggestive: temp__migrateLabelPref(
res.contentLabels?.suggestive ||
DEFAULT_LABEL_PREFERENCES.suggestive,
),
gore: temp__migrateLabelPref(
res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
),
hate: temp__migrateLabelPref(
res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
),
spam: temp__migrateLabelPref(
res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
),
impersonation: temp__migrateLabelPref(
res.contentLabels?.impersonation ||
DEFAULT_LABEL_PREFERENCES.impersonation,
),
},
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
},
threadViewPrefs: {
...DEFAULT_THREAD_VIEW_PREFS,
...(res.threadViewPrefs ?? {}),
},
userAge: res.birthDate ? getAge(res.birthDate) : undefined,
}
return preferences
},
})
}
export function useModerationOpts() {
const {currentAccount} = useSession()
const [opts, setOpts] = useState<ModerationOpts | undefined>()
const prefs = usePreferencesQuery()
const opts = useMemo(() => {
useEffect(() => {
if (!prefs.data) {
return
}
return getModerationOpts({
// only update this hook when the moderation options change
const newOpts = getModerationOpts({
userDid: currentAccount?.did || '',
preferences: prefs.data,
})
}, [currentAccount?.did, prefs.data])
if (!isEqual(opts, newOpts)) {
setOpts(newOpts)
}
}, [prefs.data, currentAccount, opts, setOpts])
return opts
}
@@ -112,7 +117,7 @@ export function useClearPreferencesMutation() {
await getAgent().app.bsky.actor.putPreferences({preferences: []})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -130,7 +135,7 @@ export function usePreferencesSetContentLabelMutation() {
await getAgent().setContentLabelPref(labelGroup, visibility)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -144,7 +149,7 @@ export function usePreferencesSetAdultContentMutation() {
await getAgent().setAdultContentEnabled(enabled)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -158,7 +163,7 @@ export function usePreferencesSetBirthDateMutation() {
await getAgent().setPersonalDetails({birthDate})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -172,7 +177,7 @@ export function useSetFeedViewPreferencesMutation() {
await getAgent().setFeedViewPrefs('home', prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -186,7 +191,7 @@ export function useSetThreadViewPreferencesMutation() {
await getAgent().setThreadViewPrefs(prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -204,7 +209,7 @@ export function useSetSaveFeedsMutation() {
await getAgent().setSavedFeeds(saved, pinned)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -219,7 +224,7 @@ export function useSaveFeedMutation() {
track('CustomFeed:Save')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -234,7 +239,7 @@ export function useRemoveFeedMutation() {
track('CustomFeed:Unsave')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -249,7 +254,7 @@ export function usePinFeedMutation() {
track('CustomFeed:Pin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -264,7 +269,7 @@ export function useUnpinFeedMutation() {
track('CustomFeed:Unpin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
queryKey: usePreferencesQueryKey,
})
},
})
@@ -34,24 +34,6 @@ export const DEFAULT_LABEL_PREFERENCES: Record<
impersonation: 'hide',
}
/**
* More strict than our default settings for logged in users.
*
* TODO(pwi)
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
ConfigurableLabelGroup,
LabelPreference
> = {
nsfw: 'hide',
nudity: 'hide',
suggestive: 'hide',
gore: 'hide',
hate: 'hide',
spam: 'hide',
impersonation: 'hide',
}
export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
id: 'illegal',
title: 'Illegal Content',
+1 -4
View File
@@ -43,10 +43,7 @@ export type UsePreferencesQueryResponse = Omit<
}
}
export type ThreadViewPreferences = Pick<
BskyThreadViewPreference,
'prioritizeFollowedUsers'
> & {
export type ThreadViewPreferences = Omit<BskyThreadViewPreference, 'sort'> & {
sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string
lab_treeViewEnabled?: boolean
}
+14 -63
View File
@@ -1,76 +1,27 @@
import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query'
import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {AtUri} from '@atproto/api'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
import {ThreadNode} from './post-thread'
export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle]
export const RQKEY = (uri: string) => ['resolved-uri', uri]
type UriUseQueryResult = UseQueryResult<{did: string; uri: string}, Error>
export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
const urip = new AtUri(uri || '')
const res = useResolveDidQuery(urip.host)
if (res.data) {
urip.host = res.data
return {
...res,
data: {did: urip.host, uri: urip.toString()},
} as UriUseQueryResult
}
return res as UriUseQueryResult
}
export function useResolveDidQuery(didOrHandle: string | undefined) {
return useQuery<string, Error>({
export function useResolveUriQuery(uri: string | undefined) {
return useQuery<{uri: string; did: string}, Error>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(didOrHandle || ''),
queryKey: RQKEY(uri || ''),
async queryFn() {
if (!didOrHandle) {
return ''
const urip = new AtUri(uri || '')
if (!urip.host.startsWith('did:')) {
const res = await getAgent().resolveHandle({handle: urip.host})
urip.host = res.data.did
}
if (!didOrHandle.startsWith('did:')) {
const res = await getAgent().resolveHandle({handle: didOrHandle})
didOrHandle = res.data.did
}
return didOrHandle
return {did: urip.host, uri: urip.toString()}
},
enabled: !!didOrHandle,
enabled: !!uri,
})
}
export function precacheProfile(
queryClient: QueryClient,
profile:
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
) {
queryClient.setQueryData(RQKEY(profile.handle), profile.did)
}
export function precacheFeedPosts(
queryClient: QueryClient,
posts: AppBskyFeedDefs.FeedViewPost[],
) {
for (const post of posts) {
precacheProfile(queryClient, post.post.author)
}
}
export function precacheThreadPosts(
queryClient: QueryClient,
node: ThreadNode,
) {
if (node.type === 'post') {
precacheProfile(queryClient, node.post.author)
if (node.parent) {
precacheThreadPosts(queryClient, node.parent)
}
if (node.replies?.length) {
for (const reply of node.replies) {
precacheThreadPosts(queryClient, reply)
}
}
}
export function useResolveDidQuery(didOrHandle: string | undefined) {
return useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined)
}
+17 -49
View File
@@ -8,8 +8,6 @@ import * as persisted from '#/state/persisted'
import {PUBLIC_BSKY_AGENT} from '#/state/queries'
import {IS_PROD} from '#/lib/constants'
import {emitSessionLoaded, emitSessionDropped} from '../events'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
@@ -42,20 +40,7 @@ export type ApiContext = {
identifier: string
password: string
}) => Promise<void>
/**
* A full logout. Clears the `currentAccount` from session, AND removes
* access tokens from all accounts, so that returning as any user will
* require a full login.
*/
logout: () => Promise<void>
/**
* A partial logout. Clears the `currentAccount` from session, but DOES NOT
* clear access tokens from accounts, allowing the user to return to their
* other accounts without logging in.
*
* Used when adding a new account, deleting an account.
*/
clearCurrentAccount: () => void
initSession: (account: SessionAccount) => Promise<void>
resumeSession: (account?: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void
@@ -65,6 +50,7 @@ export type ApiContext = {
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
>,
) => void
clearCurrentAccount: () => void
}
const StateContext = React.createContext<StateContext>({
@@ -268,26 +254,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[upsertAccount, queryClient],
)
const clearCurrentAccount = React.useCallback(() => {
logger.debug(
`session: clear current account`,
{},
logger.DebugContext.session,
)
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
...s,
currentAccount: undefined,
}))
}, [setStateAndPersist, queryClient])
const logout = React.useCallback<ApiContext['logout']>(async () => {
clearCurrentAccount()
logger.debug(`session: logout`, {}, logger.DebugContext.session)
setStateAndPersist(s => {
return {
...s,
agent: PUBLIC_BSKY_AGENT,
currentAccount: undefined,
accounts: s.accounts.map(a => ({
...a,
refreshJwt: undefined,
@@ -295,7 +268,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})),
}
})
}, [clearCurrentAccount, setStateAndPersist])
}, [setStateAndPersist])
const initSession = React.useCallback<ApiContext['initSession']>(
async account => {
@@ -429,6 +402,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[setState, initSession],
)
/**
* Clears the `currentAccount` from session. Typically used to drop the user
* back to the sign-in page.
*/
const clearCurrentAccount = React.useCallback(() => {
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
...s,
currentAccount: undefined,
}))
}, [setStateAndPersist, queryClient])
React.useEffect(() => {
if (isDirty.current) {
isDirty.current = false
@@ -529,21 +515,3 @@ export function useSession() {
export function useSessionApi() {
return React.useContext(ApiContext)
}
export function useRequireAuth() {
const {hasSession} = useSession()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAll = useCloseAllActiveElements()
return React.useCallback(
(fn: () => void) => {
if (hasSession) {
fn()
} else {
closeAll()
setShowLoggedOut(true)
}
},
[hasSession, setShowLoggedOut, closeAll],
)
}
-37
View File
@@ -1,37 +0,0 @@
import React from 'react'
type StateContext = {
showLoggedOut: boolean
}
const StateContext = React.createContext<StateContext>({
showLoggedOut: false,
})
const ControlsContext = React.createContext<{
setShowLoggedOut: (show: boolean) => void
}>({
setShowLoggedOut: () => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [showLoggedOut, setShowLoggedOut] = React.useState(false)
const state = React.useMemo(() => ({showLoggedOut}), [showLoggedOut])
const controls = React.useMemo(() => ({setShowLoggedOut}), [setShowLoggedOut])
return (
<StateContext.Provider value={state}>
<ControlsContext.Provider value={controls}>
{children}
</ControlsContext.Provider>
</StateContext.Provider>
)
}
export function useLoggedOutView() {
return React.useContext(StateContext)
}
export function useLoggedOutViewControls() {
return React.useContext(ControlsContext)
}
+14 -55
View File
@@ -1,10 +1,5 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {isIOS} from 'platform/detection'
import {SafeAreaView} from 'react-native'
import {Login} from 'view/com/auth/login/Login'
import {CreateAccount} from 'view/com/auth/create/CreateAccount'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -13,7 +8,6 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {SplashScreen} from './SplashScreen'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
enum ScreenState {
S_LoginOrCreateAccount,
@@ -21,66 +15,31 @@ enum ScreenState {
S_CreateAccount,
}
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {_} = useLingui()
export function LoggedOut() {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {screen} = useAnalytics()
const [screenState, setScreenState] = React.useState<ScreenState>(
ScreenState.S_LoginOrCreateAccount,
)
const {isMobile} = useWebMediaQueries()
React.useEffect(() => {
screen('Login')
setMinimalShellMode(true)
}, [screen, setMinimalShellMode])
return (
<View
testID="noSessionView"
style={[
s.hContentRegion,
pal.view,
{
// only needed if dismiss button is present
paddingTop: onDismiss && isMobile ? 40 : 0,
},
]}>
<ErrorBoundary>
{onDismiss && (
<Pressable
accessibilityHint={_(msg`Go back`)}
accessibilityLabel={_(msg`Go back`)}
accessibilityRole="button"
style={{
position: 'absolute',
top: isIOS ? 0 : 20,
right: 20,
padding: 10,
zIndex: 100,
backgroundColor: pal.text.color,
borderRadius: 100,
}}
onPress={onDismiss}>
<FontAwesomeIcon
icon="x"
size={12}
style={{
color: String(pal.textInverted.color),
}}
/>
</Pressable>
)}
if (screenState === ScreenState.S_LoginOrCreateAccount) {
return (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Login)}
onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
/>
)
}
{screenState === ScreenState.S_LoginOrCreateAccount ? (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Login)}
onPressCreateAccount={() =>
setScreenState(ScreenState.S_CreateAccount)
}
/>
) : undefined}
return (
<SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}>
<ErrorBoundary>
{screenState === ScreenState.S_Login ? (
<Login
onPressBack={() =>
@@ -96,6 +55,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/>
) : undefined}
</ErrorBoundary>
</View>
</SafeAreaView>
)
}
+2 -13
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {SafeAreaView, Platform} from 'react-native'
import {SafeAreaView} from 'react-native'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
@@ -23,18 +23,7 @@ export function Onboarding() {
const skip = () => onboardingDispatch({type: 'skip'})
return (
<SafeAreaView
testID="onboardingView"
style={[
s.hContentRegion,
pal.view,
// @ts-ignore web only -esb
Platform.select({
web: {
height: '100vh',
},
}),
]}>
<SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
<ErrorBoundary>
{onboardingState.step === 'Welcome' && (
<Welcome skip={skip} next={next} />
+35 -33
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles'
@@ -20,40 +20,42 @@ export const SplashScreen = ({
return (
<CenteredView style={[styles.container, pal.view]}>
<ErrorBoundary>
<View style={styles.hero}>
<Text style={[styles.title, pal.link]}>
<Trans>Bluesky</Trans>
</Text>
<Text style={[styles.subtitle, pal.textLight]}>
<Trans>See what's next</Trans>
</Text>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}
accessibilityRole="button"
accessibilityLabel={_(msg`Create new account`)}
accessibilityHint="Opens flow to create a new Bluesky account">
<Text style={[s.white, styles.btnLabel]}>
<Trans>Create a new account</Trans>
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
<View style={styles.hero}>
<Text style={[styles.title, pal.link]}>
<Trans>Bluesky</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[styles.btn, pal.btn]}
onPress={onPressSignin}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign in`)}
accessibilityHint="Opens flow to sign into your existing Bluesky account">
<Text style={[pal.text, styles.btnLabel]}>
<Trans>Sign In</Trans>
<Text style={[styles.subtitle, pal.textLight]}>
<Trans>See what's next</Trans>
</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
</View>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}
accessibilityRole="button"
accessibilityLabel={_(msg`Create new account`)}
accessibilityHint="Opens flow to create a new Bluesky account">
<Text style={[s.white, styles.btnLabel]}>
<Trans>Create a new account</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[styles.btn, pal.btn]}
onPress={onPressSignin}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign in`)}
accessibilityHint="Opens flow to sign into your existing Bluesky account">
<Text style={[pal.text, styles.btnLabel]}>
<Trans>Sign In</Trans>
</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
</SafeAreaView>
</CenteredView>
)
}
+42 -68
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from '../util/Link'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -12,11 +11,9 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans} from '@lingui/macro'
export const SplashScreen = ({
onDismiss,
onPressSignin,
onPressCreateAccount,
}: {
onDismiss?: () => void
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
@@ -26,70 +23,47 @@ export const SplashScreen = ({
const isMobileWeb = isWeb && isTabletOrMobile
return (
<>
{onDismiss && (
<Pressable
accessibilityRole="button"
style={{
position: 'absolute',
top: 20,
right: 20,
padding: 20,
zIndex: 100,
}}
onPress={onDismiss}>
<FontAwesomeIcon
icon="x"
size={24}
style={{
color: String(pal.text.color),
}}
/>
</Pressable>
)}
<CenteredView style={[styles.container, pal.view]}>
<View
testID="noSessionView"
style={[
styles.containerInner,
isMobileWeb && styles.containerInnerMobile,
pal.border,
]}>
<ErrorBoundary>
<Text style={isMobileWeb ? styles.titleMobile : styles.title}>
Bluesky
</Text>
<Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}>
See what's next
</Text>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}
// TODO: web accessibility
accessibilityRole="button">
<Text style={[s.white, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[styles.btn, pal.btn]}
onPress={onPressSignin}
// TODO: web accessibility
accessibilityRole="button">
<Text style={[pal.text, styles.btnLabel]}>
<Trans>Sign In</Trans>
</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
</View>
<Footer styles={styles} />
</CenteredView>
</>
<CenteredView style={[styles.container, pal.view]}>
<View
testID="noSessionView"
style={[
styles.containerInner,
isMobileWeb && styles.containerInnerMobile,
pal.border,
]}>
<ErrorBoundary>
<Text style={isMobileWeb ? styles.titleMobile : styles.title}>
Bluesky
</Text>
<Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}>
See what's next
</Text>
<View testID="signinOrCreateAccount" style={styles.btns}>
<TouchableOpacity
testID="createAccountButton"
style={[styles.btn, {backgroundColor: colors.blue3}]}
onPress={onPressCreateAccount}
// TODO: web accessibility
accessibilityRole="button">
<Text style={[s.white, styles.btnLabel]}>
Create a new account
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="signInButton"
style={[styles.btn, pal.btn]}
onPress={onPressSignin}
// TODO: web accessibility
accessibilityRole="button">
<Text style={[pal.text, styles.btnLabel]}>
<Trans>Sign In</Trans>
</Text>
</TouchableOpacity>
</View>
</ErrorBoundary>
</View>
<Footer styles={styles} />
</CenteredView>
)
}
+1 -6
View File
@@ -136,12 +136,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
</LoggedOutLayout>
) : undefined}
{currentForm === Forms.PasswordUpdated ? (
<LoggedOutLayout
leadin=""
title={_(msg`Password updated`)}
description={_(msg`You can now sign in with your new password.`)}>
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
</LoggedOutLayout>
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
) : undefined}
</KeyboardAvoidingView>
)
@@ -10,8 +10,6 @@ import {RecommendedFeedsItem} from './RecommendedFeedsItem'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
type Props = {
@@ -19,45 +17,40 @@ type Props = {
}
export function RecommendedFeeds({next}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries()
const {isLoading, data} = useSuggestedFeedsQuery()
const hasFeeds = data && data.pages[0].feeds.length
const hasFeeds = data && data?.pages?.[0]?.feeds?.length
const title = (
<>
<Trans>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Choose your
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Feeds
</Text>
</Trans>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Choose your
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Feeds
</Text>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
<Trans>
Feeds are created by users to curate content. Choose some feeds that
you find interesting.
</Trans>
Feeds are created by users to curate content. Choose some feeds that you
find interesting.
</Text>
<View
style={{
@@ -76,7 +69,7 @@ export function RecommendedFeeds({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans>Next</Trans>
Next
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
@@ -106,22 +99,20 @@ export function RecommendedFeeds({next}: Props) {
<ActivityIndicator size="large" />
</View>
) : (
<ErrorMessage message={_(msg`Failed to load recommended feeds`)} />
<ErrorMessage message="Failed to load recommended feeds" />
)}
</TitleColumnLayout>
</TabletOrDesktop>
<Mobile>
<View style={[mStyles.container]} testID="recommendedFeedsOnboarding">
<ViewHeader
title={_(msg`Recommended Feeds`)}
title="Recommended Feeds"
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
<Trans>
Check out some recommended feeds. Tap + to add them to your list
of pinned feeds.
</Trans>
Check out some recommended feeds. Tap + to add them to your list of
pinned feeds.
</Text>
{hasFeeds ? (
@@ -137,15 +128,13 @@ export function RecommendedFeeds({next}: Props) {
</View>
) : (
<View style={{flex: 1}}>
<ErrorMessage
message={_(msg`Failed to load recommended feeds`)}
/>
<ErrorMessage message="Failed to load recommended feeds" />
</View>
)}
<Button
onPress={next}
label={_(msg`Continue`)}
label="Continue"
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
@@ -14,17 +14,14 @@ import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
import {useModerationOpts} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
type Props = {
next: () => void
}
export function RecommendedFollows({next}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries()
const {data: suggestedFollows} = useSuggestedFollowsQuery()
const {data: suggestedFollows, dataUpdatedAt} = useSuggestedFollowsQuery()
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
[did: string]: AppBskyActorDefs.ProfileView[]
@@ -34,37 +31,33 @@ export function RecommendedFollows({next}: Props) {
const title = (
<>
<Trans>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Follow some
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Users
</Text>
</Trans>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Follow some
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Users
</Text>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
<Trans>
Follow some users to get started. We can recommend you more users
based on who you find interesting.
</Trans>
Follow some users to get started. We can recommend you more users based
on who you find interesting.
</Text>
<View
style={{
@@ -83,7 +76,7 @@ export function RecommendedFollows({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans>Done</Trans>
Done
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
@@ -162,6 +155,7 @@ export function RecommendedFollows({next}: Props) {
renderItem={({item}) => (
<RecommendedFollowsItem
profile={item}
dataUpdatedAt={dataUpdatedAt}
onFollowStateChange={onFollowStateChange}
moderation={moderateProfile(item, moderationOpts)}
/>
@@ -177,15 +171,13 @@ export function RecommendedFollows({next}: Props) {
<View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
<View>
<ViewHeader
title={_(msg`Recommended Users`)}
title="Recommended Follows"
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
<Trans>
Check out some recommended users. Follow them to see similar
users.
</Trans>
Check out some recommended users. Follow them to see similar
users.
</Text>
</View>
{!suggestedFollows || !moderationOpts ? (
@@ -196,6 +188,7 @@ export function RecommendedFollows({next}: Props) {
renderItem={({item}) => (
<RecommendedFollowsItem
profile={item}
dataUpdatedAt={dataUpdatedAt}
onFollowStateChange={onFollowStateChange}
moderation={moderateProfile(item, moderationOpts)}
/>
@@ -206,7 +199,7 @@ export function RecommendedFollows({next}: Props) {
)}
<Button
onPress={next}
label={_(msg`Continue`)}
label="Continue"
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
@@ -18,6 +18,7 @@ import {logger} from '#/logger'
type Props = {
profile: AppBskyActorDefs.ProfileViewBasic
dataUpdatedAt: number
moderation: ProfileModeration
onFollowStateChange: (props: {
did: string
@@ -27,12 +28,13 @@ type Props = {
export function RecommendedFollowsItem({
profile,
dataUpdatedAt,
moderation,
onFollowStateChange,
}: React.PropsWithChildren<Props>) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const shadowedProfile = useProfileShadow(profile)
const shadowedProfile = useProfileShadow(profile, dataUpdatedAt)
return (
<Animated.View
@@ -43,10 +43,10 @@ export function WelcomeMobile({next, skip}: Props) {
/>
<View>
<Text style={[pal.text, styles.title]}>
<Trans>
Welcome to{' '}
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
</Trans>
Welcome to{' '}
<Text style={[pal.text, pal.link, styles.title]}>
<Trans>Bluesky</Trans>
</Text>
</Text>
<View style={styles.spacer} />
<View style={[styles.row]}>
+79
View File
@@ -0,0 +1,79 @@
import React from 'react'
import {
ActivityIndicator,
Linking,
StyleSheet,
TouchableOpacity,
} from 'react-native'
import {CenteredView} from '../util/Views'
import {LoggedOut} from './LoggedOut'
import {Onboarding} from './Onboarding'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {STATUS_PAGE_URL} from 'lib/constants'
import {useOnboardingState} from '#/state/shell'
import {useSession} from '#/state/session'
export const withAuthRequired = <P extends object>(
Component: React.ComponentType<P>,
): React.FC<P> =>
function AuthRequired(props: P) {
const {isInitialLoad, hasSession} = useSession()
const onboardingState = useOnboardingState()
if (isInitialLoad) {
return <Loading />
}
if (!hasSession) {
return <LoggedOut />
}
if (onboardingState.isActive) {
return <Onboarding />
}
return <Component {...props} />
}
function Loading() {
const pal = usePalette('default')
const [isTakingTooLong, setIsTakingTooLong] = React.useState(false)
React.useEffect(() => {
const t = setTimeout(() => setIsTakingTooLong(true), 15e3) // 15 seconds
return () => clearTimeout(t)
}, [setIsTakingTooLong])
return (
<CenteredView style={[styles.loading, pal.view]}>
<ActivityIndicator size="large" />
<Text type="2xl" style={[styles.loadingText, pal.textLight]}>
{isTakingTooLong
? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..."
: 'Connecting...'}
</Text>
{isTakingTooLong ? (
<TouchableOpacity
onPress={() => {
Linking.openURL(STATUS_PAGE_URL)
}}
accessibilityRole="button">
<Text type="2xl" style={[styles.loadingText, pal.link]}>
Check Bluesky status page
</Text>
</TouchableOpacity>
) : null}
</CenteredView>
)
}
const styles = StyleSheet.create({
loading: {
height: '100%',
alignContent: 'center',
justifyContent: 'center',
paddingBottom: 100,
},
loadingText: {
paddingVertical: 20,
paddingHorizontal: 20,
textAlign: 'center',
},
})
+4 -4
View File
@@ -129,19 +129,19 @@ export const ComposePost = observer(function ComposePost({
}
openModal({
name: 'confirm',
title: _(msg`Discard draft`),
title: 'Discard draft',
onPressConfirm: onClose,
onPressCancel: () => {
closeModal()
},
message: _(msg`Are you sure you'd like to discard this draft?`),
confirmBtnText: _(msg`Discard`),
message: "Are you sure you'd like to discard this draft?",
confirmBtnText: 'Discard',
confirmBtnStyle: {backgroundColor: colors.red4},
})
} else {
onClose()
}
}, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
}, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery])
// android back button
useEffect(() => {
if (!isAndroid) {
+27 -58
View File
@@ -41,7 +41,7 @@ export function FeedPage({
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
}) {
const {isSandbox, hasSession} = useSession()
const {isSandbox} = useSession()
const pal = usePalette('default')
const {_} = useLingui()
const {isDesktop} = useWebMediaQueries()
@@ -123,35 +123,24 @@ export function FeedPage({
}
onPress={emitSoftReset}
/>
{hasSession && (
<TextLink
type="title-lg"
href="/settings/home-feed"
style={{fontWeight: 'bold'}}
accessibilityLabel={_(msg`Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
)}
<TextLink
type="title-lg"
href="/settings/home-feed"
style={{fontWeight: 'bold'}}
accessibilityLabel={_(msg`Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
</View>
)
}
return <></>
}, [
isDesktop,
pal.view,
pal.text,
pal.textLight,
hasNew,
_,
isSandbox,
hasSession,
])
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, isSandbox])
return (
<View testID={testID} style={s.h100pct}>
@@ -177,17 +166,14 @@ export function FeedPage({
showIndicator={hasNew}
/>
)}
{hasSession && (
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
</View>
)
}
@@ -195,30 +181,13 @@ export function FeedPage({
function useHeaderOffset() {
const {isDesktop, isTablet} = useWebMediaQueries()
const {fontScale} = useWindowDimensions()
const {hasSession} = useSession()
if (isDesktop) {
return 0
}
if (isTablet) {
if (hasSession) {
return 50
} else {
return 0
}
}
if (hasSession) {
const navBarPad = 16
const navBarText = 21 * fontScale
const tabBarPad = 20 + 3 // nav bar padding + border
const tabBarText = 16 * fontScale
const magic = 7 * fontScale
return navBarPad + navBarText + tabBarPad + tabBarText + magic
} else {
const navBarPad = 16
const navBarText = 21 * fontScale
const magic = 4 * fontScale
return navBarPad + navBarText + magic
return 50
}
// default text takes 44px, plus 34px of pad
// scale the 44px by the font scale
return 34 + 44 * fontScale
}
+3 -6
View File
@@ -14,8 +14,6 @@ import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
@@ -70,7 +68,6 @@ export function FeedSourceCardLoaded({
showLikes?: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
@@ -88,8 +85,8 @@ export function FeedSourceCardLoaded({
if (isSaved) {
openModal({
name: 'confirm',
title: _(msg`Remove from my feeds`),
message: _(msg`Remove ${feed.displayName} from my feeds?`),
title: 'Remove from my feeds',
message: `Remove ${feed?.displayName} from my feeds?`,
onPressConfirm: async () => {
try {
await removeFeed({uri: feed.uri})
@@ -110,7 +107,7 @@ export function FeedSourceCardLoaded({
logger.error('Failed to save feed', {error: e})
}
}
}, [isSaved, openModal, feed, removeFeed, saveFeed, _])
}, [isSaved, openModal, feed, removeFeed, saveFeed])
if (!feed || !preferences) return null
+14 -38
View File
@@ -8,14 +8,13 @@ import {
View,
ViewStyle,
} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views'
import {FeedSourceCardLoaded} from './FeedSourceCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
import {useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
@@ -30,37 +29,25 @@ const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
interface SectionRef {
scrollToTop: () => void
}
interface ProfileFeedgensProps {
export function ProfileFeedgens({
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
}: {
did: string
scrollElRef: MutableRefObject<FlatList<any> | null>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollHandler
scrollEventThrottle?: number
headerOffset: number
enabled?: boolean
style?: StyleProp<ViewStyle>
testID?: string
}
export const ProfileFeedgens = React.forwardRef<
SectionRef,
ProfileFeedgensProps
>(function ProfileFeedgensImpl(
{
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
},
ref,
) {
}) {
const pal = usePalette('default')
const theme = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false)
@@ -101,17 +88,6 @@ export const ProfileFeedgens = React.forwardRef<
// events
// =
const queryClient = useQueryClient()
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
queryClient.invalidateQueries({queryKey: RQKEY(did)})
}, [scrollElRef, queryClient, headerOffset, did])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
@@ -216,7 +192,7 @@ export const ProfileFeedgens = React.forwardRef<
/>
</View>
)
})
}
const styles = StyleSheet.create({
item: {
+4 -1
View File
@@ -64,6 +64,7 @@ export function ListMembers({
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isError,
@@ -184,6 +185,7 @@ export function ListMembers({
(item as AppBskyGraphDefs.ListItemView).subject.handle
}`}
profile={(item as AppBskyGraphDefs.ListItemView).subject}
dataUpdatedAt={dataUpdatedAt}
renderButton={renderMemberButton}
style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
/>
@@ -196,6 +198,7 @@ export function ListMembers({
onPressTryAgain,
onPressRetryLoadMore,
isMobile,
dataUpdatedAt,
],
)
@@ -215,7 +218,7 @@ export function ListMembers({
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item.subject?.did || item._reactKey}
keyExtractor={(item: any) => item.uri || item._reactKey}
renderItem={renderItem}
ListHeaderComponent={renderHeader}
ListFooterComponent={Footer}
+144 -175
View File
@@ -8,7 +8,6 @@ import {
View,
ViewStyle,
} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views'
import {ListCard} from './ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
@@ -16,7 +15,7 @@ import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
import {useProfileListsQuery} from '#/state/queries/profile-lists'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
@@ -29,199 +28,169 @@ const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
interface SectionRef {
scrollToTop: () => void
}
interface ProfileListsProps {
export function ProfileLists({
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
}: {
did: string
scrollElRef: MutableRefObject<FlatList<any> | null>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollHandler
scrollEventThrottle?: number
headerOffset: number
enabled?: boolean
style?: StyleProp<ViewStyle>
testID?: string
}
}) {
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
data,
isFetching,
isFetched,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useProfileListsQuery(did, opts)
const isEmpty = !isFetching && !data?.pages[0]?.lists.length
export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
function ProfileListsImpl(
{
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
},
ref,
) {
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
data,
isFetching,
isFetched,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useProfileListsQuery(did, opts)
const isEmpty = !isFetching && !data?.pages[0]?.lists.length
const items = React.useMemo(() => {
let items: any[] = []
if (isError && isEmpty) {
items = items.concat([ERROR_ITEM])
const items = React.useMemo(() => {
let items: any[] = []
if (isError && isEmpty) {
items = items.concat([ERROR_ITEM])
}
if (!isFetched && isFetching) {
items = items.concat([LOADING])
} else if (isEmpty) {
items = items.concat([EMPTY])
} else if (data?.pages) {
for (const page of data?.pages) {
items = items.concat(page.lists)
}
if (!isFetched && isFetching) {
items = items.concat([LOADING])
} else if (isEmpty) {
items = items.concat([EMPTY])
} else if (data?.pages) {
for (const page of data?.pages) {
items = items.concat(
page.lists.map(l => ({
...l,
_reactKey: l.uri,
})),
)
}
}
if (isError && !isEmpty) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
}, [isError, isEmpty, isFetched, isFetching, data])
}
if (isError && !isEmpty) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
}, [isError, isEmpty, isFetched, isFetching, data])
// events
// =
// events
// =
const queryClient = useQueryClient()
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh lists', {error: err})
}
setIsPTRing(false)
}, [refetch, track, setIsPTRing])
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
queryClient.invalidateQueries({queryKey: RQKEY(did)})
}, [scrollElRef, queryClient, headerOffset, did])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
track('Lists:onEndReached')
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more lists', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage, track])
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh lists', {error: err})
}
setIsPTRing(false)
}, [refetch, track, setIsPTRing])
const onPressRetryLoadMore = React.useCallback(() => {
fetchNextPage()
}, [fetchNextPage])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
// rendering
// =
track('Lists:onEndReached')
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more lists', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage, track])
const onPressRetryLoadMore = React.useCallback(() => {
fetchNextPage()
}, [fetchNextPage])
// rendering
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY) {
return (
<View
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no lists.</Trans>
</Text>
</View>
)
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
message={cleanError(error)}
onPressTryAgain={refetch}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING) {
return (
<View style={{padding: 20}}>
<ActivityIndicator />
</View>
)
}
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY) {
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
<View
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no lists.</Trans>
</Text>
</View>
)
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
},
[error, refetch, onPressRetryLoadMore, pal],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item._reactKey}
renderItem={renderItemInner}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
onEndReached={onEndReached}
} else if (item === LOADING) {
return (
<View style={{padding: 20}}>
<ActivityIndicator />
</View>
)
}
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
/>
</View>
)
},
)
)
},
[error, refetch, onPressRetryLoadMore, pal],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item._reactKey}
renderItem={renderItemInner}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
onEndReached={onEndReached}
/>
</View>
)
}
const styles = StyleSheet.create({
item: {
+11 -2
View File
@@ -22,6 +22,7 @@ export function Component({did}: {did: string}) {
const moderationOpts = useModerationOpts()
const {
data: profile,
dataUpdatedAt,
error: profileError,
refetch: refetchProfile,
isFetching: isFetchingProfile,
@@ -50,7 +51,13 @@ export function Component({did}: {did: string}) {
)
}
if (profile && moderationOpts) {
return <ComponentLoaded profile={profile} moderationOpts={moderationOpts} />
return (
<ComponentLoaded
profile={profile}
dataUpdatedAt={dataUpdatedAt}
moderationOpts={moderationOpts}
/>
)
}
// should never happen
return (
@@ -64,13 +71,15 @@ export function Component({did}: {did: string}) {
function ComponentLoaded({
profile: profileUnshadowed,
dataUpdatedAt,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
dataUpdatedAt: number
moderationOpts: ModerationOpts
}) {
const pal = usePalette('default')
const profile = useProfileShadow(profileUnshadowed)
const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
const {screen} = useAnalytics()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
+3 -3
View File
@@ -45,10 +45,10 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
{profile?.displayName || account?.handle}
{profile?.displayName || currentAccount?.handle}
</Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{account?.handle}
{currentAccount?.handle}
</Text>
</View>
@@ -75,7 +75,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
did: currentAccount.did,
handle: currentAccount.handle,
})}
title={_(msg`Your profile`)}
title="Your profile"
noFeedback>
{contents}
</Link>
+9 -2
View File
@@ -38,6 +38,7 @@ export function Feed({
const {markAllRead} = useUnreadNotificationsApi()
const {
data,
dataUpdatedAt,
isLoading,
isFetching,
isFetched,
@@ -131,9 +132,15 @@ export function Feed({
} else if (item === LOADING_ITEM) {
return <NotificationFeedLoadingPlaceholder />
}
return <FeedItem item={item} moderationOpts={moderationOpts!} />
return (
<FeedItem
item={item}
dataUpdatedAt={dataUpdatedAt}
moderationOpts={moderationOpts!}
/>
)
},
[onPressRetryLoadMore, moderationOpts],
[onPressRetryLoadMore, dataUpdatedAt, moderationOpts],
)
const showHeaderSpinner = !isPTRing && isFetching && !isLoading
+7 -7
View File
@@ -1,4 +1,4 @@
import React, {memo, useMemo, useState, useEffect} from 'react'
import React, {useMemo, useState, useEffect} from 'react'
import {
Animated,
TouchableOpacity,
@@ -56,13 +56,15 @@ interface Author {
moderation: ProfileModeration
}
let FeedItem = ({
export function FeedItem({
item,
dataUpdatedAt,
moderationOpts,
}: {
item: FeedNotification
dataUpdatedAt: number
moderationOpts: ModerationOpts
}): React.ReactNode => {
}) {
const pal = usePalette('default')
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
@@ -133,6 +135,7 @@ let FeedItem = ({
accessible={false}>
<Post
post={item.subject}
dataUpdatedAt={dataUpdatedAt}
style={
item.notification.isRead
? undefined
@@ -232,8 +235,7 @@ let FeedItem = ({
{authors.length > 1 ? (
<>
<Text style={[pal.text, s.mr5, s.ml5]}>
{' '}
<Trans>and</Trans>{' '}
<Trans>and</Trans>
</Text>
<Text style={[pal.text, s.bold]}>
{formatCount(authors.length - 1)}{' '}
@@ -260,8 +262,6 @@ let FeedItem = ({
</Link>
)
}
FeedItem = memo(FeedItem)
export {FeedItem}
function ExpandListPressable({
hasMultipleAuthors,
+3 -57
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {View, StyleSheet} from 'react-native'
import {StyleSheet} from 'react-native'
import Animated from 'react-native-reanimated'
import {TabBar} from 'view/com/pager/TabBar'
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
@@ -9,82 +9,28 @@ import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useShellLayout} from '#/state/shell/shell-layout'
import {usePinnedFeedsInfos} from '#/state/queries/feed'
import {useSession} from '#/state/session'
import {TextLink} from '#/view/com/util/Link'
import {CenteredView} from '../util/Views'
export function FeedsTabBar(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const {isMobile, isTablet} = useWebMediaQueries()
const {hasSession} = useSession()
if (isMobile) {
return <FeedsTabBarMobile {...props} />
} else if (isTablet) {
if (hasSession) {
return <FeedsTabBarTablet {...props} />
} else {
return <FeedsTabBarPublic />
}
return <FeedsTabBarTablet {...props} />
} else {
return null
}
}
function FeedsTabBarPublic() {
const pal = usePalette('default')
const {isSandbox} = useSession()
return (
<CenteredView sideBorders>
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
{/*hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)*/}
</>
}
// onPress={emitSoftReset}
/>
</View>
</CenteredView>
)
}
function FeedsTabBarTablet(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const feeds = usePinnedFeedsInfos()
const pal = usePalette('default')
const {hasSession} = useSession()
const {headerMinimalShellTransform} = useMinimalShellMode()
const {headerHeight} = useShellLayout()
const items = hasSession ? feeds.map(f => f.displayName) : []
const items = feeds.map(f => f.displayName)
return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
+26 -30
View File
@@ -23,14 +23,14 @@ export function FeedsTabBar(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const pal = usePalette('default')
const {isSandbox, hasSession} = useSession()
const {isSandbox} = useSession()
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const feeds = usePinnedFeedsInfos()
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
const {headerHeight} = useShellLayout()
const {headerMinimalShellTransform} = useMinimalShellMode()
const items = hasSession ? feeds.map(f => f.displayName) : []
const items = feeds.map(f => f.displayName)
const onPressAvi = React.useCallback(() => {
setDrawerOpen(true)
@@ -61,35 +61,30 @@ export function FeedsTabBar(
<Text style={[brandBlue, s.bold, styles.title]}>
{isSandbox ? 'SANDBOX' : 'Bluesky'}
</Text>
<View style={[pal.view, {width: 18}]}>
{hasSession && (
<Link
testID="viewHeaderHomeFeedPrefsBtn"
href="/settings/home-feed"
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Home Feed Preferences`)}
accessibilityHint="">
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
</Link>
)}
<View style={[pal.view]}>
<Link
testID="viewHeaderHomeFeedPrefsBtn"
href="/settings/home-feed"
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Home Feed Preferences`)}
accessibilityHint="">
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
</Link>
</View>
</View>
{items.length > 0 && (
<TabBar
key={items.join(',')}
onPressSelected={props.onPressSelected}
selectedPage={props.selectedPage}
onSelect={props.onSelect}
testID={props.testID}
items={items}
indicatorColor={pal.colors.link}
/>
)}
<TabBar
key={items.join(',')}
onPressSelected={props.onPressSelected}
selectedPage={props.selectedPage}
onSelect={props.onSelect}
testID={props.testID}
items={items}
indicatorColor={pal.colors.link}
/>
</Animated.View>
)
}
@@ -109,7 +104,8 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 8,
paddingTop: 8,
paddingBottom: 2,
width: '100%',
},
title: {
+13 -5
View File
@@ -20,6 +20,7 @@ export function PostLikedBy({uri}: {uri: string}) {
} = useResolveUriQuery(uri)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -54,11 +55,18 @@ export function PostLikedBy({uri}: {uri: string}) {
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = useCallback(({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
)
}, [])
const renderItem = useCallback(
({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn
key={item.actor.did}
profile={item.actor}
dataUpdatedAt={dataUpdatedAt}
/>
)
},
[dataUpdatedAt],
)
if (isFetchingResolvedUri || !isFetched) {
return (
+9 -2
View File
@@ -20,6 +20,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
} = useResolveUriQuery(uri)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -56,9 +57,15 @@ export function PostRepostedBy({uri}: {uri: string}) {
const renderItem = useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => {
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
return (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
)
},
[],
[dataUpdatedAt],
)
if (isFetchingResolvedUri || !isFetched) {
+93 -84
View File
@@ -38,11 +38,8 @@ import {
UsePreferencesQueryResponse,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {isNative} from '#/platform/detection'
import {logger} from '#/logger'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
const TOP_COMPONENT = {_reactKey: '__top_component__'}
const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
@@ -74,7 +71,9 @@ export function PostThread({
isError,
error,
refetch,
isRefetching,
data: thread,
dataUpdatedAt,
} = usePostThreadQuery(uri)
const {data: preferences} = usePreferencesQuery()
const rootPost = thread?.type === 'post' ? thread.post : undefined
@@ -111,6 +110,8 @@ export function PostThread({
return (
<PostThreadLoaded
thread={thread}
isRefetching={isRefetching}
dataUpdatedAt={dataUpdatedAt}
threadViewPrefs={preferences.threadViewPrefs}
onRefresh={refetch}
onPressReply={onPressReply}
@@ -120,29 +121,46 @@ export function PostThread({
function PostThreadLoaded({
thread,
isRefetching,
dataUpdatedAt,
threadViewPrefs,
onRefresh,
onPressReply,
}: {
thread: ThreadNode
isRefetching: boolean
dataUpdatedAt: number
threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
onRefresh: () => void
onPressReply: () => void
}) {
const {hasSession} = useSession()
const {_} = useLingui()
const pal = usePalette('default')
const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null)
const highlightedPostRef = useRef<View | null>(null)
const needsScrollAdjustment = useRef<boolean>(
!isNative || // web always uses scroll adjustment
(thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
)
// const hasScrolledIntoView = useRef<boolean>(false) TODO
const [maxVisible, setMaxVisible] = React.useState(100)
const [isPTRing, setIsPTRing] = React.useState(false)
// construct content
// TODO
// const posts = React.useMemo(() => {
// if (view.thread) {
// let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
// if (arr.length > maxVisible) {
// arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
// }
// if (view.isLoadingFromCache) {
// if (view.thread?.postRecord?.reply) {
// arr.unshift(PARENT_SPINNER)
// }
// arr.push(CHILD_SPINNER)
// } else {
// arr.push(BOTTOM_COMPONENT)
// }
// return arr
// }
// return []
// }, [view.isLoadingFromCache, view.thread, maxVisible])
// const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
const posts = React.useMemo(() => {
let arr = [TOP_COMPONENT].concat(
Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
@@ -150,73 +168,66 @@ function PostThreadLoaded({
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
}
if (arr.indexOf(CHILD_SPINNER) === -1) {
arr.push(BOTTOM_COMPONENT)
}
arr.push(BOTTOM_COMPONENT)
return arr
}, [thread, maxVisible, threadViewPrefs])
/**
* NOTE
* Scroll positioning
*
* This callback is run if needsScrollAdjustment.current == true, which is...
* - On web: always
* - On native: when the placeholder cache is not being used
*
* It then only runs when viewing a reply, and the goal is to scroll the
* reply into view.
*
* On native, if the placeholder cache is being used then maintainVisibleContentPosition
* is a more effective solution, so we use that. Otherwise, typically we're loading from
* the react-query cache, so we just need to immediately scroll down to the post.
*
* On desktop, maintainVisibleContentPosition isn't supported so we just always use
* this technique.
*
* -prf
*/
const onContentSizeChange = React.useCallback(() => {
// TODO
/*const onContentSizeChange = React.useCallback(() => {
// only run once
if (!needsScrollAdjustment.current) {
if (hasScrolledIntoView.current) {
return
}
// wait for loading to finish
if (thread.type === 'post' && !!thread.parent) {
highlightedPostRef.current?.measure(
(_x, _y, _width, _height, _pageX, pageY) => {
ref.current?.scrollToOffset({
animated: false,
offset: pageY - (isDesktop ? 0 : 50),
})
},
)
needsScrollAdjustment.current = false
if (
!view.hasContent ||
(view.isFromCache && view.isLoadingFromCache) ||
view.isLoading
) {
return
}
}, [thread, isDesktop])
const onPTR = React.useCallback(async () => {
setIsPTRing(true)
try {
await onRefresh()
} catch (err) {
logger.error('Failed to refresh posts thread', {error: err})
if (highlightedPostIndex !== -1) {
ref.current?.scrollToIndex({
index: highlightedPostIndex,
animated: false,
viewPosition: 0,
})
hasScrolledIntoView.current = true
}
setIsPTRing(false)
}, [setIsPTRing, onRefresh])
}, [
highlightedPostIndex,
view.hasContent,
view.isFromCache,
view.isLoadingFromCache,
view.isLoading,
])*/
const onScrollToIndexFailed = React.useCallback(
(info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
ref.current?.scrollToOffset({
animated: false,
offset: info.averageItemLength * info.index,
})
},
[ref],
)
const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => {
if (item === TOP_COMPONENT) {
return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
return isTablet ? <ViewHeader title="Post" /> : null
} else if (item === PARENT_SPINNER) {
return (
<View style={styles.parentSpinner}>
<ActivityIndicator />
</View>
)
} else if (item === REPLY_PROMPT && hasSession) {
} else if (item === REPLY_PROMPT) {
return (
<View>
{isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
@@ -281,27 +292,24 @@ function PostThreadLoaded({
? (posts[index - 1] as ThreadPost)
: undefined
return (
<View
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
<PostThreadItem
post={item.post}
record={item.record}
treeView={threadViewPrefs.lab_treeViewEnabled || false}
depth={item.ctx.depth}
isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore}
showChildReplyLine={item.ctx.showChildReplyLine}
showParentReplyLine={item.ctx.showParentReplyLine}
hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
onPostReply={onRefresh}
/>
</View>
<PostThreadItem
post={item.post}
record={item.record}
dataUpdatedAt={dataUpdatedAt}
treeView={threadViewPrefs.lab_treeViewEnabled || false}
depth={item.ctx.depth}
isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore}
showChildReplyLine={item.ctx.showChildReplyLine}
showParentReplyLine={item.ctx.showParentReplyLine}
hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
onPostReply={onRefresh}
/>
)
}
return null
},
[
hasSession,
isTablet,
isDesktop,
onPressReply,
@@ -314,6 +322,7 @@ function PostThreadLoaded({
posts,
onRefresh,
threadViewPrefs.lab_treeViewEnabled,
dataUpdatedAt,
_,
],
)
@@ -324,21 +333,25 @@ function PostThreadLoaded({
data={posts}
initialNumToRender={posts.length}
maintainVisibleContentPosition={
!needsScrollAdjustment.current
? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined
undefined // TODO
// isNative && view.isFromCache && view.isCachedPostAReply
// ? MAINTAIN_VISIBLE_CONTENT_POSITION
// : undefined
}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onPTR}
refreshing={isRefetching}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onContentSizeChange={onContentSizeChange}
onContentSizeChange={
undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
}
onScrollToIndexFailed={onScrollToIndexFailed}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
@@ -455,8 +468,6 @@ function* flattenThreadSkeleton(
if (node.type === 'post') {
if (node.parent) {
yield* flattenThreadSkeleton(node.parent)
} else if (node.ctx.isParentLoading) {
yield PARENT_SPINNER
}
yield node
if (node.ctx.isHighlightedPost) {
@@ -466,8 +477,6 @@ function* flattenThreadSkeleton(
for (const reply of node.replies) {
yield* flattenThreadSkeleton(reply)
}
} else if (node.ctx.isChildLoading) {
yield CHILD_SPINNER
}
} else if (node.type === 'not-found') {
yield DELETED
+9 -10
View File
@@ -1,4 +1,4 @@
import React, {memo, useMemo} from 'react'
import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {
AtUri,
@@ -35,8 +35,7 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {MAX_POST_LINES} from 'lib/constants'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/macro'
import {useLanguagePrefs} from '#/state/preferences'
import {useComposerControls} from '#/state/shell/composer'
import {useModerationOpts} from '#/state/queries/preferences'
@@ -45,6 +44,7 @@ import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export function PostThreadItem({
post,
record,
dataUpdatedAt,
treeView,
depth,
isHighlightedPost,
@@ -56,6 +56,7 @@ export function PostThreadItem({
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
dataUpdatedAt: number
treeView: boolean
depth: number
isHighlightedPost?: boolean
@@ -66,7 +67,7 @@ export function PostThreadItem({
onPostReply: () => void
}) {
const moderationOpts = useModerationOpts()
const postShadowed = usePostShadow(post)
const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo(
() =>
new RichTextAPI({
@@ -117,7 +118,7 @@ function PostThreadItemDeleted() {
)
}
let PostThreadItemLoaded = ({
function PostThreadItemLoaded({
post,
record,
richText,
@@ -143,12 +144,12 @@ let PostThreadItemLoaded = ({
showParentReplyLine?: boolean
hasPrecedingItem: boolean
onPostReply: () => void
}): React.ReactNode => {
}) {
const pal = usePalette('default')
const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
countLines(richText?.text) >= MAX_POST_LINES,
)
const styles = useStyles()
const hasEngagement = post.likeCount || post.repostCount
@@ -564,7 +565,6 @@ let PostThreadItemLoaded = ({
)
}
}
PostThreadItemLoaded = memo(PostThreadItemLoaded)
function PostOuterWrapper({
post,
@@ -636,14 +636,13 @@ function ExpandedPostDetails({
translatorUrl: string
}) {
const pal = usePalette('default')
const {_} = useLingui()
return (
<View style={[s.flexRow, s.mt2, s.mb10]}>
<Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
{needsTranslation && (
<>
<Text style={[pal.textLight, s.ml5, s.mr5]}></Text>
<Link href={translatorUrl} title={_(msg`Translate`)}>
<Link href={translatorUrl} title="Translate">
<Text style={pal.link}>
<Trans>Translate</Trans>
</Text>
+4 -2
View File
@@ -30,10 +30,12 @@ import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export function Post({
post,
dataUpdatedAt,
showReplyLine,
style,
}: {
post: AppBskyFeedDefs.PostView
dataUpdatedAt: number
showReplyLine?: boolean
style?: StyleProp<ViewStyle>
}) {
@@ -46,7 +48,7 @@ export function Post({
: undefined,
[post],
)
const postShadowed = usePostShadow(post)
const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo(
() =>
record
@@ -97,7 +99,7 @@ function PostInner({
const pal = usePalette('default')
const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
countLines(richText?.text) >= MAX_POST_LINES,
)
const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
+3
View File
@@ -76,6 +76,7 @@ let Feed = ({
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isError,
@@ -199,6 +200,7 @@ let Feed = ({
return (
<FeedSlice
slice={item}
dataUpdatedAt={dataUpdatedAt}
// we check for this before creating the feedItems array
moderationOpts={moderationOpts!}
/>
@@ -206,6 +208,7 @@ let Feed = ({
},
[
feed,
dataUpdatedAt,
error,
onPressTryAgain,
onPressRetryLoadMore,
+3 -6
View File
@@ -10,8 +10,6 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg as msgLingui} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors'
@@ -88,7 +86,6 @@ function FeedgenErrorMessage({
knownError: KnownError
}) {
const pal = usePalette('default')
const {_: _l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const msg = MESSAGES[knownError]
const [_, uri] = feedDesc.split('|')
@@ -103,8 +100,8 @@ function FeedgenErrorMessage({
const onRemoveFeed = React.useCallback(async () => {
openModal({
name: 'confirm',
title: _l(msgLingui`Remove feed`),
message: _l(msgLingui`Remove this feed from your saved feeds?`),
title: 'Remove feed',
message: 'Remove this feed from your saved feeds?',
async onPressConfirm() {
try {
await removeFeed({uri})
@@ -119,7 +116,7 @@ function FeedgenErrorMessage({
closeModal()
},
})
}, [openModal, closeModal, uri, removeFeed, _l])
}, [openModal, closeModal, uri, removeFeed])
return (
<View
+4 -2
View File
@@ -40,6 +40,7 @@ export function FeedItem({
record,
reason,
moderation,
dataUpdatedAt,
isThreadChild,
isThreadLastChild,
isThreadParent,
@@ -48,11 +49,12 @@ export function FeedItem({
record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
moderation: PostModeration
dataUpdatedAt: number
isThreadChild?: boolean
isThreadLastChild?: boolean
isThreadParent?: boolean
}) {
const postShadowed = usePostShadow(post)
const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo(
() =>
new RichTextAPI({
@@ -104,7 +106,7 @@ let FeedItemInner = ({
const pal = usePalette('default')
const {track} = useAnalytics()
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
countLines(richText.text) >= MAX_POST_LINES,
)
const href = useMemo(() => {
+6
View File
@@ -11,10 +11,12 @@ import {makeProfileLink} from 'lib/routes/links'
let FeedSlice = ({
slice,
dataUpdatedAt,
ignoreFilterFor,
moderationOpts,
}: {
slice: FeedPostSlice
dataUpdatedAt: number
ignoreFilterFor?: string
moderationOpts: ModerationOpts
}): React.ReactNode => {
@@ -42,6 +44,7 @@ let FeedSlice = ({
record={slice.items[0].record}
reason={slice.items[0].reason}
moderation={moderations[0]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
/>
@@ -51,6 +54,7 @@ let FeedSlice = ({
record={slice.items[1].record}
reason={slice.items[1].reason}
moderation={moderations[1]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)}
/>
@@ -61,6 +65,7 @@ let FeedSlice = ({
record={slice.items[last].record}
reason={slice.items[last].reason}
moderation={moderations[last]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)}
isThreadLastChild
@@ -78,6 +83,7 @@ let FeedSlice = ({
record={slice.items[i].record}
reason={slice.items[i].reason}
moderation={moderations[i]}
dataUpdatedAt={dataUpdatedAt}
isThreadParent={isThreadParentAt(slice.items, i)}
isThreadChild={isThreadChildAt(slice.items, i)}
isThreadLastChild={
+6 -1
View File
@@ -27,6 +27,7 @@ import {useSession} from '#/state/session'
export function ProfileCard({
testID,
profile: profileUnshadowed,
dataUpdatedAt,
noBg,
noBorder,
followers,
@@ -35,6 +36,7 @@ export function ProfileCard({
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
dataUpdatedAt: number
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
@@ -44,7 +46,7 @@ export function ProfileCard({
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const profile = useProfileShadow(profileUnshadowed)
const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
const moderationOpts = useModerationOpts()
if (!moderationOpts) {
return null
@@ -200,11 +202,13 @@ export function ProfileCardWithFollowBtn({
noBg,
noBorder,
followers,
dataUpdatedAt,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
dataUpdatedAt: number
}) {
const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did
@@ -220,6 +224,7 @@ export function ProfileCardWithFollowBtn({
? undefined
: profileShadow => <FollowButton profile={profileShadow} />
}
dataUpdatedAt={dataUpdatedAt}
/>
)
}
+8 -3
View File
@@ -20,6 +20,7 @@ export function ProfileFollowers({name}: {name: string}) {
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -28,7 +29,7 @@ export function ProfileFollowers({name}: {name: string}) {
isError,
error,
refetch,
} = useProfileFollowersQuery(resolvedDid)
} = useProfileFollowersQuery(resolvedDid?.did)
const followers = React.useMemo(() => {
if (data?.pages) {
@@ -57,9 +58,13 @@ export function ProfileFollowers({name}: {name: string}) {
const renderItem = React.useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => (
<ProfileCardWithFollowBtn key={item.did} profile={item} />
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
),
[],
[dataUpdatedAt],
)
if (isFetchingDid || !isFetched) {
+8 -3
View File
@@ -20,6 +20,7 @@ export function ProfileFollows({name}: {name: string}) {
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -28,7 +29,7 @@ export function ProfileFollows({name}: {name: string}) {
isError,
error,
refetch,
} = useProfileFollowsQuery(resolvedDid)
} = useProfileFollowsQuery(resolvedDid?.did)
const follows = React.useMemo(() => {
if (data?.pages) {
@@ -57,9 +58,13 @@ export function ProfileFollows({name}: {name: string}) {
const renderItem = React.useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => (
<ProfileCardWithFollowBtn key={item.did} profile={item} />
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
),
[],
[dataUpdatedAt],
)
if (isFetchingDid || !isFetched) {
+89 -105
View File
@@ -51,7 +51,6 @@ import {s, colors} from 'lib/styles'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {Shadow} from '#/state/cache/types'
import {useRequireAuth} from '#/state/session'
interface Props {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null
@@ -114,8 +113,7 @@ let ProfileHeaderLoaded = ({
}: LoadedProps): React.ReactNode => {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {currentAccount, hasSession} = useSession()
const requireAuth = useRequireAuth()
const {currentAccount} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
const {openLightbox} = useLightboxControls()
@@ -152,42 +150,38 @@ let ProfileHeaderLoaded = ({
}
}, [openLightbox, profile, moderation])
const onPressFollow = () => {
requireAuth(async () => {
try {
track('ProfileHeader:FollowButtonClicked')
await queueFollow()
Toast.show(
`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {error: String(e)})
Toast.show(`There was an issue! ${e.toString()}`)
}
const onPressFollow = async () => {
try {
track('ProfileHeader:FollowButtonClicked')
await queueFollow()
Toast.show(
`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {error: String(e)})
Toast.show(`There was an issue! ${e.toString()}`)
}
})
}
}
const onPressUnfollow = () => {
requireAuth(async () => {
try {
track('ProfileHeader:UnfollowButtonClicked')
await queueUnfollow()
Toast.show(
`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {error: String(e)})
Toast.show(`There was an issue! ${e.toString()}`)
}
const onPressUnfollow = async () => {
try {
track('ProfileHeader:UnfollowButtonClicked')
await queueUnfollow()
Toast.show(
`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {error: String(e)})
Toast.show(`There was an issue! ${e.toString()}`)
}
})
}
}
const onPressEditProfile = React.useCallback(() => {
@@ -242,10 +236,9 @@ let ProfileHeaderLoaded = ({
track('ProfileHeader:BlockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Block Account`),
message: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
),
title: 'Block Account',
message:
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
onPressConfirm: async () => {
try {
await queueBlock()
@@ -258,16 +251,15 @@ let ProfileHeaderLoaded = ({
}
},
})
}, [track, queueBlock, openModal, _])
}, [track, queueBlock, openModal])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
title: 'Unblock Account',
message:
'The account will be able to interact with you after unblocking.',
onPressConfirm: async () => {
try {
await queueUnblock()
@@ -280,7 +272,7 @@ let ProfileHeaderLoaded = ({
}
},
})
}, [track, queueUnblock, openModal, _])
}, [track, queueUnblock, openModal])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
@@ -298,7 +290,7 @@ let ProfileHeaderLoaded = ({
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: _(msg`Share`),
label: 'Share',
onPress: onPressShare,
icon: {
ios: {
@@ -309,75 +301,68 @@ let ProfileHeaderLoaded = ({
},
},
]
if (hasSession) {
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: _(msg`Add to Lists`),
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
})
if (!isMe) {
if (!profile.viewer?.blocking) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`),
onPress: profile.viewer?.muted
? onPressUnmuteAccount
: onPressMuteAccount,
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
})
}
if (!profile.viewer?.blockingByList) {
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: profile.viewer?.blocking
? _(msg`Unblock Account`)
: _(msg`Block Account`),
onPress: profile.viewer?.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
}
android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!profile.viewer?.blocking) {
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: _(msg`Report Account`),
onPress: onPressReportAccount,
testID: 'profileHeaderDropdownMuteBtn',
label: profile.viewer?.muted ? 'Unmute Account' : 'Mute Account',
onPress: profile.viewer?.muted
? onPressUnmuteAccount
: onPressMuteAccount,
icon: {
ios: {
name: 'exclamationmark.triangle',
name: 'speaker.slash',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
})
}
if (!profile.viewer?.blockingByList) {
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: profile.viewer?.blocking ? 'Unblock Account' : 'Block Account',
onPress: profile.viewer?.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
}
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
onPress: onPressReportAccount,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
})
}
return items
}, [
isMe,
hasSession,
profile.viewer?.muted,
profile.viewer?.blocking,
profile.viewer?.blockingByList,
@@ -388,7 +373,6 @@ let ProfileHeaderLoaded = ({
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
const blockHide =
@@ -430,7 +414,7 @@ let ProfileHeaderLoaded = ({
)
) : !profile.viewer?.blockedBy ? (
<>
{!isProfilePreview && hasSession && (
{!isProfilePreview && (
<TouchableOpacity
testID="suggestedFollowsBtn"
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
@@ -65,7 +65,7 @@ export function ProfileHeaderSuggestedFollows({
}
}, [active, animatedHeight, track])
const {isLoading, data} = useSuggestedFollowsByActorQuery({
const {isLoading, data, dataUpdatedAt} = useSuggestedFollowsByActorQuery({
did: actorDid,
})
@@ -127,7 +127,11 @@ export function ProfileHeaderSuggestedFollows({
</>
) : data ? (
data.suggestions.map(profile => (
<SuggestedFollow key={profile.did} profile={profile} />
<SuggestedFollow
key={profile.did}
profile={profile}
dataUpdatedAt={dataUpdatedAt}
/>
))
) : (
<View />
@@ -192,13 +196,15 @@ function SuggestedFollowSkeleton() {
function SuggestedFollow({
profile: profileUnshadowed,
dataUpdatedAt,
}: {
profile: AppBskyActorDefs.ProfileView
dataUpdatedAt: number
}) {
const {track} = useAnalytics()
const pal = usePalette('default')
const moderationOpts = useModerationOpts()
const profile = useProfileShadow(profileUnshadowed)
const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const onPressFollow = React.useCallback(async () => {
+1 -1
View File
@@ -19,7 +19,7 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
const items: DropdownItem[] = [
{
label: _(msg`Remove account`),
label: 'Remove account',
onPress: () => {
removeAccount(account)
Toast.show('Account removed from quick access')
+2 -3
View File
@@ -1,7 +1,6 @@
import React, {Component, ErrorInfo, ReactNode} from 'react'
import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views'
import {t} from '@lingui/macro'
interface Props {
children?: ReactNode
@@ -31,8 +30,8 @@ export class ErrorBoundary extends Component<Props, State> {
return (
<CenteredView style={{height: '100%', flex: 1}}>
<ErrorScreen
title={t`Oh no!`}
message={t`There was an unexpected issue in the application. Please let us know if this happened to you!`}
title="Oh no!"
message="There was an unexpected issue in the application. Please let us know if this happened to you!"
details={this.state.error.toString()}
/>
</CenteredView>
+1
View File
@@ -3,6 +3,7 @@ import {ago} from 'lib/strings/time'
import {useTickEveryMinute} from '#/state/shell'
// FIXME(dan): Figure out why the false positives
/* eslint-disable react/prop-types */
export function TimeElapsed({
timestamp,
+4 -11
View File
@@ -43,13 +43,7 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
const BLUR_AMOUNT = isWeb ? 5 : 100
export function DefaultAvatar({
type,
size,
}: {
type: UserAvatarType
size: number
}) {
function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) {
if (type === 'algo') {
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return (
@@ -214,7 +208,7 @@ export function EditableUserAvatar({
[
!isWeb && {
testID: 'changeAvatarCameraBtn',
label: _(msg`Camera`),
label: 'Camera',
icon: {
ios: {
name: 'camera',
@@ -238,7 +232,7 @@ export function EditableUserAvatar({
},
{
testID: 'changeAvatarLibraryBtn',
label: _(msg`Library`),
label: 'Library',
icon: {
ios: {
name: 'photo.on.rectangle.angled',
@@ -275,7 +269,7 @@ export function EditableUserAvatar({
},
!!avatar && {
testID: 'changeAvatarRemoveBtn',
label: _(msg`Remove`),
label: 'Remove',
icon: {
ios: {
name: 'trash',
@@ -293,7 +287,6 @@ export function EditableUserAvatar({
onSelectNewAvatar,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
+3 -4
View File
@@ -35,7 +35,7 @@ export function UserBanner({
[
!isWeb && {
testID: 'changeBannerCameraBtn',
label: _(msg`Camera`),
label: 'Camera',
icon: {
ios: {
name: 'camera',
@@ -57,7 +57,7 @@ export function UserBanner({
},
{
testID: 'changeBannerLibraryBtn',
label: _(msg`Library`),
label: 'Library',
icon: {
ios: {
name: 'photo.on.rectangle.angled',
@@ -86,7 +86,7 @@ export function UserBanner({
},
!!banner && {
testID: 'changeBannerRemoveBtn',
label: _(msg`Remove`),
label: 'Remove',
icon: {
ios: {
name: 'trash',
@@ -104,7 +104,6 @@ export function UserBanner({
onSelectNewBanner,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
+28 -32
View File
@@ -20,8 +20,6 @@ import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
export function PostDropdownBtn({
@@ -35,9 +33,8 @@ export function PostDropdownBtn({
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const {hasSession, currentAccount} = useSession()
const {currentAccount} = useSession()
const theme = useTheme()
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
@@ -94,7 +91,7 @@ export function PostDropdownBtn({
const dropdownItems: NativeDropdownItem[] = [
{
label: _(msg`Translate`),
label: 'Translate',
onPress() {
onOpenTranslate()
},
@@ -108,7 +105,7 @@ export function PostDropdownBtn({
},
},
{
label: _(msg`Copy post text`),
label: 'Copy post text',
onPress() {
onCopyPostText()
},
@@ -122,7 +119,7 @@ export function PostDropdownBtn({
},
},
{
label: _(msg`Share`),
label: 'Share',
onPress() {
const url = toShareUrl(href)
shareUrl(url)
@@ -136,11 +133,11 @@ export function PostDropdownBtn({
web: 'share',
},
},
hasSession && {
{
label: 'separator',
},
hasSession && {
label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`),
{
label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
onPress() {
onToggleThreadMute()
},
@@ -153,38 +150,37 @@ export function PostDropdownBtn({
web: 'comment-slash',
},
},
hasSession && {
{
label: 'separator',
},
!isAuthor &&
hasSession && {
label: _(msg`Report post`),
onPress() {
openModal({
name: 'report',
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
!isAuthor && {
label: 'Report post',
onPress() {
openModal({
name: 'report',
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
isAuthor && {
label: 'separator',
},
isAuthor && {
label: _(msg`Delete post`),
label: 'Delete post',
onPress() {
openModal({
name: 'confirm',
title: _(msg`Delete this post?`),
message: _(msg`Are you sure? This cannot be undone.`),
title: 'Delete this post?',
message: 'Are you sure? This can not be undone.',
onPressConfirm: onDeletePost,
})
},
+2 -8
View File
@@ -25,7 +25,6 @@ import {
} from '#/state/queries/post'
import {useComposerControls} from '#/state/shell/composer'
import {Shadow} from '#/state/cache/types'
import {useRequireAuth} from '#/state/session'
export function PostCtrls({
big,
@@ -47,7 +46,6 @@ export function PostCtrls({
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const requireAuth = useRequireAuth()
const defaultCtrlColor = React.useMemo(
() => ({
@@ -109,9 +107,7 @@ export function PostCtrls({
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={() => {
requireAuth(() => onPressReply())
}}
onPress={onPressReply}
accessibilityRole="button"
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
@@ -139,9 +135,7 @@ export function PostCtrls({
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={() => {
requireAuth(() => onPressToggleLike())
}}
onPress={onPressToggleLike}
accessibilityRole="button"
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
@@ -7,7 +7,6 @@ import {Text} from '../text/Text'
import {pluralize} from 'lib/strings/helpers'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {useRequireAuth} from '#/state/session'
interface Props {
isReposted: boolean
@@ -26,7 +25,6 @@ export const RepostButton = ({
}: Props) => {
const theme = useTheme()
const {openModal} = useModalControls()
const requireAuth = useRequireAuth()
const defaultControlColor = React.useMemo(
() => ({
@@ -47,9 +45,7 @@ export const RepostButton = ({
return (
<TouchableOpacity
testID="repostBtn"
onPress={() => {
requireAuth(() => onPressToggleRepostWrapper())
}}
onPress={onPressToggleRepostWrapper}
style={[styles.control, !big && styles.controlPad]}
accessibilityRole="button"
accessibilityLabel={`${
@@ -1,5 +1,5 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle, Pressable} from 'react-native'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {RepostIcon} from 'lib/icons'
import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
@@ -12,8 +12,6 @@ import {
import {EventStopper} from '../EventStopper'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useRequireAuth} from '#/state/session'
import {useSession} from '#/state/session'
interface Props {
isReposted: boolean
@@ -33,8 +31,6 @@ export const RepostButton = ({
}: Props) => {
const theme = useTheme()
const {_} = useLingui()
const {hasSession} = useSession()
const requireAuth = useRequireAuth()
const defaultControlColor = React.useMemo(
() => ({
@@ -45,7 +41,7 @@ export const RepostButton = ({
const dropdownItems: NativeDropdownItem[] = [
{
label: isReposted ? _(msg`Undo repost`) : _(msg`Repost`),
label: isReposted ? 'Undo repost' : 'Repost',
testID: 'repostDropdownRepostBtn',
icon: {
ios: {name: 'repeat'},
@@ -55,7 +51,7 @@ export const RepostButton = ({
onPress: onRepost,
},
{
label: _(msg`Quote post`),
label: 'Quote post',
testID: 'repostDropdownQuoteBtn',
icon: {
ios: {name: 'quote.bubble'},
@@ -66,46 +62,32 @@ export const RepostButton = ({
},
]
const inner = (
<View
style={[
styles.control,
!big && styles.controlPad,
(isReposted
? styles.reposted
: defaultControlColor) as StyleProp<ViewStyle>,
]}>
<RepostIcon strokeWidth={2.2} size={big ? 24 : 20} />
{typeof repostCount !== 'undefined' ? (
<Text
testID="repostCount"
type={isReposted ? 'md-bold' : 'md'}
style={styles.repostCount}>
{repostCount ?? 0}
</Text>
) : undefined}
</View>
)
return hasSession ? (
return (
<EventStopper>
<NativeDropdown
items={dropdownItems}
accessibilityLabel={_(msg`Repost or quote post`)}
accessibilityHint="">
{inner}
<View
style={[
styles.control,
!big && styles.controlPad,
(isReposted
? styles.reposted
: defaultControlColor) as StyleProp<ViewStyle>,
]}>
<RepostIcon strokeWidth={2.2} size={big ? 24 : 20} />
{typeof repostCount !== 'undefined' ? (
<Text
testID="repostCount"
type={isReposted ? 'md-bold' : 'md'}
style={styles.repostCount}>
{repostCount ?? 0}
</Text>
) : undefined}
</View>
</NativeDropdown>
</EventStopper>
) : (
<Pressable
accessibilityRole="button"
onPress={() => {
requireAuth(() => {})
}}
accessibilityLabel={_(msg`Repost or quote post`)}
accessibilityHint="">
{inner}
</Pressable>
)
}
+142 -144
View File
@@ -12,6 +12,7 @@ import {Button} from '../com/util/forms/Button'
import * as Toast from '../com/util/Toast'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from 'lib/routes/types'
import {useAnalytics} from 'lib/analytics/analytics'
@@ -31,111 +32,125 @@ import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {cleanError} from '#/lib/strings/errors'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
export function AppPasswords({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {screen} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
const {data: appPasswords, error} = useAppPasswordsQuery()
export const AppPasswords = withAuthRequired(
function AppPasswordsImpl({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {screen} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
const {data: appPasswords, error} = useAppPasswordsQuery()
useFocusEffect(
React.useCallback(() => {
screen('AppPasswords')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onAdd = React.useCallback(async () => {
openModal({name: 'add-app-password'})
}, [openModal])
if (error) {
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<ErrorScreen
title="Oops!"
message="There was an issue with fetching your app passwords"
details={cleanError(error)}
/>
</CenteredView>
useFocusEffect(
React.useCallback(() => {
screen('AppPasswords')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
}
// no app passwords (empty) state
if (appPasswords?.length === 0) {
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<AppPasswordsHeader />
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
<Trans>
You have not created any app passwords yet. You can create one by
pressing the button below.
</Trans>
</Text>
</View>
{!isTabletOrDesktop && <View style={styles.flex1} />}
<View
style={[
styles.btnContainer,
isTabletOrDesktop && styles.btnContainerDesktop,
]}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
</CenteredView>
)
}
const onAdd = React.useCallback(async () => {
openModal({name: 'add-app-password'})
}, [openModal])
if (appPasswords?.length) {
// has app passwords
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<AppPasswordsHeader />
<ScrollView
if (error) {
return (
<CenteredView
style={[
styles.scrollContainer,
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
!isTabletOrDesktop && styles.flex1,
]}>
{appPasswords.map((password, i) => (
<AppPassword
key={password.name}
testID={`appPassword-${i}`}
name={password.name}
createdAt={password.createdAt}
]}
testID="appPasswordsScreen">
<ErrorScreen
title="Oops!"
message="There was an issue with fetching your app passwords"
details={cleanError(error)}
/>
</CenteredView>
)
}
// no app passwords (empty) state
if (appPasswords?.length === 0) {
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<AppPasswordsHeader />
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
<Trans>
You have not created any app passwords yet. You can create one
by pressing the button below.
</Trans>
</Text>
</View>
{!isTabletOrDesktop && <View style={styles.flex1} />}
<View
style={[
styles.btnContainer,
isTabletOrDesktop && styles.btnContainerDesktop,
]}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
))}
{isTabletOrDesktop && (
<View style={[styles.btnContainer, styles.btnContainerDesktop]}>
</View>
</CenteredView>
)
}
if (appPasswords?.length) {
// has app passwords
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<AppPasswordsHeader />
<ScrollView
style={[
styles.scrollContainer,
pal.border,
!isTabletOrDesktop && styles.flex1,
]}>
{appPasswords.map((password, i) => (
<AppPassword
key={password.name}
testID={`appPassword-${i}`}
name={password.name}
createdAt={password.createdAt}
/>
))}
{isTabletOrDesktop && (
<View style={[styles.btnContainer, styles.btnContainerDesktop]}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
)}
</ScrollView>
{!isTabletOrDesktop && (
<View style={styles.btnContainer}>
<Button
testID="appPasswordBtn"
type="primary"
@@ -146,44 +161,31 @@ export function AppPasswords({}: Props) {
/>
</View>
)}
</ScrollView>
{!isTabletOrDesktop && (
<View style={styles.btnContainer}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
)}
</CenteredView>
)
}
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<ActivityIndicator />
</CenteredView>
)
}
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<ActivityIndicator />
</CenteredView>
)
}
},
)
function AppPasswordsHeader() {
const {isTabletOrDesktop} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
return (
<>
<ViewHeader title={_(msg`App Passwords`)} showOnDesktop />
<ViewHeader title="App Passwords" showOnDesktop />
<Text
type="sm"
style={[
@@ -218,16 +220,14 @@ function AppPassword({
const onDelete = React.useCallback(async () => {
openModal({
name: 'confirm',
title: _(msg`Delete app password`),
message: _(
msg`Are you sure you want to delete the app password "${name}"?`,
),
title: 'Delete App Password',
message: `Are you sure you want to delete the app password "${name}"?`,
async onPressConfirm() {
await deleteMutation.mutateAsync({name})
Toast.show('App password deleted')
},
})
}, [deleteMutation, openModal, name, _])
}, [deleteMutation, openModal, name])
const primaryLocale =
contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
@@ -245,17 +245,15 @@ function AppPassword({
{name}
</Text>
<Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}>
<Trans>
Created{' '}
{Intl.DateTimeFormat(primaryLocale, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(createdAt))}
</Trans>
Created{' '}
{Intl.DateTimeFormat(primaryLocale, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(createdAt))}
</Text>
</View>
<FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} />
+7 -12
View File
@@ -9,8 +9,6 @@ import {ScrollView} from 'view/com/util/Views'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {useSetMinimalShellMode} from '#/state/shell'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -18,7 +16,6 @@ type Props = NativeStackScreenProps<
>
export const CommunityGuidelinesScreen = (_props: Props) => {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
useFocusEffect(
@@ -29,18 +26,16 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
return (
<View>
<ViewHeader title={_(msg`Community Guidelines`)} />
<ViewHeader title="Community Guidelines" />
<ScrollView style={[s.hContentRegion, pal.view]}>
<View style={[s.p20]}>
<Text style={pal.text}>
<Trans>
The Community Guidelines have been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines"
/>
</Trans>
The Community Guidelines have been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines"
/>
</Text>
</View>
<View style={s.footerSpacer} />
+7 -12
View File
@@ -9,13 +9,10 @@ import {ScrollView} from 'view/com/util/Views'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {useSetMinimalShellMode} from '#/state/shell'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CopyrightPolicy'>
export const CopyrightPolicyScreen = (_props: Props) => {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
useFocusEffect(
@@ -26,18 +23,16 @@ export const CopyrightPolicyScreen = (_props: Props) => {
return (
<View>
<ViewHeader title={_(msg`Copyright Policy`)} />
<ViewHeader title="Copyright Policy" />
<ScrollView style={[s.hContentRegion, pal.view]}>
<View style={[s.p20]}>
<Text style={pal.text}>
<Trans>
The Copyright Policy has been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines"
/>
</Trans>
The Copyright Policy has been moved to{' '}
<TextLink
style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines"
/>
</Text>
</View>
<View style={s.footerSpacer} />
+48 -54
View File
@@ -2,6 +2,7 @@ import React from 'react'
import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {FAB} from 'view/com/util/fab/FAB'
import {Link} from 'view/com/util/Link'
@@ -33,7 +34,6 @@ import {
} from '#/state/queries/feed'
import {cleanError} from 'lib/strings/errors'
import {useComposerControls} from '#/state/shell/composer'
import {useSession} from '#/state/session'
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
@@ -87,7 +87,9 @@ type FlatlistSlice =
key: string
}
export function FeedsScreen(_props: Props) {
export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
_props: Props,
) {
const pal = usePalette('default')
const {openComposer} = useComposerControls()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
@@ -116,7 +118,6 @@ export function FeedsScreen(_props: Props) {
isPending: isSearchPending,
error: searchError,
} = useSearchPopularFeedsMutation()
const {hasSession} = useSession()
/**
* A search query is present. We may not have search results yet.
@@ -180,52 +181,50 @@ export function FeedsScreen(_props: Props) {
const items = React.useMemo(() => {
let slices: FlatlistSlice[] = []
if (hasSession) {
slices.push({
key: 'savedFeedsHeader',
type: 'savedFeedsHeader',
})
slices.push({
key: 'savedFeedsHeader',
type: 'savedFeedsHeader',
})
if (preferencesError) {
if (preferencesError) {
slices.push({
key: 'savedFeedsError',
type: 'error',
error: cleanError(preferencesError.toString()),
})
} else {
if (isPreferencesLoading || !preferences?.feeds?.saved) {
slices.push({
key: 'savedFeedsError',
type: 'error',
error: cleanError(preferencesError.toString()),
key: 'savedFeedsLoading',
type: 'savedFeedsLoading',
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
})
} else {
if (isPreferencesLoading || !preferences?.feeds?.saved) {
if (preferences?.feeds?.saved.length === 0) {
slices.push({
key: 'savedFeedsLoading',
type: 'savedFeedsLoading',
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
key: 'savedFeedNoResults',
type: 'savedFeedNoResults',
})
} else {
if (preferences?.feeds?.saved.length === 0) {
slices.push({
key: 'savedFeedNoResults',
type: 'savedFeedNoResults',
})
} else {
const {saved, pinned} = preferences.feeds
const {saved, pinned} = preferences.feeds
slices = slices.concat(
pinned.map(uri => ({
slices = slices.concat(
pinned.map(uri => ({
key: `savedFeed:${uri}`,
type: 'savedFeed',
feedUri: uri,
})),
)
slices = slices.concat(
saved
.filter(uri => !pinned.includes(uri))
.map(uri => ({
key: `savedFeed:${uri}`,
type: 'savedFeed',
feedUri: uri,
})),
)
slices = slices.concat(
saved
.filter(uri => !pinned.includes(uri))
.map(uri => ({
key: `savedFeed:${uri}`,
type: 'savedFeed',
feedUri: uri,
})),
)
}
)
}
}
}
@@ -307,7 +306,6 @@ export function FeedsScreen(_props: Props) {
return slices
}, [
hasSession,
preferences,
isPreferencesLoading,
preferencesError,
@@ -394,8 +392,7 @@ export function FeedsScreen(_props: Props) {
pal.view,
styles.header,
{
// This is first in the flatlist without a session -esb
marginTop: hasSession ? 16 : 0,
marginTop: 16,
paddingLeft: isMobile ? 12 : undefined,
paddingRight: 10,
paddingBottom: isMobile ? 6 : undefined,
@@ -434,7 +431,7 @@ export function FeedsScreen(_props: Props) {
return (
<FeedSourceCard
feedUri={item.feedUri}
showSaveBtn={hasSession}
showSaveBtn
showDescription
showLikes
/>
@@ -457,7 +454,6 @@ export function FeedsScreen(_props: Props) {
},
[
_,
hasSession,
isMobile,
pal,
query,
@@ -471,7 +467,7 @@ export function FeedsScreen(_props: Props) {
<View style={[pal.view, styles.container]}>
{isMobile && (
<ViewHeader
title={_(msg`Feeds`)}
title="Feeds"
canGoBack={false}
renderButton={renderHeaderBtn}
showBorder
@@ -500,19 +496,17 @@ export function FeedsScreen(_props: Props) {
desktopFixedHeight
/>
{hasSession && (
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
</View>
)
}
})
function SavedFeed({feedUri}: {feedUri: string}) {
const pal = usePalette('default')

Some files were not shown because too many files have changed in this diff Show More