Compare commits

...

27 Commits

Author SHA1 Message Date
Eric Bailey add7f13a96 Fix soft reset scroll on profile 2023-11-26 12:46:43 -06: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
128 changed files with 7924 additions and 4123 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],
+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', () =>
+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>
+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'
+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
+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
+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>
}
+14
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 ||
@@ -128,6 +130,8 @@ export async function migrate() {
const newData = transform(legacyData)
await write(newData)
logger.debug('persisted state: migrated legacy storage')
} else {
logger.debug('persisted state: no migration needed')
}
} catch (e) {
logger.error('persisted state: error migrating legacy storage', {
@@ -135,3 +139,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],
)
+29
View File
@@ -160,6 +160,35 @@ 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
}
return true
}
},
})
}
export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
export function useGetPopularFeedsQuery() {
+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
}
+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) {
+37 -23
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>
)
}
+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: {
-3
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,
],
)
+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,
+3 -1
View File
@@ -212,7 +212,9 @@ export function Component() {
<Button
testID="cancelBtn"
type="default"
onPress={() => closeModal()}
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint=""
label={_(msg`Cancel`)}
+3 -1
View File
@@ -79,7 +79,9 @@ export function Component({text, href}: {text: string; href: string}) {
<Button
testID="cancelBtn"
type="default"
onPress={() => closeModal()}
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint=""
label="Cancel"
+3 -1
View File
@@ -136,7 +136,9 @@ export function Component({
<Button
testID="doneBtn"
type="default"
onPress={() => closeModal()}
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Done`)}
accessibilityHint=""
label="Done"
+6 -1
View File
@@ -99,7 +99,12 @@ export function Component({
{description}
</Text>
<View style={s.flex1} />
<Button type="primary" style={styles.btn} onPress={() => closeModal()}>
<Button
type="primary"
style={styles.btn}
onPress={() => {
closeModal()
}}>
<Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
Okay
</Text>
+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),
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -224,7 +224,9 @@ export function Component({showReminder}: {showReminder?: boolean}) {
<Button
testID="cancelBtn"
type="default"
onPress={() => closeModal()}
onPress={() => {
closeModal()
}}
accessibilityLabel={
stage === Stages.Reminder ? 'Not right now' : 'Cancel'
}
+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,
+1 -1
View File
@@ -22,7 +22,7 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
const SCROLLED_DOWN_LIMIT = 200
interface PagerWithHeaderChildParams {
export interface PagerWithHeaderChildParams {
headerHeight: number
isFocused: boolean
onScroll: OnScrollHandler
+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) {
+5 -8
View File
@@ -38,6 +38,7 @@ import {
UsePreferencesQueryResponse,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
@@ -73,7 +74,6 @@ export function PostThread({
refetch,
isRefetching,
data: thread,
dataUpdatedAt,
} = usePostThreadQuery(uri)
const {data: preferences} = usePreferencesQuery()
const rootPost = thread?.type === 'post' ? thread.post : undefined
@@ -111,7 +111,6 @@ export function PostThread({
<PostThreadLoaded
thread={thread}
isRefetching={isRefetching}
dataUpdatedAt={dataUpdatedAt}
threadViewPrefs={preferences.threadViewPrefs}
onRefresh={refetch}
onPressReply={onPressReply}
@@ -122,18 +121,17 @@ 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()
@@ -220,14 +218,14 @@ function PostThreadLoaded({
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} />}
@@ -295,7 +293,6 @@ function PostThreadLoaded({
<PostThreadItem
post={item.post}
record={item.record}
dataUpdatedAt={dataUpdatedAt}
treeView={threadViewPrefs.lab_treeViewEnabled || false}
depth={item.ctx.depth}
isHighlightedPost={item.ctx.isHighlightedPost}
@@ -310,6 +307,7 @@ function PostThreadLoaded({
return null
},
[
hasSession,
isTablet,
isDesktop,
onPressReply,
@@ -322,7 +320,6 @@ function PostThreadLoaded({
posts,
onRefresh,
threadViewPrefs.lab_treeViewEnabled,
dataUpdatedAt,
_,
],
)
+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}
/>
)
}
+2 -7
View File
@@ -20,7 +20,6 @@ export function ProfileFollowers({name}: {name: string}) {
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -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) {
+2 -7
View File
@@ -20,7 +20,6 @@ export function ProfileFollows({name}: {name: string}) {
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
@@ -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')
+34 -5
View File
@@ -3,7 +3,6 @@ import {View, ActivityIndicator, StyleSheet} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
@@ -14,12 +13,17 @@ import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {emitSoftReset} from '#/state/events'
import {useSession} from '#/state/session'
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = withAuthRequired(function HomeScreenImpl(
props: Props,
) {
export function HomeScreen(props: Props) {
const {hasSession} = useSession()
const {data: preferences} = usePreferencesQuery()
if (!hasSession) {
return <HomeScreenPublic />
}
if (preferences) {
return <HomeScreenReady {...props} preferences={preferences} />
} else {
@@ -29,7 +33,31 @@ export const HomeScreen = withAuthRequired(function HomeScreenImpl(
</View>
)
}
})
}
function HomeScreenPublic() {
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const renderCustomFeedEmptyState = React.useCallback(() => {
return <CustomFeedEmptyState />
}, [])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(false)
}, [setDrawerSwipeDisabled, setMinimalShellMode]),
)
return (
<FeedPage
isPageFocused
feed={`feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot`}
renderEmptyState={renderCustomFeedEmptyState}
/>
)
}
function HomeScreenReady({
preferences,
@@ -83,6 +111,7 @@ function HomeScreenReady({
emitSoftReset()
}, [])
// TODO(pwi) may need this in public view
const onPageScrollStateChanged = React.useCallback(
(state: 'idle' | 'dragging' | 'settling') => {
if (state === 'dragging') {
+117 -8
View File
@@ -14,16 +14,19 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {useAnalytics} from 'lib/analytics/analytics'
import {useFocusEffect} from '@react-navigation/native'
import {LANGUAGES} from 'lib/../locale/languages'
import {APP_LANGUAGES, LANGUAGES} from 'lib/../locale/languages'
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
export function LanguageSettingsScreen(_: Props) {
export function LanguageSettingsScreen(_props: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const {isTabletOrDesktop} = useWebMediaQueries()
@@ -52,6 +55,15 @@ export function LanguageSettingsScreen(_: Props) {
[langPrefs, setLangPrefs],
)
const onChangeAppLanguage = React.useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (langPrefs.appLanguage !== value) {
setLangPrefs.setAppLanguage(value)
}
},
[langPrefs, setLangPrefs],
)
const myLanguages = React.useMemo(() => {
return (
langPrefs.contentLanguages
@@ -71,15 +83,109 @@ export function LanguageSettingsScreen(_: Props) {
styles.container,
isTabletOrDesktop && styles.desktopContainer,
]}>
<ViewHeader title="Language Settings" showOnDesktop />
<ViewHeader title={_(msg`Language Settings`)} showOnDesktop />
<View style={{paddingTop: 20, paddingHorizontal: 20}}>
{/* APP LANGUAGE */}
<View style={{paddingBottom: 20}}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
Primary Language
<Trans>App Language</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
Select your preferred language for translations in your feed.
<Trans>
Select your app language for the default text to display in the
app
</Trans>
</Text>
<View style={{position: 'relative'}}>
<RNPickerSelect
value={langPrefs.appLanguage}
onValueChange={onChangeAppLanguage}
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: pal.viewLight.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 24,
},
inputIOS: {
backgroundColor: pal.viewLight.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 24,
},
inputWeb: {
// @ts-ignore web only
cursor: 'pointer',
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 0,
backgroundColor: pal.viewLight.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 24,
},
}}
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
backgroundColor: pal.viewLight.backgroundColor,
borderRadius: 24,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</View>
</View>
<View
style={{
height: 1,
backgroundColor: pal.border.borderColor,
marginBottom: 20,
}}
/>
{/* PRIMARY LANGUAGE */}
<View style={{paddingBottom: 20}}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Primary Language</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Select your preferred language for translations in your feed.
</Trans>
</Text>
<View style={{position: 'relative'}}>
@@ -161,13 +267,16 @@ export function LanguageSettingsScreen(_: Props) {
}}
/>
{/* CONTENT LANGUAGES */}
<View style={{paddingBottom: 20}}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
Content Languages
<Trans>Content Languages</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
Select which languages you want your subscribed feeds to include. If
none are selected, all languages will be shown.
<Trans>
Select which languages you want your subscribed feeds to include.
If none are selected, all languages will be shown.
</Trans>
</Text>
<Button
+62 -64
View File
@@ -4,7 +4,6 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {MyLists} from '#/view/com/lists/MyLists'
import {Text} from 'view/com/util/text/Text'
import {Button} from 'view/com/util/forms/Button'
@@ -15,72 +14,71 @@ import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {s} from 'lib/styles'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {Trans} from '@lingui/macro'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
export const ListsScreen = withAuthRequired(
function ListsScreenImpl({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
export function ListsScreen({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const onPressNewList = React.useCallback(() => {
openModal({
name: 'create-or-edit-list',
purpose: 'app.bsky.graph.defs#curatelist',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [openModal, navigation])
const onPressNewList = React.useCallback(() => {
openModal({
name: 'create-or-edit-list',
purpose: 'app.bsky.graph.defs#curatelist',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [openModal, navigation])
return (
<View style={s.hContentRegion} testID="listsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
User Lists
return (
<View style={s.hContentRegion} testID="listsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
<Trans>User Lists</Trans>
</Text>
<Text style={pal.textLight}>
<Trans>Public, shareable lists which can drive feeds.</Trans>
</Text>
</View>
<View>
<Button
testID="newUserListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
<Trans>New</Trans>
</Text>
<Text style={pal.textLight}>
Public, shareable lists which can drive feeds.
</Text>
</View>
<View>
<Button
testID="newUserListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
</Button>
</View>
</SimpleViewHeader>
<MyLists filter="curate" style={s.flexGrow1} />
</View>
)
},
)
</Button>
</View>
</SimpleViewHeader>
<MyLists filter="curate" style={s.flexGrow1} />
</View>
)
}
+91 -91
View File
@@ -6,7 +6,6 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {s} from 'lib/styles'
import {CenteredView} from '../com/util/Views'
import {ViewHeader} from '../com/util/ViewHeader'
@@ -17,101 +16,102 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
export const ModerationScreen = withAuthRequired(
function Moderation({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
export function ModerationScreen({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
useFocusEffect(
React.useCallback(() => {
screen('Moderation')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
useFocusEffect(
React.useCallback(() => {
screen('Moderation')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onPressContentFiltering = React.useCallback(() => {
track('Moderation:ContentfilteringButtonClicked')
openModal({name: 'content-filtering-settings'})
}, [track, openModal])
const onPressContentFiltering = React.useCallback(() => {
track('Moderation:ContentfilteringButtonClicked')
openModal({name: 'content-filtering-settings'})
}, [track, openModal])
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
]}
testID="moderationScreen">
<ViewHeader title="Moderation" showOnDesktop />
<View style={styles.spacer} />
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view]}
onPress={onPressContentFiltering}
accessibilityRole="tab"
accessibilityHint="Content filtering"
accessibilityLabel="">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Content filtering
</Text>
</TouchableOpacity>
<Link
testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Moderation lists
</Text>
</Link>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="user-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Muted accounts
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Blocked accounts
</Text>
</Link>
</CenteredView>
)
},
)
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
]}
testID="moderationScreen">
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
<View style={styles.spacer} />
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view]}
onPress={onPressContentFiltering}
accessibilityRole="tab"
accessibilityHint="Content filtering"
accessibilityLabel="">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Content filtering</Trans>
</Text>
</TouchableOpacity>
<Link
testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Moderation lists</Trans>
</Text>
</Link>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="user-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted accounts</Trans>
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Blocked accounts</Trans>
</Text>
</Link>
</CenteredView>
)
}
const styles = StyleSheet.create({
desktopContainer: {
+127 -125
View File
@@ -10,7 +10,6 @@ import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {Text} from '../com/util/text/Text'
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'
@@ -21,6 +20,8 @@ import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts'
import {cleanError} from '#/lib/strings/errors'
@@ -28,143 +29,144 @@ type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationBlockedAccounts'
>
export const ModerationBlockedAccounts = withAuthRequired(
function ModerationBlockedAccountsImpl({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data,
dataUpdatedAt,
isFetching,
isError,
error,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useMyBlockedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.blocks.length
const profiles = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.blocks)
}
return []
}, [data])
export function ModerationBlockedAccounts({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data,
isFetching,
isError,
error,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useMyBlockedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.blocks.length
const profiles = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.blocks)
}
return []
}, [data])
useFocusEffect(
React.useCallback(() => {
screen('BlockedAccounts')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
useFocusEffect(
React.useCallback(() => {
screen('BlockedAccounts')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh my muted accounts', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh my muted accounts', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more of my muted accounts', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more of my muted accounts', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = ({
item,
index,
}: {
item: ActorDefs.ProfileView
index: number
}) => (
<ProfileCard
testID={`blockedAccount-${index}`}
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
)
return (
<CenteredView
const renderItem = ({
item,
index,
}: {
item: ActorDefs.ProfileView
index: number
}) => (
<ProfileCard
testID={`blockedAccount-${index}`}
key={item.did}
profile={item}
/>
)
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="blockedAccountsScreen">
<ViewHeader title={_(msg`Blocked Accounts`)} showOnDesktop />
<Text
type="sm"
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="blockedAccountsScreen">
<ViewHeader title="Blocked Accounts" showOnDesktop />
<Text
type="sm"
style={[
styles.description,
pal.text,
isTabletOrDesktop && styles.descriptionDesktop,
]}>
styles.description,
pal.text,
isTabletOrDesktop && styles.descriptionDesktop,
]}>
<Trans>
Blocked accounts cannot reply in your threads, mention you, or
otherwise interact with you. You will not see their content and they
will be prevented from seeing yours.
</Text>
{isEmpty ? (
<View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
{isError ? (
<ErrorScreen
title="Oops!"
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
</Trans>
</Text>
{isEmpty ? (
<View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
{isError ? (
<ErrorScreen
title="Oops!"
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
<Trans>
You have not blocked any accounts yet. To block an account, go
to their profile and selected "Block account" from the menu on
their account.
</Text>
</View>
)}
</View>
) : (
<FlatList
style={[!isTabletOrDesktop && styles.flex1]}
data={profiles}
keyExtractor={(item: ActorDefs.ProfileView) => item.did}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
</Trans>
</Text>
</View>
)}
</View>
) : (
<FlatList
style={[!isTabletOrDesktop && styles.flex1]}
data={profiles}
keyExtractor={(item: ActorDefs.ProfileView) => item.did}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
ListFooterComponent={() => (
<View style={styles.footer}>
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</CenteredView>
)
},
)
ListFooterComponent={() => (
<View style={styles.footer}>
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</CenteredView>
)
}
const styles = StyleSheet.create({
container: {
+61 -64
View File
@@ -4,7 +4,6 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {MyLists} from '#/view/com/lists/MyLists'
import {Text} from 'view/com/util/text/Text'
import {Button} from 'view/com/util/forms/Button'
@@ -17,70 +16,68 @@ import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
export const ModerationModlistsScreen = withAuthRequired(
function ModerationModlistsScreenImpl({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
export function ModerationModlistsScreen({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const onPressNewList = React.useCallback(() => {
openModal({
name: 'create-or-edit-list',
purpose: 'app.bsky.graph.defs#modlist',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [openModal, navigation])
const onPressNewList = React.useCallback(() => {
openModal({
name: 'create-or-edit-list',
purpose: 'app.bsky.graph.defs#modlist',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [openModal, navigation])
return (
<View style={s.hContentRegion} testID="moderationModlistsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
Moderation Lists
return (
<View style={s.hContentRegion} testID="moderationModlistsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
Moderation Lists
</Text>
<Text style={pal.textLight}>
Public, shareable lists of users to mute or block in bulk.
</Text>
</View>
<View>
<Button
testID="newModListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
<Text style={pal.textLight}>
Public, shareable lists of users to mute or block in bulk.
</Text>
</View>
<View>
<Button
testID="newModListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
</Button>
</View>
</SimpleViewHeader>
<MyLists filter="mod" style={s.flexGrow1} />
</View>
)
},
)
</Button>
</View>
</SimpleViewHeader>
<MyLists filter="mod" style={s.flexGrow1} />
</View>
)
}
+127 -125
View File
@@ -10,7 +10,6 @@ import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {Text} from '../com/util/text/Text'
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'
@@ -21,6 +20,8 @@ import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts'
import {cleanError} from '#/lib/strings/errors'
@@ -28,142 +29,143 @@ type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationMutedAccounts'
>
export const ModerationMutedAccounts = withAuthRequired(
function ModerationMutedAccountsImpl({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data,
dataUpdatedAt,
isFetching,
isError,
error,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useMyMutedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.mutes.length
const profiles = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.mutes)
}
return []
}, [data])
export function ModerationMutedAccounts({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data,
isFetching,
isError,
error,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useMyMutedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.mutes.length
const profiles = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.mutes)
}
return []
}, [data])
useFocusEffect(
React.useCallback(() => {
screen('MutedAccounts')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
useFocusEffect(
React.useCallback(() => {
screen('MutedAccounts')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh my muted accounts', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh my muted accounts', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more of my muted accounts', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more of my muted accounts', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = ({
item,
index,
}: {
item: ActorDefs.ProfileView
index: number
}) => (
<ProfileCard
testID={`mutedAccount-${index}`}
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
)
return (
<CenteredView
const renderItem = ({
item,
index,
}: {
item: ActorDefs.ProfileView
index: number
}) => (
<ProfileCard
testID={`mutedAccount-${index}`}
key={item.did}
profile={item}
/>
)
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="mutedAccountsScreen">
<ViewHeader title={_(msg`Muted Accounts`)} showOnDesktop />
<Text
type="sm"
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="mutedAccountsScreen">
<ViewHeader title="Muted Accounts" showOnDesktop />
<Text
type="sm"
style={[
styles.description,
pal.text,
isTabletOrDesktop && styles.descriptionDesktop,
]}>
styles.description,
pal.text,
isTabletOrDesktop && styles.descriptionDesktop,
]}>
<Trans>
Muted accounts have their posts removed from your feed and from your
notifications. Mutes are completely private.
</Text>
{isEmpty ? (
<View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
{isError ? (
<ErrorScreen
title="Oops!"
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
</Trans>
</Text>
{isEmpty ? (
<View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
{isError ? (
<ErrorScreen
title="Oops!"
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
<Trans>
You have not muted any accounts yet. To mute an account, go to
their profile and selected "Mute account" from the menu on
their account.
</Text>
</View>
)}
</View>
) : (
<FlatList
style={[!isTabletOrDesktop && styles.flex1]}
data={profiles}
keyExtractor={item => item.did}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
</Trans>
</Text>
</View>
)}
</View>
) : (
<FlatList
style={[!isTabletOrDesktop && styles.flex1]}
data={profiles}
keyExtractor={item => item.did}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
ListFooterComponent={() => (
<View style={styles.footer}>
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</CenteredView>
)
},
)
ListFooterComponent={() => (
<View style={styles.footer}>
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</CenteredView>
)
}
const styles = StyleSheet.create({
container: {
+8 -3
View File
@@ -12,9 +12,12 @@ import {NavigationProp} from 'lib/routes/types'
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'
export const NotFoundScreen = () => {
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const setMinimalShellMode = useSetMinimalShellMode()
@@ -36,13 +39,15 @@ export const NotFoundScreen = () => {
return (
<View testID="notFoundView" style={pal.view}>
<ViewHeader title="Page not found" />
<ViewHeader title={_(msg`Page not found`)} />
<View style={styles.container}>
<Text type="title-2xl" style={[pal.text, s.mb10]}>
Page not found
<Trans>Page not found</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb10]}>
We're sorry! We can't find the page you were looking for.
<Trans>
We're sorry! We can't find the page you were looking for.
</Trans>
</Text>
<Button
type="primary"
+94 -94
View File
@@ -6,7 +6,6 @@ import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed'
import {TextLink} from 'view/com/util/Link'
@@ -18,6 +17,8 @@ import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
import {listenSoftReset, emitSoftReset} from '#/state/events'
@@ -26,101 +27,100 @@ type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams,
'Notifications'
>
export const NotificationsScreen = withAuthRequired(
function NotificationsScreenImpl({}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
const pal = usePalette('default')
const {isDesktop} = useWebMediaQueries()
const unreadNotifs = useUnreadNotifications()
const queryClient = useQueryClient()
const hasNew = !!unreadNotifs
export function NotificationsScreen({}: Props) {
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
const pal = usePalette('default')
const {isDesktop} = useWebMediaQueries()
const unreadNotifs = useUnreadNotifications()
const queryClient = useQueryClient()
const hasNew = !!unreadNotifs
// event handlers
// =
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
resetMainScroll()
}, [scrollElRef, resetMainScroll])
// event handlers
// =
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
queryClient.invalidateQueries({
queryKey: NOTIFS_RQKEY(),
})
}, [scrollToTop, queryClient])
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
queryClient.invalidateQueries({
queryKey: NOTIFS_RQKEY(),
})
}, [scrollToTop, queryClient])
// on-visible setup
// =
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
logger.debug('NotificationsScreen: Updating feed')
screen('Notifications')
return listenSoftReset(onPressLoadLatest)
}, [screen, onPressLoadLatest, setMinimalShellMode]),
)
// on-visible setup
// =
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
logger.debug('NotificationsScreen: Updating feed')
screen('Notifications')
return listenSoftReset(onPressLoadLatest)
}, [screen, onPressLoadLatest, setMinimalShellMode]),
)
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/notifications"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
Notifications{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={emitSoftReset}
/>
</View>
)
}
return <></>
}, [isDesktop, pal, hasNew])
return (
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
<Feed
onScroll={onMainScroll}
scrollElRef={scrollElRef}
ListHeaderComponent={ListHeaderComponent}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new notifications"
showIndicator={hasNew}
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/notifications"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
<Trans>Notifications</Trans>{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={emitSoftReset}
/>
)}
</View>
)
},
)
</View>
)
}
return <></>
}, [isDesktop, pal, hasNew])
return (
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title={_(msg`Notifications`)} canGoBack={false} />
<Feed
onScroll={onMainScroll}
scrollElRef={scrollElRef}
ListHeaderComponent={ListHeaderComponent}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label={_(msg`Load new notifications`)}
showIndicator={hasNew}
/>
)}
</View>
)
}
+6 -4
View File
@@ -2,17 +2,19 @@ import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
export const PostLikedByScreen = ({route}: Props) => {
const setMinimalShellMode = useSetMinimalShellMode()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const {_} = useLingui()
useFocusEffect(
React.useCallback(() => {
@@ -22,8 +24,8 @@ export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
return (
<View>
<ViewHeader title="Liked by" />
<ViewHeader title={_(msg`Liked by`)} />
<PostLikedByComponent uri={uri} />
</View>
)
})
}

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