Compare commits

..

63 Commits

Author SHA1 Message Date
Eric Bailey 7a4e1b8a3f Align dist 2023-11-28 21:43:19 -06:00
Eric Bailey 2053fece6f Just use package version 2023-11-28 21:34:53 -06:00
Eric Bailey 65d9d234fb Add build version 2023-11-28 17:39:50 -06:00
Eric Bailey c84b0b4a54 Align on release/dist 2023-11-28 17:35:12 -06:00
Eric Bailey 09bd795088 Replace invalid character 2023-11-28 15:04:54 -06:00
Eric Bailey ab05426f8b Clarify 2023-11-28 14:46:17 -06:00
Eric Bailey 76640d8b9e Remove scripts 2023-11-28 14:36:56 -06:00
Eric Bailey 28fadb1a90 Set env in Expo 2023-11-28 14:30:58 -06:00
Eric Bailey 11bb077045 Fix typo 2023-11-28 14:27:47 -06:00
Eric Bailey 0880295e21 Remove unecessary config 2023-11-28 14:26:07 -06:00
Eric Bailey 5138df038d Remove debug 2023-11-28 14:20:52 -06:00
Eric Bailey 2524b38295 Fix typo 2023-11-28 14:20:35 -06:00
Eric Bailey 7d191aa7b7 Add TF build 2023-11-28 14:17:06 -06:00
Eric Bailey a0d984ad38 Remove debug 2023-11-28 14:16:50 -06:00
Eric Bailey 69bf8a59b6 Ok actually enable in dev 2023-11-28 14:16:06 -06:00
Eric Bailey 8cb12b3e17 Enable sentry 2023-11-28 14:08:12 -06:00
Eric Bailey 6406627c39 Update build profiles, sentry config 2023-11-28 14:06:16 -06:00
Eric Bailey bb9d340d42 Fix pinned feeds mutation issue (#2016) 2023-11-28 12:50:41 -06:00
Eric Bailey 0b2c85b967 Fix pinned feeds re-ordering (#2014) 2023-11-28 09:04:15 -08:00
Eric Bailey bdabfa9d38 Guard against following tab stub being used un-authed (#2013) 2023-11-28 08:44:25 -08:00
Eric Bailey cf1b0b1881 Handle other feed auth response (#2012) 2023-11-28 08:43:25 -08:00
Paul Frazee 60886b76c8 Bump ios build number 2023-11-27 17:46:13 -08:00
Eric Bailey 17a183514a Ensure we use following feed if merge is disabled (#2005) 2023-11-27 17:42:02 -08:00
Paul Frazee f580d4daf0 Restore post-thread caching behaviors (react-query refactor) (#2010)
* Rework resolve-did and resolve-uri queries to be smarter about cache reuse

* Precache handle resolutions

* Remove old unused code

* Load placeholder threads from the post-feed and notifications-feed queries

* Remove logs

* Fix bad ref

* Add loading spinners to the cache-loading thread view

* Scroll replies into view when loading threads

* Add caching within a thread

* Fix: dont show bottom border when the child spinner is active
2023-11-27 17:41:30 -08:00
Eric Bailey d4714baf13 [PWI] Home (#2007)
* Fix up home page

* Fix spacing

* Remove unused default tab

* Update logic
2023-11-27 17:17:09 -08:00
Eric Bailey a741a201b9 Don't check unread if session doesn't exist (#2008) 2023-11-27 16:40:20 -08:00
Eric Bailey 26bbe07068 Add more debugging to auth (#2009) 2023-11-27 16:39:53 -08:00
Eric Bailey 5fd2d36273 Filter out blocked posts from thread view (#2006) 2023-11-27 12:41:53 -08:00
Paul Frazee cd43adf698 Two fixes to react-key generation (#2004)
* Fix missing react keys in ListMembers

* Fix react key construction for the posts feed
2023-11-27 12:30:41 -08:00
Eric Bailey 1dc017367e Make sure logger serializes errors (#2001) 2023-11-27 10:28:21 -08:00
Eric Bailey 6758755316 Nav home after switch accounts (#2002) 2023-11-27 10:27:38 -08:00
Eric Bailey 828e53d533 Fix soft reset scroll on profile (#1998) 2023-11-27 09:43:05 -06:00
Ansh 0079030244 create build v1.57 (#2000) 2023-11-26 13:46:31 -08:00
Eric Bailey 08355e5166 Raise levels of some logs during pre-release (#1994) 2023-11-26 13:44:30 -08:00
Eric Bailey 4a59227459 Fix translation strings (#1995) 2023-11-26 13:44:17 -08:00
Eric Bailey 3007c1dc8a Fix bad references in account switcher (#1999) 2023-11-26 13:44:08 -08:00
Eric Bailey 486fb72798 Fix double border, improve load state (#1997) 2023-11-26 13:43:49 -08:00
Eric Bailey e9a11114d3 [PWI] Clarify different ways of clearing current account/logout (#1991)
* Clarify different ways of clearing current account/logout

* Reorder log
2023-11-24 17:41:42 -06:00
Eric Bailey 20b699a008 Fall back for non-public feeds (#1988) 2023-11-24 17:37:28 -06:00
Eric Bailey 1bcbc0cf2a Fix onboarding web layout (#1990) 2023-11-24 16:40:07 -06:00
dan f2d164ec23 PWI: Refactor Shell (#1989)
* Vendor createNativeStackNavigator for further tweaks

* Completely disable withAuthRequired

* Render LoggedOut for protected routes

* Move web shell into the navigator

* Simplify the logic

* Add login modal

* Delete withAuthRequired

* Reset app state on session change

* Move TS suppression
2023-11-24 22:31:33 +00:00
Ansh 4b59a21cac Allow dynamic locale imports for web (#1971)
* allow dynamic locale imports for web

* remove unnecessary file
2023-11-24 12:35:25 -08:00
dan 43c8fb6f03 Fix missing effect cleanup (#1986) 2023-11-24 19:22:00 +00:00
dan 7ccbd14d5f Show feeds in PWI (#1984) 2023-11-23 19:43:23 +00:00
dan 4272d291a9 PWI: Profile (#1982)
* PWI: Profile

* Show replies conditionally

* Dismiss modals on auth action
2023-11-23 00:30:49 +00:00
Eric Bailey edf3114e47 Fixes 1731, compare URLs case-insensitive (#1980) 2023-11-22 17:20:35 -06:00
Eric Bailey ec819f06ce [PWI] Clean up logged out screen (#1978)
* Clean up logged out screen

* Better responsive styles
2023-11-22 16:53:25 -06:00
Eric Bailey 95f8bd8ddb [PWI] Misc (#1977)
* PostDropdownBtn

* Hide reply prompt

* Hide FABs
2023-11-22 16:53:11 -06:00
dan 08333002cc Implement "scroll to top" for profile tabs (#1973)
* Hook up scroll to top handlers

* Scroll and invalidate Feeds/Lists

* Fix index calc due to conditional tabs

* Reorder lines for clarity
2023-11-22 04:25:11 +00:00
Eric Bailey 3de1d556a9 [PWI] Feeds (#1970)
* Feeds page

* Hide save buttons on Feeds page

* ProfileFeed
2023-11-21 17:36:29 -06:00
Eric Bailey 8da10a5edc Search page (#1969) 2023-11-21 17:35:53 -06:00
Eric Bailey 47d2d3cbf2 [PWI] Shell (#1967)
* Sidebars

* Bottom bar

* Drawer

* Translate

* Spacing fix

* Fix responsive regression

* Fix nit
2023-11-21 16:58:13 -06:00
dan 4c4ba553bd Shadow refactoring and improvements (#1959)
* Make shadow a type-only concept

* Prevent unnecessary init state recalc

* Use derived state instead of effects

* Batch emitter updates

* Use object first seen time instead of dataUpdatedAt

* Stop threading dataUpdatedAt through

* Use same value consistently
2023-11-21 22:42:30 +00:00
Eric Bailey f18b9b32b0 PWI Base (#1964)
* Base work for public view

* Make default moderation settings more restrictive

* Fix type

* Handle showing sign-in on authed actions

* Fix hoc logic

* Simplify prefs logic

* Remove duplicate method

* Add todo

* Clean up RepostButton.web

* Fix x button color

* Add todo

* Retain existing label prefs for now, use separate logged out settings

* Clean up useAuthedMethod, rename to useRequireAuth

* Add todos

* Move dismiss logic to withAuthRequired

* Ooops add web

* Block public view in prod

* Add todo

* Fix bad import
2023-11-21 10:57:34 -06:00
Eric Bailey 71b59021b9 Add dev-only button to clear storage (#1965)
* Add dev-only button to clear storage

* Add legacy storage clearing too

* Use try/catch
2023-11-21 10:16:22 -06:00
Eric Bailey 3a21c02b8a Re-wrap forgot pw success in layout (#1966) 2023-11-21 10:04:23 -06:00
Ansh c5b6f88e9a Hindi Internationalization (#1914)
* get basic hindi support to work

* get web app language switcher in

* Refactor i18n implementation and remove unused
code

* add missing strings

* add dropdowns and modals missing strings

* complete all hindi translations

* fix merge conflicts

* fix legeacy persisted state

* fix data in RecommendedFeeds

* fix lint
2023-11-20 13:29:27 -08:00
Eric Bailey 019aae5f01 Improve dedupe logic on search suggestions (#1958) 2023-11-17 12:15:14 -08:00
dan 7c51a3931a Optimize notifications rendering (#1957) 2023-11-17 17:51:44 +00:00
dan d1c14940fb Add missing type (#1955) 2023-11-17 09:47:36 -08:00
Eric Bailey 0dfc039a47 Fix some key errors (#1951) 2023-11-17 11:34:27 -06:00
dan c858b58307 Do less work (#1953) 2023-11-17 09:31:26 -08:00
Eric Bailey 9c8a1b8a31 Fix some type errors (#1952)
* Fix some low-hanging type errors

* Override scrollRef types on profile
2023-11-17 11:08:45 -06:00
138 changed files with 9774 additions and 4923 deletions
@@ -1,3 +1,5 @@
import {it, describe, expect} from '@jest/globals'
import {
linkRequiresWarning,
isPossiblyAUrl,
@@ -6,6 +8,7 @@ import {
describe('linkRequiresWarning', () => {
type Case = [string, string, boolean]
const cases: Case[] = [
['http://example.com', 'http://example.com', false],
['http://example.com', 'example.com', false],
@@ -64,6 +67,10 @@ 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],
+39 -5
View File
@@ -1,12 +1,41 @@
const pkg = require('./package.json')
module.exports = function () {
const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN
/**
* 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
return {
expo: {
version: VERSION,
name: 'Bluesky',
slug: 'bluesky',
scheme: 'bluesky',
owner: 'blueskysocial',
version: '1.56.0',
runtimeVersion: {
policy: 'appVersion',
},
@@ -19,7 +48,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
ios: {
buildNumber: '3',
buildNumber: IOS_BUILD_NUMBER,
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -43,7 +72,7 @@ module.exports = function () {
backgroundColor: '#ffffff',
},
android: {
versionCode: 46,
versionCode: ANDROID_VERSION_CODE,
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
@@ -74,7 +103,7 @@ module.exports = function () {
},
plugins: [
'expo-localization',
hasSentryToken && 'sentry-expo',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
[
'expo-build-properties',
{
@@ -100,11 +129,16 @@ 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}`,
},
},
],
+13 -22
View File
@@ -4,42 +4,33 @@
"promptToConfigurePushNotifications": false
},
"build": {
"base": {
"node": "18.18.2"
},
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"ios": {
"simulator": true,
"resourceClass": "m-large"
},
"channel": "development"
},
"development-device": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-large"
},
"channel": "development"
"resourceClass": "large"
}
},
"preview": {
"extends": "base",
"distribution": "internal",
"channel": "preview",
"ios": {
"resourceClass": "m-large"
},
"channel": "preview"
"resourceClass": "large"
}
},
"production": {
"extends": "base",
"ios": {
"resourceClass": "m-large"
"resourceClass": "large"
},
"channel": "production"
},
"dev-android-apk": {
"developmentClient": true,
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},
"submit": {
+3
View File
@@ -2,6 +2,9 @@
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', () =>
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.56.0",
"version": "1.57.0",
"private": true,
"scripts": {
"prepare": "is-ci || husky install",
@@ -28,7 +28,6 @@
"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"
},
+25 -22
View File
@@ -28,6 +28,8 @@ 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,18 +37,13 @@ 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} = useSession()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
// init
@@ -73,21 +70,25 @@ function InnerApp() {
*/
return (
<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>
<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>
)
}
@@ -115,7 +116,9 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+25 -22
View File
@@ -16,15 +16,14 @@ 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,
@@ -36,7 +35,7 @@ import * as persisted from '#/state/persisted'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad} = useSession()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
@@ -44,8 +43,6 @@ function InnerApp() {
useEffect(() => {
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
@@ -61,21 +58,25 @@ function InnerApp() {
*/
return (
<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>
<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>
)
}
@@ -103,7 +104,9 @@ function App() {
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
<I18nProvider>
<InnerApp />
</I18nProvider>
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
+25 -23
View File
@@ -9,7 +9,6 @@ import {
DefaultTheme,
DarkTheme,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {
BottomTabBarProps,
createBottomTabNavigator,
@@ -69,16 +68,18 @@ 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 = createNativeStackNavigator<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigator<NotificationsTabNavigatorParams>()
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigator<FlatNavigatorParams>()
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
@@ -97,37 +98,37 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists')}}
options={{title: title('Lists'), requireAuth: true}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation')}}
options={{title: title('Moderation'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists')}}
options={{title: title('Moderation Lists'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts')}}
options={{title: title('Muted Accounts'), requireAuth: true}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
options={{title: title('Blocked Accounts'), requireAuth: true}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
options={{title: title('Settings')}}
options={{title: title('Settings'), requireAuth: true}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings')}}
options={{title: title('Language Settings'), requireAuth: true}}
/>
<Stack.Screen
name="Profile"
@@ -154,7 +155,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title('List')}}
options={{title: title('List'), requireAuth: true}}
/>
<Stack.Screen
name="PostThread"
@@ -184,12 +185,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title('Debug')}}
options={{title: title('Debug'), requireAuth: true}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
options={{title: title('Log')}}
options={{title: title('Log'), requireAuth: true}}
/>
<Stack.Screen
name="Support"
@@ -219,22 +220,22 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswords}
options={{title: title('App Passwords')}}
options={{title: title('App Passwords'), requireAuth: true}}
/>
<Stack.Screen
name="SavedFeeds"
getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds')}}
options={{title: title('Edit My Feeds'), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences')}}
options={{title: title('Home Feed Preferences'), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences')}}
options={{title: title('Threads Preferences'), requireAuth: true}}
/>
</>
)
@@ -339,6 +340,7 @@ function NotificationsTabNavigator() {
<NotificationsTab.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{requireAuth: true}}
/>
{commonScreens(NotificationsTab as typeof HomeTab)}
</NotificationsTab.Navigator>
@@ -357,8 +359,8 @@ function MyProfileTabNavigator() {
contentStyle,
}}>
<MyProfileTab.Screen
name="MyProfile"
// @ts-ignore // TODO: fix this broken type in ProfileScreen
name="MyProfile"
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
@@ -405,7 +407,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
options={{title: title('Notifications'), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
+3 -2
View File
@@ -19,8 +19,9 @@ export class FeedViewPostsSlice {
constructor(public items: FeedViewPost[] = []) {}
get _reactKey() {
return `slice-${this.items[0].post.uri}-${
this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
const rootItem = this.isFlattenedReply ? this.items[1] : this.items[0]
return `slice-${rootItem.post.uri}-${
rootItem.reason?.indexedAt || rootItem.post.indexedAt
}`
}
+1
View File
@@ -0,0 +1 @@
export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
+2
View File
@@ -0,0 +1,2 @@
// @ts-ignore
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
+14 -2
View File
@@ -1,4 +1,8 @@
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'
@@ -8,21 +12,29 @@ 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 {
await selectAccount(acct)
closeAllActiveElements()
navigation.navigate(isWeb ? 'Home' : 'HomeTab')
await selectAccount(acct)
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],
[
track,
clearCurrentAccount,
selectAccount,
closeAllActiveElements,
navigation,
],
)
return {onPressSwitchAccount}
+3 -3
View File
@@ -3,6 +3,7 @@ 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()
@@ -11,9 +12,8 @@ export function useOTAUpdate() {
const showUpdatePopup = useCallback(() => {
openModal({
name: 'confirm',
title: 'Update Available',
message:
'A new version of the app is available. Please update to continue using the app.',
title: t`Update Available`,
message: t`A new version of the app is available. Please update to continue using the app.`,
onPressConfirm: async () => {
Updates.reloadAsync().catch(err => {
throw err
+40 -2
View File
@@ -1,8 +1,46 @@
/**
* 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
environment: __DEV__ ? 'development' : 'production', // Set the environment
enableInExpoDevelopment: true,
environment: buildChannel,
dist,
release,
})
+20 -5
View File
@@ -168,8 +168,15 @@ 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)
@@ -177,7 +184,9 @@ export function linkRequiresWarning(uri: string, label: string) {
return true
}
if (urip.hostname === 'bsky.app') {
const host = urip.hostname.toLowerCase()
if (host === 'bsky.app') {
// if this is a link to internal content,
// warn if it represents itself as a URL to another app
if (
@@ -194,20 +203,26 @@ export function linkRequiresWarning(uri: string, label: string) {
if (!labelDomain) {
return true
}
return labelDomain !== urip.hostname
return labelDomain !== host
}
}
function labelToDomain(label: string): string | undefined {
/**
* 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 {
// any spaces just immediately consider the label a non-url
if (/\s/.test(label)) {
return undefined
}
try {
return new URL(label).hostname
return new URL(label).hostname.toLowerCase()
} catch {}
try {
return new URL('https://' + label).hostname
return new URL('https://' + label).hostname.toLowerCase()
} catch {}
return undefined
}
+21 -3
View File
@@ -1,4 +1,8 @@
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',
@@ -14,7 +18,21 @@ export const defaultLocale = 'en'
* @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)
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])
}
+29
View File
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,9 @@
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,6 +4,16 @@ 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,6 +222,26 @@ 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})
+20 -10
View File
@@ -90,6 +90,16 @@ 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
*/
@@ -100,7 +110,8 @@ export const consoleTransport: Transport = (
timestamp,
) => {
const extra = Object.keys(metadata).length
? ' ' + JSON.stringify(metadata, null, ' ')
? // don't prepareMetadata here, in dev we want the stack trace
' ' + JSON.stringify(metadata, null, ' ')
: ''
const log = {
[LogLevel.Debug]: console.debug,
@@ -119,6 +130,8 @@ export const sentryTransport: Transport = (
{type, tags, ...metadata},
timestamp,
) => {
const meta = prepareMetadata(metadata)
/**
* If a string, report a breadcrumb
*/
@@ -135,7 +148,7 @@ export const sentryTransport: Transport = (
Sentry.addBreadcrumb({
message,
data: metadata,
data: meta,
type: type || 'default',
level: severity,
timestamp: timestamp / 1000, // Sentry expects seconds
@@ -155,7 +168,7 @@ export const sentryTransport: Transport = (
Sentry.captureMessage(message, {
level: messageLevel,
tags,
extra: metadata,
extra: meta,
})
}
} else {
@@ -164,7 +177,7 @@ export const sentryTransport: Transport = (
*/
Sentry.captureException(message, {
tags,
extra: metadata,
extra: meta,
})
}
}
@@ -275,16 +288,13 @@ 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)
/**
* Uncomment this to test Sentry in dev
* Comment this out to disable Sentry transport in dev
*/
// logger.addTransport(sentryTransport);
logger.addTransport(sentryTransport)
} else if (env.IS_PROD) {
// logger.addTransport(sentryTransport)
logger.addTransport(sentryTransport)
}
+39 -29
View File
@@ -1,7 +1,8 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import {useEffect, useState, useMemo, useCallback} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {Shadow} from './types'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
@@ -21,15 +22,36 @@ 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 [state, setState] = useState<CacheEntry>({
ts: Date.now(),
const postSeenTS = getFirstSeenTS(post)
const [state, setState] = useState<CacheEntry>(() => ({
ts: postSeenTS,
value: fromPost(post),
})
const firstRun = useRef(true)
}))
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 onUpdate = useCallback(
(value: Partial<PostShadow>) => {
@@ -46,28 +68,17 @@ export function usePostShadow(
}
}, [post.uri, onUpdate])
// 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}
return useMemo(() => {
return state.ts > postSeenTS
? mergeShadow(post, state.value)
: castAsShadow(post)
}, [post, state, postSeenTS])
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
emitter.emit(uri, value)
}
export function isPostShadowed(
v: AppBskyFeedDefs.PostView | Shadow<AppBskyFeedDefs.PostView>,
): v is Shadow<AppBskyFeedDefs.PostView> {
return 'isShadowed' in v && !!v.isShadowed
batchedUpdates(() => {
emitter.emit(uri, value)
})
}
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
@@ -87,7 +98,7 @@ function mergeShadow(
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return {
return castAsShadow({
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
@@ -96,6 +107,5 @@ function mergeShadow(
like: shadow.likeUri,
repost: shadow.repostUri,
},
isShadowed: true,
}
})
}
+40 -32
View File
@@ -1,7 +1,8 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import {useEffect, useState, useMemo, useCallback} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {Shadow} from './types'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {Shadow, castAsShadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
@@ -22,15 +23,34 @@ type ProfileView =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed
export function useProfileShadow(
profile: ProfileView,
ifAfterTS: number,
): Shadow<ProfileView> {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
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,
value: fromProfile(profile),
})
const firstRun = useRef(true)
}))
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 onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
@@ -47,31 +67,20 @@ export function useProfileShadow(
}
}, [profile.did, onUpdate])
// 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}
return useMemo(() => {
return state.ts > profileSeenTS
? mergeShadow(profile, state.value)
: castAsShadow(profile)
}, [profile, state, profileSeenTS])
}
export function updateProfileShadow(
uri: string,
value: Partial<ProfileShadow>,
) {
emitter.emit(uri, value)
}
export function isProfileShadowed<T extends ProfileView>(
v: T | Shadow<T>,
): v is Shadow<T> {
return 'isShadowed' in v && !!v.isShadowed
batchedUpdates(() => {
emitter.emit(uri, value)
})
}
function fromProfile(profile: ProfileView): ProfileShadow {
@@ -86,7 +95,7 @@ function mergeShadow(
profile: ProfileView,
shadow: ProfileShadow,
): Shadow<ProfileView> {
return {
return castAsShadow({
...profile,
viewer: {
...(profile.viewer || {}),
@@ -94,6 +103,5 @@ function mergeShadow(
muted: shadow.muted,
blocking: shadow.blockingUri,
},
isShadowed: true,
}
})
}
+7 -1
View File
@@ -1 +1,7 @@
export type Shadow<T> = T & {isShadowed: true}
// 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>
}
+3 -2
View File
@@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
* the Provider.
*/
export async function init() {
logger.debug('persisted state: initializing')
logger.info('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage
@@ -28,11 +28,12 @@ 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 failured, but we can still continue in memory
// AsyncStorage failure, but we can still continue in memory
return defaults
}
}
+48 -4
View File
@@ -94,6 +94,8 @@ 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 ||
@@ -114,20 +116,52 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists.
*/
export async function migrate() {
logger.debug('persisted state: migrate')
logger.info('persisted state: migrate')
try {
const rawLegacyData = await AsyncStorage.getItem(
DEPRECATED_ROOT_STATE_STORAGE_KEY,
)
const alreadyMigrated = Boolean(await read())
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})
}
if (!alreadyMigrated && rawLegacyData) {
logger.debug('persisted state: migrating legacy storage')
logger.info('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
await write(newData)
logger.debug('persisted state: migrated legacy storage')
// track successful migrations
logger.log('persisted state: migrated legacy storage')
} else {
// track successful migrations
logger.log('persisted state: no migration needed')
}
} catch (e) {
logger.error('persisted state: error migrating legacy storage', {
@@ -135,3 +169,13 @@ 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,6 +30,7 @@ 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
@@ -58,6 +59,7 @@ 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,6 +1,7 @@
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'
@@ -16,3 +17,11 @@ 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,6 +11,7 @@ type ApiContext = {
toggleContentLanguage: (code2: string) => void
togglePostLanguage: (code2: string) => void
savePostLanguageToHistory: () => void
setAppLanguage: (code2: string) => void
}
const stateContext = React.createContext<StateContext>(
@@ -22,6 +23,7 @@ const apiContext = React.createContext<ApiContext>({
toggleContentLanguage: (_: string) => {},
togglePostLanguage: (_: string) => {},
savePostLanguageToHistory: () => {},
setAppLanguage: (_: string) => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -104,6 +106,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.slice(0, 6),
}))
},
setAppLanguage(code2: string) {
setStateWrapped(s => ({...s, appLanguage: code2}))
},
}),
[state, setStateWrapped],
)
+33 -8
View File
@@ -160,6 +160,38 @@ 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() {
@@ -220,7 +252,6 @@ 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
@@ -267,13 +298,7 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] {
}
fetchFeedInfo()
}, [
queryClient,
setTabs,
preferences?.feeds?.pinned,
// ensure we react to re-ordering
pinnedFeedsKey,
])
}, [queryClient, setTabs, preferences?.feeds?.pinned])
return tabs
}
+38 -1
View File
@@ -7,11 +7,18 @@ import {
BskyAgent,
} from '@atproto/api'
import chunk from 'lodash.chunk'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
useQueryClient,
QueryClient,
} 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
@@ -48,6 +55,7 @@ export interface FeedPage {
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const enabled = opts?.enabled !== false
@@ -80,6 +88,9 @@ 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
}
}
}
@@ -99,6 +110,32 @@ 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[] {
+5 -1
View File
@@ -70,8 +70,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
async checkUnread() {
const agent = getAgent()
if (!agent.session) return
// count
const res = await getAgent().listNotifications({limit: 40})
const res = await agent.listNotifications({limit: 40})
const filtered = res.data.notifications.filter(
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
)
+39 -3
View File
@@ -1,6 +1,12 @@
import {useCallback, useMemo} from 'react'
import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {
useInfiniteQuery,
InfiniteData,
QueryKey,
QueryClient,
useQueryClient,
} from '@tanstack/react-query'
import {getAgent} from '../session'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
@@ -14,6 +20,7 @@ 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 =
@@ -66,6 +73,7 @@ export function usePostFeedQuery(
params?: FeedParams,
opts?: {enabled?: boolean},
) {
const queryClient = useQueryClient()
const feedTuners = useFeedTuners(feedDesc)
const enabled = opts?.enabled !== false
const moderationOpts = useModerationOpts()
@@ -136,11 +144,12 @@ export function usePostFeedQuery(
staleTime: STALE.INFINITY,
queryKey: RQKEY(feedDesc, params),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
console.log('fetch', feedDesc, pageParam)
logger.debug('usePostFeedQuery', {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,
@@ -152,7 +161,6 @@ 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 (
@@ -180,3 +188,31 @@ 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
}
+148 -5
View File
@@ -3,11 +3,17 @@ import {
AppBskyFeedPost,
AppBskyFeedGetPostThread,
} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {useQuery, useQueryClient, QueryClient} 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']
@@ -18,6 +24,8 @@ export interface ThreadCtx {
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
isParentLoading?: boolean
isChildLoading?: boolean
}
export type ThreadPost = {
@@ -58,17 +66,44 @@ 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) {
return responseToThreadNodes(res.data.thread)
const nodes = responseToThreadNodes(res.data.thread)
precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
return nodes
}
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
},
})
}
@@ -151,9 +186,10 @@ function responseToThreadNodes(
: undefined,
replies:
node.replies?.length && direction !== 'up'
? node.replies.map(reply =>
responseToThreadNodes(reply, depth + 1, 'down'),
)
? node.replies
.map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
// do not show blocked posts in replies
.filter(node => node.type !== 'blocked')
: undefined,
viewer: node.viewer,
ctx: {
@@ -177,3 +213,110 @@ 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,6 +2,7 @@ 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'] =
{
@@ -25,3 +26,26 @@ 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)
}
+72 -77
View File
@@ -1,11 +1,6 @@
import {useEffect, useState} from 'react'
import {useMemo} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {
LabelPreference,
BskyFeedViewPreference,
ModerationOpts,
} from '@atproto/api'
import isEqual from 'lodash.isequal'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
@@ -20,6 +15,7 @@ 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'
@@ -28,84 +24,83 @@ export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/const'
export const usePreferencesQueryKey = ['getPreferences']
export const preferencesQueryKey = ['getPreferences']
export function usePreferencesQuery() {
const {hasSession} = useSession()
return useQuery({
enabled: hasSession,
staleTime: STALE.MINUTES.ONE,
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
queryFn: async () => {
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,
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
}
return preferences
},
})
}
export function useModerationOpts() {
const {currentAccount} = useSession()
const [opts, setOpts] = useState<ModerationOpts | undefined>()
const prefs = usePreferencesQuery()
useEffect(() => {
const opts = useMemo(() => {
if (!prefs.data) {
return
}
// only update this hook when the moderation options change
const newOpts = getModerationOpts({
return getModerationOpts({
userDid: currentAccount?.did || '',
preferences: prefs.data,
})
if (!isEqual(opts, newOpts)) {
setOpts(newOpts)
}
}, [prefs.data, currentAccount, opts, setOpts])
}, [currentAccount?.did, prefs.data])
return opts
}
@@ -117,7 +112,7 @@ export function useClearPreferencesMutation() {
await getAgent().app.bsky.actor.putPreferences({preferences: []})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -135,7 +130,7 @@ export function usePreferencesSetContentLabelMutation() {
await getAgent().setContentLabelPref(labelGroup, visibility)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -149,7 +144,7 @@ export function usePreferencesSetAdultContentMutation() {
await getAgent().setAdultContentEnabled(enabled)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -163,7 +158,7 @@ export function usePreferencesSetBirthDateMutation() {
await getAgent().setPersonalDetails({birthDate})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -177,7 +172,7 @@ export function useSetFeedViewPreferencesMutation() {
await getAgent().setFeedViewPrefs('home', prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -191,7 +186,7 @@ export function useSetThreadViewPreferencesMutation() {
await getAgent().setThreadViewPrefs(prefs)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -209,7 +204,7 @@ export function useSetSaveFeedsMutation() {
await getAgent().setSavedFeeds(saved, pinned)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -224,7 +219,7 @@ export function useSaveFeedMutation() {
track('CustomFeed:Save')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -239,7 +234,7 @@ export function useRemoveFeedMutation() {
track('CustomFeed:Unsave')
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -254,7 +249,7 @@ export function usePinFeedMutation() {
track('CustomFeed:Pin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -269,7 +264,7 @@ export function useUnpinFeedMutation() {
track('CustomFeed:Unpin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: usePreferencesQueryKey,
queryKey: preferencesQueryKey,
})
},
})
@@ -34,6 +34,24 @@ 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',
+4 -1
View File
@@ -43,7 +43,10 @@ export type UsePreferencesQueryResponse = Omit<
}
}
export type ThreadViewPreferences = Omit<BskyThreadViewPreference, 'sort'> & {
export type ThreadViewPreferences = Pick<
BskyThreadViewPreference,
'prioritizeFollowedUsers'
> & {
sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string
lab_treeViewEnabled?: boolean
}
+67 -18
View File
@@ -1,27 +1,76 @@
import {useQuery} from '@tanstack/react-query'
import {AtUri} from '@atproto/api'
import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query'
import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
import {ThreadNode} from './post-thread'
export const RQKEY = (uri: string) => ['resolved-uri', uri]
export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle]
export function useResolveUriQuery(uri: string | undefined) {
return useQuery<{uri: string; did: string}, Error>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(uri || ''),
async queryFn() {
const urip = new AtUri(uri || '')
if (!urip.host.startsWith('did:')) {
const res = await getAgent().resolveHandle({handle: urip.host})
urip.host = res.data.did
}
return {did: urip.host, uri: urip.toString()}
},
enabled: !!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 useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined)
return useQuery<string, Error>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(didOrHandle || ''),
async queryFn() {
if (!didOrHandle) {
return ''
}
if (!didOrHandle.startsWith('did:')) {
const res = await getAgent().resolveHandle({handle: didOrHandle})
didOrHandle = res.data.did
}
return didOrHandle
},
enabled: !!didOrHandle,
})
}
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)
}
}
}
}
+49 -17
View File
@@ -8,6 +8,8 @@ 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
@@ -40,7 +42,20 @@ 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
@@ -50,7 +65,6 @@ export type ApiContext = {
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
>,
) => void
clearCurrentAccount: () => void
}
const StateContext = React.createContext<StateContext>({
@@ -254,13 +268,26 @@ 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,
@@ -268,7 +295,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})),
}
})
}, [setStateAndPersist])
}, [clearCurrentAccount, setStateAndPersist])
const initSession = React.useCallback<ApiContext['initSession']>(
async account => {
@@ -402,19 +429,6 @@ 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
@@ -515,3 +529,21 @@ 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
@@ -0,0 +1,37 @@
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)
}
+54 -13
View File
@@ -1,5 +1,10 @@
import React from 'react'
import {SafeAreaView} from 'react-native'
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 {Login} from 'view/com/auth/login/Login'
import {CreateAccount} from 'view/com/auth/create/CreateAccount'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -8,6 +13,7 @@ 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,
@@ -15,31 +21,66 @@ enum ScreenState {
S_CreateAccount,
}
export function LoggedOut() {
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {_} = useLingui()
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])
if (screenState === ScreenState.S_LoginOrCreateAccount) {
return (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Login)}
onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
/>
)
}
return (
<SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}>
<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>
)}
{screenState === ScreenState.S_LoginOrCreateAccount ? (
<SplashScreen
onPressSignin={() => setScreenState(ScreenState.S_Login)}
onPressCreateAccount={() =>
setScreenState(ScreenState.S_CreateAccount)
}
/>
) : undefined}
{screenState === ScreenState.S_Login ? (
<Login
onPressBack={() =>
@@ -55,6 +96,6 @@ export function LoggedOut() {
/>
) : undefined}
</ErrorBoundary>
</SafeAreaView>
</View>
)
}
+13 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {SafeAreaView} from 'react-native'
import {SafeAreaView, Platform} from 'react-native'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
@@ -23,7 +23,18 @@ export function Onboarding() {
const skip = () => onboardingDispatch({type: 'skip'})
return (
<SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
<SafeAreaView
testID="onboardingView"
style={[
s.hContentRegion,
pal.view,
// @ts-ignore web only -esb
Platform.select({
web: {
height: '100vh',
},
}),
]}>
<ErrorBoundary>
{onboardingState.step === 'Welcome' && (
<Welcome skip={skip} next={next} />
+33 -35
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {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,42 +20,40 @@ export const SplashScreen = ({
return (
<CenteredView style={[styles.container, pal.view]}>
<SafeAreaView testID="noSessionView" style={styles.container}>
<ErrorBoundary>
<View style={styles.hero}>
<Text style={[styles.title, pal.link]}>
<Trans>Bluesky</Trans>
<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>
</Text>
<Text style={[styles.subtitle, pal.textLight]}>
<Trans>See what's next</Trans>
</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>
</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>
</TouchableOpacity>
</View>
</ErrorBoundary>
</CenteredView>
)
}
+68 -42
View File
@@ -1,5 +1,6 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from '../util/Link'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -11,9 +12,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans} from '@lingui/macro'
export const SplashScreen = ({
onDismiss,
onPressSignin,
onPressCreateAccount,
}: {
onDismiss?: () => void
onPressSignin: () => void
onPressCreateAccount: () => void
}) => {
@@ -23,47 +26,70 @@ export const SplashScreen = ({
const isMobileWeb = isWeb && isTabletOrMobile
return (
<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>
<>
{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>
</>
)
}
+6 -1
View File
@@ -136,7 +136,12 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
</LoggedOutLayout>
) : undefined}
{currentForm === Forms.PasswordUpdated ? (
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
<LoggedOutLayout
leadin=""
title={_(msg`Password updated`)}
description={_(msg`You can now sign in with your new password.`)}>
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
</LoggedOutLayout>
) : undefined}
</KeyboardAvoidingView>
)
@@ -10,6 +10,8 @@ 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 = {
@@ -17,40 +19,45 @@ 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 = (
<>
<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>
</Trans>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
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.
</Trans>
</Text>
<View
style={{
@@ -69,7 +76,7 @@ export function RecommendedFeeds({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
Next
<Trans>Next</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
@@ -99,20 +106,22 @@ export function RecommendedFeeds({next}: Props) {
<ActivityIndicator size="large" />
</View>
) : (
<ErrorMessage message="Failed to load recommended feeds" />
<ErrorMessage message={_(msg`Failed to load recommended feeds`)} />
)}
</TitleColumnLayout>
</TabletOrDesktop>
<Mobile>
<View style={[mStyles.container]} testID="recommendedFeedsOnboarding">
<ViewHeader
title="Recommended Feeds"
title={_(msg`Recommended Feeds`)}
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
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.
</Trans>
</Text>
{hasFeeds ? (
@@ -128,13 +137,15 @@ export function RecommendedFeeds({next}: Props) {
</View>
) : (
<View style={{flex: 1}}>
<ErrorMessage message="Failed to load recommended feeds" />
<ErrorMessage
message={_(msg`Failed to load recommended feeds`)}
/>
</View>
)}
<Button
onPress={next}
label="Continue"
label={_(msg`Continue`)}
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
@@ -14,14 +14,17 @@ 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, dataUpdatedAt} = useSuggestedFollowsQuery()
const {data: suggestedFollows} = useSuggestedFollowsQuery()
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
[did: string]: AppBskyActorDefs.ProfileView[]
@@ -31,33 +34,37 @@ export function RecommendedFollows({next}: Props) {
const title = (
<>
<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>
</Trans>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
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.
</Trans>
</Text>
<View
style={{
@@ -76,7 +83,7 @@ export function RecommendedFollows({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
Done
<Trans>Done</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
@@ -155,7 +162,6 @@ export function RecommendedFollows({next}: Props) {
renderItem={({item}) => (
<RecommendedFollowsItem
profile={item}
dataUpdatedAt={dataUpdatedAt}
onFollowStateChange={onFollowStateChange}
moderation={moderateProfile(item, moderationOpts)}
/>
@@ -171,13 +177,15 @@ export function RecommendedFollows({next}: Props) {
<View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
<View>
<ViewHeader
title="Recommended Follows"
title={_(msg`Recommended Users`)}
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
Check out some recommended users. Follow them to see similar
users.
<Trans>
Check out some recommended users. Follow them to see similar
users.
</Trans>
</Text>
</View>
{!suggestedFollows || !moderationOpts ? (
@@ -188,7 +196,6 @@ export function RecommendedFollows({next}: Props) {
renderItem={({item}) => (
<RecommendedFollowsItem
profile={item}
dataUpdatedAt={dataUpdatedAt}
onFollowStateChange={onFollowStateChange}
moderation={moderateProfile(item, moderationOpts)}
/>
@@ -199,7 +206,7 @@ export function RecommendedFollows({next}: Props) {
)}
<Button
onPress={next}
label="Continue"
label={_(msg`Continue`)}
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
@@ -18,7 +18,6 @@ import {logger} from '#/logger'
type Props = {
profile: AppBskyActorDefs.ProfileViewBasic
dataUpdatedAt: number
moderation: ProfileModeration
onFollowStateChange: (props: {
did: string
@@ -28,13 +27,12 @@ type Props = {
export function RecommendedFollowsItem({
profile,
dataUpdatedAt,
moderation,
onFollowStateChange,
}: React.PropsWithChildren<Props>) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const shadowedProfile = useProfileShadow(profile, dataUpdatedAt)
const shadowedProfile = useProfileShadow(profile)
return (
<Animated.View
@@ -43,10 +43,10 @@ export function WelcomeMobile({next, skip}: Props) {
/>
<View>
<Text style={[pal.text, styles.title]}>
Welcome to{' '}
<Text style={[pal.text, pal.link, styles.title]}>
<Trans>Bluesky</Trans>
</Text>
<Trans>
Welcome to{' '}
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
</Trans>
</Text>
<View style={styles.spacer} />
<View style={[styles.row]}>
-79
View File
@@ -1,79 +0,0 @@
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: 'Discard draft',
title: _(msg`Discard draft`),
onPressConfirm: onClose,
onPressCancel: () => {
closeModal()
},
message: "Are you sure you'd like to discard this draft?",
confirmBtnText: 'Discard',
message: _(msg`Are you sure you'd like to discard this draft?`),
confirmBtnText: _(msg`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) {
+58 -27
View File
@@ -41,7 +41,7 @@ export function FeedPage({
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
}) {
const {isSandbox} = useSession()
const {isSandbox, hasSession} = useSession()
const pal = usePalette('default')
const {_} = useLingui()
const {isDesktop} = useWebMediaQueries()
@@ -123,24 +123,35 @@ export function FeedPage({
}
onPress={emitSoftReset}
/>
<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}
/>
}
/>
{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}
/>
}
/>
)}
</View>
)
}
return <></>
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, isSandbox])
}, [
isDesktop,
pal.view,
pal.text,
pal.textLight,
hasNew,
_,
isSandbox,
hasSession,
])
return (
<View testID={testID} style={s.h100pct}>
@@ -166,14 +177,17 @@ export function FeedPage({
showIndicator={hasNew}
/>
)}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
{hasSession && (
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
)}
</View>
)
}
@@ -181,13 +195,30 @@ export function FeedPage({
function useHeaderOffset() {
const {isDesktop, isTablet} = useWebMediaQueries()
const {fontScale} = useWindowDimensions()
const {hasSession} = useSession()
if (isDesktop) {
return 0
}
if (isTablet) {
return 50
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
}
// default text takes 44px, plus 34px of pad
// scale the 44px by the font scale
return 34 + 44 * fontScale
}
+6 -3
View File
@@ -14,6 +14,8 @@ 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,
@@ -68,6 +70,7 @@ export function FeedSourceCardLoaded({
showLikes?: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
@@ -85,8 +88,8 @@ export function FeedSourceCardLoaded({
if (isSaved) {
openModal({
name: 'confirm',
title: 'Remove from my feeds',
message: `Remove ${feed?.displayName} from my feeds?`,
title: _(msg`Remove from my feeds`),
message: _(msg`Remove ${feed.displayName} from my feeds?`),
onPressConfirm: async () => {
try {
await removeFeed({uri: feed.uri})
@@ -107,7 +110,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
+38 -14
View File
@@ -8,13 +8,14 @@ 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} from '#/state/queries/profile-feedgens'
import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
@@ -29,25 +30,37 @@ const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export function ProfileFeedgens({
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
}: {
interface SectionRef {
scrollToTop: () => void
}
interface ProfileFeedgensProps {
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)
@@ -88,6 +101,17 @@ export function ProfileFeedgens({
// 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 {
@@ -192,7 +216,7 @@ export function ProfileFeedgens({
/>
</View>
)
}
})
const styles = StyleSheet.create({
item: {
+1 -4
View File
@@ -64,7 +64,6 @@ export function ListMembers({
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isError,
@@ -185,7 +184,6 @@ 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}}
/>
@@ -198,7 +196,6 @@ export function ListMembers({
onPressTryAgain,
onPressRetryLoadMore,
isMobile,
dataUpdatedAt,
],
)
@@ -218,7 +215,7 @@ export function ListMembers({
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item.uri || item._reactKey}
keyExtractor={(item: any) => item.subject?.did || item._reactKey}
renderItem={renderItem}
ListHeaderComponent={renderHeader}
ListFooterComponent={Footer}
+187 -156
View File
@@ -8,6 +8,7 @@ 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'
@@ -15,7 +16,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} from '#/state/queries/profile-lists'
import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
@@ -28,170 +29,200 @@ const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export function ProfileLists({
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
}: {
interface SectionRef {
scrollToTop: () => void
}
interface ProfileListsProps {
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
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 (isError && !isEmpty) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
}, [isError, isEmpty, isFetched, isFetching, data])
// events
// =
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 onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
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>
)
}
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
/>
)
},
[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>
)
}
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])
}
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])
// 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 () => {
track('Lists:onRefresh')
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh lists', {error: err})
}
setIsPTRing(false)
}, [refetch, track, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
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>
)
}
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
/>
)
},
[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: {
paddingHorizontal: 18,
+2 -11
View File
@@ -22,7 +22,6 @@ export function Component({did}: {did: string}) {
const moderationOpts = useModerationOpts()
const {
data: profile,
dataUpdatedAt,
error: profileError,
refetch: refetchProfile,
isFetching: isFetchingProfile,
@@ -51,13 +50,7 @@ export function Component({did}: {did: string}) {
)
}
if (profile && moderationOpts) {
return (
<ComponentLoaded
profile={profile}
dataUpdatedAt={dataUpdatedAt}
moderationOpts={moderationOpts}
/>
)
return <ComponentLoaded profile={profile} moderationOpts={moderationOpts} />
}
// should never happen
return (
@@ -71,15 +64,13 @@ 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, dataUpdatedAt)
const profile = useProfileShadow(profileUnshadowed)
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 || currentAccount?.handle}
{profile?.displayName || account?.handle}
</Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{currentAccount?.handle}
{account?.handle}
</Text>
</View>
@@ -75,7 +75,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
did: currentAccount.did,
handle: currentAccount.handle,
})}
title="Your profile"
title={_(msg`Your profile`)}
noFeedback>
{contents}
</Link>
+2 -9
View File
@@ -38,7 +38,6 @@ export function Feed({
const {markAllRead} = useUnreadNotificationsApi()
const {
data,
dataUpdatedAt,
isLoading,
isFetching,
isFetched,
@@ -132,15 +131,9 @@ export function Feed({
} else if (item === LOADING_ITEM) {
return <NotificationFeedLoadingPlaceholder />
}
return (
<FeedItem
item={item}
dataUpdatedAt={dataUpdatedAt}
moderationOpts={moderationOpts!}
/>
)
return <FeedItem item={item} moderationOpts={moderationOpts!} />
},
[onPressRetryLoadMore, dataUpdatedAt, moderationOpts],
[onPressRetryLoadMore, moderationOpts],
)
const showHeaderSpinner = !isPTRing && isFetching && !isLoading
+7 -7
View File
@@ -1,4 +1,4 @@
import React, {useMemo, useState, useEffect} from 'react'
import React, {memo, useMemo, useState, useEffect} from 'react'
import {
Animated,
TouchableOpacity,
@@ -56,15 +56,13 @@ interface Author {
moderation: ProfileModeration
}
export function FeedItem({
let 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(() => {
@@ -135,7 +133,6 @@ export function FeedItem({
accessible={false}>
<Post
post={item.subject}
dataUpdatedAt={dataUpdatedAt}
style={
item.notification.isRead
? undefined
@@ -235,7 +232,8 @@ export function 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)}{' '}
@@ -262,6 +260,8 @@ export function FeedItem({
</Link>
)
}
FeedItem = memo(FeedItem)
export {FeedItem}
function ExpandListPressable({
hasMultipleAuthors,
+57 -3
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {View, 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,28 +9,82 @@ 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) {
return <FeedsTabBarTablet {...props} />
if (hasSession) {
return <FeedsTabBarTablet {...props} />
} else {
return <FeedsTabBarPublic />
}
} 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 = feeds.map(f => f.displayName)
const items = hasSession ? 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
+30 -26
View File
@@ -23,14 +23,14 @@ export function FeedsTabBar(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const pal = usePalette('default')
const {isSandbox} = useSession()
const {isSandbox, hasSession} = useSession()
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const feeds = usePinnedFeedsInfos()
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
const {headerHeight} = useShellLayout()
const {headerMinimalShellTransform} = useMinimalShellMode()
const items = feeds.map(f => f.displayName)
const items = hasSession ? feeds.map(f => f.displayName) : []
const onPressAvi = React.useCallback(() => {
setDrawerOpen(true)
@@ -61,30 +61,35 @@ export function FeedsTabBar(
<Text style={[brandBlue, s.bold, styles.title]}>
{isSandbox ? 'SANDBOX' : 'Bluesky'}
</Text>
<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 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>
</View>
<TabBar
key={items.join(',')}
onPressSelected={props.onPressSelected}
selectedPage={props.selectedPage}
onSelect={props.onSelect}
testID={props.testID}
items={items}
indicatorColor={pal.colors.link}
/>
{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}
/>
)}
</Animated.View>
)
}
@@ -104,8 +109,7 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 18,
paddingTop: 8,
paddingBottom: 2,
paddingVertical: 8,
width: '100%',
},
title: {
+5 -13
View File
@@ -20,7 +20,6 @@ export function PostLikedBy({uri}: {uri: string}) {
} = useResolveUriQuery(uri)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -55,18 +54,11 @@ 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}
dataUpdatedAt={dataUpdatedAt}
/>
)
},
[dataUpdatedAt],
)
const renderItem = useCallback(({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
)
}, [])
if (isFetchingResolvedUri || !isFetched) {
return (
+2 -9
View File
@@ -20,7 +20,6 @@ export function PostRepostedBy({uri}: {uri: string}) {
} = useResolveUriQuery(uri)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -57,15 +56,9 @@ export function PostRepostedBy({uri}: {uri: string}) {
const renderItem = useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => {
return (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
)
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
},
[dataUpdatedAt],
[],
)
if (isFetchingResolvedUri || !isFetched) {
+84 -93
View File
@@ -38,8 +38,11 @@ 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} TODO
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
const TOP_COMPONENT = {_reactKey: '__top_component__'}
const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
@@ -71,9 +74,7 @@ export function PostThread({
isError,
error,
refetch,
isRefetching,
data: thread,
dataUpdatedAt,
} = usePostThreadQuery(uri)
const {data: preferences} = usePreferencesQuery()
const rootPost = thread?.type === 'post' ? thread.post : undefined
@@ -110,8 +111,6 @@ export function PostThread({
return (
<PostThreadLoaded
thread={thread}
isRefetching={isRefetching}
dataUpdatedAt={dataUpdatedAt}
threadViewPrefs={preferences.threadViewPrefs}
onRefresh={refetch}
onPressReply={onPressReply}
@@ -121,46 +120,29 @@ 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 hasScrolledIntoView = useRef<boolean>(false) TODO
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 [maxVisible, setMaxVisible] = React.useState(100)
const [isPTRing, setIsPTRing] = React.useState(false)
// 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)
// construct content
const posts = React.useMemo(() => {
let arr = [TOP_COMPONENT].concat(
Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))),
@@ -168,66 +150,73 @@ function PostThreadLoaded({
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
}
arr.push(BOTTOM_COMPONENT)
if (arr.indexOf(CHILD_SPINNER) === -1) {
arr.push(BOTTOM_COMPONENT)
}
return arr
}, [thread, maxVisible, threadViewPrefs])
// TODO
/*const onContentSizeChange = React.useCallback(() => {
/**
* 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(() => {
// only run once
if (hasScrolledIntoView.current) {
if (!needsScrollAdjustment.current) {
return
}
// wait for loading to finish
if (
!view.hasContent ||
(view.isFromCache && view.isLoadingFromCache) ||
view.isLoading
) {
return
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
}
}, [thread, isDesktop])
if (highlightedPostIndex !== -1) {
ref.current?.scrollToIndex({
index: highlightedPostIndex,
animated: false,
viewPosition: 0,
})
hasScrolledIntoView.current = true
const onPTR = React.useCallback(async () => {
setIsPTRing(true)
try {
await onRefresh()
} catch (err) {
logger.error('Failed to refresh posts thread', {error: err})
}
}, [
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],
)
setIsPTRing(false)
}, [setIsPTRing, onRefresh])
const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => {
if (item === TOP_COMPONENT) {
return isTablet ? <ViewHeader title="Post" /> : null
return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
} else if (item === PARENT_SPINNER) {
return (
<View style={styles.parentSpinner}>
<ActivityIndicator />
</View>
)
} else if (item === REPLY_PROMPT) {
} else if (item === REPLY_PROMPT && hasSession) {
return (
<View>
{isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
@@ -292,24 +281,27 @@ function PostThreadLoaded({
? (posts[index - 1] as ThreadPost)
: undefined
return (
<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}
/>
<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>
)
}
return null
},
[
hasSession,
isTablet,
isDesktop,
onPressReply,
@@ -322,7 +314,6 @@ function PostThreadLoaded({
posts,
onRefresh,
threadViewPrefs.lab_treeViewEnabled,
dataUpdatedAt,
_,
],
)
@@ -333,25 +324,21 @@ function PostThreadLoaded({
data={posts}
initialNumToRender={posts.length}
maintainVisibleContentPosition={
undefined // TODO
// isNative && view.isFromCache && view.isCachedPostAReply
// ? MAINTAIN_VISIBLE_CONTENT_POSITION
// : undefined
!needsScrollAdjustment.current
? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined
}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={onRefresh}
refreshing={isPTRing}
onRefresh={onPTR}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onContentSizeChange={
undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
}
onScrollToIndexFailed={onScrollToIndexFailed}
onContentSizeChange={onContentSizeChange}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
@@ -468,6 +455,8 @@ 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) {
@@ -477,6 +466,8 @@ 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
+10 -9
View File
@@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React, {memo, useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {
AtUri,
@@ -35,7 +35,8 @@ 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} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useLanguagePrefs} from '#/state/preferences'
import {useComposerControls} from '#/state/shell/composer'
import {useModerationOpts} from '#/state/queries/preferences'
@@ -44,7 +45,6 @@ import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export function PostThreadItem({
post,
record,
dataUpdatedAt,
treeView,
depth,
isHighlightedPost,
@@ -56,7 +56,6 @@ export function PostThreadItem({
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
dataUpdatedAt: number
treeView: boolean
depth: number
isHighlightedPost?: boolean
@@ -67,7 +66,7 @@ export function PostThreadItem({
onPostReply: () => void
}) {
const moderationOpts = useModerationOpts()
const postShadowed = usePostShadow(post, dataUpdatedAt)
const postShadowed = usePostShadow(post)
const richText = useMemo(
() =>
new RichTextAPI({
@@ -118,7 +117,7 @@ function PostThreadItemDeleted() {
)
}
function PostThreadItemLoaded({
let PostThreadItemLoaded = ({
post,
record,
richText,
@@ -144,12 +143,12 @@ function 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
@@ -565,6 +564,7 @@ function PostThreadItemLoaded({
)
}
}
PostThreadItemLoaded = memo(PostThreadItemLoaded)
function PostOuterWrapper({
post,
@@ -636,13 +636,14 @@ 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="Translate">
<Link href={translatorUrl} title={_(msg`Translate`)}>
<Text style={pal.link}>
<Trans>Translate</Trans>
</Text>
+2 -4
View File
@@ -30,12 +30,10 @@ 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>
}) {
@@ -48,7 +46,7 @@ export function Post({
: undefined,
[post],
)
const postShadowed = usePostShadow(post, dataUpdatedAt)
const postShadowed = usePostShadow(post)
const richText = useMemo(
() =>
record
@@ -99,7 +97,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,7 +76,6 @@ let Feed = ({
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isError,
@@ -200,7 +199,6 @@ let Feed = ({
return (
<FeedSlice
slice={item}
dataUpdatedAt={dataUpdatedAt}
// we check for this before creating the feedItems array
moderationOpts={moderationOpts!}
/>
@@ -208,7 +206,6 @@ let Feed = ({
},
[
feed,
dataUpdatedAt,
error,
onPressTryAgain,
onPressRetryLoadMore,
+6 -3
View File
@@ -10,6 +10,8 @@ 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'
@@ -86,6 +88,7 @@ function FeedgenErrorMessage({
knownError: KnownError
}) {
const pal = usePalette('default')
const {_: _l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const msg = MESSAGES[knownError]
const [_, uri] = feedDesc.split('|')
@@ -100,8 +103,8 @@ function FeedgenErrorMessage({
const onRemoveFeed = React.useCallback(async () => {
openModal({
name: 'confirm',
title: 'Remove feed',
message: 'Remove this feed from your saved feeds?',
title: _l(msgLingui`Remove feed`),
message: _l(msgLingui`Remove this feed from your saved feeds?`),
async onPressConfirm() {
try {
await removeFeed({uri})
@@ -116,7 +119,7 @@ function FeedgenErrorMessage({
closeModal()
},
})
}, [openModal, closeModal, uri, removeFeed])
}, [openModal, closeModal, uri, removeFeed, _l])
return (
<View
+2 -4
View File
@@ -40,7 +40,6 @@ export function FeedItem({
record,
reason,
moderation,
dataUpdatedAt,
isThreadChild,
isThreadLastChild,
isThreadParent,
@@ -49,12 +48,11 @@ 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, dataUpdatedAt)
const postShadowed = usePostShadow(post)
const richText = useMemo(
() =>
new RichTextAPI({
@@ -106,7 +104,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,12 +11,10 @@ import {makeProfileLink} from 'lib/routes/links'
let FeedSlice = ({
slice,
dataUpdatedAt,
ignoreFilterFor,
moderationOpts,
}: {
slice: FeedPostSlice
dataUpdatedAt: number
ignoreFilterFor?: string
moderationOpts: ModerationOpts
}): React.ReactNode => {
@@ -44,7 +42,6 @@ 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)}
/>
@@ -54,7 +51,6 @@ 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)}
/>
@@ -65,7 +61,6 @@ 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
@@ -83,7 +78,6 @@ 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={
+1 -6
View File
@@ -27,7 +27,6 @@ import {useSession} from '#/state/session'
export function ProfileCard({
testID,
profile: profileUnshadowed,
dataUpdatedAt,
noBg,
noBorder,
followers,
@@ -36,7 +35,6 @@ export function ProfileCard({
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
dataUpdatedAt: number
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
@@ -46,7 +44,7 @@ export function ProfileCard({
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
const profile = useProfileShadow(profileUnshadowed)
const moderationOpts = useModerationOpts()
if (!moderationOpts) {
return null
@@ -202,13 +200,11 @@ 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
@@ -224,7 +220,6 @@ export function ProfileCardWithFollowBtn({
? undefined
: profileShadow => <FollowButton profile={profileShadow} />
}
dataUpdatedAt={dataUpdatedAt}
/>
)
}
+3 -8
View File
@@ -20,7 +20,6 @@ export function ProfileFollowers({name}: {name: string}) {
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -29,7 +28,7 @@ export function ProfileFollowers({name}: {name: string}) {
isError,
error,
refetch,
} = useProfileFollowersQuery(resolvedDid?.did)
} = useProfileFollowersQuery(resolvedDid)
const followers = React.useMemo(() => {
if (data?.pages) {
@@ -58,13 +57,9 @@ export function ProfileFollowers({name}: {name: string}) {
const renderItem = React.useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
),
[dataUpdatedAt],
[],
)
if (isFetchingDid || !isFetched) {
+3 -8
View File
@@ -20,7 +20,6 @@ export function ProfileFollows({name}: {name: string}) {
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -29,7 +28,7 @@ export function ProfileFollows({name}: {name: string}) {
isError,
error,
refetch,
} = useProfileFollowsQuery(resolvedDid?.did)
} = useProfileFollowsQuery(resolvedDid)
const follows = React.useMemo(() => {
if (data?.pages) {
@@ -58,13 +57,9 @@ export function ProfileFollows({name}: {name: string}) {
const renderItem = React.useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
),
[dataUpdatedAt],
[],
)
if (isFetchingDid || !isFetched) {
+107 -91
View File
@@ -51,6 +51,7 @@ 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
@@ -113,7 +114,8 @@ let ProfileHeaderLoaded = ({
}: LoadedProps): React.ReactNode => {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {currentAccount} = useSession()
const {currentAccount, hasSession} = useSession()
const requireAuth = useRequireAuth()
const {_} = useLingui()
const {openModal} = useModalControls()
const {openLightbox} = useLightboxControls()
@@ -150,38 +152,42 @@ let ProfileHeaderLoaded = ({
}
}, [openLightbox, profile, moderation])
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 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 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 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 onPressEditProfile = React.useCallback(() => {
@@ -236,9 +242,10 @@ let ProfileHeaderLoaded = ({
track('ProfileHeader:BlockAccountButtonClicked')
openModal({
name: 'confirm',
title: 'Block Account',
message:
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
title: _(msg`Block Account`),
message: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
),
onPressConfirm: async () => {
try {
await queueBlock()
@@ -251,15 +258,16 @@ let ProfileHeaderLoaded = ({
}
},
})
}, [track, queueBlock, openModal])
}, [track, queueBlock, openModal, _])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
openModal({
name: 'confirm',
title: 'Unblock Account',
message:
'The account will be able to interact with you after unblocking.',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
onPressConfirm: async () => {
try {
await queueUnblock()
@@ -272,7 +280,7 @@ let ProfileHeaderLoaded = ({
}
},
})
}, [track, queueUnblock, openModal])
}, [track, queueUnblock, openModal, _])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
@@ -290,7 +298,7 @@ let ProfileHeaderLoaded = ({
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: 'Share',
label: _(msg`Share`),
onPress: onPressShare,
icon: {
ios: {
@@ -301,68 +309,75 @@ let ProfileHeaderLoaded = ({
},
},
]
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!profile.viewer?.blocking) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: profile.viewer?.muted ? 'Unmute Account' : '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 ? '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',
},
})
}
if (hasSession) {
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
onPress: onPressReportAccount,
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: _(msg`Add to Lists`),
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'exclamationmark.triangle',
name: 'list.bullet',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
android: 'ic_menu_add',
web: 'list',
},
})
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',
},
})
}
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: _(msg`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,
@@ -373,6 +388,7 @@ let ProfileHeaderLoaded = ({
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
const blockHide =
@@ -414,7 +430,7 @@ let ProfileHeaderLoaded = ({
)
) : !profile.viewer?.blockedBy ? (
<>
{!isProfilePreview && (
{!isProfilePreview && hasSession && (
<TouchableOpacity
testID="suggestedFollowsBtn"
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
@@ -65,7 +65,7 @@ export function ProfileHeaderSuggestedFollows({
}
}, [active, animatedHeight, track])
const {isLoading, data, dataUpdatedAt} = useSuggestedFollowsByActorQuery({
const {isLoading, data} = useSuggestedFollowsByActorQuery({
did: actorDid,
})
@@ -127,11 +127,7 @@ export function ProfileHeaderSuggestedFollows({
</>
) : data ? (
data.suggestions.map(profile => (
<SuggestedFollow
key={profile.did}
profile={profile}
dataUpdatedAt={dataUpdatedAt}
/>
<SuggestedFollow key={profile.did} profile={profile} />
))
) : (
<View />
@@ -196,15 +192,13 @@ 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, dataUpdatedAt)
const profile = useProfileShadow(profileUnshadowed)
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: 'Remove account',
label: _(msg`Remove account`),
onPress: () => {
removeAccount(account)
Toast.show('Account removed from quick access')
+3 -2
View File
@@ -1,6 +1,7 @@
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
@@ -30,8 +31,8 @@ export class ErrorBoundary extends Component<Props, State> {
return (
<CenteredView style={{height: '100%', flex: 1}}>
<ErrorScreen
title="Oh no!"
message="There was an unexpected issue in the application. Please let us know if this happened to you!"
title={t`Oh no!`}
message={t`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,7 +3,6 @@ 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,
+11 -4
View File
@@ -43,7 +43,13 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
const BLUR_AMOUNT = isWeb ? 5 : 100
function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) {
export 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 (
@@ -208,7 +214,7 @@ export function EditableUserAvatar({
[
!isWeb && {
testID: 'changeAvatarCameraBtn',
label: 'Camera',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
@@ -232,7 +238,7 @@ export function EditableUserAvatar({
},
{
testID: 'changeAvatarLibraryBtn',
label: 'Library',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
@@ -269,7 +275,7 @@ export function EditableUserAvatar({
},
!!avatar && {
testID: 'changeAvatarRemoveBtn',
label: 'Remove',
label: _(msg`Remove`),
icon: {
ios: {
name: 'trash',
@@ -287,6 +293,7 @@ export function EditableUserAvatar({
onSelectNewAvatar,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
+4 -3
View File
@@ -35,7 +35,7 @@ export function UserBanner({
[
!isWeb && {
testID: 'changeBannerCameraBtn',
label: 'Camera',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
@@ -57,7 +57,7 @@ export function UserBanner({
},
{
testID: 'changeBannerLibraryBtn',
label: 'Library',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
@@ -86,7 +86,7 @@ export function UserBanner({
},
!!banner && {
testID: 'changeBannerRemoveBtn',
label: 'Remove',
label: _(msg`Remove`),
icon: {
ios: {
name: 'trash',
@@ -104,6 +104,7 @@ export function UserBanner({
onSelectNewBanner,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
+31 -27
View File
@@ -20,6 +20,8 @@ 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({
@@ -33,8 +35,9 @@ export function PostDropdownBtn({
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const {currentAccount} = useSession()
const {hasSession, currentAccount} = useSession()
const theme = useTheme()
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
@@ -91,7 +94,7 @@ export function PostDropdownBtn({
const dropdownItems: NativeDropdownItem[] = [
{
label: 'Translate',
label: _(msg`Translate`),
onPress() {
onOpenTranslate()
},
@@ -105,7 +108,7 @@ export function PostDropdownBtn({
},
},
{
label: 'Copy post text',
label: _(msg`Copy post text`),
onPress() {
onCopyPostText()
},
@@ -119,7 +122,7 @@ export function PostDropdownBtn({
},
},
{
label: 'Share',
label: _(msg`Share`),
onPress() {
const url = toShareUrl(href)
shareUrl(url)
@@ -133,11 +136,11 @@ export function PostDropdownBtn({
web: 'share',
},
},
{
hasSession && {
label: 'separator',
},
{
label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
hasSession && {
label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`),
onPress() {
onToggleThreadMute()
},
@@ -150,37 +153,38 @@ export function PostDropdownBtn({
web: 'comment-slash',
},
},
{
hasSession && {
label: 'separator',
},
!isAuthor && {
label: 'Report post',
onPress() {
openModal({
name: 'report',
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
!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',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
isAuthor && {
label: 'separator',
},
isAuthor && {
label: 'Delete post',
label: _(msg`Delete post`),
onPress() {
openModal({
name: 'confirm',
title: 'Delete this post?',
message: 'Are you sure? This can not be undone.',
title: _(msg`Delete this post?`),
message: _(msg`Are you sure? This cannot be undone.`),
onPressConfirm: onDeletePost,
})
},
+8 -2
View File
@@ -25,6 +25,7 @@ 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,
@@ -46,6 +47,7 @@ export function PostCtrls({
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const requireAuth = useRequireAuth()
const defaultCtrlColor = React.useMemo(
() => ({
@@ -107,7 +109,9 @@ export function PostCtrls({
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={onPressReply}
onPress={() => {
requireAuth(() => onPressReply())
}}
accessibilityRole="button"
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
@@ -135,7 +139,9 @@ export function PostCtrls({
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={onPressToggleLike}
onPress={() => {
requireAuth(() => onPressToggleLike())
}}
accessibilityRole="button"
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
@@ -7,6 +7,7 @@ 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
@@ -25,6 +26,7 @@ export const RepostButton = ({
}: Props) => {
const theme = useTheme()
const {openModal} = useModalControls()
const requireAuth = useRequireAuth()
const defaultControlColor = React.useMemo(
() => ({
@@ -45,7 +47,9 @@ export const RepostButton = ({
return (
<TouchableOpacity
testID="repostBtn"
onPress={onPressToggleRepostWrapper}
onPress={() => {
requireAuth(() => onPressToggleRepostWrapper())
}}
style={[styles.control, !big && styles.controlPad]}
accessibilityRole="button"
accessibilityLabel={`${
@@ -1,5 +1,5 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {StyleProp, StyleSheet, View, ViewStyle, Pressable} from 'react-native'
import {RepostIcon} from 'lib/icons'
import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
@@ -12,6 +12,8 @@ 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
@@ -31,6 +33,8 @@ export const RepostButton = ({
}: Props) => {
const theme = useTheme()
const {_} = useLingui()
const {hasSession} = useSession()
const requireAuth = useRequireAuth()
const defaultControlColor = React.useMemo(
() => ({
@@ -41,7 +45,7 @@ export const RepostButton = ({
const dropdownItems: NativeDropdownItem[] = [
{
label: isReposted ? 'Undo repost' : 'Repost',
label: isReposted ? _(msg`Undo repost`) : _(msg`Repost`),
testID: 'repostDropdownRepostBtn',
icon: {
ios: {name: 'repeat'},
@@ -51,7 +55,7 @@ export const RepostButton = ({
onPress: onRepost,
},
{
label: 'Quote post',
label: _(msg`Quote post`),
testID: 'repostDropdownQuoteBtn',
icon: {
ios: {name: 'quote.bubble'},
@@ -62,32 +66,46 @@ export const RepostButton = ({
},
]
return (
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 ? (
<EventStopper>
<NativeDropdown
items={dropdownItems}
accessibilityLabel={_(msg`Repost or quote post`)}
accessibilityHint="">
<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>
{inner}
</NativeDropdown>
</EventStopper>
) : (
<Pressable
accessibilityRole="button"
onPress={() => {
requireAuth(() => {})
}}
accessibilityLabel={_(msg`Repost or quote post`)}
accessibilityHint="">
{inner}
</Pressable>
)
}
+142 -140
View File
@@ -12,7 +12,6 @@ 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'
@@ -32,125 +31,111 @@ import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {cleanError} from '#/lib/strings/errors'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
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()
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()
useFocusEffect(
React.useCallback(() => {
screen('AppPasswords')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
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>
)
}
const onAdd = React.useCallback(async () => {
openModal({name: 'add-app-password'})
}, [openModal])
if (error) {
return (
<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.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)}
styles.btnContainer,
isTabletOrDesktop && styles.btnContainerDesktop,
]}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</CenteredView>
)
}
</View>
</CenteredView>
)
}
// no app passwords (empty) state
if (appPasswords?.length === 0) {
return (
<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.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
styles.scrollContainer,
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 && styles.flex1,
]}>
{appPasswords.map((password, i) => (
<AppPassword
key={password.name}
testID={`appPassword-${i}`}
name={password.name}
createdAt={password.createdAt}
/>
</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}>
))}
{isTabletOrDesktop && (
<View style={[styles.btnContainer, styles.btnContainerDesktop]}>
<Button
testID="appPasswordBtn"
type="primary"
@@ -161,31 +146,44 @@ export const AppPasswords = withAuthRequired(
/>
</View>
)}
</CenteredView>
)
}
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<ActivityIndicator />
</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>
)
}
function AppPasswordsHeader() {
const {isTabletOrDesktop} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
return (
<>
<ViewHeader title="App Passwords" showOnDesktop />
<ViewHeader title={_(msg`App Passwords`)} showOnDesktop />
<Text
type="sm"
style={[
@@ -220,14 +218,16 @@ function AppPassword({
const onDelete = React.useCallback(async () => {
openModal({
name: 'confirm',
title: 'Delete App Password',
message: `Are you sure you want to delete the app password "${name}"?`,
title: _(msg`Delete app password`),
message: _(
msg`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,15 +245,17 @@ function AppPassword({
{name}
</Text>
<Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}>
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))}
</Trans>
</Text>
</View>
<FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} />
+12 -7
View File
@@ -9,6 +9,8 @@ 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,
@@ -16,6 +18,7 @@ type Props = NativeStackScreenProps<
>
export const CommunityGuidelinesScreen = (_props: Props) => {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
useFocusEffect(
@@ -26,16 +29,18 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
return (
<View>
<ViewHeader title="Community Guidelines" />
<ViewHeader title={_(msg`Community Guidelines`)} />
<ScrollView style={[s.hContentRegion, pal.view]}>
<View style={[s.p20]}>
<Text style={pal.text}>
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"
/>
</Trans>
</Text>
</View>
<View style={s.footerSpacer} />
+12 -7
View File
@@ -9,10 +9,13 @@ 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(
@@ -23,16 +26,18 @@ export const CopyrightPolicyScreen = (_props: Props) => {
return (
<View>
<ViewHeader title="Copyright Policy" />
<ViewHeader title={_(msg`Copyright Policy`)} />
<ScrollView style={[s.hContentRegion, pal.view]}>
<View style={[s.p20]}>
<Text style={pal.text}>
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"
/>
</Trans>
</Text>
</View>
<View style={s.footerSpacer} />
+53 -47
View File
@@ -2,7 +2,6 @@ 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'
@@ -34,6 +33,7 @@ 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,9 +87,7 @@ type FlatlistSlice =
key: string
}
export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
_props: Props,
) {
export function FeedsScreen(_props: Props) {
const pal = usePalette('default')
const {openComposer} = useComposerControls()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
@@ -118,6 +116,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
isPending: isSearchPending,
error: searchError,
} = useSearchPopularFeedsMutation()
const {hasSession} = useSession()
/**
* A search query is present. We may not have search results yet.
@@ -181,50 +180,52 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
const items = React.useMemo(() => {
let slices: FlatlistSlice[] = []
slices.push({
key: 'savedFeedsHeader',
type: 'savedFeedsHeader',
})
if (preferencesError) {
if (hasSession) {
slices.push({
key: 'savedFeedsError',
type: 'error',
error: cleanError(preferencesError.toString()),
key: 'savedFeedsHeader',
type: 'savedFeedsHeader',
})
} else {
if (isPreferencesLoading || !preferences?.feeds?.saved) {
if (preferencesError) {
slices.push({
key: 'savedFeedsLoading',
type: 'savedFeedsLoading',
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
key: 'savedFeedsError',
type: 'error',
error: cleanError(preferencesError.toString()),
})
} else {
if (preferences?.feeds?.saved.length === 0) {
if (isPreferencesLoading || !preferences?.feeds?.saved) {
slices.push({
key: 'savedFeedNoResults',
type: 'savedFeedNoResults',
key: 'savedFeedsLoading',
type: 'savedFeedsLoading',
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
})
} else {
const {saved, pinned} = preferences.feeds
if (preferences?.feeds?.saved.length === 0) {
slices.push({
key: 'savedFeedNoResults',
type: 'savedFeedNoResults',
})
} else {
const {saved, pinned} = preferences.feeds
slices = slices.concat(
pinned.map(uri => ({
key: `savedFeed:${uri}`,
type: 'savedFeed',
feedUri: uri,
})),
)
slices = slices.concat(
saved
.filter(uri => !pinned.includes(uri))
.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,
})),
)
}
}
}
}
@@ -306,6 +307,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
return slices
}, [
hasSession,
preferences,
isPreferencesLoading,
preferencesError,
@@ -392,7 +394,8 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
pal.view,
styles.header,
{
marginTop: 16,
// This is first in the flatlist without a session -esb
marginTop: hasSession ? 16 : 0,
paddingLeft: isMobile ? 12 : undefined,
paddingRight: 10,
paddingBottom: isMobile ? 6 : undefined,
@@ -431,7 +434,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
return (
<FeedSourceCard
feedUri={item.feedUri}
showSaveBtn
showSaveBtn={hasSession}
showDescription
showLikes
/>
@@ -454,6 +457,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
},
[
_,
hasSession,
isMobile,
pal,
query,
@@ -467,7 +471,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
<View style={[pal.view, styles.container]}>
{isMobile && (
<ViewHeader
title="Feeds"
title={_(msg`Feeds`)}
canGoBack={false}
renderButton={renderHeaderBtn}
showBorder
@@ -496,17 +500,19 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl(
desktopFixedHeight
/>
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
{hasSession && (
<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